refactor(jarca-ledger): implement BAT-010 backend core layering
This commit is contained in:
@@ -16,3 +16,4 @@ __pycache__/
|
|||||||
*.csv
|
*.csv
|
||||||
tmp_*
|
tmp_*
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.worktrees/
|
||||||
|
|||||||
+1
-1
@@ -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.
|
||||||
@@ -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/) |
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
|
|
||||||
@@ -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
@@ -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
@@ -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를 반영했다.
|
||||||
|
|||||||
+84
@@ -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로 전환.
|
||||||
+87
@@ -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
@@ -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
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
+38
-3376
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user