refactor(jarca-ledger): implement BAT-010 backend core layering

This commit is contained in:
Jarca
2026-07-03 07:03:17 +09:00
parent 02380a88ab
commit 7ac25a6cf7
36 changed files with 4892 additions and 4113 deletions
+1
View File
@@ -16,3 +16,4 @@ __pycache__/
*.csv *.csv
tmp_* tmp_*
.DS_Store .DS_Store
.worktrees/
+1 -1
View File
@@ -15,7 +15,7 @@ import plotly.express as px
import plotly.io as pio import plotly.io as pio
import streamlit as st import streamlit as st
from jarca_ledger_mcp import service from jarca_ledger_mcp.adapters.streamlit import service_gateway as service
from jarca_ledger_mcp.db import resolve_db_path from jarca_ledger_mcp.db import resolve_db_path
st.set_page_config(page_title="Jarca Ledger", page_icon="💰", layout="wide") st.set_page_config(page_title="Jarca Ledger", page_icon="💰", layout="wide")
@@ -0,0 +1,50 @@
# BAT-010 Design
## Layering design
- `jarca_ledger_mcp/application/shared.py`
- 공통 helper, validation, clock, dedupe, parser imports, shared policy를 보유한다.
- `jarca_ledger_mcp/application/system_services.py`
- health/status/backup/account create-list 등 system/persistence-adjacent service를 담당한다.
- `jarca_ledger_mcp/application/asset_account_services.py`
- account update, manual asset, snapshot, liquidity/net-worth read/write helper를 담당한다.
- `jarca_ledger_mcp/application/transaction_report_services.py`
- transaction CRUD, budgets, monthly/category/fixed-cost/reporting helper를 담당한다.
- `jarca_ledger_mcp/application/import_transfer_services.py`
- import preview/import, self-transfer/cancel reconciliation, cross-transfer matching을 담당한다.
- `jarca_ledger_mcp/application/categorization_services.py`
- category rules, preview/apply, uncategorized workbench, ledger categories를 담당한다.
## Persistence design
- `jarca_ledger_mcp/infrastructure/sqlite/connection.py`
- DB path resolution, sqlite connect, `db()` context manager를 담당한다.
- `jarca_ledger_mcp/infrastructure/sqlite/schema.py`
- schema version, DDL, additive column maps를 보유한다.
- `jarca_ledger_mcp/infrastructure/sqlite/migrations.py`
- idempotent/additive migration과 `init_db()`를 담당한다.
- top-level `jarca_ledger_mcp/db.py`는 compatibility shim으로 유지한다.
## Adapter / gateway design
- `jarca_ledger_mcp/adapters/mcp/server.py`
- 실제 FastMCP registration 위치.
- top-level `jarca_ledger_mcp/server.py`
- CLI entrypoint shim.
- `jarca_ledger_mcp/adapters/streamlit/service_gateway.py`
- dashboard가 실제로 사용하는 service surface만 재노출한다.
- `dashboard/app.py`
- gateway import를 통해 backend core를 호출한다.
## Compatibility design
- top-level `service.py`는 public API를 그대로 재노출한다.
- monkeypatch 관성을 위해 `service._current_kst_datetime` 변경이 shared/application modules에 전파되도록 module proxy를 유지한다.
- 기존 tests/import callers는 `from jarca_ledger_mcp import service` / `from jarca_ledger_mcp.db import init_db` 경로를 그대로 쓴다.
## Verification design
- `python -m py_compile`로 새 package tree syntax check.
- `pytest -q`로 full regression 및 AppTest 포함 회귀 검증.
- `hermes mcp test jarca-ledger`로 MCP tool discovery/registration 확인.
- Browser smoke로 synthetic fixture DB 기반 `요약`, `자산/마감` 렌더 및 console JS error 유무 확인.
@@ -0,0 +1,61 @@
# BAT-010 Implementation
## Plan summary
BAT-010 implements the confirmed dcy-010 Phase 0~3 scope by extracting application services, SQLite infrastructure, and thin MCP/Streamlit adapter seams while preserving legacy import paths and response contracts.
## Changed files
- `jarca_ledger_mcp/service.py`
- `jarca_ledger_mcp/db.py`
- `jarca_ledger_mcp/server.py`
- `jarca_ledger_mcp/application/__init__.py`
- `jarca_ledger_mcp/application/shared.py`
- `jarca_ledger_mcp/application/system_services.py`
- `jarca_ledger_mcp/application/asset_account_services.py`
- `jarca_ledger_mcp/application/transaction_report_services.py`
- `jarca_ledger_mcp/application/import_transfer_services.py`
- `jarca_ledger_mcp/application/categorization_services.py`
- `jarca_ledger_mcp/infrastructure/__init__.py`
- `jarca_ledger_mcp/infrastructure/sqlite/__init__.py`
- `jarca_ledger_mcp/infrastructure/sqlite/connection.py`
- `jarca_ledger_mcp/infrastructure/sqlite/schema.py`
- `jarca_ledger_mcp/infrastructure/sqlite/migrations.py`
- `jarca_ledger_mcp/adapters/__init__.py`
- `jarca_ledger_mcp/adapters/mcp/__init__.py`
- `jarca_ledger_mcp/adapters/mcp/server.py`
- `jarca_ledger_mcp/adapters/streamlit/__init__.py`
- `jarca_ledger_mcp/adapters/streamlit/service_gateway.py`
- `dashboard/app.py`
- `docs/discovery/dcy-010_20260702_backend-core-layering-mcp-adapter-realignment.md`
- `docs/project-structure.md`
- `docs/runtime-flows.md`
- `docs/srs/index.md`
- `docs/srs/Non-Functional/req-013_backend-core-layering-sqlite-infrastructure-service-facade-compatibility.md`
- `docs/srs/Interface/req-014_mcp-streamlit-adapter-seam-realignment-contract-preserving-gateway.md`
- `docs/batches/index.md`
- `docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/index.md`
- `docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/planning.md`
- `docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/design.md`
- `docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/implementation.md`
- `docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/verification.md`
## Execution log
- 2026-07-03 00:15 KST: BAT-010 standard batch scaffold and REQ-013/REQ-014 문서를 생성하고 승인 상태로 정리했다.
- 2026-07-03 00:17 KST: `service.py` giant implementation을 application modules로 분리하고 top-level facade compatibility / monkeypatch proxy를 추가했다.
- 2026-07-03 00:17 KST: `db.py` responsibilities를 `infrastructure/sqlite`로 분리하고 기존 init/commit/rollback semantics를 유지하는 shim을 추가했다.
- 2026-07-03 00:18 KST: MCP adapter package, Streamlit gateway, baseline docs를 반영하고 regression fixes(`SCHEMA_VERSION import`, `db()` init semantics, underscore helper imports, service monkeypatch proxy)를 정리했다.
## Validation
- `python -m py_compile jarca_ledger_mcp/*.py jarca_ledger_mcp/application/*.py jarca_ledger_mcp/infrastructure/sqlite/*.py jarca_ledger_mcp/adapters/mcp/*.py jarca_ledger_mcp/adapters/streamlit/*.py dashboard/app.py` → PASS
- `pytest -q` → PASS (`42 passed, 4 skipped`)
- `hermes mcp test jarca-ledger` → PASS (`Connected`, `Tools discovered: 56`)
- Browser smoke: synthetic fixture DB(`/tmp/fixture.sqlite3`)로 Streamlit `요약`과 `자산/마감` 페이지 렌더 확인, console JS errors 없음.
## Remaining risks / follow-up notes
- Phase 4+ write/import/advanced workflow의 더 세밀한 domain/service 분리는 아직 TODO다.
- Streamlit gateway는 현재 필요한 surface만 노출하지만, 후속 phase에서 page/domain 기준으로 더 세분화할 수 있다.
- application modules는 여전히 큰 편이므로 후속 phase에서 deeper repository/use-case split이 필요하다.
@@ -0,0 +1,37 @@
# BAT-010 Backend core layering (Phase 0~3)
- Profile: standard
- Status: release-candidate
- Included REQ: REQ-013, REQ-014
- Discovery: dcy-010
- Owner: 렛저장인
- Date (KST): 2026-07-03
## 목표
REQ-013/REQ-014에 따라 `service.py` giant implementation과 `db.py` persistence 집중을 application / infrastructure / adapter seam으로 분리한다. 동시에 top-level facade/shim 호환, MCP tool contract, Streamlit 핵심 화면, additive migration discipline을 유지해 Phase 4+ 분리의 안전한 기반을 만든다.
## 산출물
- `jarca_ledger_mcp/application/` phase-0~3 service modules + shared helpers
- `jarca_ledger_mcp/infrastructure/sqlite/` connection/schema/migration split
- `jarca_ledger_mcp/adapters/mcp/server.py` + top-level `server.py` shim
- `jarca_ledger_mcp/adapters/streamlit/service_gateway.py` + dashboard import seam 정렬
- `service.py` / `db.py` compatibility facade & shim
- full pytest / MCP discovery / browser smoke evidence
- `docs/project-structure.md`, `docs/runtime-flows.md` baseline updates
- BAT-010 planning/design/implementation/verification documents
## 범위 제한
- Phase 4+ transactional write/import/advanced workflow 분리
- 신규 HTTP adapter 추가
- response envelope, tool names, dashboard IA 재설계
- destructive DB migration
## 문서
- `planning.md`: Phase 0~3 delivery plan, risks, validation gate
- `design.md`: layering/seam/shim design
- `implementation.md`: changed files, execution log, validation commands
- `verification.md`: AC mapping, PASS/FAIL, REQ Implemented decision
@@ -0,0 +1,34 @@
# BAT-010 Planning
## Scope
- Implement REQ-013: application/infrastructure split, SQLite persistence extraction, service/db compatibility facade.
- Implement REQ-014: MCP adapter split, Streamlit service gateway, contract-preserving verification.
- Update architecture baseline docs to reflect the new layering and remaining Phase 4+ TODOs.
- Use synthetic/temp data only for verification evidence.
## Dependencies
- dcy-010 confirmed Discovery and approved/implemented REQ-013/REQ-014.
- Existing BAT-009 runtime behavior, response contract expectations, and Streamlit dashboard pages.
- Additive/idempotent SQLite migration discipline from REQ-002.
## Work plan
1. Split `service.py` implementation into application modules and preserve top-level facade compatibility.
2. Split `db.py` responsibilities into `infrastructure/sqlite` and preserve existing import paths.
3. Introduce MCP adapter package and Streamlit service gateway.
4. Update baseline docs (`docs/project-structure.md`, `docs/runtime-flows.md`).
5. Run py_compile, full pytest, MCP discovery, and browser smoke against synthetic fixture DB.
6. If evidence satisfies ACs, mark BAT-010 release-candidate and REQ-013/REQ-014 Implemented.
## Design Gate
- Result: standard batch 유지.
- Reason: package boundary 변화, compatibility shim, MCP adapter seam, Streamlit browser verification, baseline docs가 함께 변경되므로 planning/design/verification depth가 필요하다.
## Risks and mitigations
- Hidden helper coupling after module split: mitigate by full pytest, monkeypatch compatibility, and targeted smoke on boundary-sensitive flows.
- DB initialization drift after infrastructure split: mitigate by preserving init/commit/rollback semantics and verifying schema initialization through tests.
- MCP/dashboard contract drift: mitigate by `hermes mcp test jarca-ledger`, Streamlit AppTest/full pytest, and browser smoke on key pages.
@@ -0,0 +1,45 @@
# BAT-010 Verification
## Verification summary
Status: PASS
Evidence was collected against synthetic/temp data only. No production ledger DB mutation or real financial files were used.
## Acceptance criteria mapping
### REQ-013
- AC-1: PASS — `jarca_ledger_mcp/application/` 아래 shared/system/asset-account/transaction-report/import-transfer/categorization modules가 생성됐다.
- AC-2: PASS — `jarca_ledger_mcp/infrastructure/sqlite/` 아래 connection/schema/migrations split이 생성됐고 top-level `db.py` shim이 유지된다.
- AC-3: PASS — `jarca_ledger_mcp/service.py`는 compatibility facade로 축소됐으며 현재 40 lines 수준이다(실질 구현은 application modules로 이동).
- AC-4: PASS — `pytest -q` full regression PASS (`42 passed, 4 skipped`), 기존 `service`/`db` import paths 유지.
- AC-5: PASS — `docs/project-structure.md`와 `docs/runtime-flows.md`가 새 계층 구조와 shim/gateway 흐름을 반영한다.
### REQ-014
- AC-1: PASS — `jarca_ledger_mcp/adapters/mcp/server.py` 도입 후 `hermes mcp test jarca-ledger` PASS, tools discovered 56.
- AC-2: PASS — `jarca_ledger_mcp/adapters/streamlit/service_gateway.py` 도입 후 `dashboard/app.py`가 gateway import를 사용한다.
- AC-3: PASS — full pytest PASS로 Streamlit AppTest / contract regression 포함 회귀가 유지된다.
- AC-4: PASS — local browser smoke에서 `요약`과 `자산/마감` 페이지 렌더를 확인했고 console JS errors가 없었다.
- AC-5: PASS — baseline docs와 BAT-010 implementation/verification 문서가 새 adapter seam을 반영한다.
## Requirement state decision
- REQ-013: Implemented.
- REQ-014: Implemented.
## Validation evidence
- `python -m py_compile jarca_ledger_mcp/*.py jarca_ledger_mcp/application/*.py jarca_ledger_mcp/infrastructure/sqlite/*.py jarca_ledger_mcp/adapters/mcp/*.py jarca_ledger_mcp/adapters/streamlit/*.py dashboard/app.py` → PASS
- `pytest -q` → PASS (`42 passed, 4 skipped`)
- `hermes mcp test jarca-ledger` → PASS (`Connected`, `Tools discovered: 56`)
- Browser smoke on Streamlit `요약` / `자산/마감` → PASS, JS errors 없음
## Remaining issues
### Hard blockers
- None.
### Non-blocking follow-up
- Phase 4+ transactional write services, import pipeline deeper split, advanced workflow separation, narrower adapter surfaces remain tracked as GitHub Project TODO items.
+1
View File
@@ -17,3 +17,4 @@
| BAT-007 | standard | release-candidate | REQ-007, REQ-008 | dcy-007 | [bat-007_20260630_mcp-response-contract-ci-versioning](bat-007_20260630_mcp-response-contract-ci-versioning/) | | BAT-007 | standard | release-candidate | REQ-007, REQ-008 | dcy-007 | [bat-007_20260630_mcp-response-contract-ci-versioning](bat-007_20260630_mcp-response-contract-ci-versioning/) |
| BAT-008 | standard | release-candidate | REQ-009, REQ-010 | dcy-008 | [bat-008_20260630_asset-snapshots-manual-assets-read-mcp](bat-008_20260630_asset-snapshots-manual-assets-read-mcp/) | | BAT-008 | standard | release-candidate | REQ-009, REQ-010 | dcy-008 | [bat-008_20260630_asset-snapshots-manual-assets-read-mcp](bat-008_20260630_asset-snapshots-manual-assets-read-mcp/) |
| BAT-009 | standard | release-candidate | REQ-011, REQ-012 | dcy-009 | [bat-009_20260701_manual-asset-crud-snapshot-close](bat-009_20260701_manual-asset-crud-snapshot-close/) | | BAT-009 | standard | release-candidate | REQ-011, REQ-012 | dcy-009 | [bat-009_20260701_manual-asset-crud-snapshot-close](bat-009_20260701_manual-asset-crud-snapshot-close/) |
| BAT-010 | standard | release-candidate | REQ-013, REQ-014 | dcy-010 | [bat-010_20260703_backend-core-layering-phase-0-3](bat-010_20260703_backend-core-layering-phase-0-3/) |
@@ -5,9 +5,9 @@
- 문서 ID: dcy-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow - 문서 ID: dcy-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow
- 이슈명: Jarca Ledger 대시보드 운영 허브 사양 정립 (이번 달 현금흐름 + 자산 유동성 + 운영 미처리) - 이슈명: Jarca Ledger 대시보드 운영 허브 사양 정립 (이번 달 현금흐름 + 자산 유동성 + 운영 미처리)
- 작성 시각(KST): 2026-06-29 14:02 KST - 작성 시각(KST): 2026-06-29 14:02 KST
- 마지막 갱신 시각(KST): 2026-06-29 20:03 KST - 마지막 갱신 시각(KST): 2026-06-30 20:43 KST
- 대체됨: 없음 - 대체됨: 없음
- 후속 Discovery 참조: 없음 - 후속 Discovery 참조: docs/discovery/dcy-008_20260630_asset-snapshot-manual-valuation-system.md
- 생성된 REQ 참조: REQ-005 (`docs/srs/Interface/req-005_dashboard-operations-hub-liquidity-monthly-cashflow.md`) - 생성된 REQ 참조: REQ-005 (`docs/srs/Interface/req-005_dashboard-operations-hub-liquidity-monthly-cashflow.md`)
# 1. 계획 상태 요약 # 1. 계획 상태 요약
@@ -0,0 +1,219 @@
# 0. 문서 상태
이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다.
- 상태: confirmed
- 문서 ID: dcy-010_20260702_backend-core-layering-mcp-adapter-realignment
- 이슈명: Jarca Ledger 백엔드 코어 분리 및 layered 아키텍처(application/domain/infrastructure/adapters) 재편, MCP adapter 재정렬
- 작성 시각(KST): 2026-07-02 23:32
- 마지막 갱신 시각(KST): 2026-07-02 23:56
- 대체됨: 없음
- 후속 Discovery 참조: 없음
- 생성된 REQ 참조: `REQ-013`, `REQ-014`
# 1. 계획 상태 요약
현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다.
- 연계 문서 현황:
- 관련 blueprint: `.hermes/plans/2026-07-02_jarca-ledger-backend-refactor-blueprint.md` (근거 리팩터링 청사진)
- 관련 baseline 문서: `docs/project-structure.md`, `docs/runtime-flows.md`
- 해석:
- `jarca_ledger_mcp/service.py`가 약 3,378줄로 비대해져 MCP/Streamlit/SQLite 접근/import/reporting/reconciliation/asset 워크플로우가 하나의 함수 네임스페이스에 과결합된 상태이며, 향후 HTTP 등 인터페이스 확장 전에 layered backend 경계 도입이 필요하다.
- 이번 Discovery는 MCP를 제거하지 않고 adapter로 재정의하면서 application/domain/infrastructure/adapters 경계로 단계적(strangler-fig) 재편하는 구조 개선 주기를 신규로 시작할지 판정하고, baseline 문서(project-structure/runtime-flows) 갱신 요구까지 포함한 초안을 만든다.
# 2. 요구사항 판정 결과
이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다.
- 판정: 신규
- 근거
- 기존 dcy-001~009는 기능/운영 워크플로우(분류·환급, 백업·복구, 잔액대조, E2E evidence, 대시보드, CI, MCP 응답 계약, 자산 스냅샷/CRUD)를 다루며, service.py 분해와 layered backend 아키텍처 재편이라는 구조 리팩터링 주제를 다룬 Discovery는 없다.
- dcy-007(MCP response contract)은 응답 계약의 안정성을 다루지만 내부 계층 구조 분해나 persistence 분리를 정의하지 않아 문제 정의·범위·성공 기준이 실질적으로 다르다.
> **판정이 `완전 중복`인 경우**: 이하 섹션 작성을 중단하고 `# 9. 파일 처리 결과`에 중복 판정 사유와 참조 문서 경로를 기록한 뒤 문서를 닫는다. REQ 초안 작성으로 넘기지 않는다.
# 3. 문제점의 요약
문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다.
- 현재 상태
- `jarca_ledger_mcp/service.py`(약 3,378줄, blueprint 기준 약 126 함수)가 사실상 애플리케이션 전체 역할을 하며 비즈니스 규칙·오케스트레이션·SQL·mutation 안전·import·reporting·cross-transfer가 한 모듈에 집중되어 있다.
- `jarca_ledger_mcp/server.py`(약 296줄, 약 57개 MCP tool wrapper)와 `dashboard/app.py`(약 1,811줄)가 모두 `from jarca_ledger_mcp import service`로 동일한 넓은 함수 네임스페이스에 직접 결합되어 있다.
- `jarca_ledger_mcp/db.py`(약 333줄)에 schema·connection·additive migration helper가 한 파일에 섞여 있어 persistence 경계가 비즈니스 규칙과 분리되어 있지 않다.
- baseline 문서는 이미 존재한다(`docs/project-structure.md`, `docs/runtime-flows.md`). 다만 이번 구조 재편은 패키지 레이아웃과 런타임 호출 흐름을 크게 바꾸므로 두 baseline 문서의 갱신 갭이 발생한다.
- 기대 상태
- MCP는 유지하되 `adapters/mcp`로 위치를 재정의하고, Streamlit은 `adapters/streamlit` gateway를 통해 좁은 표면에만 의존하는 thin adapter가 된다.
- domain/application/infrastructure/adapters 경계가 명시되고, service.py는 호환 facade로 축소되거나 호출부 이관 후 제거되며, 각 도메인 영역이 독립적으로 이해·테스트 가능해진다.
- 이해관계자
- 문서 작성자(AI agent) 및 리팩터링 실행 subagent
- 리뷰어/승인자(Hermes 및 저장소 소유자)
- MCP/Streamlit 사용자(마이그레이션 중 동작 유지가 보장되어야 하는 소비자)
- 제약사항
- 마이그레이션 전 구간에서 MCP tool 이름/주요 응답 필드/카테고리·현금흐름 의미/import 증빙 필드/additive-only migration/Streamlit 페이지 동작이 호환 계약으로 유지되어야 한다.
- big-bang rewrite 금지, 금융 데이터 특성상 destructive migration은 승인 없이 금지하고 fixture/복사 DB만 사용한다.
## 주요 사용자 시나리오
사용자 또는 운영자가 실제로 어떤 상황에서 이 변경을 필요로 하는지 대표 시나리오를 적는다.
- 개발자가 특정 도메인(예: 자산 스냅샷) 로직을 수정할 때 3,378줄 service.py 전체를 탐색하지 않고 해당 application service와 repository만 열어 변경·테스트하려는 상황.
- 운영자가 DB I/O/integrity 장애를 진단할 때, persistence 계층이 분리되어 있어 문제를 전체 애플리케이션 경로가 아닌 infrastructure/sqlite 경계로 국소화하려는 상황.
- 향후 HTTP/FastAPI adapter를 추가하려는 개발자가 비즈니스 로직을 다시 이동시키지 않고 `application/*` 위에 adapter 패키지만 추가하려는 상황.
# 4. 이번에 정의할 변경
이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. 기능, 구조, 규칙 변경을 함께 포함해도 된다.
- service.py 분해: 비즈니스 로직을 domain(types/invariants/validation), application(use case/orchestration), infrastructure(sqlite repo/parsers)로 이관하고 service.py는 임시 호환 facade로 축소한다.
- persistence 분리: db.py의 schema/connection/migration을 `infrastructure/sqlite`로 분리하고 테이블/use-case별 repository를 도입한다.
- MCP/Streamlit thin adapter화: MCP tool 등록을 `adapters/mcp/server.py`로 이동해 top-level server.py는 shim으로 남기고, Streamlit은 `adapters/streamlit/service_gateway.py`의 좁은 gateway 호출로 전환한다.
- baseline 문서 갱신 요구: 새 패키지 레이아웃과 런타임 호출 경로 변화를 반영해 `docs/project-structure.md`와 `docs/runtime-flows.md`를 갱신한다.
- 단계적 migration 원칙 확정: strangler-fig 순서(foundations → read-only/reporting → write CRUD → import → cross-transfer/reconciliation/assets → adapter narrowing)로 blast radius를 최소화한다.
## 영향 범위
변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다.
- 코드 영향:
- `jarca_ledger_mcp/service.py`, `db.py`, `server.py` 분해 및 호환 facade/shim화
- 신규 패키지 `jarca_ledger_mcp/{domain,application,infrastructure,adapters}/*` 도입
- `dashboard/app.py`의 `service.*` 광범위 의존을 gateway 호출로 축소, `pyproject.toml` 패키지 discovery/entrypoint 정합성 유지
- 테스트를 repository/application-service/adapter 계층으로 재배치(Layer A/B/C)
- 문서 영향:
- `docs/project-structure.md`, `docs/runtime-flows.md` 갱신 요구
- 신규 Discovery 및 `docs/discovery/index.md` 행 추가
- 운영 영향:
- 마이그레이션 중 MCP/Streamlit 동작 유지 필요, 배포 절차 자체 변경은 없음(entrypoint 호환 유지)
- destructive migration 금지, DB 안전 정책(복사/fixture DB) 유지
# 5. 요구사항 목록
실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다.
## 기능 요구사항
- FR-1: `jarca_ledger_mcp`에 domain/application/infrastructure/adapters 경계를 도입하고 service.py의 로직을 도메인별 application service와 repository로 이관하되, service.py는 호환 facade로 남겨 기존 호출부가 깨지지 않게 한다.
- FR-2: db.py의 schema/connection/additive migration을 `infrastructure/sqlite`(connection/schema/migrations/row_mapping + 테이블별 repo)로 분리하고, db.py는 호환 shim으로 유지한다.
- FR-3: MCP tool 등록을 `adapters/mcp`로 이동해 top-level `server.py`를 thin shim으로 만들고, Streamlit이 `adapters/streamlit/service_gateway.py`의 좁은 gateway에만 의존하도록 전환한다.
- FR-4: 구조/런타임 baseline 갭을 해소하기 위해 `docs/project-structure.md`와 `docs/runtime-flows.md`에 신규 계층 구조와 호출 흐름 갱신 요구를 반영한다.
## 비기능 요구사항
성능, 운영성, 일관성, 자동화 안정성 등 기능 외 요구사항을 적는다.
- NFR-1: 호환 계약 안정성 — MCP tool 이름, 주요 응답 필드(`success`, `count`, `items`, `error`, `validation`, `matched_count` 등), 카테고리·현금흐름 의미, import 증빙 필드(`raw_summary`/`counterparty`/`raw_payload`/`external_id`/`import_batch_id`), additive-only migration, Streamlit 페이지 동작/순서가 의도적 버전 변경·승인 없이 바뀌지 않는다.
- NFR-2: 단계적 안전성 — 각 phase는 독립적으로 검증 가능해야 하고(pytest 회귀 + py_compile), 한 번에 하나의 도메인 슬라이스만 이관해 blast radius를 최소화한다.
- NFR-3: DB 안전성 — 리팩터링 중 fixture/복사 DB만 사용하고 승인 없는 destructive migration을 수행하지 않는다.
## 범위 경계
이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다.
- In Scope
- blueprint 기준 Phase 0~3 우선: behavioral baseline 고정 → 새 skeleton/compat seam 도입 → persistence 분리 → read-only application service 추출
- baseline 문서(project-structure/runtime-flows) 갱신 요구
- 단계적(strangler-fig) migration 원칙 정의
- Out of Scope
- 즉시 FastAPI/HTTP 전환(향후 adapter 준비만, 강제하지 않음)
- Postgres/원격 DB 이관
- 멀티유저/권한 체계 도입
- 원격 운영(remote ops) 재설계
- Phase 4 이후의 write/import/advanced workflow/adapter narrowing 구현(별도 후속 REQ·Batch·GitHub Project TODO로 추적)
# 6. 리스크/가정 목록
불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다.
## 리스크
- R-1 (High): service.py 내부 숨은 결합 — 공유 helper 이관 시 예상치 못한 호출부가 깨질 수 있다.
- 완화 방안: 리팩터링 전에 characterization/contract 회귀 테스트를 먼저 추가하고, helper는 첫 소비자와 함께 이동한다.
- R-2 (High): MCP/Streamlit 계약 drift — tool 응답 형태나 페이지 동작이 조용히 바뀔 수 있다.
- 완화 방안: 리팩터링 전 응답 형태(shape) 테스트와 Streamlit 페이지/서비스 통합 테스트를 확보하고 adapter gateway로 페이지 단위 점진 이관한다.
- R-3 (Medium): 신규 repository 간 SQL/row mapping 중복으로 유지보수성 저하.
- 완화 방안: row mapping과 공통 predicate를 중앙화(`row_mapping.py`)하고 repository 규칙(메시지 포매팅 금지, dict/dataclass 반환)을 강제한다.
## 가정
아직 확정되지 않았지만 현재 문서 작성의 기반이 되는 전제를 적는다.
- A-1: 기존 pytest 스위트(transaction/import/rules/dashboard 회귀 등)가 동작하며 baseline 고정과 회귀 검증의 기준으로 사용할 수 있다.
- A-2: 현재 SQLite schema가 additive-oriented이므로 destructive 변경 없이 계층 분리를 진행할 수 있다.
- A-3: MCP는 이미 spirit 상 adapter(server.py가 대부분 thin wrapper)이므로 adapter 재정렬이 큰 동작 변경 없이 가능하다.
## 오픈 질문
답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다.
- OQ-1: 이번 리팩터링을 전체 phase(1~7)를 한 주기로 다룰지, blueprint Batch A(Phase 0~3)만 첫 배치로 축소해 시작할지?
- 상태: Answered
- 처리 방안: 사용자 승인에 따라 Discovery 범위를 Phase 0~3 우선 배치로 고정하고, Phase 4+는 GitHub Project TODO와 후속 REQ/Batch로 분리 추적한다.
- 종료 조건: `# 5. 요구사항 목록 > 범위 경계`에 1차 범위가 Phase 0~3으로 반영되었으므로 종료한다.
- OQ-2: 마이그레이션 완료 판정 기준(service.py 목표 라인 수 ~400~600줄 축소 vs 완전 제거) 중 어느 것을 Definition of Done으로 채택할지?
- 상태: Answered
- 처리 방안: 사용자 승인에 따라 1차 완료 기준을 service.py 호환 facade 수준(~400~600줄) 축소로 채택하고, 완전 제거는 후속 단계 최적화로 남긴다.
- 종료 조건: 성공 기준 S-2와 REQ handoff 기준에 facade 축소 DoD가 반영되었으므로 종료한다.
### 오픈 질문 처리 기준
- `Open`: 아직 답이 없고 다음 단계 진행에 영향이 있는 상태다. 문서에 유지한다.
- `Answered`: 답이 확정된 상태다. 관련 본문/요구사항에 반영한 뒤 오픈 질문 목록에서는 제거하거나 resolved 기록으로 이동한다.
- `Deferred`: 지금 단계에서 결정하지 않고 다음 단계 또는 후속 이슈로 넘기는 상태다. 넘길 대상과 이유를 반드시 적는다.
- `Dropped`: 더 이상 유효하지 않은 질문이다. 삭제 사유를 짧게 남기고 목록에서 제거한다.
- 처리 원칙 1: 질문만 남기지 말고 반드시 `상태`, `처리 방안`, `종료 조건`을 함께 적는다.
- 처리 원칙 2: 답이 나온 질문은 본문 반영 없이 단순 삭제하지 않는다. 반영 위치를 먼저 갱신한다.
- 처리 원칙 3: High 영향 질문은 `Deferred` 처리 후 처리 방안에 REQ 인계 추적 방식을 명시한다.
# 7. 초기 성공 기준
이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다.
- S-1: 마이그레이션 전 구간에서 MCP와 Streamlit이 동작하며 기존/대체 회귀 테스트가 통과한다.
- S-2: 1차 리팩터링 완료 시 service.py가 호환 glue/facade 수준(blueprint §11 기준 ~400~600줄)으로 축소되고, db.py는 shim 또는 얇은 compatibility entry로 축소된다.
- S-3: 각 주요 도메인 영역이 자체 application service와 persistence 경계를 갖고, `docs/project-structure.md`·`docs/runtime-flows.md`가 신규 계층 구조를 반영하도록 갱신되어 이후 REQ/Batch 산출물이 baseline 문서를 참조할 수 있다.
## 측정 방식 및 데이터 출처
각 성공 기준을 어떻게 측정할지와 어떤 데이터나 문서를 근거로 쓸지 적는다.
- S-1 측정 방식: 각 phase 종료 시 pytest 회귀 스위트 + MCP tool contract 테스트 전부 통과 확인 / 데이터 출처: `tests/` 실행 결과, `docs/discovery/dcy-007` 응답 계약
- S-2 측정 방식: `wc -l jarca_ledger_mcp/service.py`, `db.py` 라인 수와 import 의존성 그래프를 확인하고 1차 목표를 facade 수준 축소로 검증 / 데이터 출처: 리팩터링 후 소스 파일, blueprint §11 DoD
- S-3 측정 방식: 각 도메인 application service/repository 존재 여부 및 baseline 문서 diff 확인 / 데이터 출처: `jarca_ledger_mcp/{application,infrastructure}/*`, `docs/project-structure.md`, `docs/runtime-flows.md`
# 8. REQ로 넘기기 전 확인 체크
REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다.
- [x] 범위(In Scope/Out of Scope) 확정
- 기준: 사용자 승인에 따라 Phase 0~3 우선 범위와 Out of Scope 항목이 문서에 반영되어 있으며, Phase 4+는 별도 TODO/후속 REQ로 추적한다.
- [x] 성공 지표의 측정 방식/데이터 출처 확정
- 기준: S-2의 1차 DoD를 service.py facade 수준 축소로 채택했고, 세부 baseline 테스트 목록은 REQ/Phase 0 planning에서 계측 대상으로 명시한다.
- [x] High 영향 리스크 우선순위 및 해소 책임자 지정
- 기준: R-1, R-2는 REQ/Batch planning 단계에서 characterization/contract 테스트 선행 책임자를 지정하는 비차단 항목으로 정리되었고, Discovery 범위·성공 기준·검증 시나리오 확정은 완료되었다.
- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
- 기준: OQ 목록의 항목이 모두 `Answered`이거나 `Deferred` 처리 완료
- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정
- 기준: 사용자 승인에 따라 저장소 소유자 `진환 고`가 Confirmed By로 기록되었다.
# 9. 파일 처리 결과
이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다.
- 처리 결과: 갱신
- 생성 경로: docs/discovery/dcy-010_20260702_backend-core-layering-mcp-adapter-realignment.md
- 참조 문서:
- .hermes/plans/2026-07-02_jarca-ledger-backend-refactor-blueprint.md (근거 blueprint)
- stage-pilot/templates/project-structure.md (baseline 갱신 논리 템플릿 참조)
- stage-pilot/templates/runtime-flows.md (baseline 갱신 논리 템플릿 참조)
# 10. 사용자 결정 필요 항목 요약
사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다.
## DECIDE
- 없음 — 이번 반복 범위는 blueprint 기준 Phase 0~3 우선으로 고정한다.
- 없음 — 1차 완료 기준은 service.py를 ~400~600줄 호환 facade 수준으로 축소하는 것으로 채택한다.
## CONFIRM
- 없음 — MCP tool 이름/주요 응답 필드/import 증빙 필드/Streamlit 페이지 동작은 이번 리팩터링 동안 호환 계약으로 동결한다.
## DATA
- REQ 단계 계측: Phase 0 baseline 고정용 현재 통과 pytest 스위트 목록과 커버 대상 MCP tool 집합은 REQ/Phase 0 planning 산출물에서 명시한다.
# 11. Discovery Freeze
REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다.
## 섹션 참조
- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록)
- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록)
- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준)
- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크)
- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약)
## Freeze 플래그
- Handoff Decision: 승인
- Handoff Rationale: 사용자 승인에 따라 1차 범위를 Phase 0~3으로 축소하고 1차 완료 기준을 service.py facade 수준 축소로 확정했다. 호환 계약 동결 정책도 반영되었고, 남은 baseline 테스트 상세 목록과 리스크 책임자 지정은 REQ/Phase 0 planning 단계 비차단 계측·할당 항목으로 정리되었다.
- Ready for REQ Drafting: true
- Confirmed By: 진환 고
- Confirmed At (KST): 2026-07-02 23:56 KST
+1
View File
@@ -15,3 +15,4 @@
| dcy-007_20260630_mcp-response-contract-ci-contract-versioning | 20260630 | MCP response contract / CI contract versioning 정립 | [Discovery](./dcy-007_20260630_mcp-response-contract-ci-contract-versioning.md) | confirmed | | dcy-007_20260630_mcp-response-contract-ci-contract-versioning | 20260630 | MCP response contract / CI contract versioning 정립 | [Discovery](./dcy-007_20260630_mcp-response-contract-ci-contract-versioning.md) | confirmed |
| dcy-008_20260630_asset-snapshot-manual-valuation-system | 20260630 | 자산 스냅샷/수기 자산 테이블/평가 체계 정립 | [Discovery](./dcy-008_20260630_asset-snapshot-manual-valuation-system.md) | confirmed | | dcy-008_20260630_asset-snapshot-manual-valuation-system | 20260630 | 자산 스냅샷/수기 자산 테이블/평가 체계 정립 | [Discovery](./dcy-008_20260630_asset-snapshot-manual-valuation-system.md) | confirmed |
| dcy-009_20260701_manual-asset-crud-month-end-snapshot-close-workflow | 20260701 | 수기 자산 CRUD + 월말 스냅샷 생성/마감 운영 워크플로우 | [Discovery](./dcy-009_20260701_manual-asset-crud-month-end-snapshot-close-workflow.md) | confirmed | | dcy-009_20260701_manual-asset-crud-month-end-snapshot-close-workflow | 20260701 | 수기 자산 CRUD + 월말 스냅샷 생성/마감 운영 워크플로우 | [Discovery](./dcy-009_20260701_manual-asset-crud-month-end-snapshot-close-workflow.md) | confirmed |
| dcy-010_20260702_backend-core-layering-mcp-adapter-realignment | 20260702 | Jarca Ledger 백엔드 코어 분리 및 layered 아키텍처 재편, MCP adapter 재정렬 | [Discovery](./dcy-010_20260702_backend-core-layering-mcp-adapter-realignment.md) | confirmed |
+75 -41
View File
@@ -4,21 +4,21 @@
- Baseline Seed: `.stage-pilot/bootstrap/baseline.yaml` - Baseline Seed: `.stage-pilot/bootstrap/baseline.yaml`
- Status: draft - Status: draft
- Owner: Jarca / project maintainer - Owner: Jarca / project maintainer
- Last Updated (KST): 2026-06-30 - Last Updated (KST): 2026-07-03
- Source Discovery / Batch: bootstrap-baseline, BAT-006, BAT-007 - Source Discovery / Batch: bootstrap-baseline, BAT-006, BAT-007, BAT-010
## Project Summary ## Project Summary
- Jarca 가계부 전용 MCP 서버와 Streamlit 대시보드로 계좌/카드 거래, 예산, 분류 규칙, import, 현금흐름 분석을 SQLite에 저장하고 운영한다. - Jarca 가계부 전용 MCP 서버와 Streamlit 대시보드로 계좌/카드 거래, 예산, 분류 규칙, import, 자산/스냅샷, 현금흐름 분석을 SQLite에 저장하고 운영한다.
## Primary Domain ## Primary Domain
- Korean household finance ledger and transaction reconciliation - Korean household finance ledger, reconciliation, and household asset tracking
## Tech Stack ## Tech Stack
- Python >=3.11 - Python >=3.11
- FastMCP / MCP server - FastMCP / MCP server
- SQLite - SQLite
- Streamlit dashboard - Streamlit
- pandas / plotly dashboard dependencies - pandas / plotly
- PyMuPDF, openpyxl, msoffcrypto-tool - PyMuPDF, openpyxl, msoffcrypto-tool
- pytest - pytest
- ruff - ruff
@@ -53,9 +53,28 @@ jarca-ledger-mcp/
│ └── app.py │ └── app.py
├── jarca_ledger_mcp/ ├── jarca_ledger_mcp/
│ ├── __init__.py │ ├── __init__.py
│ ├── db.py │ ├── service.py # compatibility facade
│ ├── service.py │ ├── db.py # compatibility shim
│ ├── server.py │ ├── server.py # compatibility shim / CLI entrypoint
│ ├── application/
│ │ ├── __init__.py
│ │ ├── shared.py
│ │ ├── system_services.py
│ │ ├── asset_account_services.py
│ │ ├── transaction_report_services.py
│ │ ├── import_transfer_services.py
│ │ └── categorization_services.py
│ ├── infrastructure/
│ │ └── sqlite/
│ │ ├── __init__.py
│ │ ├── connection.py
│ │ ├── schema.py
│ │ └── migrations.py
│ ├── adapters/
│ │ ├── mcp/
│ │ │ └── server.py
│ │ └── streamlit/
│ │ └── service_gateway.py
│ ├── kb_bank_pdf.py │ ├── kb_bank_pdf.py
│ ├── toss_bank_pdf.py │ ├── toss_bank_pdf.py
│ ├── toss_securities_pdf.py │ ├── toss_securities_pdf.py
@@ -64,7 +83,9 @@ jarca-ledger-mcp/
│ ├── contract_snapshots/ │ ├── contract_snapshots/
│ │ └── response_contract_v1_0.json │ │ └── response_contract_v1_0.json
│ ├── test_contract_regression.py │ ├── test_contract_regression.py
│ ├── test_dashboard_app.py
│ ├── test_service.py │ ├── test_service.py
│ ├── test_asset_snapshots.py
│ ├── test_categorization.py │ ├── test_categorization.py
│ ├── test_category_rule_filters.py │ ├── test_category_rule_filters.py
│ ├── test_cross_account_transfer_matching.py │ ├── test_cross_account_transfer_matching.py
@@ -93,60 +114,72 @@ jarca-ledger-mcp/
``` ```
## Package / Module Responsibilities ## Package / Module Responsibilities
- `jarca_ledger_mcp/db.py`
- SQLite connection, schema initialization, migrations, row conversion, and database path resolution.
- `jarca_ledger_mcp/service.py` - `jarca_ledger_mcp/service.py`
- Domain service layer for accounts, transactions, categories, category rules, imports, reports, asset liquidity/net-worth aggregation, transfer matching, and manual link operations. - Legacy-compatible top-level service facade. Public call sites continue to import from here while implementation lives in application modules.
- `jarca_ledger_mcp/db.py`
- Legacy-compatible top-level DB shim. Public call sites continue to import `init_db`, `db`, `resolve_db_path` from here.
- `jarca_ledger_mcp/server.py` - `jarca_ledger_mcp/server.py`
- Legacy-compatible MCP entrypoint shim that delegates to `adapters.mcp.server`.
- `jarca_ledger_mcp/application/shared.py`
- Shared helpers, validation, KST clock, dedupe policy, parser imports, and cross-domain constants.
- `jarca_ledger_mcp/application/system_services.py`
- Health/status/backup/account create-list/update and persistence-adjacent service behavior.
- `jarca_ledger_mcp/application/asset_account_services.py`
- Account mutation, manual asset, asset snapshot, liquidity/net-worth logic.
- `jarca_ledger_mcp/application/transaction_report_services.py`
- Transaction CRUD, budgets, monthly/category/fixed-cost/investable cashflow reports.
- `jarca_ledger_mcp/application/import_transfer_services.py`
- Import preview/import, self-transfer/cancel matching, cross-account transfer workflow.
- `jarca_ledger_mcp/application/categorization_services.py`
- Category rules, categorization preview/apply, uncategorized workbench, ledger categories.
- `jarca_ledger_mcp/infrastructure/sqlite/connection.py`
- DB path resolution, sqlite connection setup, context manager semantics.
- `jarca_ledger_mcp/infrastructure/sqlite/schema.py`
- Schema version, DDL, additive column definitions.
- `jarca_ledger_mcp/infrastructure/sqlite/migrations.py`
- Additive/idempotent migration routines and DB initialization.
- `jarca_ledger_mcp/adapters/mcp/server.py`
- FastMCP tool registration layer that exposes service functions to Hermes/MCP clients. - FastMCP tool registration layer that exposes service functions to Hermes/MCP clients.
- `jarca_ledger_mcp/kb_bank_pdf.py` - `jarca_ledger_mcp/adapters/streamlit/service_gateway.py`
- KB 국민은행 encrypted PDF transaction parsing. - Narrow dashboard-facing service surface used by `dashboard/app.py`.
- `jarca_ledger_mcp/toss_bank_pdf.py` - Parser modules (`kb_bank_pdf.py`, `toss_bank_pdf.py`, `toss_securities_pdf.py`, `seoulpay_xlsx.py`)
- Toss Bank transaction-history PDF parsing. - Source-file parsing and preview conversion only.
- `jarca_ledger_mcp/toss_securities_pdf.py`
- Toss Securities transaction-history PDF parsing and transaction preview conversion.
- `jarca_ledger_mcp/seoulpay_xlsx.py`
- SeoulPay encrypted XLSX parsing and preview conversion.
- `dashboard/app.py` - `dashboard/app.py`
- Streamlit operator UI for the operations hub summary, transaction filtering, uncategorized triage, rule management, review queues, categories, accounts/liquidity bucket editing, and import operations. - Streamlit operator UI for the operations hub summary, 자산/마감, 거래내역, 확인필요 큐, 미분류, 분류 규칙, 카테고리, 계좌, Import pages.
- `tests/` - `tests/`
- Regression tests for service behavior, parser behavior, categorization, category rules, cross-account transfer matching, ledger category semantics, MCP response contract compatibility, and Streamlit AppTest dashboard smoke. - Regression tests for facade compatibility, service behavior, parser behavior, categorization, transfer matching, MCP contract compatibility, and Streamlit AppTest smoke.
- `tests/contract_snapshots/`
- Representative MCP response contract metadata/schemas. Version `1.0` is used by `tests/test_contract_regression.py` and intentionally asserts required subset/types so optional additive fields remain compatible.
- `.github/workflows/ci.yml` - `.github/workflows/ci.yml`
- GitHub Actions workflow for `pull_request`, `push` to `main`, and `workflow_dispatch`; stable required gate jobs are `pytest-regression`, `dashboard-app-test`, `dashboard-http-smoke`, `db-safety-smoke`, `contract-regression`, and `lint`. - GitHub Actions workflow for `pull_request`, `push` to `main`, and `workflow_dispatch`; stable required gate jobs are `pytest-regression`, `dashboard-app-test`, `dashboard-http-smoke`, `db-safety-smoke`, `contract-regression`, and `lint`.
- `scripts/ci_fixture_db.py` - `scripts/ci_fixture_db.py`
- Synthetic fixture DB builder for CI/AppTest/smoke checks. Refuses the default operating DB path and seeds only synthetic ledger data. - Synthetic fixture DB builder for CI/AppTest/browser smoke.
- `scripts/ci_dashboard_http_smoke.py` - `scripts/ci_dashboard_http_smoke.py`
- CI HTTP smoke wrapper that creates a synthetic fixture DB, starts Streamlit on loopback, polls HTTP 200 readiness, and cleans up the process with status-only JSON output. - Synthetic-db Streamlit readiness smoke wrapper.
- `scripts/ci_db_safety_smoke.py` - `scripts/ci_db_safety_smoke.py`
- CI DB safety wrapper that runs restore smoke through temp synthetic source/backup/target DB paths only and emits status-only JSON. - Synthetic-db backup/restore safety smoke wrapper.
- `scripts/streamlit_browser_evidence.py` - `scripts/streamlit_browser_evidence.py`
- Local browser evidence helper that creates a synthetic fixture DB, starts Streamlit with that DB, and captures Chrome screenshot/DOM snapshot artifacts for QC. - Local browser evidence helper using synthetic/validation DBs only.
- `scripts/ledger_restore_smoke.py` - `scripts/ledger_restore_smoke.py`
- Script-only restore smoke helper for validation DBs; restore remains outside the MCP tool surface. - Script-only restore smoke helper; restore remains outside the MCP tool surface.
## Dependency Rules ## Dependency Rules
- `server.py` should remain a thin tool adapter and delegate business logic to `service.py`. - `adapters/mcp/server.py` should remain a thin tool adapter and delegate business logic to `service.py` facade/application modules.
- `dashboard/app.py` should call `adapters/streamlit/service_gateway.py`, not giant implementation modules directly.
- `service.py` / `db.py` / `server.py` shims preserve public import paths while new implementation lives under `application/`, `infrastructure/`, `adapters/`.
- Parser modules should parse source documents into structured records and avoid direct UI responsibilities. - Parser modules should parse source documents into structured records and avoid direct UI responsibilities.
- `dashboard/app.py` may call service functions but should not duplicate core classification/report semantics.
- DB schema migrations should be additive and idempotent by default. - DB schema migrations should be additive and idempotent by default.
## Shared Boundaries ## Shared Boundaries
- `cashflow_kind` is the boundary between actual household income/expense and account movement/exclusion semantics. - `cashflow_kind` is the boundary between actual household income/expense and account movement/exclusion semantics.
- `liquidity_bucket` is the account-level boundary for asset liquidity/composition/debt/net-worth views; `dashboard/app.py` displays it and `service.py` computes it from `accounts.last_balance`. - `liquidity_bucket` is the account/manual-asset boundary for liquidity/composition/debt/net-worth views.
- Import evidence fields (`source`, `external_id`, `raw_summary`, `raw_payload`, `counterparty`, `import_batch_id`) are audit boundaries and should be preserved. - Import evidence fields (`source`, `external_id`, `raw_summary`, `raw_payload`, `counterparty`, `import_batch_id`) are audit boundaries and should be preserved.
- Manual reconciliation is represented through memo tags and narrow transaction updates, not broad rewrites. - Manual reconciliation is represented through memo tags and narrow transaction updates, not broad rewrites.
- Browser evidence artifacts are generated from synthetic/validation DBs and should not embed real financial identifiers or raw source payloads. - Browser evidence artifacts are generated from synthetic/validation DBs and should not embed real financial identifiers or raw source payloads.
- CI gate fixtures are generated at runtime under temp space by `scripts/ci_fixture_db.py`; CI and AppTest must not read the default operating DB path. - CI gate fixtures are generated at runtime under temp space by `scripts/ci_fixture_db.py`; CI/AppTest/browser smoke must not read the default operating DB path.
- MCP contract regression fixtures use pytest `tmp_path` synthetic DBs and committed schema metadata only; they must not read the operating DB or expose runtime `contract_version` fields.
- Branch protection required-check contract is documented in `docs/interface-contract.md` and represented by stable workflow job names. Applying branch protection remains an external GitHub repository setting.
## Current Gaps / Planned Changes ## Current Gaps / Planned Changes
- Local browser evidence capture exists for core Streamlit pages; browser screenshot/visual regression remains local evidence rather than a required CI gate. - Application modules are now separated by phase-0~3 domain seams, but deeper repository/use-case split remains future Phase 4+ work.
- CI workflow now covers pytest/AppTest/HTTP smoke/DB safety smoke plus BAT-007 contract-regression/lint required-check jobs; branch protection enforcement remains a repository setting outside this codebase. - Streamlit gateway is intentionally narrow for current pages; further page/domain-specific gateways may follow in later phases.
- Long-term policy for accounts without downloadable statements remains partially operational/documentation work. - HTTP adapter and remote ops re-architecture remain out of scope.
- Separate automatic asset snapshots/manual asset tables are not present; BAT-005 uses account rows as the manual asset/liability source.
## Update Triggers ## Update Triggers
- Update this document when package boundaries change. - Update this document when package boundaries change.
@@ -157,5 +190,6 @@ jarca-ledger-mcp/
- 2026-06-26 22:24 KST: Created by `bootstrap-baseline` from repository observation. - 2026-06-26 22:24 KST: Created by `bootstrap-baseline` from repository observation.
- 2026-06-29 KST: REQ-004/BAT-004 기준으로 `scripts/streamlit_browser_evidence.py` 및 browser evidence artifact 구조 추가. - 2026-06-29 KST: REQ-004/BAT-004 기준으로 `scripts/streamlit_browser_evidence.py` 및 browser evidence artifact 구조 추가.
- 2026-06-29 KST: REQ-005/BAT-005 기준으로 dashboard operations hub와 service asset liquidity aggregation 책임 추가. - 2026-06-29 KST: REQ-005/BAT-005 기준으로 dashboard operations hub와 service asset liquidity aggregation 책임 추가.
- 2026-06-30 KST: REQ-006/BAT-006 기준으로 GitHub Actions CI workflow, synthetic fixture DB builder, AppTest/HTTP/DB safety smoke wrappers, CI-vs-local-browser evidence 경계 추가. - 2026-06-30 KST: REQ-006/BAT-006 기준으로 GitHub Actions CI workflow, synthetic fixture DB builder, AppTest/HTTP/DB safety smoke wrappers 추가.
- 2026-06-30 KST: REQ-007/REQ-008/BAT-007 기준으로 MCP response contract snapshot/test 위치와 `contract-regression`/`lint` required-check job 계약 추가. - 2026-06-30 KST: REQ-007/REQ-008/BAT-007 기준으로 MCP response contract snapshot/test 위치와 required-check job 계약 추가.
- 2026-07-03 KST: REQ-013/REQ-014/BAT-010 기준으로 application / infrastructure / adapter layering, compatibility facade/shim, Streamlit gateway 구조를 반영했다.
+53 -69
View File
@@ -4,19 +4,19 @@
- Baseline Seed: `.stage-pilot/bootstrap/baseline.yaml` - Baseline Seed: `.stage-pilot/bootstrap/baseline.yaml`
- Status: draft - Status: draft
- Owner: Jarca / project maintainer - Owner: Jarca / project maintainer
- Last Updated (KST): 2026-06-30 - Last Updated (KST): 2026-07-03
- Source Discovery / Batch: bootstrap-baseline, BAT-006, BAT-007, BAT-008 - Source Discovery / Batch: bootstrap-baseline, BAT-006, BAT-007, BAT-008, BAT-010
- Primary Runtime: mixed - Primary Runtime: mixed
## Project Summary ## Project Summary
- Jarca 가계부 전용 MCP 서버와 Streamlit 대시보드로 계좌/카드 거래, 예산, 분류 규칙, import, 현금흐름 분석을 SQLite에 저장하고 운영한다. - Jarca 가계부 전용 MCP 서버와 Streamlit 대시보드로 계좌/카드 거래, 예산, 분류 규칙, import, 현금흐름 분석, 자산/스냅샷 운영을 SQLite에 저장하고 제공한다.
## Purpose ## Purpose
- This document is the current execution-flow baseline for the project. - This document is the current execution-flow baseline for the project.
- It is a cross-cutting reference document for Discovery, REQ, Batch Design, Batch Verification, and implementation updates. - It is a cross-cutting reference document for Discovery, REQ, Batch Design, Batch Verification, and implementation updates.
## Scope ## Scope
- Covers representative command, MCP, dashboard, import, and reconciliation flows. - Covers representative command, MCP, dashboard, import, reconciliation, snapshot, and verification flows.
- Does not duplicate full acceptance criteria or low-level implementation details. - Does not duplicate full acceptance criteria or low-level implementation details.
## Covered Entry Points ## Covered Entry Points
@@ -27,56 +27,48 @@
## Shared Components ## Shared Components
- SQLite database at `~/.local/share/jarca/ledger.sqlite3` by default. - SQLite database at `~/.local/share/jarca/ledger.sqlite3` by default.
- `jarca_ledger_mcp.service` as the central domain/service layer. - `jarca_ledger_mcp/service.py` compatibility facade over application modules.
- `jarca_ledger_mcp/infrastructure/sqlite/*` for DB path resolution, schema, migration, and connection lifecycle.
- `jarca_ledger_mcp/adapters/mcp/server.py` for MCP registration.
- `jarca_ledger_mcp/adapters/streamlit/service_gateway.py` for dashboard-facing service calls.
- Parser modules for KB/Toss/SeoulPay sources. - Parser modules for KB/Toss/SeoulPay sources.
- Category and `cashflow_kind` semantics shared by reports and UI.
## Flow: `MCP server tool call` ## Flow: `MCP server tool call`
1. Hermes MCP client invokes a tool exposed by `jarca_ledger_mcp.server`. 1. Hermes MCP client invokes a tool exposed by `jarca_ledger_mcp.server` / `jarca_ledger_mcp.adapters.mcp.server`.
2. `server.py` forwards arguments to the matching `service.py` function. 2. MCP adapter forwards arguments to the matching `jarca_ledger_mcp.service` facade function.
3. `service.py` opens the SQLite database path from env/default, validates inputs, and performs the operation. 3. Service facade routes into the relevant application module.
4. Service returns a dictionary payload with success data or error information. 4. Application code opens SQLite through `infrastructure/sqlite` helpers, validates inputs, and performs the operation.
5. Hermes surfaces the tool result to the user or uses it in a follow-up workflow. 5. Application returns a dictionary payload with success data or error information.
6. Hermes surfaces the tool result to the user or uses it in a follow-up workflow.
## Flow: `Streamlit dashboard operation` ## Flow: `Streamlit dashboard operation`
1. Operator starts `streamlit run dashboard/app.py --server.address 0.0.0.0 --server.port 8501`. 1. Operator starts `streamlit run dashboard/app.py --server.address 0.0.0.0 --server.port 8501`.
2. Dashboard reads the configured/default SQLite database. 2. Dashboard reads the configured/default SQLite database.
3. User navigates to 요약, 거래내역, 확인필요 큐, 미분류, 분류 규칙, 카테고리, 계좌, or Import pages. 3. User navigates to 요약, 자산/마감, 거래내역, 확인필요 큐, 미분류, 분류 규칙, 카테고리, 계좌, or Import pages.
4. UI calls service functions for reads, updates, imports, categorization, and manual linking. 4. UI calls `jarca_ledger_mcp.adapters.streamlit.service_gateway` rather than giant implementation modules directly.
5. UI displays success/error status and refreshes visible tables. 5. Gateway delegates to the service facade/application modules and returns data to the page.
6. UI displays success/error status and refreshes visible tables.
## Flow: `Dashboard operations hub overview` ## Flow: `Dashboard operations hub overview`
1. Operator opens the `요약` page. 1. Operator opens the `요약` page.
2. Dashboard resolves the KST current month and calls `get_monthly_cashflow_report(month)` using `transactions.transaction_date` and `ledger_categories.cashflow_kind` semantics. 2. Dashboard calls `get_monthly_cashflow_report(month)` using `transactions.transaction_date` and `ledger_categories.cashflow_kind` semantics.
3. Dashboard calls `get_asset_liquidity_report`, which uses the shared valuation helper: account-backed assets from `accounts.last_balance` plus active `manual_assets` rows when present. 3. Dashboard calls `get_asset_liquidity_report`, which uses the shared valuation helper: account-backed assets from `accounts.last_balance` plus active `manual_assets` rows when present.
4. If `manual_assets` and `asset_snapshots` are empty, the report keeps the REQ-005 account-only fallback and preserves existing totals/keys. 4. If `manual_assets` and `asset_snapshots` are empty, the report keeps the REQ-005 account-only fallback and preserves existing totals/keys.
5. Dashboard renders sections in REQ-005 priority order: this-month actual income/expense/net/savings rate, asset liquidity, asset composition, budget/spend, operational warnings. 5. Dashboard renders sections in REQ-005 priority order: this-month actual income/expense/net/savings rate, asset liquidity, asset composition, budget/spend, operational warnings.
6. Missing `last_balance` or `manual_assets.valuation_amount` values are excluded from asset totals and surfaced as warnings; transaction sums are not used to estimate non-transaction assets. 6. Missing `last_balance` or `manual_assets.valuation_amount` values are excluded from asset totals and surfaced as warnings.
## Flow: `CI validation gates` ## Flow: `Asset snapshot and net-worth workflow`
1. GitHub Actions starts `.github/workflows/ci.yml` on `pull_request`, `push` to `main`, or `workflow_dispatch`. 1. Operator can open `자산/마감` in Streamlit or call snapshot tools through MCP.
2. `lint` installs dev dependencies and runs `ruff check .` with the conservative BAT-007 required rule set. 2. `preview_asset_snapshot_close(basis_date)` computes authority-source items from `accounts.last_balance` + active `manual_assets` without DB side effects.
3. `contract-regression` installs dev dependencies and runs `pytest tests/test_contract_regression.py`; the test uses synthetic pytest temp DBs and `tests/contract_snapshots/response_contract_v1_0.json` metadata for health/status, list, report, and mutation success envelope scopes. 3. `close_asset_snapshot(basis_date, source_note, memo, correction_reason)` writes append-only snapshot rows with correction metadata when re-closing the same basis date.
4. `pytest-regression` installs the project with dev/dashboard extras and runs `pytest --ignore=tests/test_dashboard_app.py` so service/parser/regression failures fail the workflow. 4. `get_asset_snapshot_report(basis_date)` first checks stored snapshot rows; if none exist, it falls back to live authority sources and marks `source='live_authority'`.
5. `dashboard-app-test` runs `pytest tests/test_dashboard_app.py`; the AppTest test creates a synthetic fixture DB through `scripts/ci_fixture_db.py`, sets `JARCA_LEDGER_DB_PATH`, and asserts core dashboard pages render without Streamlit exceptions. 5. `get_net_worth_timeseries(start_date, end_date, months)` reads stored snapshot basis dates and applies the same asset-buckets-minus-loan formula to every point.
6. `dashboard-http-smoke` runs `scripts/ci_dashboard_http_smoke.py`; the wrapper creates a temp fixture DB, starts Streamlit on loopback, polls for HTTP 200, then terminates the process.
7. `db-safety-smoke` runs `scripts/ci_db_safety_smoke.py`; the wrapper creates temp source/backup/target DB files and invokes `scripts/ledger_restore_smoke.py` without touching the operating DB path.
8. Any required job failure fails the workflow. Stable required-check job names are `pytest-regression`, `dashboard-app-test`, `dashboard-http-smoke`, `db-safety-smoke`, `contract-regression`, and `lint`; actual branch protection toggle enforcement is configured outside the repository.
9. CI logs are status-oriented and synthetic-data-only; they must not contain real account identifiers, raw payloads, statement filenames, balances from the operating DB, or passwords.
## Flow: `Asset snapshot and net-worth read model`
1. Month-end manual close records item-level rows in `asset_snapshots` from the live authority sources: account `last_balance` rows and `manual_assets` valuations. BAT-008 stores the schema and service helper but does not expose snapshot creation as an MCP mutation.
2. `get_asset_snapshot_report(basis_date)` first checks for snapshot rows on the requested basis date.
3. If snapshot rows exist, the report computes bucket totals, total assets, total liabilities, and net worth from the stored rows.
4. If no snapshot rows exist, the report falls back to live authority sources (`accounts.last_balance` + active `manual_assets`) and marks `source='live_authority'`.
5. `get_net_worth_timeseries(start_date, end_date, months)` reads distinct stored snapshot basis dates and applies the same asset-buckets-minus-loan formula to every point.
6. `list_manual_assets` exposes current manual asset/liability rows for read-only MCP consumers. Manual asset CRUD and snapshot creation MCP tools remain future scope.
## Flow: `Import preview and import` ## Flow: `Import preview and import`
1. Operator chooses or supplies a supported source file: KB Bank PDF, Toss Bank PDF, Toss Securities PDF, SeoulPay XLSX, or CSV. 1. Operator chooses a supported source file: KB Bank PDF, Toss Bank PDF, Toss Securities PDF, SeoulPay XLSX, or CSV.
2. Preview function parses the file and reports counts, sample rows, duplicate status, and classification preview. 2. Preview function parses the file and reports counts, sample rows, duplicate status, and classification preview.
3. On explicit import, service creates/fetches the target account, inserts non-duplicate transactions, writes raw provenance, and records an import batch. 3. On explicit import, application code creates/fetches the target account, inserts non-duplicate transactions, writes provenance, and records an import batch.
4. Reports and dashboard views read the inserted rows using category and cashflow semantics. 4. Reports and dashboard views read the inserted rows using shared category/cashflow semantics.
5. If an import is wrong, rollback uses import batch data and soft-deletes by default. 5. If an import is wrong, rollback uses import batch data and soft-deletes by default.
## Flow: `Categorization rule lifecycle` ## Flow: `Categorization rule lifecycle`
@@ -86,7 +78,7 @@
4. Category `cashflow_kind` controls actual cashflow/report inclusion. 4. Category `cashflow_kind` controls actual cashflow/report inclusion.
## Flow: `Cross-account and cancel/original reconciliation` ## Flow: `Cross-account and cancel/original reconciliation`
1. Preview functions search candidate pairs by amount, date, direction, account/source compatibility, and text similarity. 1. Preview helpers search candidate pairs by amount, date, direction, account/source compatibility, and text similarity.
2. Operator reviews candidates in the dashboard or runs the automatic matcher where confidence rules are satisfied. 2. Operator reviews candidates in the dashboard or runs the automatic matcher where confidence rules are satisfied.
3. Matched self-transfers become `이체 / 본인계좌이동`; investment movements become `이체 / 투자이동`; ambiguous rows remain `확인필요`. 3. Matched self-transfers become `이체 / 본인계좌이동`; investment movements become `이체 / 투자이동`; ambiguous rows remain `확인필요`.
4. Cancel/refund rows can be linked to original expense rows; memo tags preserve manual link evidence. 4. Cancel/refund rows can be linked to original expense rows; memo tags preserve manual link evidence.
@@ -97,26 +89,23 @@
3. Risky import, rollback, categorization apply, reconciliation, or migration work is tested first against a synthetic fixture DB or repo-external validation DB copy selected through `JARCA_LEDGER_DB_PATH`. 3. Risky import, rollback, categorization apply, reconciliation, or migration work is tested first against a synthetic fixture DB or repo-external validation DB copy selected through `JARCA_LEDGER_DB_PATH`.
4. Backup list/status/hash checks use `list_ledger_backups` and `get_ledger_backup_status`. 4. Backup list/status/hash checks use `list_ledger_backups` and `get_ledger_backup_status`.
5. Restore execution is not an MCP flow. Restore smoke uses `scripts/ledger_restore_smoke.py` or the runbook and requires explicit approval before overwriting an existing target DB. 5. Restore execution is not an MCP flow. Restore smoke uses `scripts/ledger_restore_smoke.py` or the runbook and requires explicit approval before overwriting an existing target DB.
6. Verification evidence must not include full transaction dumps, full merchant/counterparty dumps, account identifiers, raw_payload, original financial file contents, or passwords.
## Flow: `Statement-unavailable manual balance reconciliation` ## Flow: `CI validation gates`
1. Operator follows the dcy-002 safety standard first: capture DB status, create or confirm a recent backup, and run preview/reconcile on a validation DB before operating on the production DB. 1. GitHub Actions starts `.github/workflows/ci.yml` on `pull_request`, `push` to `main`, or `workflow_dispatch`.
2. Operator marks accounts with `statement_status`: `manual_only` for statement-unavailable accounts, `partial_import` for accounts that require manual supplementation, or `import_supported` where normal statement import is sufficient. 2. `lint` runs `ruff check .` with the conservative required rule set.
3. Operator records manual evidence transactions with `source='manual'`, positive amount, date, account, and non-sensitive memo/source context. 3. `contract-regression` runs `pytest tests/test_contract_regression.py` with synthetic pytest temp DBs and committed contract snapshots.
4. Operator runs `get_balance_reconciliation_report` from MCP/service or the dashboard account page. If `baseline_at` is `YYYY-MM`, the snapshot time is that month end at 23:59 KST; ad-hoc reconciliation uses the user-supplied snapshot timestamp. 4. `pytest-regression` runs the non-dashboard regression suite.
5. The report compares `baseline_balance`/`last_balance` with the latest `balance_after` if available, otherwise with net signed account transactions. Absolute tolerance is 1,000 KRW; 0.1% of baseline is reference-only. 5. `dashboard-app-test` runs `pytest tests/test_dashboard_app.py` using `scripts/ci_fixture_db.py` synthetic DB setup.
6. `OK` rows need no immediate triage. `REVIEW_NEEDED` rows present manual decision candidates: `계좌이동`, `실지출`, `실수입`, `투자이동`, `분석제외`, `확인필요`. 6. `dashboard-http-smoke` starts Streamlit against a synthetic DB and polls readiness.
7. This flow does not run automatic scheduling, notifications, restore, or auto-confirming mutation. 7. `db-safety-smoke` runs restore smoke through temp source/backup/target DB paths only.
8. Any required job failure fails the workflow. Stable required-check job names remain `pytest-regression`, `dashboard-app-test`, `dashboard-http-smoke`, `db-safety-smoke`, `contract-regression`, and `lint`.
## Flow: `Streamlit browser evidence capture` ## Flow: `Streamlit browser evidence capture`
1. Operator runs `scripts/streamlit_browser_evidence.py` from the repository root. 1. Operator runs `scripts/streamlit_browser_evidence.py` or starts Streamlit against a synthetic fixture DB.
2. The script creates a synthetic fixture DB under the evidence output directory and refuses to use the default operating DB path. 2. The script/flow refuses to use the default operating DB path for evidence capture.
3. Streamlit starts with `JARCA_LEDGER_DB_PATH=<fixture.sqlite3>` on a local loopback port. 3. Browser smoke opens key pages such as `요약`, `자산/마감`, `분류 규칙`, `확인필요 큐`, and `Import`.
4. `/usr/bin/google-chrome-stable` runs in headless mode through Chrome DevTools Protocol and opens target dashboard pages such as `분류 규칙`, `확인필요 큐`, and `Import`. 4. Verification records rendered headings/content and browser console JS error absence.
5. After page title text is rendered, the script stores PNG screenshots, DOM snapshots, and `manifest.json` in the evidence directory. 5. UI/dashboard-impacting batches must attach browser screenshot/DOM evidence or equivalent browser smoke notes.
6. UI/dashboard-impacting batches must attach browser screenshot/snapshot evidence. Non-UI batches may treat it as optional, and docs-only batches do not require browser evidence.
7. If Chrome/browser capture is unavailable, AppTest + HTTP 200 fallback evidence is allowed only as `PASS_WITH_NOTES` with the environment failure and remaining browser-evidence item recorded.
8. Browser screenshot/DOM evidence is not a required GitHub Actions gate in BAT-006. Run it locally for visual/layout-sensitive dashboard changes, while non-visual backend/CI changes can rely on pytest/AppTest/HTTP/DB safety gates.
## Flow Constraints ## Flow Constraints
- Preview before broad import or categorization mutation where supported. - Preview before broad import or categorization mutation where supported.
@@ -125,20 +114,14 @@
- Avoid destructive migrations without a backup/approval plan. - Avoid destructive migrations without a backup/approval plan.
- Before broad DB mutation, capture DB status evidence and create or confirm a recent backup. - Before broad DB mutation, capture DB status evidence and create or confirm a recent backup.
- Restore execution is script/runbook only and must not be exposed as an MCP tool. - Restore execution is script/runbook only and must not be exposed as an MCP tool.
- Manual balance reconciliation must remain preview/report-first unless a future REQ approves persistent review routing and confirmation mutation. - Streamlit browser evidence must use synthetic/validation DBs only and must not screenshot/log real account numbers, source financial filenames, passwords, full merchant/counterparty dumps, or raw payloads.
- Streamlit browser evidence must use synthetic/validation DBs only and must not screenshot/log real account numbers, balances, source financial filenames, passwords, full merchant/counterparty dumps, or raw payloads. - CI gates must use synthetic/temp DBs only. The default operating DB path is forbidden for AppTest, HTTP smoke, and DB safety smoke wrappers.
- CI gates must use synthetic/temp DBs only. The default operating DB path (`~/.local/share/jarca/ledger.sqlite3`) is forbidden for AppTest, HTTP smoke, and DB safety smoke wrappers. - Top-level `service.py`, `db.py`, and `server.py` import paths remain compatibility surfaces until a future approved REQ retires them.
- Contract regression must use synthetic/temp DBs only and must not expose runtime contract-version fields in MCP success payloads unless a future approved REQ changes the policy.
- Branch protection required-check drift should be audited against the six stable workflow job names documented in `docs/interface-contract.md`; changing a job name requires an explicit REQ/BAT note because it can break external repository settings.
- Dashboard and MCP asset valuation reports must use the shared service valuation policy: `accounts.last_balance` plus `manual_assets`, asset buckets minus loan, and missing valuation exclusion.
- Snapshot read tools are read-only in BAT-008. Snapshot creation and manual asset CRUD MCP mutation tools require a future REQ.
- Sensitive passwords are runtime inputs and must not be committed.
## Current Gaps / Planned Changes ## Current Gaps / Planned Changes
- Browser-level evidence is available locally through `scripts/streamlit_browser_evidence.py`; it remains outside required CI gates by design. - Browser-level evidence exists locally and remains outside required CI gates by design.
- Contract-regression and lint gates are now part of the required-check contract for CI; full MCP tool-surface snapshot coverage remains deferred beyond the representative health/status/list/report/mutation scope. - Phase 4+ write/import/advanced workflow service thinning and narrower adapters are deferred to GitHub Project TODO items.
- Dedicated reconciliation workbench and persistent review-decision queue are deferred beyond REQ-003. - HTTP adapter and remote ops re-architecture remain deferred.
- Separate asset snapshot/manual asset tables and automatic market/real-estate valuation are deferred beyond REQ-005.
## Update Triggers ## Update Triggers
- Update this document when a command entry point changes. - Update this document when a command entry point changes.
@@ -151,5 +134,6 @@
- 2026-06-29 KST: REQ-003/BAT-003 기준으로 statement-unavailable 수동 잔액대조 flow 추가. - 2026-06-29 KST: REQ-003/BAT-003 기준으로 statement-unavailable 수동 잔액대조 flow 추가.
- 2026-06-29 KST: REQ-004/BAT-004 기준으로 Streamlit browser evidence capture flow 및 UI evidence gate 기준 추가. - 2026-06-29 KST: REQ-004/BAT-004 기준으로 Streamlit browser evidence capture flow 및 UI evidence gate 기준 추가.
- 2026-06-29 KST: REQ-005/BAT-005 기준으로 dashboard operations hub 및 `liquidity_bucket`/`last_balance` 자산 유동성 flow 추가. - 2026-06-29 KST: REQ-005/BAT-005 기준으로 dashboard operations hub 및 `liquidity_bucket`/`last_balance` 자산 유동성 flow 추가.
- 2026-06-30 KST: REQ-006/BAT-006 기준으로 GitHub Actions CI validation gates, synthetic fixture DB boundary, AppTest/HTTP/DB safety smoke flow, local browser evidence boundary 추가. - 2026-06-30 KST: REQ-006/BAT-006 기준으로 GitHub Actions CI validation gates, synthetic fixture DB boundary, AppTest/HTTP/DB safety smoke flow 추가.
- 2026-06-30 KST: REQ-007/REQ-008/BAT-007 기준으로 `contract-regression`/`lint` CI required-check flow, contract snapshot scope, branch protection drift boundary 추가. - 2026-06-30 KST: REQ-007/REQ-008/BAT-007 기준으로 contract-regression/lint CI required-check flow 추가.
- 2026-07-03 KST: REQ-013/REQ-014/BAT-010 기준으로 service facade → application, db shim → infrastructure/sqlite, MCP/Streamlit adapter seam flow를 반영했다.
@@ -0,0 +1,84 @@
# REQ-014 MCP/Streamlit adapter seam realignment and contract-preserving gateway
- Status: Implemented
- Type: Interface
- Priority: High
- Owner: 렛저장인
- Source Discovery: `docs/discovery/dcy-010_20260702_backend-core-layering-mcp-adapter-realignment.md`
- Batch: BAT-010
- Created At (KST): 2026-07-03 00:15 KST
- Approved At (KST): 2026-07-03 00:15 KST
- Implemented At (KST): 2026-07-03 00:18 KST
## 1. 배경
`dcy-010`은 MCP server와 Streamlit dashboard가 giant service namespace에 직접 결합돼 있어 내부 구조 개선이 곧 external contract drift로 이어질 위험이 크다고 판단했다. Phase 0~3에서는 adapter를 얇게 만들되, tool names/response envelope/dashboard 핵심 페이지를 유지하는 것이 핵심이다.
## 2. Intent
이 REQ의 목적은 MCP와 Streamlit을 제거하는 것이 아니라, backend core 위에 얹힌 thin adapter로 재정렬하는 것이다. 즉 `server.py`와 `dashboard/app.py`가 더 좁은 seam을 통해 application core를 호출하면서도 기존 사용자의 운영 경험과 계약은 유지해야 한다.
## 3. 범위
### In Scope
- `jarca_ledger_mcp/adapters/mcp/server.py`를 도입하고 top-level `server.py`를 compatibility shim으로 유지한다.
- `jarca_ledger_mcp/adapters/streamlit/service_gateway.py`를 도입하고 dashboard가 이 gateway를 통해 service functions를 호출하도록 정렬한다.
- MCP tool discovery 결과(56 tools)와 success envelope contract를 유지한다.
- Streamlit 핵심 페이지(`요약`, `자산/마감` 포함)가 synthetic fixture DB에서 렌더링돼야 한다.
- baseline docs와 batch verification evidence에 adapter seam을 반영한다.
### Out of Scope
- 신규 MCP 도구 추가나 응답 schema 변경.
- 새로운 Streamlit IA/디자인 재구성.
- HTTP adapter/REST route 추가.
## 4. Requirement
### FR-1. MCP adapter thinness
- 실제 FastMCP registration은 `jarca_ledger_mcp/adapters/mcp/server.py`에 위치해야 한다.
- top-level `jarca_ledger_mcp/server.py`는 CLI/script entrypoint 호환을 위한 shim이어야 한다.
- MCP 도구명/설명/인자 contract는 유지돼야 한다.
### FR-2. Streamlit gateway seam
- `dashboard/app.py`는 giant service implementation이 아니라 명시적 gateway module을 통해 service functions를 사용해야 한다.
- gateway는 dashboard가 실제로 필요한 surface만 노출해야 한다.
- dashboard 렌더 결과는 synthetic fixture DB 기준 기존 핵심 페이지를 깨지 않아야 한다.
### FR-3. Contract-preserving verification
- full pytest regression과 MCP tool discovery가 PASS 해야 한다.
- browser-level smoke는 synthetic fixture DB에서 `요약`과 구조 변경 영향 페이지(`자산/마감`)를 렌더링해야 한다.
- 브라우저 콘솔 JS 에러가 없어야 한다.
### FR-4. Documentation alignment
- `docs/project-structure.md`와 `docs/runtime-flows.md`는 adapter/gateway seam과 compatibility shim 구조를 반영해야 한다.
- BAT verification 문서는 synthetic-only evidence와 잔여 Phase 4+ TODO를 기록해야 한다.
## 5. Acceptance Criteria
- AC-1: `jarca_ledger_mcp/adapters/mcp/server.py`와 top-level `server.py` shim이 존재하고 `hermes mcp test jarca-ledger`가 PASS 한다.
- AC-2: `jarca_ledger_mcp/adapters/streamlit/service_gateway.py`가 도입되고 `dashboard/app.py`가 이를 사용한다.
- AC-3: full pytest가 PASS 하고 Streamlit AppTest smoke가 계속 PASS 한다.
- AC-4: local browser smoke에서 `요약`과 `자산/마감` 페이지가 렌더링되고 console JS errors가 없다.
- AC-5: baseline docs와 batch evidence 문서가 새 adapter seam을 반영한다.
## 6. Impacted Area
- `jarca_ledger_mcp/server.py`
- `jarca_ledger_mcp/adapters/mcp/server.py`
- `jarca_ledger_mcp/adapters/streamlit/service_gateway.py`
- `dashboard/app.py`
- `tests/test_dashboard_app.py`
- `tests/test_contract_regression.py`
- `docs/project-structure.md`
- `docs/runtime-flows.md`
## 7. Notes
- 이 REQ는 MCP/Streamlit surface와 operator-facing 호출 경계를 다루므로 `Interface` 타입으로 관리한다.
- gateway는 현재 dashboard가 사용하는 surface만 노출하며, Phase 4+에서 더 좁은 domain-specific gateway로 재분할할 수 있다.
## 8. Change Log
- 2026-07-03 00:15 KST: dcy-010 confirmed 범위를 바탕으로 Proposed 초안을 생성.
- 2026-07-03 00:15 KST: 사용자 승인 방향(계약 동결, adapter thinness, Phase 0~3 우선)에 따라 Approved로 전환.
- 2026-07-03 00:18 KST: MCP adapter split, Streamlit gateway 도입, browser smoke, MCP discovery, full pytest PASS를 근거로 Implemented로 전환.
@@ -0,0 +1,87 @@
# REQ-013 Backend core layering / SQLite infrastructure split and service facade compatibility
- Status: Implemented
- Type: Non-Functional
- Priority: High
- Owner: 렛저장인
- Source Discovery: `docs/discovery/dcy-010_20260702_backend-core-layering-mcp-adapter-realignment.md`
- Batch: BAT-010
- Created At (KST): 2026-07-03 00:15 KST
- Approved At (KST): 2026-07-03 00:15 KST
- Implemented At (KST): 2026-07-03 00:18 KST
## 1. 배경
`dcy-010` confirmed Discovery는 `jarca_ledger_mcp/service.py`와 `jarca_ledger_mcp/db.py`에 과도하게 집중된 책임 때문에 MCP adapter, Streamlit dashboard, SQLite persistence가 하나의 giant module 집합에 강결합돼 있다고 판단했다. Phase 0~3 우선 범위에서는 기능 계약을 바꾸지 않고도 backend core의 최소 layering을 세워 이후 write/import/advanced workflow 분리를 안전하게 이어갈 수 있어야 한다.
## 2. Intent
이 REQ의 목적은 서비스/DB 코드를 한 번에 다시 쓰는 것이 아니라, 기존 동작을 유지한 채 `application` / `infrastructure/sqlite` / compatibility facade 경계를 도입해 코드 탐색성, 변경 안전성, 후속 분리 가능성을 확보하는 것이다.
## 3. 범위
### In Scope
- `jarca_ledger_mcp/service.py`를 compatibility facade로 축소하고 실제 구현을 application 모듈로 분리한다.
- 공통 helper/정책을 `jarca_ledger_mcp/application/shared.py`로 정리한다.
- SQLite connection/schema/migration 책임을 `jarca_ledger_mcp/infrastructure/sqlite/`로 이동한다.
- 기존 `jarca_ledger_mcp/db.py` import surface는 compatibility shim으로 유지한다.
- additive/idempotent migration discipline과 기존 테스트 contract를 유지한다.
- `docs/project-structure.md`, `docs/runtime-flows.md`에 새 경계를 반영한다.
### Out of Scope
- FastAPI/HTTP adapter 추가.
- write/import/advanced workflow의 본격적인 Phase 4+ domain 분리.
- 데이터 모델 semantics 변경, 응답 envelope 변경, destructive migration.
## 4. Requirement
### FR-1. Application service decomposition
- 기존 `service.py`의 기능 구현은 최소한 `system`, `asset/account`, `transaction/report`, `import/transfer`, `categorization` 성격의 application 모듈로 분리돼야 한다.
- shared helper와 공통 정책(`cashflow_kind`, KST clock, dedupe, validation helpers)은 application shared module로 이동해야 한다.
- 분리 후에도 기존 `from jarca_ledger_mcp import service` 사용자는 동일 함수명을 계속 사용할 수 있어야 한다.
### FR-2. SQLite infrastructure split
- 기존 `db.py`의 connection/schema/migration 책임은 `jarca_ledger_mcp/infrastructure/sqlite/`로 이동해야 한다.
- top-level `db.py`는 기존 import 경로를 유지하는 compatibility shim이어야 한다.
- `db()` context manager는 기존처럼 초기화/commit/rollback semantics를 유지해야 한다.
- 스키마 migration은 additive/idempotent 원칙을 유지해야 한다.
### FR-3. Service facade compatibility
- `jarca_ledger_mcp/service.py`는 thin facade여야 하며 구현 디테일 대신 application 모듈을 재노출해야 한다.
- 기존 pytest/service call sites와 monkeypatch 관성(`_current_kst_datetime`)은 깨지지 않아야 한다.
- facade 축소 결과는 더 이상 giant implementation file이 아니어야 한다.
### FR-4. Baseline documentation alignment
- `docs/project-structure.md`는 새 package tree와 책임 경계를 반영해야 한다.
- `docs/runtime-flows.md`는 service facade, Streamlit gateway, MCP adapter, SQLite infrastructure flow를 반영해야 한다.
- 문서는 Phase 4+가 GitHub Project TODO로 남아 있음을 드러내야 한다.
## 5. Acceptance Criteria
- AC-1: `jarca_ledger_mcp/application/` 아래에 phase-0~3 범위의 application service modules와 shared module이 생성된다.
- AC-2: `jarca_ledger_mcp/infrastructure/sqlite/` 아래에 connection/schema/migration 분리가 생성되고 top-level `db.py` shim이 동작한다.
- AC-3: `jarca_ledger_mcp/service.py`는 compatibility facade로 축소되고 600줄 이하를 유지한다.
- AC-4: 전체 pytest regression이 PASS 하며 기존 service/db import surface가 유지된다.
- AC-5: `docs/project-structure.md`, `docs/runtime-flows.md`가 새 계층 구조를 반영한다.
## 6. Impacted Area
- `jarca_ledger_mcp/service.py`
- `jarca_ledger_mcp/db.py`
- `jarca_ledger_mcp/application/*`
- `jarca_ledger_mcp/infrastructure/sqlite/*`
- `tests/test_service.py`
- `tests/test_asset_snapshots.py`
- `docs/project-structure.md`
- `docs/runtime-flows.md`
## 7. Notes
- 이 REQ는 주로 maintainability/architecture boundary와 compatibility behavior를 다루므로 `Non-Functional`로 관리한다.
- write/import/advanced workflow의 deeper split은 dcy-010에서 별도 Phase 4+ TODO로 남겼다.
## 8. Change Log
- 2026-07-03 00:15 KST: dcy-010 confirmed 범위를 바탕으로 Proposed 초안을 생성.
- 2026-07-03 00:15 KST: 사용자 승인 방향(Phase 0~3 우선, facade 축소, 계약 동결)에 따라 Approved로 전환.
- 2026-07-03 00:18 KST: application/infrastructure split, facade/shim compatibility, full pytest PASS, MCP tool discovery, browser smoke evidence를 근거로 Implemented로 전환.
+6 -2
View File
@@ -28,12 +28,12 @@
| **Integration** | 외부 시스템 연동, 플러그인, 확장 | KB/토스/서울페이 import | | **Integration** | 외부 시스템 연동, 플러그인, 확장 | KB/토스/서울페이 import |
| **Interface** | 사용자 상호작용 방식 | MCP tool, Streamlit UI | | **Interface** | 사용자 상호작용 방식 | MCP tool, Streamlit UI |
| **Migration** | 데이터 이전, 버전 업그레이드 | SQLite schema migration | | **Migration** | 데이터 이전, 버전 업그레이드 | SQLite schema migration |
| **Non-Functional** | 성능, 보안, 확장성, 호환성, 신뢰성 | 개인정보/금융정보 보호, UX 성능 | | **Non-Functional** | 성능, 보안, 확장성, 호환성, 신뢰성 | 개인정보/금융정보 보호, UX 성능, 아키텍처 유지보수성 |
| **Testing** | 테스트 전략, QA 요구사항, 검증 방식 | parser regression, report validation | | **Testing** | 테스트 전략, QA 요구사항, 검증 방식 | parser regression, report validation |
## Next Requirement ID ## Next Requirement ID
- Current: `REQ-013` - Current: `REQ-015`
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
## Requirement Register ## Requirement Register
@@ -52,6 +52,8 @@
| REQ-010 | Asset snapshot/net worth read MCP and dashboard alignment | Interface | Implemented | High | 렛저장인 | [req-010_asset-snapshot-net-worth-read-mcp-and-dashboard-alignment.md](Interface/req-010_asset-snapshot-net-worth-read-mcp-and-dashboard-alignment.md) | | REQ-010 | Asset snapshot/net worth read MCP and dashboard alignment | Interface | Implemented | High | 렛저장인 | [req-010_asset-snapshot-net-worth-read-mcp-and-dashboard-alignment.md](Interface/req-010_asset-snapshot-net-worth-read-mcp-and-dashboard-alignment.md) |
| REQ-011 | Manual asset CRUD / month-end snapshot write model | Data | Implemented | High | 렛저장인 | [req-011_manual-asset-crud-month-end-snapshot-write-model.md](Data/req-011_manual-asset-crud-month-end-snapshot-write-model.md) | | REQ-011 | Manual asset CRUD / month-end snapshot write model | Data | Implemented | High | 렛저장인 | [req-011_manual-asset-crud-month-end-snapshot-write-model.md](Data/req-011_manual-asset-crud-month-end-snapshot-write-model.md) |
| REQ-012 | Manual asset CRUD / snapshot close MCP and operator dashboard alignment | Interface | Implemented | High | 렛저장인 | [req-012_manual-asset-crud-snapshot-close-mcp-and-operator-dashboard-alignment.md](Interface/req-012_manual-asset-crud-snapshot-close-mcp-and-operator-dashboard-alignment.md) | | REQ-012 | Manual asset CRUD / snapshot close MCP and operator dashboard alignment | Interface | Implemented | High | 렛저장인 | [req-012_manual-asset-crud-snapshot-close-mcp-and-operator-dashboard-alignment.md](Interface/req-012_manual-asset-crud-snapshot-close-mcp-and-operator-dashboard-alignment.md) |
| REQ-013 | Backend core layering / SQLite infrastructure split and service facade compatibility | Non-Functional | Implemented | High | 렛저장인 | [req-013_backend-core-layering-sqlite-infrastructure-service-facade-compatibility.md](Non-Functional/req-013_backend-core-layering-sqlite-infrastructure-service-facade-compatibility.md) |
| REQ-014 | MCP/Streamlit adapter seam realignment and contract-preserving gateway | Interface | Implemented | High | 렛저장인 | [req-014_mcp-streamlit-adapter-seam-realignment-contract-preserving-gateway.md](Interface/req-014_mcp-streamlit-adapter-seam-realignment-contract-preserving-gateway.md) |
## Recent Change Log Summary ## Recent Change Log Summary
@@ -76,3 +78,5 @@
- 2026-07-01 09:00 KST: dcy-009 confirmed 범위를 `REQ-011`(manual asset CRUD / month-end snapshot write model)과 `REQ-012`(manual asset CRUD / snapshot close MCP and operator dashboard alignment) Proposed 상태로 생성했다. - 2026-07-01 09:00 KST: dcy-009 confirmed 범위를 `REQ-011`(manual asset CRUD / month-end snapshot write model)과 `REQ-012`(manual asset CRUD / snapshot close MCP and operator dashboard alignment) Proposed 상태로 생성했다.
- 2026-07-01 09:02 KST: `REQ-011`, `REQ-012`를 승인 게이트 재검토 후 Approved로 전환했다. - 2026-07-01 09:02 KST: `REQ-011`, `REQ-012`를 승인 게이트 재검토 후 Approved로 전환했다.
- 2026-07-01 10:52 KST: BAT-009 구현·검증 evidence, full pytest PASS, MCP tool discovery, browser smoke를 근거로 `REQ-011`, `REQ-012`를 Implemented로 전환했다. - 2026-07-01 10:52 KST: BAT-009 구현·검증 evidence, full pytest PASS, MCP tool discovery, browser smoke를 근거로 `REQ-011`, `REQ-012`를 Implemented로 전환했다.
- 2026-07-03 00:15 KST: dcy-010 confirmed 범위를 `REQ-013`, `REQ-014`로 생성하고 승인 상태를 정리했다.
- 2026-07-03 00:18 KST: BAT-010 구현·검증 evidence, full pytest PASS, MCP tool discovery, browser smoke를 근거로 `REQ-013`, `REQ-014`를 Implemented로 전환했다.
@@ -0,0 +1 @@
from .server import main, mcp
+296
View File
@@ -0,0 +1,296 @@
from __future__ import annotations
from pathlib import Path
from dotenv import load_dotenv
from mcp.server.fastmcp import FastMCP
from ... import service
load_dotenv(Path(__file__).resolve().parents[3] / ".env")
mcp = FastMCP("jarca-ledger-mcp")
@mcp.tool()
def ledger_health_check() -> dict:
"""Verify the Jarca Ledger MCP server and SQLite database are reachable."""
return service.health_check()
@mcp.tool()
def get_ledger_db_status(include_hash: bool = True, include_integrity: bool = True) -> dict:
"""Return non-sensitive DB status evidence: path, size, mtime, checksum, table row counts, and aggregate totals."""
return service.get_ledger_db_status(include_hash=include_hash, include_integrity=include_integrity)
@mcp.tool()
def create_ledger_backup(backup_dir: str = "", retention_days: int = 30, retention_count: int = 20) -> dict:
"""Create a timestamped ledger DB backup. Restore is intentionally not exposed as an MCP tool."""
return service.create_ledger_backup(backup_dir=backup_dir or None, retention_days=retention_days, retention_count=retention_count)
@mcp.tool()
def list_ledger_backups(backup_dir: str = "", include_hash: bool = False, limit: int = 50) -> dict:
"""List ledger DB backups with metadata-only evidence."""
return service.list_ledger_backups(backup_dir=backup_dir or None, include_hash=include_hash, limit=limit)
@mcp.tool()
def get_ledger_backup_status(file_path: str, include_hash: bool = True, include_integrity: bool = True) -> dict:
"""Return metadata-only status and integrity evidence for one backup file."""
return service.get_ledger_backup_status(file_path, include_hash=include_hash, include_integrity=include_integrity)
@mcp.tool()
def create_ledger_account(name: str, type: str = "bank", institution: str = "", currency: str = "KRW", active: bool = True, memo: str = "", account_number_masked: str = "", holder_name: str = "", last_balance: int | None = None, last_synced_at: str = "", statement_status: str = "unknown", liquidity_bucket: str = "other") -> dict:
"""Create an account/card/cash/investment/loan ledger account."""
return service.create_ledger_account(name, type, institution, currency, active, memo, account_number_masked, holder_name, last_balance, last_synced_at, statement_status, liquidity_bucket)
@mcp.tool()
def list_ledger_accounts(active_only: bool = True) -> dict:
"""List ledger accounts and cards."""
return service.list_ledger_accounts(active_only)
@mcp.tool()
def update_ledger_account(account_id: str, name: str | None = None, type: str | None = None, institution: str | None = None, currency: str | None = None, active: bool | None = None, memo: str | None = None, account_number_masked: str | None = None, holder_name: str | None = None, last_balance: int | None = None, last_synced_at: str | None = None, statement_status: str | None = None, liquidity_bucket: str | None = None) -> dict:
"""Update selected account fields, including statement_status/liquidity_bucket for manual reconciliation."""
return service.update_ledger_account(account_id, name=name, type=type, institution=institution, currency=currency, active=active, memo=memo, account_number_masked=account_number_masked, holder_name=holder_name, last_balance=last_balance, last_synced_at=last_synced_at, statement_status=statement_status, liquidity_bucket=liquidity_bucket)
@mcp.tool()
def get_asset_liquidity_report() -> dict:
"""Return asset liquidity, debt, and net worth from shared account/manual valuation sources."""
return service.get_asset_liquidity_report()
@mcp.tool()
def list_manual_assets(active_only: bool = True) -> dict:
"""List manual non-transaction assets/liabilities used for net-worth valuation."""
return service.list_manual_assets(active_only=active_only)
@mcp.tool()
def create_manual_asset(name: str, valuation_amount: int, valuation_date: str, liquidity_bucket: str, source_note: str, memo: str = "", currency: str = "KRW", active: bool = True) -> dict:
"""Create a manual non-transaction asset/liability valuation row."""
return service.create_manual_asset(name, valuation_amount, valuation_date, liquidity_bucket, source_note=source_note, memo=memo, currency=currency, active=active)
@mcp.tool()
def update_manual_asset(asset_id: str, name: str | None = None, valuation_amount: int | None = None, valuation_date: str | None = None, liquidity_bucket: str | None = None, source_note: str | None = None, memo: str | None = None, currency: str | None = None, active: bool | None = None) -> dict:
"""Update selected fields on a manual asset/liability valuation row."""
return service.update_manual_asset(asset_id, name=name, valuation_amount=valuation_amount, valuation_date=valuation_date, liquidity_bucket=liquidity_bucket, source_note=source_note, memo=memo, currency=currency, active=active)
@mcp.tool()
def deactivate_manual_asset(asset_id: str, hard_delete: bool = False) -> dict:
"""Deactivate a manual asset/liability by default; hard delete only for exceptional cleanup."""
return service.deactivate_manual_asset(asset_id, hard_delete=hard_delete)
@mcp.tool()
def preview_asset_snapshot_close(basis_date: str = "") -> dict:
"""Preview a month-end asset snapshot close without mutating the database."""
return service.preview_asset_snapshot_close(basis_date=basis_date)
@mcp.tool()
def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "") -> dict:
"""Close an asset snapshot for a basis date; same-date re-close appends correction rows."""
return service.close_asset_snapshot(basis_date=basis_date, source_note=source_note, memo=memo, correction_reason=correction_reason)
@mcp.tool()
def get_asset_snapshot_report(basis_date: str = "") -> dict:
"""Return basis-date asset snapshot/net-worth report with live-authority fallback."""
return service.get_asset_snapshot_report(basis_date=basis_date)
@mcp.tool()
def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: int = 12) -> dict:
"""Return net-worth timeseries from stored asset snapshot rows."""
return service.get_net_worth_timeseries(start_date=start_date, end_date=end_date, months=months)
@mcp.tool()
def get_balance_reconciliation_report(account_id: str = "", baseline_balance: int | None = None, baseline_at: str = "", source_note: str = "", memo: str = "", tolerance: int = 1000) -> dict:
"""Preview balance reconciliation for manual_only/partial_import accounts without mutating the DB."""
return service.get_balance_reconciliation_report(account_id, baseline_balance, baseline_at, source_note, memo, tolerance)
@mcp.tool()
def create_ledger_transaction(transaction_date: str = "", type: str = "expense", amount: int = 0, category: str = "", subcategory: str = "", merchant: str = "", description: str = "", account_id: str = "", payment_method: str = "", person: str = "", project: str = "", tags: list[str] | str | None = None, is_fixed: bool = False, currency: str = "KRW", posted_date: str = "", source: str = "manual", external_id: str = "", memo: str = "", dedupe: bool = True) -> dict:
"""Create an income, expense, transfer, refund, or adjustment transaction. Amount is positive KRW minor unit; type determines direction."""
return service.create_ledger_transaction(transaction_date, type, amount, category, subcategory, merchant, description, account_id, payment_method, person, project, tags, is_fixed, currency, posted_date, source, external_id, memo, dedupe)
@mcp.tool()
def list_ledger_transactions(start_date: str = "", end_date: str = "", type: str = "", category: str = "", person: str = "", account_id: str = "", include_deleted: bool = False, limit: int = 100) -> dict:
"""List ledger transactions with date/category/person/account filters."""
return service.list_ledger_transactions(start_date, end_date, type, category, person, account_id, include_deleted, limit)
@mcp.tool()
def update_ledger_transaction(transaction_id: str, transaction_date: str | None = None, type: str | None = None, amount: int | None = None, category: str | None = None, subcategory: str | None = None, merchant: str | None = None, description: str | None = None, account_id: str | None = None, payment_method: str | None = None, person: str | None = None, project: str | None = None, tags: list[str] | str | None = None, is_fixed: bool | None = None, memo: str | None = None, status: str | None = None) -> dict:
"""Update selected fields on a ledger transaction."""
return service.update_ledger_transaction(transaction_id, transaction_date=transaction_date, type=type, amount=amount, category=category, subcategory=subcategory, merchant=merchant, description=description, account_id=account_id, payment_method=payment_method, person=person, project=project, tags=tags, is_fixed=is_fixed, memo=memo, status=status)
@mcp.tool()
def delete_ledger_transaction(transaction_id: str, hard_delete: bool = False) -> dict:
"""Soft-delete a ledger transaction by default, or permanently delete with hard_delete=true."""
return service.delete_ledger_transaction(transaction_id, hard_delete)
@mcp.tool()
def create_ledger_budget(month: str, category: str, amount: int, subcategory: str = "", person: str = "", currency: str = "KRW", memo: str = "") -> dict:
"""Create or update a monthly category/person budget."""
return service.create_ledger_budget(month, category, amount, subcategory, person, currency, memo)
@mcp.tool()
def list_ledger_budgets(month: str = "") -> dict:
"""List ledger budgets, optionally for a month YYYY-MM."""
return service.list_ledger_budgets(month)
@mcp.tool()
def get_monthly_cashflow_report(month: str = "") -> dict:
"""Return income, expenses, net cashflow, and savings rate for a month."""
return service.get_monthly_cashflow_report(month)
@mcp.tool()
def get_category_spending_report(month: str = "", start_date: str = "", end_date: str = "", person: str = "") -> dict:
"""Return category/subcategory spending for a month or date range."""
return service.get_category_spending_report(month, start_date, end_date, person)
@mcp.tool()
def get_fixed_cost_report(month: str = "") -> dict:
"""Return fixed expense transactions and total fixed cost for a month."""
return service.get_fixed_cost_report(month)
@mcp.tool()
def get_budget_vs_actual_report(month: str = "") -> dict:
"""Compare monthly budgets against actual spending."""
return service.get_budget_vs_actual_report(month)
@mcp.tool()
def get_investable_cashflow_report(month: str = "", minimum_cash_buffer: int = 0, planned_extra_expense: int = 0) -> dict:
"""Estimate cashflow available for investing after buffer and planned extra expenses."""
return service.get_investable_cashflow_report(month, minimum_cash_buffer, planned_extra_expense)
@mcp.tool()
def preview_ledger_csv_import(file_path: str, source: str = "generic") -> dict:
"""Preview a UTF-8/UTF-8-BOM CSV import before saving transactions."""
return service.preview_ledger_csv_import(file_path, source)
@mcp.tool()
def preview_kb_bank_pdf_import(file_path: str, password: str, preview_limit: int = 20) -> dict:
"""Preview transactions from a password-protected KB Kookmin Bank account-history PDF without saving them."""
return service.preview_kb_bank_pdf_import(file_path, password, preview_limit)
@mcp.tool()
def import_kb_bank_pdf(file_path: str, password: str, account_id: str = "", skip_duplicates: bool = True, skip_validation: bool = False) -> dict:
"""Import transactions from a KB Kookmin Bank account-history PDF into the ledger database."""
return service.import_kb_bank_pdf(file_path, password, account_id, skip_duplicates, skip_validation=skip_validation)
@mcp.tool()
def preview_seoulpay_xlsx_import(file_path: str, password: str = "", preview_limit: int = 20) -> dict:
"""Preview transactions from a SeoulPay usage-history XLSX before saving them."""
return service.preview_seoulpay_xlsx_import(file_path, password, preview_limit)
@mcp.tool()
def import_seoulpay_xlsx(file_path: str, password: str = "", account_id: str = "", skip_duplicates: bool = True) -> dict:
"""Import transactions from a SeoulPay usage-history XLSX into the ledger database."""
return service.import_seoulpay_xlsx(file_path, password, account_id, skip_duplicates)
@mcp.tool()
def preview_toss_securities_pdf_import(file_path: str, preview_limit: int = 20) -> dict:
"""Preview transactions from a Toss Securities transaction-history PDF without saving them."""
return service.preview_toss_securities_pdf_import(file_path, preview_limit)
@mcp.tool()
def import_toss_securities_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True) -> dict:
"""Import transactions from a Toss Securities transaction-history PDF into the ledger database."""
return service.import_toss_securities_pdf(file_path, account_id, skip_duplicates)
@mcp.tool()
def preview_toss_bank_pdf_import(file_path: str, preview_limit: int = 20) -> dict:
"""Preview transactions from a Toss Bank transaction-history PDF without saving them."""
return service.preview_toss_bank_pdf_import(file_path, preview_limit)
@mcp.tool()
def import_toss_bank_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True) -> dict:
"""Import transactions from a Toss Bank transaction-history PDF into the ledger database."""
return service.import_toss_bank_pdf(file_path, account_id, skip_duplicates)
@mcp.tool()
def list_ledger_import_batches(source: str = "", include_rolled_back: bool = True, limit: int = 50) -> dict:
"""List CSV/PDF/API import batches."""
return service.list_ledger_import_batches(source, include_rolled_back, limit)
@mcp.tool()
def rollback_ledger_import_batch(import_batch_id: str, hard_delete: bool = False) -> dict:
"""Roll back all transactions created by one import batch. Soft-deletes by default."""
return service.rollback_ledger_import_batch(import_batch_id, hard_delete)
@mcp.tool()
def create_category_rule(match_field: str, pattern: str, category: str, subcategory: str = "", transaction_type: str = "", match_type: str = "contains", priority: int = 100, person: str = "", is_fixed: bool | None = None, confidence: float = 1.0, memo: str = "", amount_min: int | None = None, amount_max: int | None = None, day_of_month: int | None = None) -> dict:
"""Create or update a ledger transaction categorization rule."""
return service.create_category_rule(match_field, pattern, category, subcategory, transaction_type, match_type, priority, person, is_fixed, confidence, memo, amount_min, amount_max, day_of_month)
@mcp.tool()
def seed_default_category_rules(overwrite: bool = False) -> dict:
"""Seed default Jarca category rules for common Korean household ledger patterns."""
return service.seed_default_category_rules(overwrite)
@mcp.tool()
def list_category_rules(active_only: bool = True) -> dict:
"""List transaction categorization rules."""
return service.list_category_rules(active_only)
@mcp.tool()
def update_category_rule(rule_id: str, match_field: str, pattern: str, category: str, subcategory: str = "", transaction_type: str = "", match_type: str = "contains", priority: int = 100, person: str = "", is_fixed: bool | None = None, confidence: float = 1.0, memo: str = "", amount_min: int | None = None, amount_max: int | None = None, day_of_month: int | None = None, active: bool = True) -> dict:
"""Update an existing ledger transaction categorization rule."""
return service.update_category_rule(rule_id, match_field, pattern, category, subcategory, transaction_type, match_type, priority, person, is_fixed, confidence, memo, amount_min, amount_max, day_of_month, active)
@mcp.tool()
def delete_category_rule(rule_id: str) -> dict:
"""Delete a transaction categorization rule."""
return service.delete_category_rule(rule_id)
@mcp.tool()
def preview_categorization(limit: int = 1000, only_uncategorized: bool = True) -> dict:
"""Preview category-rule matches before writing categories to transactions."""
return service.preview_categorization(limit, only_uncategorized)
@mcp.tool()
def apply_categorization_rules(limit: int = 1000, only_uncategorized: bool = True) -> dict:
"""Apply active category rules to matching ledger transactions."""
return service.apply_categorization_rules(limit, only_uncategorized)
@mcp.tool()
def list_uncategorized_transactions(limit: int = 100, min_amount: int = 0) -> dict:
"""List active transactions that still have no category."""
return service.list_uncategorized_transactions(limit, min_amount)
@mcp.tool()
def get_uncategorized_merchant_summary(limit: int = 50) -> dict:
"""Summarize uncategorized transactions by merchant/raw_summary/type for user review."""
return service.get_uncategorized_merchant_summary(limit)
@mcp.tool()
def create_ledger_category(category: str, subcategory: str = "", sort_order: int = 100, memo: str = "", cashflow_kind: str = "") -> dict:
"""Create or reactivate a ledger category/subcategory used by the dashboard and transactions."""
return service.create_ledger_category(category, subcategory, sort_order, memo, cashflow_kind)
@mcp.tool()
def seed_default_ledger_categories(overwrite: bool = False) -> dict:
"""Seed default ledger categories and subcategories."""
return service.seed_default_ledger_categories(overwrite)
@mcp.tool()
def list_ledger_categories(active_only: bool = True, include_usage: bool = True) -> dict:
"""List ledger categories and subcategories, optionally with usage counts."""
return service.list_ledger_categories(active_only, include_usage)
@mcp.tool()
def list_cross_transfer_review_queue(limit: int = 300) -> dict:
"""List KB↔토스 계좌 이체 확인필요 큐 항목."""
return service.list_cross_transfer_review_queue(limit)
@mcp.tool()
def preview_cross_account_transfers(limit: int = 500) -> dict:
"""Preview KB↔토스은행 이체 매칭 후보를 반환합니다."""
return service.preview_cross_account_transfers(limit)
@mcp.tool()
def run_cross_account_transfer_match(limit: int = 500) -> dict:
"""Run KB↔토스은행 자동 이체 매칭 및 태그/분류 반영을 수행합니다."""
return service.run_cross_account_transfer_match(limit)
@mcp.tool()
def delete_ledger_category(category: str, subcategory: str = "", hard_delete: bool = False, force: bool = False) -> dict:
"""Deactivate or delete a ledger category/subcategory."""
return service.delete_ledger_category(category, subcategory, hard_delete, force)
def main() -> None:
mcp.run()
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
from . import service_gateway
@@ -0,0 +1,44 @@
from __future__ import annotations
from ...service import (
apply_categorization_rules,
apply_uncategorized_group_recommendation,
apply_uncategorized_recommendation,
close_asset_snapshot,
create_category_rule,
create_ledger_category,
create_manual_asset,
deactivate_manual_asset,
delete_ledger_category,
find_cancel_original_candidates,
find_self_transfer_candidates,
get_asset_liquidity_report,
get_balance_reconciliation_report,
get_budget_vs_actual_report,
get_monthly_cashflow_report,
health_check,
import_toss_bank_pdf,
import_toss_securities_pdf,
link_cancel_to_original,
link_self_transfer_pair,
list_category_rules,
list_cross_transfer_review_queue,
list_ledger_accounts,
list_ledger_backups,
list_ledger_categories,
list_ledger_import_batches,
list_manual_assets,
list_uncategorized_transactions,
preview_asset_snapshot_close,
preview_categorization,
preview_cross_account_transfers,
preview_toss_bank_pdf_import,
preview_toss_securities_pdf_import,
rollback_ledger_import_batch,
run_cross_account_transfer_match,
seed_default_category_rules,
seed_default_ledger_categories,
update_ledger_account,
update_ledger_transaction,
update_manual_asset,
)
+6
View File
@@ -0,0 +1,6 @@
from . import shared
from .asset_account_services import *
from .categorization_services import *
from .import_transfer_services import *
from .system_services import *
from .transaction_report_services import *
@@ -0,0 +1,524 @@
from __future__ import annotations
from .shared import *
def _valuation_policy() -> dict[str, Any]:
return {
"authority_sources": ["accounts.last_balance", "manual_assets"],
"snapshot_role": "historical_record_layer",
"formula": "asset_buckets - loan",
"asset_buckets": ASSET_BUCKETS,
"liability_buckets": LIABILITY_BUCKETS,
"missing_valuation_policy": "exclude_without_transaction_inference",
}
def _account_valuation_items(conn: sqlite3.Connection) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
rows = conn.execute("SELECT * FROM accounts WHERE active=1 ORDER BY name").fetchall()
included: list[dict[str, Any]] = []
excluded: list[dict[str, Any]] = []
invalid: list[dict[str, Any]] = []
for row in rows:
account = row_to_dict(row)
raw_bucket = str(account.get("liquidity_bucket") or "other")
try:
bucket = normalize_liquidity_bucket(raw_bucket)
except ValueError:
bucket = "other"
invalid.append({"id": account.get("id", ""), "name": account.get("name", ""), "liquidity_bucket": raw_bucket})
base = {
"id": account.get("id", ""),
"source_id": account.get("id", ""),
"source_type": "account",
"authority_source": "accounts.last_balance",
"name": account.get("name", ""),
"type": account.get("type", ""),
"liquidity_bucket": bucket,
"valuation_date": account.get("last_synced_at", ""),
"last_synced_at": account.get("last_synced_at", ""),
"source_note": account.get("memo", ""),
"memo": account.get("memo", ""),
}
balance = account.get("last_balance")
if balance is None:
excluded.append({**base, "reason": "missing_last_balance"})
continue
amount = int(balance)
included.append({**base, "amount": amount, "last_balance": amount})
return included, excluded, invalid
def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool = True) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
where = "WHERE active=1" if active_only else ""
rows = conn.execute(f"SELECT * FROM manual_assets {where} ORDER BY name").fetchall()
included: list[dict[str, Any]] = []
excluded: list[dict[str, Any]] = []
for row in rows:
asset = row_to_dict(row)
try:
bucket = normalize_liquidity_bucket(asset.get("liquidity_bucket"))
except ValueError:
bucket = "other"
base = {
"id": asset.get("id", ""),
"source_id": asset.get("id", ""),
"source_type": "manual_asset",
"authority_source": "manual_assets",
"name": asset.get("name", ""),
"liquidity_bucket": bucket,
"valuation_date": asset.get("valuation_date", ""),
"source_note": asset.get("source_note", ""),
"memo": asset.get("memo", ""),
"active": asset.get("active", True),
}
value = asset.get("valuation_amount")
if value is None:
excluded.append({**base, "reason": "missing_valuation_amount"})
continue
included.append({**base, "amount": int(value), "valuation_amount": int(value)})
return included, excluded
def _snapshot_rows_for_latest_run(conn: sqlite3.Connection, basis_date: str) -> tuple[list[sqlite3.Row], dict[str, Any]]:
rows = conn.execute(
"SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC",
(basis_date,),
).fetchall()
if not rows:
return [], {}
run_rows = [row for row in rows if "snapshot_run_id" in row.keys() and row["snapshot_run_id"]]
if not run_rows:
return rows, {"snapshot_run_id": "", "is_correction": False, "correction_of_run_id": "", "correction_reason": "", "total_snapshot_rows_for_basis_date": len(rows)}
latest = sorted(run_rows, key=lambda row: (int(row["_rowid"]), row["closed_at"] or row["created_at"] or "", row["snapshot_run_id"] or ""))[-1]
latest_run_id = latest["snapshot_run_id"]
selected = [row for row in run_rows if row["snapshot_run_id"] == latest_run_id]
metadata = {
"snapshot_run_id": latest_run_id,
"is_correction": bool(latest["is_correction"]),
"correction_of_run_id": latest["correction_of_run_id"] or "",
"correction_reason": latest["correction_reason"] or "",
"closed_at": latest["closed_at"] or latest["created_at"] or "",
"total_snapshot_rows_for_basis_date": len(rows),
}
return selected, metadata
def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str, Any]]:
rows, _metadata = _snapshot_rows_for_latest_run(conn, basis_date)
items: list[dict[str, Any]] = []
for row in rows:
snap = row_to_dict(row)
try:
bucket = normalize_liquidity_bucket(snap.get("liquidity_bucket"))
except ValueError:
bucket = "other"
items.append(
{
"id": snap.get("id", ""),
"source_id": snap.get("source_id", ""),
"source_type": snap.get("source_type", ""),
"authority_source": "asset_snapshots",
"valuation_source": snap.get("valuation_source", ""),
"name": snap.get("name", ""),
"liquidity_bucket": bucket,
"amount": int(snap.get("amount") or 0),
"valuation_date": snap.get("basis_date", ""),
"source_note": snap.get("source_note", ""),
"memo": snap.get("memo", ""),
"snapshot_run_id": snap.get("snapshot_run_id", ""),
"is_correction": bool(snap.get("is_correction", False)),
"correction_of_run_id": snap.get("correction_of_run_id", ""),
"correction_reason": snap.get("correction_reason", ""),
}
)
return items
def _summarize_valuation_items(included_items: list[dict[str, Any]], excluded_items: list[dict[str, Any]] | None = None) -> dict[str, Any]:
bucket_totals = {bucket: 0 for bucket in ASSET_BUCKET_ORDER}
bucket_counts = {bucket: 0 for bucket in ASSET_BUCKET_ORDER}
for item in included_items:
bucket = normalize_liquidity_bucket(item.get("liquidity_bucket"))
amount = int(item.get("amount") or 0)
bucket_counts[bucket] += 1
bucket_totals[bucket] += amount
total_assets = sum(bucket_totals[bucket] for bucket in ASSET_BUCKETS)
total_liabilities = sum(abs(bucket_totals[bucket]) for bucket in LIABILITY_BUCKETS)
net_worth = total_assets - total_liabilities
buckets = []
for bucket in ASSET_BUCKET_ORDER:
amount = bucket_totals[bucket]
denominator = total_liabilities if bucket in LIABILITY_BUCKETS else total_assets
buckets.append(
{
"liquidity_bucket": bucket,
"amount": amount,
"account_count": sum(1 for item in included_items if item.get("source_type") == "account" and item.get("liquidity_bucket") == bucket),
"manual_asset_count": sum(1 for item in included_items if item.get("source_type") == "manual_asset" and item.get("liquidity_bucket") == bucket),
"item_count": bucket_counts[bucket],
"asset_share": round(amount / total_assets, 4) if bucket not in LIABILITY_BUCKETS and total_assets else None,
"liability_share": round(abs(amount) / total_liabilities, 4) if bucket in LIABILITY_BUCKETS and total_liabilities else None,
"share": round(abs(amount) / denominator, 4) if denominator else None,
}
)
return {
"buckets": buckets,
"bucket_totals": bucket_totals,
"total_assets": total_assets,
"total_liabilities": total_liabilities,
"net_worth": net_worth,
"included_items": included_items,
"excluded_items": excluded_items or [],
"valuation_policy": _valuation_policy(),
}
def create_manual_asset(name: str, valuation_amount: int | None, valuation_date: str, liquidity_bucket: str, source_note: str = "", memo: str = "", currency: str = "KRW", active: bool = True, db_path: str | None = None) -> dict[str, Any]:
if not name.strip():
return fail("validation_error", "manual asset name is required")
try:
bucket = normalize_liquidity_bucket(liquidity_bucket)
amount = int(valuation_amount) if valuation_amount is not None else None
vdate = normalize_date(valuation_date, default_today=True)
except Exception as e:
return fail("validation_error", str(e))
ts = now_iso()
asset_id = new_id("asset")
with db(db_path) as conn:
conn.execute(
"""INSERT INTO manual_assets(id,name,liquidity_bucket,valuation_amount,valuation_date,currency,source_note,memo,active,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)""",
(asset_id, name.strip(), bucket, amount, vdate, currency, source_note, memo, int(active), ts, ts),
)
row = conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone()
return ok(manual_asset={**row_to_dict(row), "authority_source": "manual_assets"})
def list_manual_assets(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]:
where = "WHERE active=1" if active_only else ""
with db(db_path) as conn:
rows = conn.execute(f"SELECT * FROM manual_assets {where} ORDER BY name").fetchall()
assets = [{**row_to_dict(row), "authority_source": "manual_assets"} for row in rows]
return ok(manual_assets=assets, count=len(assets), valuation_policy=_valuation_policy())
def update_manual_asset(asset_id: str, name: str | None = None, valuation_amount: int | None = None, valuation_date: str | None = None, liquidity_bucket: str | None = None, source_note: str | None = None, memo: str | None = None, currency: str | None = None, active: bool | None = None, db_path: str | None = None) -> dict[str, Any]:
allowed_updates: list[str] = []
params: list[Any] = []
try:
if name is not None:
if not name.strip():
return fail("validation_error", "manual asset name is required")
allowed_updates.append("name=?")
params.append(name.strip())
if valuation_amount is not None:
allowed_updates.append("valuation_amount=?")
params.append(int(valuation_amount))
if valuation_date is not None:
allowed_updates.append("valuation_date=?")
params.append(normalize_date(valuation_date, default_today=True))
if liquidity_bucket is not None:
allowed_updates.append("liquidity_bucket=?")
params.append(normalize_liquidity_bucket(liquidity_bucket))
if source_note is not None:
allowed_updates.append("source_note=?")
params.append(source_note)
if memo is not None:
allowed_updates.append("memo=?")
params.append(memo)
if currency is not None:
allowed_updates.append("currency=?")
params.append(currency or "KRW")
if active is not None:
allowed_updates.append("active=?")
params.append(int(bool(active)))
except Exception as e:
return fail("validation_error", str(e))
if not allowed_updates:
return fail("validation_error", "no fields to update")
allowed_updates.append("updated_at=?")
params.extend([now_iso(), asset_id])
with db(db_path) as conn:
cur = conn.execute(f"UPDATE manual_assets SET {', '.join(allowed_updates)} WHERE id=?", params)
if cur.rowcount == 0:
return fail("not_found", "manual asset not found")
row = conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone()
return ok(manual_asset={**row_to_dict(row), "authority_source": "manual_assets"})
def deactivate_manual_asset(asset_id: str, hard_delete: bool = False, db_path: str | None = None) -> dict[str, Any]:
with db(db_path) as conn:
if hard_delete:
cur = conn.execute("DELETE FROM manual_assets WHERE id=?", (asset_id,))
else:
cur = conn.execute("UPDATE manual_assets SET active=0, updated_at=? WHERE id=?", (now_iso(), asset_id))
if cur.rowcount == 0:
return fail("not_found", "manual asset not found")
row = None if hard_delete else conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone()
payload = {"manual_asset_id": asset_id, "deactivated": True, "hard_delete": hard_delete}
if row is not None:
payload["manual_asset"] = {**row_to_dict(row), "authority_source": "manual_assets"}
return ok(**payload)
def _snapshot_preview_from_conn(conn: sqlite3.Connection, basis: str) -> dict[str, Any]:
account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn)
manual_items, manual_excluded = _manual_asset_valuation_items(conn)
included = account_items + manual_items
excluded = account_excluded + manual_excluded
summary = _summarize_valuation_items(included, excluded)
existing_rows = conn.execute("SELECT COUNT(*) FROM asset_snapshots WHERE basis_date=?", (basis,)).fetchone()[0]
warnings: list[dict[str, Any]] = []
for item in excluded:
warnings.append({"code": item.get("reason", "excluded_item"), "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "name": item.get("name", "")})
for item in included:
item_date = str(item.get("valuation_date") or "")[:10]
if item_date and item_date < basis:
warnings.append({"code": "stale_valuation", "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "name": item.get("name", ""), "valuation_date": item_date, "basis_date": basis})
for item in invalid_bucket_accounts:
warnings.append({"code": "invalid_liquidity_bucket", **item})
if existing_rows:
warnings.append({"code": "existing_snapshot_rows", "basis_date": basis, "snapshot_row_count": int(existing_rows), "policy": "append_only_correction_required"})
return {
"basis_date": basis,
"source": "live_authority",
"is_closed_snapshot": False,
"snapshot_count": 0,
"existing_snapshot_row_count": int(existing_rows),
"would_be_correction": bool(existing_rows),
"warnings": warnings,
**summary,
}
def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = None) -> dict[str, Any]:
try:
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
except Exception as e:
return fail("validation_error", str(e))
with db(db_path) as conn:
preview = _snapshot_preview_from_conn(conn, basis)
return ok(**preview)
def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "", db_path: str | None = None) -> dict[str, Any]:
try:
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
except Exception as e:
return fail("validation_error", str(e))
ts = now_iso()
run_id = new_id("snaprun")
with db(db_path) as conn:
preview = _snapshot_preview_from_conn(conn, basis)
included = preview["included_items"]
latest_rows, latest_meta = _snapshot_rows_for_latest_run(conn, basis)
is_correction = bool(preview["existing_snapshot_row_count"])
if is_correction and not correction_reason.strip():
return fail("validation_error", "correction_reason is required when closing a basis_date that already has snapshot rows", basis_date=basis, existing_snapshot_row_count=preview["existing_snapshot_row_count"])
correction_of_run_id = latest_meta.get("snapshot_run_id", "") if latest_rows else ""
snapshot_ids: list[str] = []
for item in included:
snapshot_id = new_id("snap")
row_note = source_note or item.get("source_note", "") or "month-end close"
row_memo = memo or item.get("memo", "")
conn.execute(
"""INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,snapshot_run_id,correction_of_run_id,correction_reason,is_correction,closed_at,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
snapshot_id,
basis,
item.get("source_type", ""),
item.get("source_id", item.get("id", "")),
item.get("name", ""),
int(item.get("amount") or 0),
item.get("currency", "KRW"),
normalize_liquidity_bucket(item.get("liquidity_bucket")),
item.get("authority_source", item.get("source_type", "")),
row_note,
row_memo,
run_id,
correction_of_run_id,
correction_reason.strip(),
int(is_correction),
ts,
ts,
),
)
snapshot_ids.append(snapshot_id)
report_items = _snapshot_items(conn, basis)
summary = _summarize_valuation_items(report_items, [])
return ok(
basis_date=basis,
source="snapshot",
is_closed_snapshot=True,
snapshot_run_id=run_id,
correction_of_run_id=correction_of_run_id,
correction_reason=correction_reason.strip(),
is_correction=is_correction,
closed_at=ts,
inserted_count=len(snapshot_ids),
inserted_snapshot_ids=snapshot_ids,
warnings=preview["warnings"],
**summary,
)
def create_asset_snapshot(basis_date: str, source_type: str, source_id: str, name: str, amount: int | None, liquidity_bucket: str, valuation_source: str = "", source_note: str = "", memo: str = "", currency: str = "KRW", db_path: str | None = None) -> dict[str, Any]:
if source_type not in {"account", "manual_asset"}:
return fail("validation_error", "source_type must be one of ['account', 'manual_asset']")
if not name.strip():
return fail("validation_error", "snapshot item name is required")
try:
basis = normalize_date(basis_date, default_today=True)
bucket = normalize_liquidity_bucket(liquidity_bucket)
amount_int = int(amount) if amount is not None else None
except Exception as e:
return fail("validation_error", str(e))
ts = now_iso()
snapshot_id = new_id("snap")
with db(db_path) as conn:
conn.execute(
"""INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""",
(snapshot_id, basis, source_type, source_id, name.strip(), amount_int, currency, bucket, valuation_source or source_type, source_note, memo, ts),
)
row = conn.execute("SELECT * FROM asset_snapshots WHERE id=?", (snapshot_id,)).fetchone()
return ok(asset_snapshot=row_to_dict(row))
def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None) -> dict[str, Any]:
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
with db(db_path) as conn:
rows, close_metadata = _snapshot_rows_for_latest_run(conn, basis)
snapshots = _snapshot_items(conn, basis) if rows else []
if snapshots:
summary = _summarize_valuation_items(snapshots, [])
return ok(
basis_date=basis,
source="asset_snapshots",
is_closed_snapshot=True,
snapshot_count=len(snapshots),
close_metadata=close_metadata,
**summary,
)
account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn)
manual_items, manual_excluded = _manual_asset_valuation_items(conn)
included = account_items + manual_items
excluded = account_excluded + manual_excluded
summary = _summarize_valuation_items(included, excluded)
return ok(
basis_date=basis,
source="live_authority",
is_closed_snapshot=False,
snapshot_count=0,
invalid_bucket_accounts=invalid_bucket_accounts,
**summary,
)
def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: int = 12, db_path: str | None = None) -> dict[str, Any]:
try:
months_int = max(1, min(int(months), 120))
end_norm = normalize_date(end_date, default_today=True) if end_date else current_kst_date().isoformat()
if start_date:
start_norm = normalize_date(start_date)
else:
end_dt = date.fromisoformat(end_norm)
start_month = end_dt.month - months_int + 1
year = end_dt.year + (start_month - 1) // 12
month = (start_month - 1) % 12 + 1
start_norm = date(year, month, 1).isoformat()
except Exception as e:
return fail("validation_error", str(e))
with db(db_path) as conn:
basis_rows = conn.execute(
"""SELECT DISTINCT basis_date FROM asset_snapshots
WHERE basis_date>=? AND basis_date<=?
ORDER BY basis_date ASC""",
(start_norm, end_norm),
).fetchall()
points = []
for row in basis_rows:
basis = row["basis_date"]
items = _snapshot_items(conn, basis)
summary = _summarize_valuation_items(items, [])
points.append(
{
"basis_date": basis,
"total_assets": summary["total_assets"],
"total_liabilities": summary["total_liabilities"],
"net_worth": summary["net_worth"],
"source": "asset_snapshots",
"item_count": len(items),
}
)
return ok(start_date=start_norm, end_date=end_norm, months=months_int, points=points, count=len(points), valuation_policy=_valuation_policy())
def get_asset_liquidity_report(db_path: str | None = None) -> dict[str, Any]:
"""Summarize assets/liabilities from shared valuation authority sources.
Existing REQ-005 keys are preserved. When manual_assets is empty, totals and
source remain equivalent to the original accounts.last_balance behavior.
"""
with db(db_path) as conn:
account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn)
manual_items, manual_excluded = _manual_asset_valuation_items(conn)
included = account_items + manual_items
excluded = account_excluded + manual_excluded
summary = _summarize_valuation_items(included, excluded)
source_accounts = [item for item in included if item.get("source_type") == "account"]
source = "accounts.last_balance" if not manual_items and not manual_excluded else "accounts.last_balance+manual_assets"
return ok(
buckets=summary["buckets"],
accounts=source_accounts,
manual_assets=manual_items,
total_assets=summary["total_assets"],
total_liabilities=summary["total_liabilities"],
net_worth=summary["net_worth"],
missing_balance_accounts=[
{"id": item.get("id", ""), "name": item.get("name", ""), "liquidity_bucket": item.get("liquidity_bucket", "")}
for item in account_excluded
],
missing_valuation_items=excluded,
invalid_bucket_accounts=invalid_bucket_accounts,
manual_asset_count=len(manual_items),
included_items=included,
excluded_items=excluded,
valuation_policy=summary["valuation_policy"],
source=source,
)
def update_ledger_account(account_id: str, db_path: str | None = None, **fields: Any) -> dict[str, Any]:
allowed = {"name", "type", "institution", "currency", "active", "memo", "account_number_masked", "holder_name", "last_balance", "last_synced_at", "statement_status", "liquidity_bucket"}
updates: list[str] = []
params: list[Any] = []
for k, v in fields.items():
if k not in allowed or v is None:
continue
try:
if k == "type" and v not in VALID_ACCOUNT_TYPES:
return fail("validation_error", f"type must be one of {sorted(VALID_ACCOUNT_TYPES)}")
if k == "statement_status":
v = normalize_statement_status(str(v))
if k == "liquidity_bucket":
v = normalize_liquidity_bucket(str(v))
if k == "active":
v = int(bool(v))
if k == "last_balance" and v != "":
v = int(v)
except Exception as e:
return fail("validation_error", str(e))
updates.append(f"{k}=?")
params.append(v)
if not updates:
return fail("validation_error", "no fields to update")
updates.append("updated_at=?")
params.extend([now_iso(), account_id])
with db(db_path) as conn:
cur = conn.execute(f"UPDATE accounts SET {', '.join(updates)} WHERE id=?", params)
if cur.rowcount == 0:
return fail("not_found", "account not found")
row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
return ok(account=row_to_dict(row))
@@ -0,0 +1,703 @@
from __future__ import annotations
from .shared import *
RULE_FIELDS = {"merchant", "counterparty", "description", "payment_method", "raw_summary", "branch", "memo"}
RULE_MATCH_TYPES = {"contains", "equals", "regex", "starts_with", "ends_with"}
DEFAULT_CATEGORY_RULES: list[dict[str, Any]] = [
{"priority": 10, "match_field": "merchant", "pattern": "급여", "transaction_type": "income", "category": "수입", "subcategory": "급여"},
{"priority": 10, "match_field": "merchant", "pattern": "이자", "transaction_type": "income", "category": "수입", "subcategory": "이자"},
{"priority": 10, "match_field": "merchant", "pattern": "환급", "transaction_type": "income", "category": "수입", "subcategory": "환급"},
{"priority": 20, "match_field": "raw_summary", "match_type": "equals", "pattern": "국민카드", "transaction_type": "expense", "category": "금융", "subcategory": "카드대금", "is_fixed": 1},
{"priority": 20, "match_field": "merchant", "pattern": "KB카드출금", "transaction_type": "expense", "category": "금융", "subcategory": "카드대금", "is_fixed": 1},
{"priority": 30, "match_field": "merchant", "pattern": "SK텔레콤", "transaction_type": "expense", "category": "통신", "subcategory": "휴대폰", "is_fixed": 1},
{"priority": 30, "match_field": "merchant", "pattern": "KT통신", "transaction_type": "expense", "category": "통신", "subcategory": "통신요금", "is_fixed": 1},
{"priority": 40, "match_field": "merchant", "pattern": "학원", "transaction_type": "expense", "category": "교육", "subcategory": "학원"},
{"priority": 40, "match_field": "merchant", "pattern": "교습소", "transaction_type": "expense", "category": "교육", "subcategory": "예체능"},
{"priority": 40, "match_field": "merchant", "pattern": "연세대학교", "transaction_type": "expense", "category": "의료", "subcategory": "병원"},
{"priority": 40, "match_field": "merchant", "pattern": "병원", "transaction_type": "expense", "category": "의료", "subcategory": "병원"},
{"priority": 40, "match_field": "merchant", "pattern": "의원", "transaction_type": "expense", "category": "의료", "subcategory": "병원"},
{"priority": 40, "match_field": "merchant", "pattern": "치과", "transaction_type": "expense", "category": "의료", "subcategory": "치과"},
{"priority": 40, "match_field": "merchant", "pattern": "약국", "transaction_type": "expense", "category": "의료", "subcategory": "약국"},
{"priority": 50, "match_field": "merchant", "pattern": "이마트", "transaction_type": "expense", "category": "식비", "subcategory": "마트"},
{"priority": 50, "match_field": "merchant", "pattern": "오아시스마켓", "transaction_type": "expense", "category": "식비", "subcategory": "마트"},
{"priority": 50, "match_field": "merchant", "pattern": "생협", "transaction_type": "expense", "category": "식비", "subcategory": "마트"},
{"priority": 50, "match_field": "merchant", "pattern": "스타벅스", "transaction_type": "expense", "category": "식비", "subcategory": "카페"},
{"priority": 50, "match_field": "merchant", "pattern": "메가MGC", "transaction_type": "expense", "category": "식비", "subcategory": "카페"},
{"priority": 50, "match_field": "merchant", "pattern": "매머드", "transaction_type": "expense", "category": "식비", "subcategory": "카페"},
{"priority": 50, "match_field": "merchant", "pattern": "커피", "transaction_type": "expense", "category": "식비", "subcategory": "카페"},
{"priority": 60, "match_field": "merchant", "pattern": "KFC", "transaction_type": "expense", "category": "식비", "subcategory": "외식"},
{"priority": 60, "match_field": "merchant", "pattern": "스시", "transaction_type": "expense", "category": "식비", "subcategory": "외식"},
{"priority": 60, "match_field": "merchant", "pattern": "쌀국수", "transaction_type": "expense", "category": "식비", "subcategory": "외식"},
{"priority": 60, "match_field": "merchant", "pattern": "짜장", "transaction_type": "expense", "category": "식비", "subcategory": "외식"},
{"priority": 80, "match_field": "raw_summary", "match_type": "equals", "pattern": "스마트출금", "category": "이체", "subcategory": "현금/계좌이동"},
{"priority": 80, "match_field": "raw_summary", "match_type": "equals", "pattern": "ATM입금", "category": "이체", "subcategory": "현금/계좌이동"},
]
def _rule_matches(rule: dict[str, Any], tx: dict[str, Any]) -> bool:
if rule.get("transaction_type") and rule.get("transaction_type") != tx.get("type", ""):
return False
amount = int(tx.get("amount") or 0)
if rule.get("amount_min") is not None and amount < int(rule["amount_min"]):
return False
if rule.get("amount_max") is not None and amount > int(rule["amount_max"]):
return False
if rule.get("day_of_month") is not None:
tx_date = tx.get("transaction_datetime") or tx.get("transaction_date") or ""
try:
if datetime.fromisoformat(str(tx_date)[:10]).day != int(rule["day_of_month"]):
return False
except ValueError:
return False
value = str(tx.get(rule["match_field"], "") or "")
pattern = str(rule.get("pattern", ""))
match_type = rule.get("match_type") or "contains"
if match_type == "contains":
return pattern.lower() in value.lower()
if match_type == "equals":
return value == pattern
if match_type == "starts_with":
return value.startswith(pattern)
if match_type == "ends_with":
return value.endswith(pattern)
if match_type == "regex":
return re.search(pattern, value) is not None
return False
def create_category_rule(match_field: str, pattern: str, category: str, subcategory: str = "", transaction_type: str = "", match_type: str = "contains", priority: int = 100, person: str = "", is_fixed: bool | None = None, confidence: float = 1.0, memo: str = "", amount_min: int | None = None, amount_max: int | None = None, day_of_month: int | None = None, db_path: str | None = None) -> dict[str, Any]:
if match_field not in RULE_FIELDS:
return fail("validation_error", f"match_field must be one of {sorted(RULE_FIELDS)}")
if match_type not in RULE_MATCH_TYPES:
return fail("validation_error", f"match_type must be one of {sorted(RULE_MATCH_TYPES)}")
if transaction_type and transaction_type not in VALID_TRANSACTION_TYPES:
return fail("validation_error", f"transaction_type must be empty or one of {sorted(VALID_TRANSACTION_TYPES)}")
if day_of_month is not None and not 1 <= int(day_of_month) <= 31:
return fail("validation_error", "day_of_month must be between 1 and 31")
if amount_min is not None and amount_max is not None and int(amount_min) > int(amount_max):
return fail("validation_error", "amount_min must be <= amount_max")
if not pattern or not category:
return fail("validation_error", "pattern and category are required")
ts=now_iso(); rule_id=new_id("rule")
with db(db_path) as conn:
conn.execute("""INSERT INTO category_rules(id,priority,match_field,match_type,pattern,amount_min,amount_max,day_of_month,transaction_type,category,subcategory,person,is_fixed,confidence,active,memo,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(match_field,match_type,pattern,transaction_type,category,subcategory,person) DO UPDATE SET priority=excluded.priority,amount_min=excluded.amount_min,amount_max=excluded.amount_max,day_of_month=excluded.day_of_month,is_fixed=excluded.is_fixed,confidence=excluded.confidence,active=1,memo=excluded.memo,updated_at=excluded.updated_at""",
(rule_id, int(priority), match_field, match_type, pattern, None if amount_min is None else int(amount_min), None if amount_max is None else int(amount_max), None if day_of_month is None else int(day_of_month), transaction_type, category, subcategory, person, None if is_fixed is None else int(is_fixed), float(confidence), 1, memo, ts, ts))
row=conn.execute("SELECT * FROM category_rules WHERE match_field=? AND match_type=? AND pattern=? AND transaction_type=? AND category=? AND subcategory=? AND person=?", (match_field, match_type, pattern, transaction_type, category, subcategory, person)).fetchone()
return ok(rule=row_to_dict(row))
def seed_default_category_rules(overwrite: bool = False, db_path: str | None = None) -> dict[str, Any]:
created=0; skipped=0
with db(db_path) as conn:
if overwrite:
conn.execute("UPDATE category_rules SET active=0 WHERE memo LIKE 'default:%'")
for rule in DEFAULT_CATEGORY_RULES:
memo = "default: initial Jarca ledger categorization"
res=create_category_rule(memo=memo, db_path=db_path, **rule)
if res.get("success"):
created += 1
else:
skipped += 1
return ok(created_or_updated=created, skipped=skipped)
def list_category_rules(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]:
sql="SELECT * FROM category_rules" + (" WHERE active=1" if active_only else "") + " ORDER BY priority, id"
with db(db_path) as conn:
rows=conn.execute(sql).fetchall()
return ok(rules=[row_to_dict(r) for r in rows], count=len(rows))
def update_category_rule(rule_id: str, match_field: str, pattern: str, category: str, subcategory: str = "", transaction_type: str = "", match_type: str = "contains", priority: int = 100, person: str = "", is_fixed: bool | None = None, confidence: float = 1.0, memo: str = "", amount_min: int | None = None, amount_max: int | None = None, day_of_month: int | None = None, active: bool = True, db_path: str | None = None) -> dict[str, Any]:
if match_field not in RULE_FIELDS:
return fail("validation_error", f"match_field must be one of {sorted(RULE_FIELDS)}")
if match_type not in RULE_MATCH_TYPES:
return fail("validation_error", f"match_type must be one of {sorted(RULE_MATCH_TYPES)}")
if transaction_type and transaction_type not in VALID_TRANSACTION_TYPES:
return fail("validation_error", f"transaction_type must be empty or one of {sorted(VALID_TRANSACTION_TYPES)}")
if day_of_month is not None and not 1 <= int(day_of_month) <= 31:
return fail("validation_error", "day_of_month must be between 1 and 31")
if amount_min is not None and amount_max is not None and int(amount_min) > int(amount_max):
return fail("validation_error", "amount_min must be <= amount_max")
if not pattern or not category:
return fail("validation_error", "pattern and category are required")
ts = now_iso()
with db(db_path) as conn:
existing = conn.execute("SELECT * FROM category_rules WHERE id=?", (rule_id,)).fetchone()
if not existing:
return fail("not_found", f"category rule not found: {rule_id}")
duplicate = conn.execute(
"SELECT id FROM category_rules WHERE match_field=? AND match_type=? AND pattern=? AND transaction_type=? AND category=? AND subcategory=? AND person=? AND id<>?",
(match_field, match_type, pattern, transaction_type, category, subcategory, person, rule_id),
).fetchone()
if duplicate:
return fail("conflict", f"another rule already exists with the same unique fields: {duplicate['id']}")
conn.execute(
"""UPDATE category_rules
SET priority=?, match_field=?, match_type=?, pattern=?, amount_min=?, amount_max=?, day_of_month=?,
transaction_type=?, category=?, subcategory=?, person=?, is_fixed=?, confidence=?, active=?, memo=?, updated_at=?
WHERE id=?""",
(
int(priority), match_field, match_type, pattern,
None if amount_min is None else int(amount_min),
None if amount_max is None else int(amount_max),
None if day_of_month is None else int(day_of_month),
transaction_type, category, subcategory, person,
None if is_fixed is None else int(is_fixed),
float(confidence), int(bool(active)), memo, ts, rule_id,
),
)
row = conn.execute("SELECT * FROM category_rules WHERE id=?", (rule_id,)).fetchone()
return ok(rule=row_to_dict(row))
def delete_category_rule(rule_id: str, db_path: str | None = None) -> dict[str, Any]:
if not rule_id:
return fail("validation_error", "rule_id is required")
with db(db_path) as conn:
existing = conn.execute("SELECT * FROM category_rules WHERE id=?", (rule_id,)).fetchone()
if not existing:
return fail("not_found", f"category rule not found: {rule_id}")
conn.execute("DELETE FROM category_rules WHERE id=?", (rule_id,))
return ok(deleted_rule_id=rule_id)
def _load_active_rules(conn: Any) -> list[dict[str, Any]]:
return [row_to_dict(r) for r in conn.execute("SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id").fetchall()]
def _categorization_preview_data(limit: int = 1000, only_uncategorized: bool = True, db_path: str | None = None) -> dict[str, Any]:
with db(db_path) as conn:
rules = _load_active_rules(conn)
where = "WHERE status='active'" + (" AND category=''" if only_uncategorized else "")
rows = [row_to_dict(r) for r in conn.execute(
f"SELECT * FROM transactions {where} ORDER BY transaction_datetime, transaction_date LIMIT ?",
(max(1, min(int(limit), 5000)),),
).fetchall()]
matches = []
unmatched = []
for tx in rows:
match = next((r for r in rules if _rule_matches(r, tx)), None)
if match:
matches.append({
"transaction_id": tx["id"],
"merchant": tx["merchant"],
"type": tx["type"],
"amount": tx["amount"],
"transaction_datetime": tx.get("transaction_datetime", ""),
"category": match["category"],
"subcategory": match["subcategory"],
"person": match["person"],
"is_fixed": match["is_fixed"],
"rule_id": match["id"],
"rule_pattern": match["pattern"],
})
else:
unmatched.append({
"transaction_id": tx["id"],
"merchant": tx["merchant"],
"raw_summary": tx.get("raw_summary", ""),
"type": tx["type"],
"amount": tx["amount"],
"transaction_datetime": tx.get("transaction_datetime", ""),
})
by_category = {}
for m in matches:
key = f"{m['category']}/{m['subcategory']}" if m['subcategory'] else m['category']
by_category.setdefault(key, {"count": 0, "amount": 0})
by_category[key]["count"] += 1
by_category[key]["amount"] += int(m["amount"])
return {
"rows": rows,
"matches": matches,
"unmatched": unmatched,
"by_category": by_category,
}
def preview_categorization(limit: int = 1000, only_uncategorized: bool = True, db_path: str | None = None) -> dict[str, Any]:
data = _categorization_preview_data(limit, only_uncategorized, db_path)
return ok(
scanned=len(data["rows"]),
matched_count=len(data["matches"]),
unmatched_count=len(data["unmatched"]),
by_category=data["by_category"],
matches=data["matches"][:100],
unmatched=data["unmatched"][:100],
)
def _category_rule_match_rows(rule_id: str, limit: int = 10000, db_path: str | None = None) -> dict[str, Any]:
if not rule_id:
return fail("validation_error", "rule_id is required")
row_limit = max(1, min(int(limit), 50000))
with db(db_path) as conn:
rule_row = conn.execute("SELECT * FROM category_rules WHERE id=?", (rule_id,)).fetchone()
if not rule_row:
return fail("not_found", f"category rule not found: {rule_id}")
rule = row_to_dict(rule_row)
rows = [row_to_dict(r) for r in conn.execute(
"""SELECT * FROM transactions
WHERE status='active'
ORDER BY transaction_datetime DESC, transaction_date DESC, created_at DESC
LIMIT ?""",
(row_limit,),
).fetchall()]
matched = []
uncategorized = []
for tx in rows:
if not _rule_matches(rule, tx):
continue
item = {
"transaction_id": tx["id"],
"transaction_date": tx.get("transaction_date", ""),
"transaction_datetime": tx.get("transaction_datetime", ""),
"type": tx.get("type", ""),
"amount": tx.get("amount"),
"merchant": tx.get("merchant", ""),
"raw_summary": tx.get("raw_summary", ""),
"counterparty": tx.get("counterparty", ""),
"current_category": tx.get("category", "") or "",
"current_subcategory": tx.get("subcategory", "") or "",
"new_category": rule.get("category", ""),
"new_subcategory": rule.get("subcategory", ""),
"rule_id": rule_id,
"rule_pattern": rule.get("pattern", ""),
}
matched.append(item)
if not (tx.get("category") or ""):
uncategorized.append(item)
return ok(rule=rule, scanned=len(rows), matched=matched, uncategorized=uncategorized)
def preview_category_rule_matches(rule_id: str, limit: int = 10000, sample_limit: int = 3, db_path: str | None = None) -> dict[str, Any]:
data = _category_rule_match_rows(rule_id, limit, db_path)
if not data.get("success"):
return data
sample_n = max(1, min(int(sample_limit), 20))
return ok(
rule=data["rule"],
scanned=data["scanned"],
matched_count=len(data["matched"]),
uncategorized_matched_count=len(data["uncategorized"]),
sample=data["matched"][:sample_n],
)
def apply_category_rule(rule_id: str, limit: int = 10000, db_path: str | None = None) -> dict[str, Any]:
data = _category_rule_match_rows(rule_id, limit, db_path)
if not data.get("success"):
return data
rule = data["rule"]
ts = now_iso()
applied = 0
with db(db_path) as conn:
for m in data["uncategorized"]:
conn.execute(
"""UPDATE transactions
SET category=?, subcategory=?,
person=CASE WHEN ?<>'' THEN ? ELSE person END,
is_fixed=CASE WHEN ? IS NULL THEN is_fixed ELSE ? END,
updated_at=?
WHERE id=? AND status='active' AND COALESCE(category,'')=''""",
(
rule.get("category", ""), rule.get("subcategory", ""),
rule.get("person", ""), rule.get("person", ""),
rule.get("is_fixed"), rule.get("is_fixed"),
ts, m["transaction_id"],
),
)
applied += 1
return ok(
applied_count=applied,
matched_count=len(data["matched"]),
uncategorized_matched_count=len(data["uncategorized"]),
rule=rule,
)
def apply_categorization_rules(limit: int = 1000, only_uncategorized: bool = True, db_path: str | None = None) -> dict[str, Any]:
data = _categorization_preview_data(limit, only_uncategorized, db_path)
preview = ok(
scanned=len(data["rows"]),
matched_count=len(data["matches"]),
unmatched_count=len(data["unmatched"]),
by_category=data["by_category"],
matches=data["matches"][:100],
unmatched=data["unmatched"][:100],
)
applied = 0
ts = now_iso()
with db(db_path) as conn:
for m in data["matches"]:
conn.execute("UPDATE transactions SET category=?, subcategory=?, person=CASE WHEN ?<>'' THEN ? ELSE person END, is_fixed=CASE WHEN ? IS NULL THEN is_fixed ELSE ? END, updated_at=? WHERE id=?", (m["category"], m["subcategory"], m["person"], m["person"], m["is_fixed"], m["is_fixed"], ts, m["transaction_id"]))
applied += 1
return ok(applied_count=applied, preview=preview)
def list_uncategorized_transactions(limit: int = 100, min_amount: int = 0, db_path: str | None = None) -> dict[str, Any]:
with db(db_path) as conn:
rows=conn.execute("""SELECT * FROM transactions WHERE status='active' AND COALESCE(category,'')='' AND amount>=? ORDER BY amount DESC, transaction_datetime DESC LIMIT ?""", (int(min_amount), max(1,min(int(limit),1000)))).fetchall()
return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows))
def get_uncategorized_merchant_summary(limit: int = 50, db_path: str | None = None) -> dict[str, Any]:
with db(db_path) as conn:
rows=conn.execute("""SELECT merchant, raw_summary, type, COUNT(*) count, SUM(amount) total_amount, MAX(amount) max_amount
FROM transactions WHERE status='active' AND COALESCE(category,'')=''
GROUP BY merchant, raw_summary, type ORDER BY total_amount DESC LIMIT ?""", (max(1,min(int(limit),500)),)).fetchall()
return ok(items=[row_to_dict(r) for r in rows], count=len(rows))
def _recommend_category_for_transaction(tx: dict[str, Any], rules: list[dict[str, Any]], history: dict[tuple[str, str], list[dict[str, Any]]]) -> dict[str, Any]:
"""Recommend a category for one transaction using deterministic, explainable signals."""
for rule in rules:
try:
if _rule_matches(rule, tx):
return {
"category": rule.get("category", ""),
"subcategory": rule.get("subcategory", ""),
"confidence": round(float(rule.get("confidence") or 1.0), 2),
"source": "rule",
"reason": f"활성 규칙 `{rule.get('pattern', '')}` 매칭",
"rule_id": rule.get("id", ""),
}
except re.error:
continue
key = (tx.get("merchant", "") or "", tx.get("type", "") or "")
candidates = history.get(key, [])
if candidates:
best = candidates[0]
count = int(best.get("count") or 0)
confidence = min(0.9, 0.55 + min(count, 5) * 0.07)
return {
"category": best.get("category", ""),
"subcategory": best.get("subcategory", ""),
"confidence": round(confidence, 2),
"source": "history",
"reason": f"같은 사용처의 기존 분류 {count}건",
"rule_id": "",
}
return {"category": "", "subcategory": "", "confidence": 0.0, "source": "none", "reason": "추천 근거 없음", "rule_id": ""}
def get_uncategorized_workbench(
limit: int = 100,
min_amount: int = 0,
sort_by: str = "amount_desc",
db_path: str | None = None,
) -> dict[str, Any]:
row_limit = max(1, min(int(limit), 1000))
minimum_amount = int(min_amount)
order_by_map = {
"amount_desc": "amount DESC, transaction_datetime DESC, transaction_date DESC, created_at DESC",
"date_desc": "transaction_datetime DESC, transaction_date DESC, created_at DESC, amount DESC",
}
order_by = order_by_map.get(sort_by, order_by_map["amount_desc"])
with db(db_path) as conn:
tx_rows = [row_to_dict(r) for r in conn.execute(
f"""SELECT id, transaction_date, transaction_datetime, type, amount, currency, merchant, raw_summary,
counterparty, account_id, payment_method, person, is_fixed, source, memo, created_at
FROM transactions
WHERE status='active' AND COALESCE(category,'')='' AND amount>=?
ORDER BY {order_by}
LIMIT ?""",
(minimum_amount, row_limit),
).fetchall()]
total_row = conn.execute(
"""SELECT COUNT(*) AS count, COALESCE(SUM(amount),0) AS total_amount,
COUNT(DISTINCT COALESCE(NULLIF(merchant,''), raw_summary, counterparty, '')) AS merchant_group_count
FROM transactions
WHERE status='active' AND COALESCE(category,'')='' AND amount>=?""",
(minimum_amount,),
).fetchone()
history_rows = [row_to_dict(r) for r in conn.execute(
"""SELECT merchant, type, category, subcategory, COUNT(*) AS count,
SUM(amount) AS total_amount, MAX(transaction_date) AS latest_date
FROM transactions
WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>''
GROUP BY merchant, type, category, subcategory
ORDER BY merchant, type, count DESC, total_amount DESC"""
).fetchall()]
rules = [row_to_dict(r) for r in conn.execute(
"SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id"
).fetchall()]
history: dict[tuple[str, str], list[dict[str, Any]]] = {}
for row in history_rows:
history.setdefault((row.get("merchant", "") or "", row.get("type", "") or ""), []).append(row)
txs: list[dict[str, Any]] = []
groups: dict[tuple[str, str, str], dict[str, Any]] = {}
for tx in tx_rows:
rec = _recommend_category_for_transaction(tx, rules, history)
public_tx = {
**tx,
"recommendation": rec,
"recommended_category": rec.get("category", ""),
"recommended_subcategory": rec.get("subcategory", ""),
"recommendation_confidence": rec.get("confidence", 0.0),
"recommendation_reason": rec.get("reason", ""),
"recommendation_source": rec.get("source", "none"),
}
txs.append(public_tx)
gkey = (tx.get("merchant", "") or "", tx.get("raw_summary", "") or "", tx.get("type", "") or "")
group = groups.setdefault(gkey, {
"merchant": gkey[0],
"raw_summary": gkey[1],
"type": gkey[2],
"count": 0,
"total_amount": 0,
"max_amount": 0,
"latest_date": "",
"sample_transaction_id": tx.get("id", ""),
"recommendation": rec,
})
group["count"] += 1
group["total_amount"] += int(tx.get("amount") or 0)
group["max_amount"] = max(int(group["max_amount"] or 0), int(tx.get("amount") or 0))
date_value = tx.get("transaction_datetime") or tx.get("transaction_date") or ""
if date_value > (group.get("latest_date") or ""):
group["latest_date"] = date_value
if not group["recommendation"].get("category") and rec.get("category"):
group["recommendation"] = rec
group_items = sorted(groups.values(), key=lambda g: (int(g["count"]), int(g["total_amount"])), reverse=True)
recommendable_count = sum(1 for tx in txs if tx.get("recommended_category"))
repeated_group_count = sum(1 for g in group_items if int(g.get("count") or 0) >= 2)
total = row_to_dict(total_row) if total_row else {"count": 0, "total_amount": 0, "merchant_group_count": 0}
return ok(
summary={
"uncategorized_count": int(total.get("count") or 0),
"uncategorized_total_amount": int(total.get("total_amount") or 0),
"merchant_group_count": int(total.get("merchant_group_count") or 0),
"recommendable_count": recommendable_count,
"repeated_group_count": repeated_group_count,
},
groups=group_items,
transactions=txs,
count=len(txs),
)
def apply_uncategorized_recommendation(transaction_id: str, db_path: str | None = None) -> dict[str, Any]:
if not transaction_id:
return fail("validation_error", "transaction_id is required")
with db(db_path) as conn:
row = conn.execute("SELECT * FROM transactions WHERE id=? AND status='active'", (transaction_id,)).fetchone()
if not row:
return fail("not_found", "transaction not found")
tx = row_to_dict(row)
if tx.get("category"):
return fail("validation_error", "transaction is already categorized")
rules = [row_to_dict(r) for r in conn.execute("SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id").fetchall()]
history_rows = [row_to_dict(r) for r in conn.execute(
"""SELECT merchant, type, category, subcategory, COUNT(*) AS count, SUM(amount) AS total_amount
FROM transactions
WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>''
GROUP BY merchant, type, category, subcategory
ORDER BY count DESC, total_amount DESC"""
).fetchall()]
history: dict[tuple[str, str], list[dict[str, Any]]] = {}
for h in history_rows:
history.setdefault((h.get("merchant", "") or "", h.get("type", "") or ""), []).append(h)
rec = _recommend_category_for_transaction(tx, rules, history)
if not rec.get("category"):
return fail("no_recommendation", "추천 분류가 없습니다")
cur = conn.execute(
"""UPDATE transactions
SET category=?, subcategory=?, updated_at=?
WHERE id=? AND status='active' AND COALESCE(category,'')=''""",
(rec.get("category", ""), rec.get("subcategory", ""), now_iso(), transaction_id),
)
return ok(applied_count=cur.rowcount, transaction_id=transaction_id, recommendation=rec)
def apply_uncategorized_group_recommendation(merchant: str, raw_summary: str = "", transaction_type: str = "", db_path: str | None = None) -> dict[str, Any]:
with db(db_path) as conn:
sample = conn.execute(
"""SELECT * FROM transactions
WHERE status='active' AND COALESCE(category,'')='' AND merchant=? AND raw_summary=? AND type=?
ORDER BY amount DESC, transaction_datetime DESC, transaction_date DESC LIMIT 1""",
(merchant, raw_summary, transaction_type),
).fetchone()
if not sample:
return fail("not_found", "uncategorized group not found")
tx = row_to_dict(sample)
rules = [row_to_dict(r) for r in conn.execute("SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id").fetchall()]
history_rows = [row_to_dict(r) for r in conn.execute(
"""SELECT merchant, type, category, subcategory, COUNT(*) AS count, SUM(amount) AS total_amount
FROM transactions
WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>''
GROUP BY merchant, type, category, subcategory
ORDER BY count DESC, total_amount DESC"""
).fetchall()]
history: dict[tuple[str, str], list[dict[str, Any]]] = {}
for h in history_rows:
history.setdefault((h.get("merchant", "") or "", h.get("type", "") or ""), []).append(h)
rec = _recommend_category_for_transaction(tx, rules, history)
if not rec.get("category"):
return fail("no_recommendation", "추천 분류가 없습니다")
cur = conn.execute(
"""UPDATE transactions
SET category=?, subcategory=?, updated_at=?
WHERE status='active' AND COALESCE(category,'')='' AND merchant=? AND raw_summary=? AND type=?""",
(rec.get("category", ""), rec.get("subcategory", ""), now_iso(), merchant, raw_summary, transaction_type),
)
return ok(applied_count=cur.rowcount, merchant=merchant, raw_summary=raw_summary, type=transaction_type, recommendation=rec)
DEFAULT_LEDGER_CATEGORIES: list[dict[str, Any]] = [
{"category": "수입", "subcategory": "급여", "sort_order": 10},
{"category": "수입", "subcategory": "이자", "sort_order": 11},
{"category": "수입", "subcategory": "환급", "sort_order": 12},
{"category": "수입", "subcategory": "배당", "sort_order": 13},
{"category": "수입", "subcategory": "기타수입", "sort_order": 19},
{"category": "이체", "subcategory": "본인계좌이동", "sort_order": 20},
{"category": "이체", "subcategory": "현금입출금", "sort_order": 21},
{"category": "이체", "subcategory": "가족송금", "sort_order": 22},
{"category": "이체", "subcategory": "투자이동", "sort_order": 23},
{"category": "이체", "subcategory": "확인필요", "sort_order": 29},
{"category": "식비", "subcategory": "마트", "sort_order": 30},
{"category": "식비", "subcategory": "외식", "sort_order": 31},
{"category": "식비", "subcategory": "카페", "sort_order": 32},
{"category": "식비", "subcategory": "배달", "sort_order": 33},
{"category": "주거", "subcategory": "관리비", "sort_order": 40},
{"category": "주거", "subcategory": "전기", "sort_order": 41},
{"category": "주거", "subcategory": "가스", "sort_order": 42},
{"category": "주거", "subcategory": "수도", "sort_order": 43},
{"category": "주거", "subcategory": "인터넷", "sort_order": 44},
{"category": "통신", "subcategory": "휴대폰", "sort_order": 50},
{"category": "통신", "subcategory": "인터넷", "sort_order": 51},
{"category": "통신", "subcategory": "통신요금", "sort_order": 52},
{"category": "교통", "subcategory": "대중교통", "sort_order": 60},
{"category": "교통", "subcategory": "택시", "sort_order": 61},
{"category": "교통", "subcategory": "주유", "sort_order": 62},
{"category": "교통", "subcategory": "주차", "sort_order": 63},
{"category": "교통", "subcategory": "차량정비", "sort_order": 64},
{"category": "교육", "subcategory": "학원", "sort_order": 70},
{"category": "교육", "subcategory": "교재", "sort_order": 71},
{"category": "교육", "subcategory": "학교", "sort_order": 72},
{"category": "교육", "subcategory": "예체능", "sort_order": 73},
{"category": "의료", "subcategory": "병원", "sort_order": 80},
{"category": "의료", "subcategory": "약국", "sort_order": 81},
{"category": "의료", "subcategory": "치과", "sort_order": 82},
{"category": "의료", "subcategory": "보험", "sort_order": 83},
{"category": "생활", "subcategory": "생필품", "sort_order": 90},
{"category": "생활", "subcategory": "의류", "sort_order": 91},
{"category": "생활", "subcategory": "미용", "sort_order": 92},
{"category": "생활", "subcategory": "세탁", "sort_order": 93},
{"category": "문화/여가", "subcategory": "여행", "sort_order": 100},
{"category": "문화/여가", "subcategory": "숙박", "sort_order": 101},
{"category": "문화/여가", "subcategory": "공연", "sort_order": 102},
{"category": "문화/여가", "subcategory": "취미", "sort_order": 103},
{"category": "문화/여가", "subcategory": "스포츠", "sort_order": 104},
{"category": "금융", "subcategory": "카드대금", "sort_order": 110},
{"category": "금융", "subcategory": "대출상환", "sort_order": 111},
{"category": "금융", "subcategory": "수수료", "sort_order": 112},
{"category": "금융", "subcategory": "보험료", "sort_order": 113},
{"category": "가족/용돈", "subcategory": "부모님", "sort_order": 120},
{"category": "가족/용돈", "subcategory": "자녀", "sort_order": 121},
{"category": "가족/용돈", "subcategory": "가족송금", "sort_order": 122},
{"category": "미분류", "subcategory": "확인필요", "sort_order": 999},
]
def infer_cashflow_kind(category: str, subcategory: str = "") -> str:
if category == "수입":
return "income"
if category == "이체":
if subcategory == "투자이동":
return "investment"
return "transfer"
if category == "금융":
if subcategory == "대출상환":
return "debt_payment"
if subcategory in {"카드대금", "수수료"}:
return "exclude"
return "exclude"
if category == "미분류":
return "unknown"
return "expense"
def create_ledger_category(category: str, subcategory: str = "", sort_order: int = 100, memo: str = "", cashflow_kind: str = "", db_path: str | None = None) -> dict[str, Any]:
category = (category or "").strip()
subcategory = (subcategory or "").strip()
cashflow_kind = (cashflow_kind or infer_cashflow_kind(category, subcategory)).strip()
if not category:
return fail("validation_error", "category is required")
if cashflow_kind not in VALID_CASHFLOW_KINDS:
return fail("validation_error", f"cashflow_kind must be one of {sorted(VALID_CASHFLOW_KINDS)}")
ts = now_iso(); category_id = new_id("cat")
with db(db_path) as conn:
conn.execute("""INSERT INTO ledger_categories(id,category,subcategory,cashflow_kind,active,sort_order,memo,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)
ON CONFLICT(category, subcategory) DO UPDATE SET active=1, cashflow_kind=excluded.cashflow_kind, sort_order=excluded.sort_order, memo=excluded.memo, updated_at=excluded.updated_at""",
(category_id, category, subcategory, cashflow_kind, 1, int(sort_order), memo, ts, ts))
row = conn.execute("SELECT * FROM ledger_categories WHERE category=? AND subcategory=?", (category, subcategory)).fetchone()
return ok(category=row_to_dict(row))
def seed_default_ledger_categories(overwrite: bool = False, db_path: str | None = None) -> dict[str, Any]:
if overwrite:
with db(db_path) as conn:
conn.execute("UPDATE ledger_categories SET active=0 WHERE memo LIKE 'default:%'")
created = 0
for item in DEFAULT_LEDGER_CATEGORIES:
res = create_ledger_category(memo="default: Jarca initial category", db_path=db_path, **item)
if res.get("success"):
created += 1
return ok(created_or_updated=created)
def list_ledger_categories(active_only: bool = True, include_usage: bool = True, db_path: str | None = None) -> dict[str, Any]:
clauses = []
if active_only:
clauses.append("active=1")
where = " WHERE " + " AND ".join(clauses) if clauses else ""
with db(db_path) as conn:
rows = conn.execute(f"SELECT * FROM ledger_categories{where} ORDER BY sort_order, category, subcategory").fetchall()
items = [row_to_dict(r) for r in rows]
if include_usage:
usage_rows = conn.execute("""SELECT category, subcategory, COUNT(*) AS transaction_count, SUM(amount) AS total_amount
FROM transactions WHERE status='active'
GROUP BY category, subcategory""").fetchall()
usage = {(r["category"], r["subcategory"]): {"transaction_count": int(r["transaction_count"] or 0), "total_amount": int(r["total_amount"] or 0)} for r in usage_rows}
for item in items:
item.update(usage.get((item["category"], item["subcategory"]), {"transaction_count": 0, "total_amount": 0}))
grouped: dict[str, list[str]] = {}
for item in items:
grouped.setdefault(item["category"], [])
if item["subcategory"] and item["subcategory"] not in grouped[item["category"]]:
grouped[item["category"]].append(item["subcategory"])
return ok(categories=items, grouped=grouped, count=len(items))
def delete_ledger_category(category: str, subcategory: str = "", hard_delete: bool = False, force: bool = False, db_path: str | None = None) -> dict[str, Any]:
category = (category or "").strip()
subcategory = (subcategory or "").strip()
if not category:
return fail("validation_error", "category is required")
with db(db_path) as conn:
row = conn.execute("SELECT * FROM ledger_categories WHERE category=? AND subcategory=?", (category, subcategory)).fetchone()
if not row:
return fail("not_found", "category not found")
tx_count = conn.execute("SELECT COUNT(*) FROM transactions WHERE status='active' AND category=? AND subcategory=?", (category, subcategory)).fetchone()[0]
budget_count = conn.execute("SELECT COUNT(*) FROM budgets WHERE category=? AND subcategory=?", (category, subcategory)).fetchone()[0]
rule_count = conn.execute("SELECT COUNT(*) FROM category_rules WHERE active=1 AND category=? AND subcategory=?", (category, subcategory)).fetchone()[0]
in_use = int(tx_count or 0) + int(budget_count or 0) + int(rule_count or 0)
if in_use and hard_delete and not force:
return fail("category_in_use", "category is referenced; use soft delete or force=true", transaction_count=tx_count, budget_count=budget_count, rule_count=rule_count)
if hard_delete:
conn.execute("DELETE FROM ledger_categories WHERE id=?", (row["id"],))
else:
conn.execute("UPDATE ledger_categories SET active=0, updated_at=? WHERE id=?", (now_iso(), row["id"]))
return ok(category=category, subcategory=subcategory, deleted=True, hard_delete=hard_delete, transaction_count=tx_count, budget_count=budget_count, rule_count=rule_count)
File diff suppressed because it is too large Load Diff
+199
View File
@@ -0,0 +1,199 @@
from __future__ import annotations
import csv
import hashlib
import json
import re
import sqlite3
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Any
from uuid import uuid4
from zoneinfo import ZoneInfo
from ..infrastructure.sqlite import db, init_db, resolve_db_path
from ..kb_bank_pdf import parse_kb_bank_pdf, transaction_preview
from ..toss_bank_pdf import parse_toss_bank_pdf, transaction_preview as toss_bank_transaction_preview
from ..toss_securities_pdf import parse_toss_securities_pdf, transaction_preview as toss_transaction_preview
from ..seoulpay_xlsx import parse_seoulpay_xlsx, transaction_preview as seoulpay_transaction_preview
VALID_TRANSACTION_TYPES = {"income", "expense", "transfer", "refund", "adjustment"}
VALID_ACCOUNT_TYPES = {"bank", "card", "cash", "investment", "loan", "other"}
VALID_STATEMENT_STATUSES = {"unknown", "import_supported", "manual_only", "partial_import"}
VALID_LIQUIDITY_BUCKETS = {"cash", "investment", "deposit", "real_estate", "loan", "other"}
ASSET_BUCKETS = ["cash", "investment", "deposit", "real_estate", "other"]
LIABILITY_BUCKETS = ["loan"]
ASSET_BUCKET_ORDER = [*ASSET_BUCKETS, *LIABILITY_BUCKETS]
RECONCILIATION_TARGET_STATUSES = {"manual_only", "partial_import"}
BALANCE_RECONCILIATION_TOLERANCE_KRW = 1_000
BALANCE_RECONCILIATION_REFERENCE_RATIO = 0.001
VALID_CASHFLOW_KINDS = {"income", "expense", "transfer", "investment", "debt_payment", "exclude", "unknown"}
CASHFLOW_KIND_SQL = """COALESCE(lc.cashflow_kind, CASE
WHEN t.category='수입' THEN 'income'
WHEN t.category='이체' AND t.subcategory='투자이동' THEN 'investment'
WHEN t.category='이체' THEN 'transfer'
WHEN t.category='금융' AND t.subcategory='대출상환' THEN 'debt_payment'
WHEN t.category='금융' THEN 'exclude'
WHEN t.category='미분류' THEN 'unknown'
WHEN COALESCE(t.category, '')='' THEN 'unknown'
ELSE 'expense'
END)"""
CROSS_TRANSFER_MATCH_TAG = "cross_transfer_match:"
CROSS_TRANSFER_REVIEW_TAG = "cross_transfer_review:"
DEFAULT_LEDGER_BACKUP_DIR = Path.home() / ".local" / "share" / "jarca" / "backups" / "ledger"
KST = ZoneInfo("Asia/Seoul")
BACKUP_RESTORE_POLICY = {
"mcp_restore_exposed": False,
"restore_requires_explicit_user_approval": True,
"restore_entrypoint": "scripts/ledger_restore_smoke.py 또는 docs/runbooks/ledger-backup-restore.md",
}
def _normalize_counterparty_for_match(value: str) -> str:
if not value:
return ""
# normalize party names for fuzzy matching across KB/토스뱅크 records
normalized = re.sub(r"\\([^)]*\\)", "", value)
normalized = normalized.lower().strip()
normalized = normalized.replace("토스뱅크", "").replace("토스", "")
normalized = normalized.replace("kb", "").replace("국민은행", "")
normalized = re.sub(r"\\s+", "", normalized)
normalized = re.sub(r"[^0-9가-힣a-zA-Z]", "", normalized)
return normalized
def _party_match_score(left: str, right: str) -> int:
left_norm = _normalize_counterparty_for_match(left)
right_norm = _normalize_counterparty_for_match(right)
if not left_norm or not right_norm:
return 0
if left_norm == right_norm:
return 2
if left_norm in right_norm or right_norm in left_norm:
return 1
return 0
def _append_memo_tag(raw_memo: str, tag: str) -> str:
memo = (raw_memo or "").strip()
if not memo:
return tag
if tag in memo:
return memo
return f"{memo}; {tag}"
def _memo_has_tag(raw_memo: str, tag_prefix: str) -> bool:
memo = (raw_memo or "").strip()
return tag_prefix in memo
def _current_kst_datetime() -> datetime:
return datetime.now(KST).replace(microsecond=0)
def current_kst_date() -> date:
return _current_kst_datetime().date()
def current_kst_month() -> str:
return current_kst_date().strftime("%Y-%m")
def now_iso() -> str:
return _current_kst_datetime().isoformat()
def new_id(prefix: str) -> str:
return f"{prefix}_{uuid4().hex[:12]}"
def ok(**data: Any) -> dict[str, Any]:
return {"success": True, **data}
def fail(error_code: str, message: str, **data: Any) -> dict[str, Any]:
return {"success": False, "error_code": error_code, "error": message, **data}
def row_to_dict(row: Any) -> dict[str, Any]:
d = dict(row)
if "tags" in d:
try:
d["tags"] = json.loads(d["tags"] or "[]")
except json.JSONDecodeError:
d["tags"] = []
for k in ("active", "is_fixed", "is_correction"):
if k in d:
d[k] = bool(d[k])
return d
def normalize_date(value: str | None, default_today: bool = False) -> str:
if not value:
if default_today:
return current_kst_date().isoformat()
raise ValueError("date is required")
return date.fromisoformat(value[:10]).isoformat()
def normalize_month(value: str | None) -> str:
if not value:
return current_kst_month()
if re.fullmatch(r"\d{4}-\d{2}", value):
return value
return date.fromisoformat(value[:10]).strftime("%Y-%m")
def normalize_statement_status(value: str | None) -> str:
status = (value or "unknown").strip() or "unknown"
if status not in VALID_STATEMENT_STATUSES:
raise ValueError(f"statement_status must be one of {sorted(VALID_STATEMENT_STATUSES)}")
return status
def normalize_liquidity_bucket(value: str | None) -> str:
bucket = (value or "other").strip() or "other"
if bucket not in VALID_LIQUIDITY_BUCKETS:
raise ValueError(f"liquidity_bucket must be one of {sorted(VALID_LIQUIDITY_BUCKETS)}")
return bucket
def normalize_snapshot_at(value: str | None, default_now: bool = True) -> str:
if not value:
if default_now:
return now_iso()
raise ValueError("baseline_at is required")
raw = value.strip()
if re.fullmatch(r"\d{4}-\d{2}", raw):
month_start = date.fromisoformat(raw + "-01")
if month_start.month == 12:
next_month = date(month_start.year + 1, 1, 1)
else:
next_month = date(month_start.year, month_start.month + 1, 1)
last_day = next_month - timedelta(days=1)
return f"{last_day.isoformat()}T23:59:00+09:00"
if "T" not in raw and len(raw) == 10:
return f"{normalize_date(raw)}T23:59:00+09:00"
datetime.fromisoformat(raw.replace("Z", "+00:00"))
return raw
def normalize_tags(tags: Any) -> str:
if tags is None or tags == "":
return "[]"
if isinstance(tags, str):
parts = [p.strip() for p in re.split(r"[,#]", tags) if p.strip()]
return json.dumps(parts, ensure_ascii=False)
if isinstance(tags, list):
return json.dumps([str(x).strip() for x in tags if str(x).strip()], ensure_ascii=False)
raise ValueError("tags must be a list or comma-separated string")
def _month_range(month: str) -> tuple[str, str]:
m = normalize_month(month)
start = date.fromisoformat(f"{m}-01")
next_month = date(start.year + (start.month // 12), ((start.month % 12) + 1), 1)
return start.isoformat(), next_month.isoformat()
def make_dedupe_key(transaction_date: str, amount: int, merchant: str = "", account_id: str = "", external_id: str = "") -> str:
if external_id:
raw = f"external:{external_id}"
else:
raw = f"{transaction_date}|{amount}|{merchant.strip().lower()}|{account_id.strip().lower()}"
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
@@ -0,0 +1,190 @@
from __future__ import annotations
from .shared import *
from .shared import _current_kst_datetime, _month_range
def health_check(db_path: str | None = None) -> dict[str, Any]:
path = init_db(db_path)
with db(path) as conn:
schema_version = conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0]
tx_count = conn.execute("SELECT COUNT(*) FROM transactions WHERE status='active'").fetchone()[0]
return ok(server="jarca-ledger-mcp", db_path=path, schema_version=schema_version, active_transactions=tx_count)
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _file_metadata(path: Path, include_hash: bool = True) -> dict[str, Any]:
stat = path.stat()
metadata: dict[str, Any] = {
"path": str(path.resolve()),
"size_bytes": stat.st_size,
"mtime": datetime.fromtimestamp(stat.st_mtime).replace(microsecond=0).isoformat(),
}
if include_hash:
metadata["sha256"] = _sha256_file(path)
return metadata
def _readonly_sqlite_connection(path: Path) -> sqlite3.Connection:
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
return conn
def _safe_table_counts(conn: sqlite3.Connection) -> dict[str, int]:
counts: dict[str, int] = {}
for table in ("accounts", "transactions", "budgets", "import_batches", "category_rules", "ledger_categories", "manual_assets", "asset_snapshots"):
exists = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone()
if exists:
counts[table] = int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
return counts
def _safe_monthly_aggregates(conn: sqlite3.Connection) -> dict[str, Any]:
current_month = current_kst_month()
start, next_month = _month_range(current_month)
rows = conn.execute(
f"""SELECT {CASHFLOW_KIND_SQL} AS cashflow_kind, SUM(t.amount) AS total, COUNT(*) AS count
FROM transactions t
LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date<?
GROUP BY {CASHFLOW_KIND_SQL}""",
(start, next_month),
).fetchall()
return {
"month": current_month,
"by_cashflow_kind": {
str(row["cashflow_kind"]): {"amount": int(row["total"] or 0), "count": int(row["count"] or 0)}
for row in rows
},
}
def _ledger_db_status_for_path(path: Path, include_hash: bool = True, include_integrity: bool = True) -> dict[str, Any]:
status: dict[str, Any] = {"db_path": str(path.resolve()), "exists": path.exists()}
if not path.exists():
return status
status.update(_file_metadata(path, include_hash=include_hash))
try:
with _readonly_sqlite_connection(path) as conn:
if include_integrity:
status["integrity_check"] = str(conn.execute("PRAGMA integrity_check").fetchone()[0])
status["table_counts"] = _safe_table_counts(conn)
status["aggregate_totals"] = _safe_monthly_aggregates(conn)
except Exception as e:
status["integrity_check"] = "error"
status["error"] = str(e)
return status
def get_ledger_db_status(db_path: str | None = None, include_hash: bool = True, include_integrity: bool = True) -> dict[str, Any]:
"""Return non-sensitive metadata evidence for the selected ledger DB."""
path = resolve_db_path(db_path)
if not path.exists():
return fail("not_found", f"ledger DB not found: {path}", db_path=str(path), exists=False)
return ok(**_ledger_db_status_for_path(path, include_hash=include_hash, include_integrity=include_integrity))
def _resolve_backup_dir(backup_dir: str | None = None) -> Path:
return Path(backup_dir).expanduser().resolve() if backup_dir else DEFAULT_LEDGER_BACKUP_DIR.resolve()
def _backup_file_metadata(path: Path, include_hash: bool = False) -> dict[str, Any]:
metadata = _file_metadata(path, include_hash=include_hash)
metadata["name"] = path.name
metadata["restore_policy"] = BACKUP_RESTORE_POLICY
return metadata
def _apply_backup_retention(backup_dir: Path, retention_days: int = 30, retention_count: int = 20) -> list[str]:
backups = sorted(backup_dir.glob("*.sqlite3"), key=lambda p: p.stat().st_mtime, reverse=True)
keep_count = max(1, int(retention_count))
# Retention comparison needs sub-second precision for retention_days=0:
# the timestamp used for backup names is second-truncated, but filesystem mtimes
# can identify files created just before retention runs as older than "now".
cutoff = datetime.now(KST) - timedelta(days=max(0, int(retention_days)))
pruned: list[str] = []
for idx, path in enumerate(backups):
mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=KST)
should_keep = idx < keep_count or mtime >= cutoff
if should_keep:
continue
path.unlink()
pruned.append(str(path.resolve()))
return pruned
def create_ledger_backup(db_path: str | None = None, backup_dir: str | None = None, retention_days: int = 30, retention_count: int = 20) -> dict[str, Any]:
"""Create a timestamped SQLite backup and return metadata-only evidence."""
source_path = resolve_db_path(db_path)
if not source_path.exists():
return fail("not_found", f"ledger DB not found: {source_path}", db_path=str(source_path), exists=False)
backup_root = _resolve_backup_dir(backup_dir)
backup_root.mkdir(parents=True, exist_ok=True)
timestamp = _current_kst_datetime().strftime("%Y%m%d%H%M%S%f")
backup_path = backup_root / f"ledger_{timestamp}.sqlite3"
try:
# Use SQLite's online backup API so WAL-mode databases are copied consistently.
with sqlite3.connect(f"file:{source_path}?mode=ro", uri=True) as source_conn:
with sqlite3.connect(backup_path) as backup_conn:
source_conn.backup(backup_conn)
except Exception:
if backup_path.exists():
backup_path.unlink()
raise
source_status = _ledger_db_status_for_path(source_path, include_hash=True, include_integrity=True)
backup_status = _ledger_db_status_for_path(backup_path, include_hash=True, include_integrity=True)
pruned_paths = _apply_backup_retention(backup_root, retention_days=retention_days, retention_count=retention_count)
return ok(
source_db=source_status,
backup=backup_status,
backup_dir=str(backup_root),
retention={"retention_days": int(retention_days), "retention_count": int(retention_count), "pruned_paths": pruned_paths},
restore_policy=BACKUP_RESTORE_POLICY,
)
def list_ledger_backups(backup_dir: str | None = None, include_hash: bool = False, limit: int = 50) -> dict[str, Any]:
backup_root = _resolve_backup_dir(backup_dir)
if not backup_root.exists():
return ok(backup_dir=str(backup_root), backups=[], count=0, restore_policy=BACKUP_RESTORE_POLICY)
max_items = max(1, min(int(limit), 500))
files = sorted(backup_root.glob("*.sqlite3"), key=lambda p: p.stat().st_mtime, reverse=True)[:max_items]
backups = [_backup_file_metadata(path, include_hash=include_hash) for path in files]
return ok(backup_dir=str(backup_root), backups=backups, count=len(backups), restore_policy=BACKUP_RESTORE_POLICY)
def get_ledger_backup_status(file_path: str, include_hash: bool = True, include_integrity: bool = True) -> dict[str, Any]:
path = Path(file_path).expanduser().resolve()
if not path.exists():
return fail("not_found", f"backup file not found: {path}", path=str(path), exists=False)
return ok(backup=_ledger_db_status_for_path(path, include_hash=include_hash, include_integrity=include_integrity), restore_policy=BACKUP_RESTORE_POLICY)
def create_ledger_account(name: str, type: str = "bank", institution: str = "", currency: str = "KRW", active: bool = True, memo: str = "", account_number_masked: str = "", holder_name: str = "", last_balance: int | None = None, last_synced_at: str = "", statement_status: str = "unknown", liquidity_bucket: str = "other", db_path: str | None = None) -> dict[str, Any]:
if not name.strip():
return fail("validation_error", "account name is required")
if type not in VALID_ACCOUNT_TYPES:
return fail("validation_error", f"type must be one of {sorted(VALID_ACCOUNT_TYPES)}")
try:
statement_status_norm = normalize_statement_status(statement_status)
liquidity_bucket_norm = normalize_liquidity_bucket(liquidity_bucket)
except Exception as e:
return fail("validation_error", str(e))
ts = now_iso(); account_id = new_id("acct")
with db(db_path) as conn:
conn.execute("""INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,statement_status,liquidity_bucket,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (account_id, name.strip(), type, institution, currency, int(active), memo, account_number_masked, holder_name, last_balance, last_synced_at, statement_status_norm, liquidity_bucket_norm, ts, ts))
row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
return ok(account=row_to_dict(row))
def list_ledger_accounts(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]:
sql = "SELECT * FROM accounts" + (" WHERE active=1" if active_only else "") + " ORDER BY name"
with db(db_path) as conn:
rows = conn.execute(sql).fetchall()
return ok(accounts=[row_to_dict(r) for r in rows], count=len(rows))
@@ -0,0 +1,272 @@
from __future__ import annotations
from .shared import *
def create_ledger_transaction(transaction_date: str = "", type: str = "expense", amount: int | float = 0, category: str = "", subcategory: str = "", merchant: str = "", description: str = "", account_id: str = "", payment_method: str = "", person: str = "", project: str = "", tags: Any = None, is_fixed: bool = False, currency: str = "KRW", posted_date: str = "", source: str = "manual", external_id: str = "", memo: str = "", dedupe: bool = True, transaction_datetime: str = "", counterparty: str = "", balance_after: int | None = None, branch: str = "", raw_summary: str = "", raw_payload: str = "", db_path: str | None = None) -> dict[str, Any]:
try:
tx_date = normalize_date(transaction_date, default_today=True)
posted = normalize_date(posted_date, default_today=False) if posted_date else ""
amount_int = int(amount)
tags_json = normalize_tags(tags)
except Exception as e:
return fail("validation_error", str(e))
if type not in VALID_TRANSACTION_TYPES:
return fail("validation_error", f"type must be one of {sorted(VALID_TRANSACTION_TYPES)}")
if amount_int <= 0:
return fail("validation_error", "amount must be a positive integer in minor units; type determines direction")
dedupe_key = make_dedupe_key(tx_date, amount_int, merchant, account_id, external_id) if dedupe else ""
ts = now_iso(); tx_id = new_id("tx")
try:
with db(db_path) as conn:
conn.execute("""INSERT INTO transactions(id,transaction_date,posted_date,type,amount,currency,category,subcategory,merchant,description,account_id,payment_method,person,project,tags,is_fixed,source,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (tx_id, tx_date, posted, type, amount_int, currency, category, subcategory, merchant, description, account_id, payment_method, person, project, tags_json, int(is_fixed), source, external_id, dedupe_key, memo, transaction_datetime, counterparty, balance_after, branch, raw_summary, raw_payload, ts, ts))
row = conn.execute("SELECT * FROM transactions WHERE id=?", (tx_id,)).fetchone()
except Exception as e:
if "UNIQUE constraint failed" in str(e):
return fail("duplicate_transaction", "transaction appears to be duplicated", dedupe_key=dedupe_key)
return fail("db_error", str(e))
return ok(transaction=row_to_dict(row))
def list_ledger_transactions(start_date: str = "", end_date: str = "", type: str = "", category: str = "", person: str = "", account_id: str = "", include_deleted: bool = False, limit: int = 100, db_path: str | None = None) -> dict[str, Any]:
clauses=[]; params=[]
if not include_deleted:
clauses.append("status='active'")
for field, value in [("type", type), ("category", category), ("person", person), ("account_id", account_id)]:
if value:
clauses.append(f"{field}=?"); params.append(value)
if start_date:
clauses.append("transaction_date>=?"); params.append(normalize_date(start_date))
if end_date:
clauses.append("transaction_date<=?"); params.append(normalize_date(end_date))
where = " WHERE " + " AND ".join(clauses) if clauses else ""
sql = f"SELECT * FROM transactions{where} ORDER BY transaction_date DESC, created_at DESC LIMIT ?"
params.append(max(1, min(int(limit), 1000)))
with db(db_path) as conn:
rows = conn.execute(sql, params).fetchall()
return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows))
def _transaction_signed_amount(tx_type: str, amount: int) -> int:
if tx_type in {"income", "refund", "adjustment"}:
return int(amount)
if tx_type in {"expense", "transfer"}:
return -int(amount)
return 0
def _account_balance_row(conn: sqlite3.Connection, account_id: str, baseline_at: str) -> dict[str, Any]:
date_cutoff = normalize_date(baseline_at[:10])
rows = conn.execute(
"""SELECT type, amount, balance_after, transaction_date, transaction_datetime, created_at
FROM transactions
WHERE status='active' AND account_id=? AND transaction_date<=?
ORDER BY transaction_date ASC, transaction_datetime ASC, created_at ASC""",
(account_id, date_cutoff),
).fetchall()
signed_total = sum(_transaction_signed_amount(row["type"], int(row["amount"] or 0)) for row in rows)
latest_balance_row = next((row for row in reversed(rows) if row["balance_after"] is not None), None)
if latest_balance_row is not None:
calculated_balance = int(latest_balance_row["balance_after"])
basis = "latest_transaction_balance_after"
basis_at = latest_balance_row["transaction_datetime"] or latest_balance_row["transaction_date"]
else:
calculated_balance = int(signed_total)
basis = "net_transaction_delta_without_opening_balance"
basis_at = baseline_at
return {
"calculated_balance": calculated_balance,
"calculation_basis": basis,
"calculation_basis_at": basis_at,
"transaction_count": len(rows),
"signed_transaction_total": int(signed_total),
}
def get_balance_reconciliation_report(account_id: str = "", baseline_balance: int | None = None, baseline_at: str = "", source_note: str = "", memo: str = "", tolerance: int = BALANCE_RECONCILIATION_TOLERANCE_KRW, db_path: str | None = None) -> dict[str, Any]:
"""Preview manual/partial-import account balance reconciliation without mutating transactions."""
try:
baseline_at_norm = normalize_snapshot_at(baseline_at.strip(), default_now=False) if baseline_at and baseline_at.strip() else None
tolerance_int = int(tolerance)
if tolerance_int < 0:
raise ValueError("tolerance must be non-negative")
baseline_balance_int = int(baseline_balance) if baseline_balance is not None else None
except Exception as e:
return fail("validation_error", str(e))
with db(db_path) as conn:
clauses = ["active=1"]
params: list[Any] = []
if account_id:
clauses.append("id=?")
params.append(account_id)
else:
placeholders = ",".join("?" for _ in RECONCILIATION_TARGET_STATUSES)
clauses.append(f"statement_status IN ({placeholders})")
params.extend(sorted(RECONCILIATION_TARGET_STATUSES))
accounts = conn.execute(f"SELECT * FROM accounts WHERE {' AND '.join(clauses)} ORDER BY name", params).fetchall()
items: list[dict[str, Any]] = []
for account in accounts:
effective_baseline = baseline_balance_int if baseline_balance_int is not None else account["last_balance"]
effective_at = baseline_at_norm or account["last_synced_at"] or now_iso()
calc = _account_balance_row(conn, account["id"], effective_at)
if effective_baseline is None:
difference = None
ratio = None
status = "BASELINE_REQUIRED"
else:
difference = int(effective_baseline) - int(calc["calculated_balance"])
ratio = abs(difference) / abs(int(effective_baseline)) if int(effective_baseline) else None
status = "OK" if abs(difference) <= tolerance_int else "REVIEW_NEEDED"
items.append({
"account_id": account["id"],
"account_name": account["name"],
"statement_status": account["statement_status"],
"is_reconciliation_target": account["statement_status"] in RECONCILIATION_TARGET_STATUSES,
"baseline_balance": effective_baseline,
"baseline_at": effective_at,
"calculated_balance": calc["calculated_balance"],
"difference": difference,
"tolerance": tolerance_int,
"reference_ratio_threshold": BALANCE_RECONCILIATION_REFERENCE_RATIO,
"difference_ratio": ratio,
"status": status,
"recommended_decisions": ["계좌이동", "실지출", "실수입", "투자이동", "분석제외", "확인필요"] if status == "REVIEW_NEEDED" else [],
"source_note": source_note,
"memo": memo,
**calc,
})
return ok(items=items, count=len(items), tolerance=tolerance_int, baseline_at=baseline_at_norm, routing="preview_only_manual_confirmation_required")
def update_ledger_transaction(transaction_id: str, db_path: str | None = None, **fields: Any) -> dict[str, Any]:
allowed = {"transaction_date","posted_date","type","amount","currency","category","subcategory","merchant","description","account_id","payment_method","person","project","tags","is_fixed","source","external_id","memo","status","transaction_datetime","counterparty","balance_after","branch","raw_summary","raw_payload"}
updates=[]; params=[]
for k,v in fields.items():
if k not in allowed or v is None:
continue
if k == "tags": v = normalize_tags(v)
if k in {"transaction_date","posted_date"} and v: v = normalize_date(v)
if k == "amount": v = int(v)
if k == "is_fixed": v = int(bool(v))
updates.append(f"{k}=?"); params.append(v)
if not updates:
return fail("validation_error", "no fields to update")
updates.append("updated_at=?"); params.append(now_iso()); params.append(transaction_id)
with db(db_path) as conn:
cur = conn.execute(f"UPDATE transactions SET {', '.join(updates)} WHERE id=?", params)
if cur.rowcount == 0:
return fail("not_found", "transaction not found")
row = conn.execute("SELECT * FROM transactions WHERE id=?", (transaction_id,)).fetchone()
return ok(transaction=row_to_dict(row))
def delete_ledger_transaction(transaction_id: str, hard_delete: bool = False, db_path: str | None = None) -> dict[str, Any]:
with db(db_path) as conn:
if hard_delete:
cur = conn.execute("DELETE FROM transactions WHERE id=?", (transaction_id,))
else:
cur = conn.execute("UPDATE transactions SET status='deleted', updated_at=? WHERE id=?", (now_iso(), transaction_id))
if cur.rowcount == 0:
return fail("not_found", "transaction not found")
return ok(transaction_id=transaction_id, deleted=True, hard_delete=hard_delete)
def create_ledger_budget(month: str, category: str, amount: int | float, subcategory: str = "", person: str = "", currency: str = "KRW", memo: str = "", db_path: str | None = None) -> dict[str, Any]:
try:
month_norm = normalize_month(month); amount_int = int(amount)
except Exception as e:
return fail("validation_error", str(e))
if not category:
return fail("validation_error", "category is required")
if amount_int < 0:
return fail("validation_error", "amount must be non-negative")
ts = now_iso(); budget_id = new_id("bud")
with db(db_path) as conn:
conn.execute("""INSERT INTO budgets(id,month,category,subcategory,person,amount,currency,memo,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(month,category,subcategory,person) DO UPDATE SET amount=excluded.amount,currency=excluded.currency,memo=excluded.memo,updated_at=excluded.updated_at""", (budget_id, month_norm, category, subcategory, person, amount_int, currency, memo, ts, ts))
row = conn.execute("SELECT * FROM budgets WHERE month=? AND category=? AND subcategory=? AND person=?", (month_norm, category, subcategory, person)).fetchone()
return ok(budget=row_to_dict(row))
def list_ledger_budgets(month: str = "", db_path: str | None = None) -> dict[str, Any]:
clauses=[]; params=[]
if month:
clauses.append("month=?"); params.append(normalize_month(month))
where = " WHERE " + " AND ".join(clauses) if clauses else ""
with db(db_path) as conn:
rows = conn.execute(f"SELECT * FROM budgets{where} ORDER BY month DESC, category", params).fetchall()
return ok(budgets=[row_to_dict(r) for r in rows], count=len(rows))
def _month_range(month: str) -> tuple[str, str]:
m = normalize_month(month)
y, mo = map(int, m.split("-"))
start = date(y, mo, 1)
end = date(y + (mo == 12), 1 if mo == 12 else mo + 1, 1)
return start.isoformat(), end.isoformat()
def get_monthly_cashflow_report(month: str = "", db_path: str | None = None) -> dict[str, Any]:
month_norm = normalize_month(month); start, next_month = _month_range(month_norm)
with db(db_path) as conn:
type_rows = conn.execute("""SELECT type, SUM(amount) AS total FROM transactions
WHERE status='active' AND transaction_date>=? AND transaction_date<?
GROUP BY type""", (start, next_month)).fetchall()
cashflow_rows = conn.execute(f"""SELECT {CASHFLOW_KIND_SQL} AS cashflow_kind, SUM(t.amount) AS total
FROM transactions t
LEFT JOIN ledger_categories lc
ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date<?
GROUP BY {CASHFLOW_KIND_SQL}""", (start, next_month)).fetchall()
type_totals = {r["type"]: int(r["total"] or 0) for r in type_rows}
cashflow_totals = {r["cashflow_kind"]: int(r["total"] or 0) for r in cashflow_rows}
income = cashflow_totals.get("income", 0)
expenses = cashflow_totals.get("expense", 0)
net_cashflow = income - expenses
savings_rate = round(net_cashflow / income, 4) if income else None
return ok(month=month_norm, income=income, expenses=expenses, net_cashflow=net_cashflow, savings_rate=savings_rate, by_type=type_totals, by_cashflow_kind=cashflow_totals)
def get_category_spending_report(month: str = "", start_date: str = "", end_date: str = "", person: str = "", db_path: str | None = None) -> dict[str, Any]:
if month:
start_date, next_month = _month_range(month); end_op = "<"; end_value = next_month
else:
start_date = normalize_date(start_date, default_today=False) if start_date else "0001-01-01"
end_value = normalize_date(end_date, default_today=False) if end_date else "9999-12-31"; end_op = "<="
params=[start_date, end_value]; person_clause=""
if person:
person_clause=" AND t.person=?"; params.append(person)
sql=f"""SELECT t.category, t.subcategory, SUM(t.amount) AS total, COUNT(*) AS count FROM transactions t
LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
WHERE t.status='active' AND {CASHFLOW_KIND_SQL}='expense' AND t.transaction_date>=? AND t.transaction_date{end_op}?{person_clause}
GROUP BY t.category, t.subcategory ORDER BY total DESC"""
with db(db_path) as conn:
rows=conn.execute(sql, params).fetchall()
total=sum(int(r["total"] or 0) for r in rows)
items=[{"category": r["category"], "subcategory": r["subcategory"], "amount": int(r["total"] or 0), "count": r["count"], "share": round((int(r["total"] or 0)/total),4) if total else 0} for r in rows]
return ok(total_expense=total, items=items, count=len(items))
def get_fixed_cost_report(month: str = "", db_path: str | None = None) -> dict[str, Any]:
month_norm=normalize_month(month); start,next_month=_month_range(month_norm)
with db(db_path) as conn:
rows=conn.execute(f"""SELECT t.category, t.merchant, t.amount, t.transaction_date, t.person, t.memo FROM transactions t
LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
WHERE t.status='active' AND {CASHFLOW_KIND_SQL}='expense' AND t.is_fixed=1 AND t.transaction_date>=? AND t.transaction_date<?
ORDER BY t.amount DESC""", (start,next_month)).fetchall()
items=[row_to_dict(r) for r in rows]
return ok(month=month_norm, total_fixed_cost=sum(i["amount"] for i in items), items=items, count=len(items))
def get_budget_vs_actual_report(month: str = "", db_path: str | None = None) -> dict[str, Any]:
month_norm=normalize_month(month); start,next_month=_month_range(month_norm)
with db(db_path) as conn:
budgets=conn.execute("SELECT * FROM budgets WHERE month=?", (month_norm,)).fetchall()
actuals=conn.execute(f"""SELECT t.category, t.subcategory, t.person, SUM(t.amount) AS amount FROM transactions t
LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
WHERE t.status='active' AND {CASHFLOW_KIND_SQL}='expense' AND t.transaction_date>=? AND t.transaction_date<?
GROUP BY t.category, t.subcategory, t.person""", (start,next_month)).fetchall()
actual_map={(r["category"], r["subcategory"], r["person"]): int(r["amount"] or 0) for r in actuals}
items=[]
for b in budgets:
key=(b["category"], b["subcategory"], b["person"]); actual=actual_map.get(key,0); budget=int(b["amount"])
items.append({"category": b["category"], "subcategory": b["subcategory"], "person": b["person"], "budget": budget, "actual": actual, "remaining": budget-actual, "usage_rate": round(actual/budget,4) if budget else None, "status": "over" if actual>budget else "ok"})
return ok(month=month_norm, items=items, total_budget=sum(i["budget"] for i in items), total_actual=sum(i["actual"] for i in items))
def get_investable_cashflow_report(month: str = "", minimum_cash_buffer: int = 0, planned_extra_expense: int = 0, db_path: str | None = None) -> dict[str, Any]:
cashflow=get_monthly_cashflow_report(month, db_path)
if not cashflow.get("success"):
return cashflow
investable=max(0, int(cashflow["net_cashflow"]) - int(minimum_cash_buffer) - int(planned_extra_expense))
return ok(month=cashflow["month"], income=cashflow["income"], expenses=cashflow["expenses"], net_cashflow=cashflow["net_cashflow"], minimum_cash_buffer=int(minimum_cash_buffer), planned_extra_expense=int(planned_extra_expense), investable_cashflow=investable)
+1 -331
View File
@@ -1,333 +1,3 @@
from __future__ import annotations from __future__ import annotations
import os from .infrastructure.sqlite import *
import sqlite3
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator
DEFAULT_DB_PATH = Path.home() / ".local" / "share" / "jarca" / "ledger.sqlite3"
SCHEMA_VERSION = "10"
SCHEMA_SQL = """
PRAGMA journal_mode=WAL;
PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS schema_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'bank',
institution TEXT NOT NULL DEFAULT '',
currency TEXT NOT NULL DEFAULT 'KRW',
active INTEGER NOT NULL DEFAULT 1,
memo TEXT NOT NULL DEFAULT '',
account_number_masked TEXT NOT NULL DEFAULT '',
holder_name TEXT NOT NULL DEFAULT '',
last_balance INTEGER,
last_synced_at TEXT NOT NULL DEFAULT '',
statement_status TEXT NOT NULL DEFAULT 'unknown',
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS transactions (
id TEXT PRIMARY KEY,
transaction_date TEXT NOT NULL,
posted_date TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL,
amount INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'KRW',
category TEXT NOT NULL DEFAULT '',
subcategory TEXT NOT NULL DEFAULT '',
merchant TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
account_id TEXT NOT NULL DEFAULT '',
payment_method TEXT NOT NULL DEFAULT '',
person TEXT NOT NULL DEFAULT '',
project TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
is_fixed INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT 'manual',
import_batch_id TEXT NOT NULL DEFAULT '',
external_id TEXT NOT NULL DEFAULT '',
dedupe_key TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
transaction_datetime TEXT NOT NULL DEFAULT '',
counterparty TEXT NOT NULL DEFAULT '',
balance_after INTEGER,
branch TEXT NOT NULL DEFAULT '',
raw_summary TEXT NOT NULL DEFAULT '',
raw_payload TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(transaction_date);
CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category, subcategory);
CREATE INDEX IF NOT EXISTS idx_transactions_person ON transactions(person);
CREATE INDEX IF NOT EXISTS idx_transactions_account ON transactions(account_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_dedupe ON transactions(dedupe_key) WHERE dedupe_key <> '';
CREATE TABLE IF NOT EXISTS budgets (
id TEXT PRIMARY KEY,
month TEXT NOT NULL,
category TEXT NOT NULL,
subcategory TEXT NOT NULL DEFAULT '',
person TEXT NOT NULL DEFAULT '',
amount INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'KRW',
memo TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_budgets_month ON budgets(month);
CREATE UNIQUE INDEX IF NOT EXISTS idx_budgets_unique ON budgets(month, category, subcategory, person);
CREATE TABLE IF NOT EXISTS import_batches (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
filename TEXT NOT NULL DEFAULT '',
imported_at TEXT NOT NULL,
row_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
memo TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS category_rules (
id TEXT PRIMARY KEY,
priority INTEGER NOT NULL DEFAULT 100,
match_field TEXT NOT NULL,
match_type TEXT NOT NULL DEFAULT 'contains',
pattern TEXT NOT NULL,
amount_min INTEGER,
amount_max INTEGER,
day_of_month INTEGER,
transaction_type TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL,
subcategory TEXT NOT NULL DEFAULT '',
person TEXT NOT NULL DEFAULT '',
is_fixed INTEGER,
confidence REAL NOT NULL DEFAULT 1.0,
active INTEGER NOT NULL DEFAULT 1,
memo TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_category_rules_active_priority ON category_rules(active, priority);
CREATE UNIQUE INDEX IF NOT EXISTS idx_category_rules_unique ON category_rules(match_field, match_type, pattern, transaction_type, category, subcategory, person);
CREATE TABLE IF NOT EXISTS ledger_categories (
id TEXT PRIMARY KEY,
category TEXT NOT NULL,
subcategory TEXT NOT NULL DEFAULT '',
cashflow_kind TEXT NOT NULL DEFAULT 'expense',
active INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 100,
memo TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_ledger_categories_active_sort ON ledger_categories(active, sort_order, category, subcategory);
CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_categories_unique ON ledger_categories(category, subcategory);
CREATE TABLE IF NOT EXISTS manual_assets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
valuation_amount INTEGER,
valuation_date TEXT NOT NULL DEFAULT '',
currency TEXT NOT NULL DEFAULT 'KRW',
source_note TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_manual_assets_active_bucket ON manual_assets(active, liquidity_bucket, name);
CREATE INDEX IF NOT EXISTS idx_manual_assets_valuation_date ON manual_assets(valuation_date);
CREATE TABLE IF NOT EXISTS asset_snapshots (
id TEXT PRIMARY KEY,
basis_date TEXT NOT NULL,
source_type TEXT NOT NULL,
source_id TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
amount INTEGER,
currency TEXT NOT NULL DEFAULT 'KRW',
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
valuation_source TEXT NOT NULL DEFAULT '',
source_note TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
snapshot_run_id TEXT NOT NULL DEFAULT '',
correction_of_run_id TEXT NOT NULL DEFAULT '',
correction_reason TEXT NOT NULL DEFAULT '',
is_correction INTEGER NOT NULL DEFAULT 0,
closed_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_asset_snapshots_basis_date ON asset_snapshots(basis_date);
CREATE INDEX IF NOT EXISTS idx_asset_snapshots_source ON asset_snapshots(source_type, source_id);
INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '10');
"""
ACCOUNT_ADDITIVE_COLUMNS: dict[str, str] = {
"account_number_masked": "TEXT NOT NULL DEFAULT ''",
"holder_name": "TEXT NOT NULL DEFAULT ''",
"last_balance": "INTEGER",
"last_synced_at": "TEXT NOT NULL DEFAULT ''",
"statement_status": "TEXT NOT NULL DEFAULT 'unknown'",
"liquidity_bucket": "TEXT NOT NULL DEFAULT 'other'",
}
TRANSACTION_ADDITIVE_COLUMNS: dict[str, str] = {
"transaction_datetime": "TEXT NOT NULL DEFAULT ''",
"counterparty": "TEXT NOT NULL DEFAULT ''",
"balance_after": "INTEGER",
"branch": "TEXT NOT NULL DEFAULT ''",
"raw_summary": "TEXT NOT NULL DEFAULT ''",
"raw_payload": "TEXT NOT NULL DEFAULT ''",
}
def resolve_db_path(db_path: str | None = None) -> Path:
raw = db_path or os.getenv("JARCA_LEDGER_DB_PATH") or str(DEFAULT_DB_PATH)
return Path(raw).expanduser().resolve()
def connect(db_path: str | None = None) -> sqlite3.Connection:
path = resolve_db_path(db_path)
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys=ON")
return conn
def _existing_columns(conn: sqlite3.Connection, table: str) -> set[str]:
return {row["name"] for row in conn.execute(f"PRAGMA table_info({table})")}
def _add_missing_columns(conn: sqlite3.Connection, table: str, columns: dict[str, str]) -> None:
existing = _existing_columns(conn, table)
for name, definition in columns.items():
if name not in existing:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {definition}")
def migrate_db(conn: sqlite3.Connection) -> None:
"""Apply additive, idempotent migrations only."""
_add_missing_columns(conn, "accounts", ACCOUNT_ADDITIVE_COLUMNS)
_add_missing_columns(conn, "transactions", TRANSACTION_ADDITIVE_COLUMNS)
conn.execute("CREATE INDEX IF NOT EXISTS idx_transactions_datetime ON transactions(transaction_datetime)")
conn.execute("""CREATE TABLE IF NOT EXISTS category_rules (
id TEXT PRIMARY KEY,
priority INTEGER NOT NULL DEFAULT 100,
match_field TEXT NOT NULL,
match_type TEXT NOT NULL DEFAULT 'contains',
pattern TEXT NOT NULL,
amount_min INTEGER,
amount_max INTEGER,
day_of_month INTEGER,
transaction_type TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL,
subcategory TEXT NOT NULL DEFAULT '',
person TEXT NOT NULL DEFAULT '',
is_fixed INTEGER,
confidence REAL NOT NULL DEFAULT 1.0,
active INTEGER NOT NULL DEFAULT 1,
memo TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)""")
_add_missing_columns(conn, "category_rules", {"amount_min": "INTEGER", "amount_max": "INTEGER", "day_of_month": "INTEGER"})
conn.execute("CREATE INDEX IF NOT EXISTS idx_category_rules_active_priority ON category_rules(active, priority)")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_category_rules_unique ON category_rules(match_field, match_type, pattern, transaction_type, category, subcategory, person)")
conn.execute("""CREATE TABLE IF NOT EXISTS ledger_categories (
id TEXT PRIMARY KEY,
category TEXT NOT NULL,
subcategory TEXT NOT NULL DEFAULT '',
cashflow_kind TEXT NOT NULL DEFAULT 'expense',
active INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 100,
memo TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)""")
_add_missing_columns(conn, "ledger_categories", {"cashflow_kind": "TEXT NOT NULL DEFAULT 'expense'"})
conn.execute("CREATE INDEX IF NOT EXISTS idx_ledger_categories_active_sort ON ledger_categories(active, sort_order, category, subcategory)")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_categories_unique ON ledger_categories(category, subcategory)")
conn.execute("""CREATE TABLE IF NOT EXISTS manual_assets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
valuation_amount INTEGER,
valuation_date TEXT NOT NULL DEFAULT '',
currency TEXT NOT NULL DEFAULT 'KRW',
source_note TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_manual_assets_active_bucket ON manual_assets(active, liquidity_bucket, name)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_manual_assets_valuation_date ON manual_assets(valuation_date)")
conn.execute("""CREATE TABLE IF NOT EXISTS asset_snapshots (
id TEXT PRIMARY KEY,
basis_date TEXT NOT NULL,
source_type TEXT NOT NULL,
source_id TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
amount INTEGER,
currency TEXT NOT NULL DEFAULT 'KRW',
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
valuation_source TEXT NOT NULL DEFAULT '',
source_note TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
snapshot_run_id TEXT NOT NULL DEFAULT '',
correction_of_run_id TEXT NOT NULL DEFAULT '',
correction_reason TEXT NOT NULL DEFAULT '',
is_correction INTEGER NOT NULL DEFAULT 0,
closed_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)""")
_add_missing_columns(conn, "asset_snapshots", {
"snapshot_run_id": "TEXT NOT NULL DEFAULT ''",
"correction_of_run_id": "TEXT NOT NULL DEFAULT ''",
"correction_reason": "TEXT NOT NULL DEFAULT ''",
"is_correction": "INTEGER NOT NULL DEFAULT 0",
"closed_at": "TEXT NOT NULL DEFAULT ''",
})
conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_basis_date ON asset_snapshots(basis_date)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_source ON asset_snapshots(source_type, source_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_run ON asset_snapshots(basis_date, snapshot_run_id, created_at)")
conn.execute("INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', ?)", (SCHEMA_VERSION,))
def init_db(db_path: str | None = None) -> str:
path = resolve_db_path(db_path)
with connect(str(path)) as conn:
conn.executescript(SCHEMA_SQL)
migrate_db(conn)
return str(path)
@contextmanager
def db(db_path: str | None = None) -> Iterator[sqlite3.Connection]:
init_db(db_path)
conn = connect(db_path)
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
@@ -0,0 +1,3 @@
from .connection import DEFAULT_DB_PATH, connect, db, resolve_db_path
from .migrations import init_db, migrate_db
from .schema import ACCOUNT_ADDITIVE_COLUMNS, SCHEMA_SQL, SCHEMA_VERSION, TRANSACTION_ADDITIVE_COLUMNS
@@ -0,0 +1,39 @@
from __future__ import annotations
import os
import sqlite3
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator
DEFAULT_DB_PATH = Path.home() / ".local" / "share" / "jarca" / "ledger.sqlite3"
def resolve_db_path(db_path: str | None = None) -> Path:
raw = db_path or os.getenv("JARCA_LEDGER_DB_PATH") or str(DEFAULT_DB_PATH)
return Path(raw).expanduser().resolve()
def connect(db_path: str | None = None) -> sqlite3.Connection:
path = resolve_db_path(db_path)
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys=ON")
return conn
@contextmanager
def db(db_path: str | None = None) -> Iterator[sqlite3.Connection]:
from .migrations import init_db
init_db(db_path)
conn = connect(db_path)
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
@@ -0,0 +1,114 @@
from __future__ import annotations
import sqlite3
from pathlib import Path
from .connection import connect, resolve_db_path
from .schema import ACCOUNT_ADDITIVE_COLUMNS, SCHEMA_SQL, SCHEMA_VERSION, TRANSACTION_ADDITIVE_COLUMNS
def _existing_columns(conn: sqlite3.Connection, table: str) -> set[str]:
return {row["name"] for row in conn.execute(f"PRAGMA table_info({table})")}
def _add_missing_columns(conn: sqlite3.Connection, table: str, columns: dict[str, str]) -> None:
existing = _existing_columns(conn, table)
for name, definition in columns.items():
if name not in existing:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {definition}")
def migrate_db(conn: sqlite3.Connection) -> None:
"""Apply additive, idempotent migrations only."""
_add_missing_columns(conn, "accounts", ACCOUNT_ADDITIVE_COLUMNS)
_add_missing_columns(conn, "transactions", TRANSACTION_ADDITIVE_COLUMNS)
conn.execute("CREATE INDEX IF NOT EXISTS idx_transactions_datetime ON transactions(transaction_datetime)")
conn.execute("""CREATE TABLE IF NOT EXISTS category_rules (
id TEXT PRIMARY KEY,
priority INTEGER NOT NULL DEFAULT 100,
match_field TEXT NOT NULL,
match_type TEXT NOT NULL DEFAULT 'contains',
pattern TEXT NOT NULL,
amount_min INTEGER,
amount_max INTEGER,
day_of_month INTEGER,
transaction_type TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL,
subcategory TEXT NOT NULL DEFAULT '',
person TEXT NOT NULL DEFAULT '',
is_fixed INTEGER,
confidence REAL NOT NULL DEFAULT 1.0,
active INTEGER NOT NULL DEFAULT 1,
memo TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)""")
_add_missing_columns(conn, "category_rules", {"amount_min": "INTEGER", "amount_max": "INTEGER", "day_of_month": "INTEGER"})
conn.execute("CREATE INDEX IF NOT EXISTS idx_category_rules_active_priority ON category_rules(active, priority)")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_category_rules_unique ON category_rules(match_field, match_type, pattern, transaction_type, category, subcategory, person)")
conn.execute("""CREATE TABLE IF NOT EXISTS ledger_categories (
id TEXT PRIMARY KEY,
category TEXT NOT NULL,
subcategory TEXT NOT NULL DEFAULT '',
cashflow_kind TEXT NOT NULL DEFAULT 'expense',
active INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 100,
memo TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)""")
_add_missing_columns(conn, "ledger_categories", {"cashflow_kind": "TEXT NOT NULL DEFAULT 'expense'"})
conn.execute("CREATE INDEX IF NOT EXISTS idx_ledger_categories_active_sort ON ledger_categories(active, sort_order, category, subcategory)")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_categories_unique ON ledger_categories(category, subcategory)")
conn.execute("""CREATE TABLE IF NOT EXISTS manual_assets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
valuation_amount INTEGER,
valuation_date TEXT NOT NULL DEFAULT '',
currency TEXT NOT NULL DEFAULT 'KRW',
source_note TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_manual_assets_active_bucket ON manual_assets(active, liquidity_bucket, name)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_manual_assets_valuation_date ON manual_assets(valuation_date)")
conn.execute("""CREATE TABLE IF NOT EXISTS asset_snapshots (
id TEXT PRIMARY KEY,
basis_date TEXT NOT NULL,
source_type TEXT NOT NULL,
source_id TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
amount INTEGER,
currency TEXT NOT NULL DEFAULT 'KRW',
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
valuation_source TEXT NOT NULL DEFAULT '',
source_note TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
snapshot_run_id TEXT NOT NULL DEFAULT '',
correction_of_run_id TEXT NOT NULL DEFAULT '',
correction_reason TEXT NOT NULL DEFAULT '',
is_correction INTEGER NOT NULL DEFAULT 0,
closed_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)""")
_add_missing_columns(conn, "asset_snapshots", {
"snapshot_run_id": "TEXT NOT NULL DEFAULT ''",
"correction_of_run_id": "TEXT NOT NULL DEFAULT ''",
"correction_reason": "TEXT NOT NULL DEFAULT ''",
"is_correction": "INTEGER NOT NULL DEFAULT 0",
"closed_at": "TEXT NOT NULL DEFAULT ''",
})
conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_basis_date ON asset_snapshots(basis_date)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_source ON asset_snapshots(source_type, source_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_run ON asset_snapshots(basis_date, snapshot_run_id, created_at)")
conn.execute("INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', ?)", (SCHEMA_VERSION,))
def init_db(db_path: str | None = None) -> str:
path = resolve_db_path(db_path)
with connect(str(path)) as conn:
conn.executescript(SCHEMA_SQL)
migrate_db(conn)
return str(path)
@@ -0,0 +1,190 @@
from __future__ import annotations
SCHEMA_VERSION = "10"
SCHEMA_SQL = """
PRAGMA journal_mode=WAL;
PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS schema_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'bank',
institution TEXT NOT NULL DEFAULT '',
currency TEXT NOT NULL DEFAULT 'KRW',
active INTEGER NOT NULL DEFAULT 1,
memo TEXT NOT NULL DEFAULT '',
account_number_masked TEXT NOT NULL DEFAULT '',
holder_name TEXT NOT NULL DEFAULT '',
last_balance INTEGER,
last_synced_at TEXT NOT NULL DEFAULT '',
statement_status TEXT NOT NULL DEFAULT 'unknown',
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS transactions (
id TEXT PRIMARY KEY,
transaction_date TEXT NOT NULL,
posted_date TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL,
amount INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'KRW',
category TEXT NOT NULL DEFAULT '',
subcategory TEXT NOT NULL DEFAULT '',
merchant TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
account_id TEXT NOT NULL DEFAULT '',
payment_method TEXT NOT NULL DEFAULT '',
person TEXT NOT NULL DEFAULT '',
project TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
is_fixed INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT 'manual',
import_batch_id TEXT NOT NULL DEFAULT '',
external_id TEXT NOT NULL DEFAULT '',
dedupe_key TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
transaction_datetime TEXT NOT NULL DEFAULT '',
counterparty TEXT NOT NULL DEFAULT '',
balance_after INTEGER,
branch TEXT NOT NULL DEFAULT '',
raw_summary TEXT NOT NULL DEFAULT '',
raw_payload TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(transaction_date);
CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category, subcategory);
CREATE INDEX IF NOT EXISTS idx_transactions_person ON transactions(person);
CREATE INDEX IF NOT EXISTS idx_transactions_account ON transactions(account_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_dedupe ON transactions(dedupe_key) WHERE dedupe_key <> '';
CREATE TABLE IF NOT EXISTS budgets (
id TEXT PRIMARY KEY,
month TEXT NOT NULL,
category TEXT NOT NULL,
subcategory TEXT NOT NULL DEFAULT '',
person TEXT NOT NULL DEFAULT '',
amount INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'KRW',
memo TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_budgets_month ON budgets(month);
CREATE UNIQUE INDEX IF NOT EXISTS idx_budgets_unique ON budgets(month, category, subcategory, person);
CREATE TABLE IF NOT EXISTS import_batches (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
filename TEXT NOT NULL DEFAULT '',
imported_at TEXT NOT NULL,
row_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
memo TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS category_rules (
id TEXT PRIMARY KEY,
priority INTEGER NOT NULL DEFAULT 100,
match_field TEXT NOT NULL,
match_type TEXT NOT NULL DEFAULT 'contains',
pattern TEXT NOT NULL,
amount_min INTEGER,
amount_max INTEGER,
day_of_month INTEGER,
transaction_type TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL,
subcategory TEXT NOT NULL DEFAULT '',
person TEXT NOT NULL DEFAULT '',
is_fixed INTEGER,
confidence REAL NOT NULL DEFAULT 1.0,
active INTEGER NOT NULL DEFAULT 1,
memo TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_category_rules_active_priority ON category_rules(active, priority);
CREATE UNIQUE INDEX IF NOT EXISTS idx_category_rules_unique ON category_rules(match_field, match_type, pattern, transaction_type, category, subcategory, person);
CREATE TABLE IF NOT EXISTS ledger_categories (
id TEXT PRIMARY KEY,
category TEXT NOT NULL,
subcategory TEXT NOT NULL DEFAULT '',
cashflow_kind TEXT NOT NULL DEFAULT 'expense',
active INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 100,
memo TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_ledger_categories_active_sort ON ledger_categories(active, sort_order, category, subcategory);
CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_categories_unique ON ledger_categories(category, subcategory);
CREATE TABLE IF NOT EXISTS manual_assets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
valuation_amount INTEGER,
valuation_date TEXT NOT NULL DEFAULT '',
currency TEXT NOT NULL DEFAULT 'KRW',
source_note TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_manual_assets_active_bucket ON manual_assets(active, liquidity_bucket, name);
CREATE INDEX IF NOT EXISTS idx_manual_assets_valuation_date ON manual_assets(valuation_date);
CREATE TABLE IF NOT EXISTS asset_snapshots (
id TEXT PRIMARY KEY,
basis_date TEXT NOT NULL,
source_type TEXT NOT NULL,
source_id TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
amount INTEGER,
currency TEXT NOT NULL DEFAULT 'KRW',
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
valuation_source TEXT NOT NULL DEFAULT '',
source_note TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
snapshot_run_id TEXT NOT NULL DEFAULT '',
correction_of_run_id TEXT NOT NULL DEFAULT '',
correction_reason TEXT NOT NULL DEFAULT '',
is_correction INTEGER NOT NULL DEFAULT 0,
closed_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_asset_snapshots_basis_date ON asset_snapshots(basis_date);
CREATE INDEX IF NOT EXISTS idx_asset_snapshots_source ON asset_snapshots(source_type, source_id);
INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '10');
"""
ACCOUNT_ADDITIVE_COLUMNS: dict[str, str] = {
"account_number_masked": "TEXT NOT NULL DEFAULT ''",
"holder_name": "TEXT NOT NULL DEFAULT ''",
"last_balance": "INTEGER",
"last_synced_at": "TEXT NOT NULL DEFAULT ''",
"statement_status": "TEXT NOT NULL DEFAULT 'unknown'",
"liquidity_bucket": "TEXT NOT NULL DEFAULT 'other'",
}
TRANSACTION_ADDITIVE_COLUMNS: dict[str, str] = {
"transaction_datetime": "TEXT NOT NULL DEFAULT ''",
"counterparty": "TEXT NOT NULL DEFAULT ''",
"balance_after": "INTEGER",
"branch": "TEXT NOT NULL DEFAULT ''",
"raw_summary": "TEXT NOT NULL DEFAULT ''",
"raw_payload": "TEXT NOT NULL DEFAULT ''",
}
+1 -291
View File
@@ -1,296 +1,6 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from .adapters.mcp.server import main, mcp
from dotenv import load_dotenv
from mcp.server.fastmcp import FastMCP
from . import service
load_dotenv(Path(__file__).resolve().parent.parent / ".env")
mcp = FastMCP("jarca-ledger-mcp")
@mcp.tool()
def ledger_health_check() -> dict:
"""Verify the Jarca Ledger MCP server and SQLite database are reachable."""
return service.health_check()
@mcp.tool()
def get_ledger_db_status(include_hash: bool = True, include_integrity: bool = True) -> dict:
"""Return non-sensitive DB status evidence: path, size, mtime, checksum, table row counts, and aggregate totals."""
return service.get_ledger_db_status(include_hash=include_hash, include_integrity=include_integrity)
@mcp.tool()
def create_ledger_backup(backup_dir: str = "", retention_days: int = 30, retention_count: int = 20) -> dict:
"""Create a timestamped ledger DB backup. Restore is intentionally not exposed as an MCP tool."""
return service.create_ledger_backup(backup_dir=backup_dir or None, retention_days=retention_days, retention_count=retention_count)
@mcp.tool()
def list_ledger_backups(backup_dir: str = "", include_hash: bool = False, limit: int = 50) -> dict:
"""List ledger DB backups with metadata-only evidence."""
return service.list_ledger_backups(backup_dir=backup_dir or None, include_hash=include_hash, limit=limit)
@mcp.tool()
def get_ledger_backup_status(file_path: str, include_hash: bool = True, include_integrity: bool = True) -> dict:
"""Return metadata-only status and integrity evidence for one backup file."""
return service.get_ledger_backup_status(file_path, include_hash=include_hash, include_integrity=include_integrity)
@mcp.tool()
def create_ledger_account(name: str, type: str = "bank", institution: str = "", currency: str = "KRW", active: bool = True, memo: str = "", account_number_masked: str = "", holder_name: str = "", last_balance: int | None = None, last_synced_at: str = "", statement_status: str = "unknown", liquidity_bucket: str = "other") -> dict:
"""Create an account/card/cash/investment/loan ledger account."""
return service.create_ledger_account(name, type, institution, currency, active, memo, account_number_masked, holder_name, last_balance, last_synced_at, statement_status, liquidity_bucket)
@mcp.tool()
def list_ledger_accounts(active_only: bool = True) -> dict:
"""List ledger accounts and cards."""
return service.list_ledger_accounts(active_only)
@mcp.tool()
def update_ledger_account(account_id: str, name: str | None = None, type: str | None = None, institution: str | None = None, currency: str | None = None, active: bool | None = None, memo: str | None = None, account_number_masked: str | None = None, holder_name: str | None = None, last_balance: int | None = None, last_synced_at: str | None = None, statement_status: str | None = None, liquidity_bucket: str | None = None) -> dict:
"""Update selected account fields, including statement_status/liquidity_bucket for manual reconciliation."""
return service.update_ledger_account(account_id, name=name, type=type, institution=institution, currency=currency, active=active, memo=memo, account_number_masked=account_number_masked, holder_name=holder_name, last_balance=last_balance, last_synced_at=last_synced_at, statement_status=statement_status, liquidity_bucket=liquidity_bucket)
@mcp.tool()
def get_asset_liquidity_report() -> dict:
"""Return asset liquidity, debt, and net worth from shared account/manual valuation sources."""
return service.get_asset_liquidity_report()
@mcp.tool()
def list_manual_assets(active_only: bool = True) -> dict:
"""List manual non-transaction assets/liabilities used for net-worth valuation."""
return service.list_manual_assets(active_only=active_only)
@mcp.tool()
def create_manual_asset(name: str, valuation_amount: int, valuation_date: str, liquidity_bucket: str, source_note: str, memo: str = "", currency: str = "KRW", active: bool = True) -> dict:
"""Create a manual non-transaction asset/liability valuation row."""
return service.create_manual_asset(name, valuation_amount, valuation_date, liquidity_bucket, source_note=source_note, memo=memo, currency=currency, active=active)
@mcp.tool()
def update_manual_asset(asset_id: str, name: str | None = None, valuation_amount: int | None = None, valuation_date: str | None = None, liquidity_bucket: str | None = None, source_note: str | None = None, memo: str | None = None, currency: str | None = None, active: bool | None = None) -> dict:
"""Update selected fields on a manual asset/liability valuation row."""
return service.update_manual_asset(asset_id, name=name, valuation_amount=valuation_amount, valuation_date=valuation_date, liquidity_bucket=liquidity_bucket, source_note=source_note, memo=memo, currency=currency, active=active)
@mcp.tool()
def deactivate_manual_asset(asset_id: str, hard_delete: bool = False) -> dict:
"""Deactivate a manual asset/liability by default; hard delete only for exceptional cleanup."""
return service.deactivate_manual_asset(asset_id, hard_delete=hard_delete)
@mcp.tool()
def preview_asset_snapshot_close(basis_date: str = "") -> dict:
"""Preview a month-end asset snapshot close without mutating the database."""
return service.preview_asset_snapshot_close(basis_date=basis_date)
@mcp.tool()
def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "") -> dict:
"""Close an asset snapshot for a basis date; same-date re-close appends correction rows."""
return service.close_asset_snapshot(basis_date=basis_date, source_note=source_note, memo=memo, correction_reason=correction_reason)
@mcp.tool()
def get_asset_snapshot_report(basis_date: str = "") -> dict:
"""Return basis-date asset snapshot/net-worth report with live-authority fallback."""
return service.get_asset_snapshot_report(basis_date=basis_date)
@mcp.tool()
def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: int = 12) -> dict:
"""Return net-worth timeseries from stored asset snapshot rows."""
return service.get_net_worth_timeseries(start_date=start_date, end_date=end_date, months=months)
@mcp.tool()
def get_balance_reconciliation_report(account_id: str = "", baseline_balance: int | None = None, baseline_at: str = "", source_note: str = "", memo: str = "", tolerance: int = 1000) -> dict:
"""Preview balance reconciliation for manual_only/partial_import accounts without mutating the DB."""
return service.get_balance_reconciliation_report(account_id, baseline_balance, baseline_at, source_note, memo, tolerance)
@mcp.tool()
def create_ledger_transaction(transaction_date: str = "", type: str = "expense", amount: int = 0, category: str = "", subcategory: str = "", merchant: str = "", description: str = "", account_id: str = "", payment_method: str = "", person: str = "", project: str = "", tags: list[str] | str | None = None, is_fixed: bool = False, currency: str = "KRW", posted_date: str = "", source: str = "manual", external_id: str = "", memo: str = "", dedupe: bool = True) -> dict:
"""Create an income, expense, transfer, refund, or adjustment transaction. Amount is positive KRW minor unit; type determines direction."""
return service.create_ledger_transaction(transaction_date, type, amount, category, subcategory, merchant, description, account_id, payment_method, person, project, tags, is_fixed, currency, posted_date, source, external_id, memo, dedupe)
@mcp.tool()
def list_ledger_transactions(start_date: str = "", end_date: str = "", type: str = "", category: str = "", person: str = "", account_id: str = "", include_deleted: bool = False, limit: int = 100) -> dict:
"""List ledger transactions with date/category/person/account filters."""
return service.list_ledger_transactions(start_date, end_date, type, category, person, account_id, include_deleted, limit)
@mcp.tool()
def update_ledger_transaction(transaction_id: str, transaction_date: str | None = None, type: str | None = None, amount: int | None = None, category: str | None = None, subcategory: str | None = None, merchant: str | None = None, description: str | None = None, account_id: str | None = None, payment_method: str | None = None, person: str | None = None, project: str | None = None, tags: list[str] | str | None = None, is_fixed: bool | None = None, memo: str | None = None, status: str | None = None) -> dict:
"""Update selected fields on a ledger transaction."""
return service.update_ledger_transaction(transaction_id, transaction_date=transaction_date, type=type, amount=amount, category=category, subcategory=subcategory, merchant=merchant, description=description, account_id=account_id, payment_method=payment_method, person=person, project=project, tags=tags, is_fixed=is_fixed, memo=memo, status=status)
@mcp.tool()
def delete_ledger_transaction(transaction_id: str, hard_delete: bool = False) -> dict:
"""Soft-delete a ledger transaction by default, or permanently delete with hard_delete=true."""
return service.delete_ledger_transaction(transaction_id, hard_delete)
@mcp.tool()
def create_ledger_budget(month: str, category: str, amount: int, subcategory: str = "", person: str = "", currency: str = "KRW", memo: str = "") -> dict:
"""Create or update a monthly category/person budget."""
return service.create_ledger_budget(month, category, amount, subcategory, person, currency, memo)
@mcp.tool()
def list_ledger_budgets(month: str = "") -> dict:
"""List ledger budgets, optionally for a month YYYY-MM."""
return service.list_ledger_budgets(month)
@mcp.tool()
def get_monthly_cashflow_report(month: str = "") -> dict:
"""Return income, expenses, net cashflow, and savings rate for a month."""
return service.get_monthly_cashflow_report(month)
@mcp.tool()
def get_category_spending_report(month: str = "", start_date: str = "", end_date: str = "", person: str = "") -> dict:
"""Return category/subcategory spending for a month or date range."""
return service.get_category_spending_report(month, start_date, end_date, person)
@mcp.tool()
def get_fixed_cost_report(month: str = "") -> dict:
"""Return fixed expense transactions and total fixed cost for a month."""
return service.get_fixed_cost_report(month)
@mcp.tool()
def get_budget_vs_actual_report(month: str = "") -> dict:
"""Compare monthly budgets against actual spending."""
return service.get_budget_vs_actual_report(month)
@mcp.tool()
def get_investable_cashflow_report(month: str = "", minimum_cash_buffer: int = 0, planned_extra_expense: int = 0) -> dict:
"""Estimate cashflow available for investing after buffer and planned extra expenses."""
return service.get_investable_cashflow_report(month, minimum_cash_buffer, planned_extra_expense)
@mcp.tool()
def preview_ledger_csv_import(file_path: str, source: str = "generic") -> dict:
"""Preview a UTF-8/UTF-8-BOM CSV import before saving transactions."""
return service.preview_ledger_csv_import(file_path, source)
@mcp.tool()
def preview_kb_bank_pdf_import(file_path: str, password: str, preview_limit: int = 20) -> dict:
"""Preview transactions from a password-protected KB Kookmin Bank account-history PDF without saving them."""
return service.preview_kb_bank_pdf_import(file_path, password, preview_limit)
@mcp.tool()
def import_kb_bank_pdf(file_path: str, password: str, account_id: str = "", skip_duplicates: bool = True, skip_validation: bool = False) -> dict:
"""Import transactions from a KB Kookmin Bank account-history PDF into the ledger database."""
return service.import_kb_bank_pdf(file_path, password, account_id, skip_duplicates, skip_validation=skip_validation)
@mcp.tool()
def preview_seoulpay_xlsx_import(file_path: str, password: str = "", preview_limit: int = 20) -> dict:
"""Preview transactions from a SeoulPay usage-history XLSX before saving them."""
return service.preview_seoulpay_xlsx_import(file_path, password, preview_limit)
@mcp.tool()
def import_seoulpay_xlsx(file_path: str, password: str = "", account_id: str = "", skip_duplicates: bool = True) -> dict:
"""Import transactions from a SeoulPay usage-history XLSX into the ledger database."""
return service.import_seoulpay_xlsx(file_path, password, account_id, skip_duplicates)
@mcp.tool()
def preview_toss_securities_pdf_import(file_path: str, preview_limit: int = 20) -> dict:
"""Preview transactions from a Toss Securities transaction-history PDF without saving them."""
return service.preview_toss_securities_pdf_import(file_path, preview_limit)
@mcp.tool()
def import_toss_securities_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True) -> dict:
"""Import transactions from a Toss Securities transaction-history PDF into the ledger database."""
return service.import_toss_securities_pdf(file_path, account_id, skip_duplicates)
@mcp.tool()
def preview_toss_bank_pdf_import(file_path: str, preview_limit: int = 20) -> dict:
"""Preview transactions from a Toss Bank transaction-history PDF without saving them."""
return service.preview_toss_bank_pdf_import(file_path, preview_limit)
@mcp.tool()
def import_toss_bank_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True) -> dict:
"""Import transactions from a Toss Bank transaction-history PDF into the ledger database."""
return service.import_toss_bank_pdf(file_path, account_id, skip_duplicates)
@mcp.tool()
def list_ledger_import_batches(source: str = "", include_rolled_back: bool = True, limit: int = 50) -> dict:
"""List CSV/PDF/API import batches."""
return service.list_ledger_import_batches(source, include_rolled_back, limit)
@mcp.tool()
def rollback_ledger_import_batch(import_batch_id: str, hard_delete: bool = False) -> dict:
"""Roll back all transactions created by one import batch. Soft-deletes by default."""
return service.rollback_ledger_import_batch(import_batch_id, hard_delete)
@mcp.tool()
def create_category_rule(match_field: str, pattern: str, category: str, subcategory: str = "", transaction_type: str = "", match_type: str = "contains", priority: int = 100, person: str = "", is_fixed: bool | None = None, confidence: float = 1.0, memo: str = "", amount_min: int | None = None, amount_max: int | None = None, day_of_month: int | None = None) -> dict:
"""Create or update a ledger transaction categorization rule."""
return service.create_category_rule(match_field, pattern, category, subcategory, transaction_type, match_type, priority, person, is_fixed, confidence, memo, amount_min, amount_max, day_of_month)
@mcp.tool()
def seed_default_category_rules(overwrite: bool = False) -> dict:
"""Seed default Jarca category rules for common Korean household ledger patterns."""
return service.seed_default_category_rules(overwrite)
@mcp.tool()
def list_category_rules(active_only: bool = True) -> dict:
"""List transaction categorization rules."""
return service.list_category_rules(active_only)
@mcp.tool()
def update_category_rule(rule_id: str, match_field: str, pattern: str, category: str, subcategory: str = "", transaction_type: str = "", match_type: str = "contains", priority: int = 100, person: str = "", is_fixed: bool | None = None, confidence: float = 1.0, memo: str = "", amount_min: int | None = None, amount_max: int | None = None, day_of_month: int | None = None, active: bool = True) -> dict:
"""Update an existing ledger transaction categorization rule."""
return service.update_category_rule(rule_id, match_field, pattern, category, subcategory, transaction_type, match_type, priority, person, is_fixed, confidence, memo, amount_min, amount_max, day_of_month, active)
@mcp.tool()
def delete_category_rule(rule_id: str) -> dict:
"""Delete a transaction categorization rule."""
return service.delete_category_rule(rule_id)
@mcp.tool()
def preview_categorization(limit: int = 1000, only_uncategorized: bool = True) -> dict:
"""Preview category-rule matches before writing categories to transactions."""
return service.preview_categorization(limit, only_uncategorized)
@mcp.tool()
def apply_categorization_rules(limit: int = 1000, only_uncategorized: bool = True) -> dict:
"""Apply active category rules to matching ledger transactions."""
return service.apply_categorization_rules(limit, only_uncategorized)
@mcp.tool()
def list_uncategorized_transactions(limit: int = 100, min_amount: int = 0) -> dict:
"""List active transactions that still have no category."""
return service.list_uncategorized_transactions(limit, min_amount)
@mcp.tool()
def get_uncategorized_merchant_summary(limit: int = 50) -> dict:
"""Summarize uncategorized transactions by merchant/raw_summary/type for user review."""
return service.get_uncategorized_merchant_summary(limit)
@mcp.tool()
def create_ledger_category(category: str, subcategory: str = "", sort_order: int = 100, memo: str = "", cashflow_kind: str = "") -> dict:
"""Create or reactivate a ledger category/subcategory used by the dashboard and transactions."""
return service.create_ledger_category(category, subcategory, sort_order, memo, cashflow_kind)
@mcp.tool()
def seed_default_ledger_categories(overwrite: bool = False) -> dict:
"""Seed default ledger categories and subcategories."""
return service.seed_default_ledger_categories(overwrite)
@mcp.tool()
def list_ledger_categories(active_only: bool = True, include_usage: bool = True) -> dict:
"""List ledger categories and subcategories, optionally with usage counts."""
return service.list_ledger_categories(active_only, include_usage)
@mcp.tool()
def list_cross_transfer_review_queue(limit: int = 300) -> dict:
"""List KB↔토스 계좌 이체 확인필요 큐 항목."""
return service.list_cross_transfer_review_queue(limit)
@mcp.tool()
def preview_cross_account_transfers(limit: int = 500) -> dict:
"""Preview KB↔토스은행 이체 매칭 후보를 반환합니다."""
return service.preview_cross_account_transfers(limit)
@mcp.tool()
def run_cross_account_transfer_match(limit: int = 500) -> dict:
"""Run KB↔토스은행 자동 이체 매칭 및 태그/분류 반영을 수행합니다."""
return service.run_cross_account_transfer_match(limit)
@mcp.tool()
def delete_ledger_category(category: str, subcategory: str = "", hard_delete: bool = False, force: bool = False) -> dict:
"""Deactivate or delete a ledger category/subcategory."""
return service.delete_ledger_category(category, subcategory, hard_delete, force)
def main() -> None:
mcp.run()
if __name__ == "__main__": if __name__ == "__main__":
main() main()
File diff suppressed because it is too large Load Diff