From ac78e945710e2a52e1d71fde1e034fe3f28f6941 Mon Sep 17 00:00:00 2001 From: Jarca Date: Fri, 3 Jul 2026 07:42:16 +0900 Subject: [PATCH] Implement BAT-011 transactional write service split --- .../design.md | 37 +++ .../implementation.md | 51 ++++ .../index.md | 33 +++ .../planning.md | 32 +++ .../verification.md | 43 ++++ docs/batches/index.md | 1 + ...d-core-layering-mcp-adapter-realignment.md | 2 +- ...write-import-workflow-adapter-narrowing.md | 243 ++++++++++++++++++ docs/discovery/index.md | 1 + ...ional-write-service-split-compatibility.md | 86 +++++++ docs/srs/index.md | 5 +- jarca_ledger_mcp/application/__init__.py | 4 + .../application/accounts_write_services.py | 55 ++++ .../application/asset_account_services.py | 33 +-- .../application/budgets_write_services.py | 21 ++ .../application/categorization_services.py | 117 +-------- .../category_rules_write_services.py | 145 +++++++++++ .../application/system_services.py | 17 +- .../transaction_report_services.py | 72 +----- .../transactions_write_services.py | 66 +++++ jarca_ledger_mcp/service.py | 8 + 21 files changed, 844 insertions(+), 228 deletions(-) create mode 100644 docs/batches/bat-011_20260703_transactional-write-service-split/design.md create mode 100644 docs/batches/bat-011_20260703_transactional-write-service-split/implementation.md create mode 100644 docs/batches/bat-011_20260703_transactional-write-service-split/index.md create mode 100644 docs/batches/bat-011_20260703_transactional-write-service-split/planning.md create mode 100644 docs/batches/bat-011_20260703_transactional-write-service-split/verification.md create mode 100644 docs/discovery/dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing.md create mode 100644 docs/srs/Non-Functional/req-015_transactional-write-service-split-compatibility.md create mode 100644 jarca_ledger_mcp/application/accounts_write_services.py create mode 100644 jarca_ledger_mcp/application/budgets_write_services.py create mode 100644 jarca_ledger_mcp/application/category_rules_write_services.py create mode 100644 jarca_ledger_mcp/application/transactions_write_services.py diff --git a/docs/batches/bat-011_20260703_transactional-write-service-split/design.md b/docs/batches/bat-011_20260703_transactional-write-service-split/design.md new file mode 100644 index 0000000..9b59c56 --- /dev/null +++ b/docs/batches/bat-011_20260703_transactional-write-service-split/design.md @@ -0,0 +1,37 @@ +# BAT-011 Design + +## Architecture summary + +- Introduce dedicated write-oriented application modules under `jarca_ledger_mcp/application/` for accounts, transactions, budgets, and category rules/categories. +- Keep broad modules (`system_services.py`, `asset_account_services.py`, `transaction_report_services.py`, `categorization_services.py`) as compatibility aggregation layers for read/report/mixed responsibilities that are not yet in scope. +- Preserve `service.py` as a thin re-export facade and avoid MCP/Streamlit/dashboard call-site churn. + +## Changed areas + +- `jarca_ledger_mcp/application/accounts_write_services.py` +- `jarca_ledger_mcp/application/transactions_write_services.py` +- `jarca_ledger_mcp/application/budgets_write_services.py` +- `jarca_ledger_mcp/application/category_rules_write_services.py` +- Existing application modules that previously held these implementations +- `jarca_ledger_mcp/application/__init__.py` +- `jarca_ledger_mcp/service.py` + +## Key decisions + +- Move only mutation implementations in BAT-011; read/report/import/advanced workflow logic stays in place. +- Favor direct function relocation plus compatibility re-export over new abstraction layers to minimize contract risk. +- Keep shared helpers in `application/shared.py`; do not introduce storage-layer refactors in this batch. + +## Edge cases + +- `create_ledger_account` remains paired with `update_ledger_account` even though the old implementations lived in different modules. +- Category rule preview/apply remains out of BAT-011 scope if moving it would force read/workbench coupling; mutation extraction takes precedence. +- Any dashboard/MCP references must continue resolving through `service.py` without signature changes. + +## Architecture Impact + +- none + +## Reference Doc Update Plan + +- No baseline doc rewrite in BAT-011 unless the final module tree differs materially from the existing dcy-011/REQ-015 description. diff --git a/docs/batches/bat-011_20260703_transactional-write-service-split/implementation.md b/docs/batches/bat-011_20260703_transactional-write-service-split/implementation.md new file mode 100644 index 0000000..e8c5250 --- /dev/null +++ b/docs/batches/bat-011_20260703_transactional-write-service-split/implementation.md @@ -0,0 +1,51 @@ +# BAT-011 Implementation + +## Plan summary + +BAT-011 implements the first dcy-011 delivery slice by extracting transactional write paths into narrower application modules while preserving the top-level `service.py` facade, MCP tool contracts, and Streamlit/dashboard call signatures. + +## Changed files + +- `jarca_ledger_mcp/application/accounts_write_services.py` +- `jarca_ledger_mcp/application/transactions_write_services.py` +- `jarca_ledger_mcp/application/budgets_write_services.py` +- `jarca_ledger_mcp/application/category_rules_write_services.py` +- `jarca_ledger_mcp/application/__init__.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/categorization_services.py` +- `jarca_ledger_mcp/service.py` +- `docs/discovery/dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing.md` +- `docs/srs/index.md` +- `docs/srs/Non-Functional/req-015_transactional-write-service-split-compatibility.md` +- `docs/batches/index.md` +- `docs/batches/bat-011_20260703_transactional-write-service-split/index.md` +- `docs/batches/bat-011_20260703_transactional-write-service-split/planning.md` +- `docs/batches/bat-011_20260703_transactional-write-service-split/design.md` +- `docs/batches/bat-011_20260703_transactional-write-service-split/implementation.md` +- `docs/batches/bat-011_20260703_transactional-write-service-split/verification.md` + +## Execution log + +- 2026-07-03 07:29 KST: dcy-011에서 REQ-015를 생성하고 source Discovery 역참조/SRS index를 갱신했다. +- 2026-07-03 07:30 KST: REQ-015를 Approved로 전환하고 BAT-011 standard batch scaffold(index/planning/design/implementation/verification)를 생성했다. +- 2026-07-03 07:34 KST: `accounts_write_services.py`, `transactions_write_services.py`, `budgets_write_services.py`, `category_rules_write_services.py`를 추가하고 account/transaction/budget/category rule-category mutation 구현을 새 모듈로 이동했다. +- 2026-07-03 07:35 KST: 기존 broad modules(`system_services.py`, `asset_account_services.py`, `transaction_report_services.py`, `categorization_services.py`)를 compatibility re-export/delegation 레이어로 축소했다. +- 2026-07-03 07:35 KST: `service.py` proxy module set에 새 write modules를 추가해 helper monkeypatch/export propagation이 계속 동작하도록 정렬했다. +- 2026-07-03 07:36 KST: category create path에서 빠진 `infer_cashflow_kind` 정책을 `category_rules_write_services.py`에 보강해 category mutation 회귀를 복구했다. +- 2026-07-03 07:39 KST: targeted regression, full pytest, MCP discovery, service export provenance 검증이 모두 PASS하여 REQ-015를 Implemented로 승격했다. + +## Validation + +- `python3 -m py_compile jarca_ledger_mcp/application/*.py jarca_ledger_mcp/service.py` → PASS +- `pytest -q tests/test_service.py tests/test_category_rule_filters.py tests/test_ledger_categories.py tests/test_contract_regression.py` → PASS (`19 passed`) +- `pytest -q` → PASS (`42 passed, 4 skipped`) +- `hermes mcp test jarca-ledger` → PASS (`Connected`, `Tools discovered: 56`) +- `python3` provenance check → PASS (`service.create_ledger_account.__module__ == jarca_ledger_mcp.application.accounts_write_services`, transaction/budget/category mutation exports도 각각 새 write module을 가리킴) + +## Remaining risks / follow-up notes + +- `preview_categorization`, `apply_categorization_rules`, uncategorized workbench/read-side helper는 아직 `categorization_services.py`에 남아 있어 Phase 4 후속 또는 Phase 7 adapter narrowing에서 추가 분리가 가능하다. +- import preview/import/rollback 및 cross-transfer/reconciliation/advanced workflow 분리는 BAT-011 범위 밖이며 dcy-011 후속 REQ/BAT로 이어져야 한다. +- 이번 배치는 contract-preserving extraction이 목적이므로 data model semantics와 response envelope은 의도적으로 변경하지 않았다. diff --git a/docs/batches/bat-011_20260703_transactional-write-service-split/index.md b/docs/batches/bat-011_20260703_transactional-write-service-split/index.md new file mode 100644 index 0000000..0346303 --- /dev/null +++ b/docs/batches/bat-011_20260703_transactional-write-service-split/index.md @@ -0,0 +1,33 @@ +# BAT-011 Transactional write service split + +- Profile: standard +- Status: release-candidate +- Included REQ: REQ-015 +- Discovery: dcy-011 +- Owner: 렛저장인 +- Date (KST): 2026-07-03 + +## 목표 + +REQ-015에 따라 account / transaction / budget / category rule-category mutation 경로를 더 좁은 write-oriented application service로 분리한다. 기존 `service.py`, MCP adapter, Streamlit gateway, dashboard 호출부와 response contract는 유지한 채 Phase 4 첫 구현 슬라이스를 실제 코드와 검증 evidence로 마무리한다. + +## 산출물 + +- `jarca_ledger_mcp/application/` write-oriented service modules +- 기존 broad application modules의 compatibility re-export / delegation 정리 +- full pytest 및 contract-sensitive regression evidence +- BAT-011 planning/design/implementation/verification documents + +## 범위 제한 + +- import preview/import/rollback split +- advanced workflow(cross-transfer/reconciliation/assets/workbench) 분리 +- HTTP adapter 추가 +- destructive migration + +## 문서 + +- `planning.md`: Phase 4 첫 delivery plan, risks, validation gate +- `design.md`: write service boundary / delegation design +- `implementation.md`: changed files, execution log, validation commands +- `verification.md`: AC mapping, PASS/FAIL, REQ Implemented decision diff --git a/docs/batches/bat-011_20260703_transactional-write-service-split/planning.md b/docs/batches/bat-011_20260703_transactional-write-service-split/planning.md new file mode 100644 index 0000000..118b3f0 --- /dev/null +++ b/docs/batches/bat-011_20260703_transactional-write-service-split/planning.md @@ -0,0 +1,32 @@ +# BAT-011 Planning + +## Scope + +- Implement REQ-015: transactional write path(account / transaction / budget / category rule-category mutation)를 더 좁은 write-oriented application module로 분리한다. +- Preserve top-level service facade, MCP adapter, Streamlit gateway, dashboard call signatures and response envelope contracts. +- Leave import/advanced workflow split for follow-up REQ/BAT under dcy-011. + +## Dependencies + +- dcy-011 confirmed Discovery and Approved REQ-015. +- BAT-010 application/adapters baseline. +- Existing pytest regression and contract-sensitive tests. + +## Work plan + +1. Create write-oriented application modules for accounts / transactions / budgets / category rules. +2. Move concrete mutation implementations into those modules and keep existing broad modules as compatibility delegation layers where needed. +3. Update `application/__init__.py` and `service.py` import surface to re-export the new modules without changing public API. +4. Run focused syntax + regression validation, then full pytest if focused checks remain green. +5. Update batch implementation/verification evidence and promote REQ/BAT state if ACs are satisfied. + +## Design Gate + +- Result: standard batch 유지. +- Reason: package boundary 변경, compatibility delegation, category/account/transaction write contract preservation, regression evidence 정리가 함께 필요하므로 planning/design/verification depth가 필요하다. + +## Risks and mitigations + +- Hidden import coupling after write extraction: mitigate by preserving re-export layers and running full pytest. +- Envelope drift on mutation responses: mitigate by preserving existing function signatures and contract-sensitive tests. +- Category rule/category mutation helpers may still share read-side helpers: mitigate by extracting only write-focused logic first and leaving broad read/report paths untouched in this batch. diff --git a/docs/batches/bat-011_20260703_transactional-write-service-split/verification.md b/docs/batches/bat-011_20260703_transactional-write-service-split/verification.md new file mode 100644 index 0000000..754b3b4 --- /dev/null +++ b/docs/batches/bat-011_20260703_transactional-write-service-split/verification.md @@ -0,0 +1,43 @@ +# BAT-011 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-015 + +- AC-1: PASS — `jarca_ledger_mcp/application/` 아래에 `accounts_write_services.py`, `transactions_write_services.py`, `budgets_write_services.py`, `category_rules_write_services.py`가 생성됐고 account/transaction/budget/category rule-category mutation 구현이 그쪽으로 이동했다. +- AC-2: PASS — top-level `service.py` public API, MCP tools, Streamlit/dashboard call signatures and mutation envelope contracts are preserved. MCP discovery still reports 56 tools, and export provenance shows the write APIs now resolve to the new modules. +- AC-3: PASS — targeted regression (`tests/test_service.py`, `tests/test_category_rule_filters.py`, `tests/test_ledger_categories.py`, `tests/test_contract_regression.py`) PASS, plus full `pytest -q` PASS (`42 passed, 4 skipped`). +- AC-4: PASS — BAT-011 implementation/verification docs now record actual changed files, commands, and outcomes. + +## Requirement state decision + +- REQ-015: Implemented. + +## Validation evidence + +- `python3 -m py_compile jarca_ledger_mcp/application/*.py jarca_ledger_mcp/service.py` → PASS +- `pytest -q tests/test_service.py tests/test_category_rule_filters.py tests/test_ledger_categories.py tests/test_contract_regression.py` → PASS (`19 passed`) +- `pytest -q` → PASS (`42 passed, 4 skipped`) +- `hermes mcp test jarca-ledger` → PASS (`Connected`, `Tools discovered: 56`) +- Python provenance check on exported write APIs → PASS + - `create_ledger_account`, `update_ledger_account` → `accounts_write_services` + - `create/update/delete_ledger_transaction` → `transactions_write_services` + - `create_ledger_budget` → `budgets_write_services` + - `create/update/delete_category_rule`, `create/delete_ledger_category` → `category_rules_write_services` + +## Remaining issues + +### Hard blockers +- None. + +### Non-blocking follow-up +- categorization preview/apply/workbench read-side extraction +- import pipeline split +- advanced finance workflow split +- narrower adapter surfaces under later dcy-011 batches diff --git a/docs/batches/index.md b/docs/batches/index.md index 7bdf7b6..4ae5844 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -18,3 +18,4 @@ | 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-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/) | +| BAT-011 | standard | release-candidate | REQ-015 | dcy-011 | [bat-011_20260703_transactional-write-service-split](bat-011_20260703_transactional-write-service-split/) | diff --git a/docs/discovery/dcy-010_20260702_backend-core-layering-mcp-adapter-realignment.md b/docs/discovery/dcy-010_20260702_backend-core-layering-mcp-adapter-realignment.md index c84f1e5..b0d735b 100644 --- a/docs/discovery/dcy-010_20260702_backend-core-layering-mcp-adapter-realignment.md +++ b/docs/discovery/dcy-010_20260702_backend-core-layering-mcp-adapter-realignment.md @@ -7,7 +7,7 @@ - 작성 시각(KST): 2026-07-02 23:32 - 마지막 갱신 시각(KST): 2026-07-02 23:56 - 대체됨: 없음 -- 후속 Discovery 참조: 없음 +- 후속 Discovery 참조: docs/discovery/dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing.md (Phase 4~7 write/import/advanced workflow/adapter narrowing 후속 주기) - 생성된 REQ 참조: `REQ-013`, `REQ-014` # 1. 계획 상태 요약 diff --git a/docs/discovery/dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing.md b/docs/discovery/dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing.md new file mode 100644 index 0000000..e0aaf50 --- /dev/null +++ b/docs/discovery/dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing.md @@ -0,0 +1,243 @@ +# 0. 문서 상태 +이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다. + +- 상태: confirmed +- 문서 ID: dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing +- 이슈명: Jarca Ledger 백엔드 리팩터링 Phase 4+ 후속 주기 (write service 분리 → import pipeline 분리 → advanced finance workflow 분리 → MCP/Streamlit adapter 표면 축소) +- 작성 시각(KST): 2026-07-03 07:10 +- 마지막 갱신 시각(KST): 2026-07-03 07:29 +- 대체됨: 없음 +- 후속 Discovery 참조: 없음 +- 생성된 REQ 참조: `REQ-015` + +# 1. 계획 상태 요약 +현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다. + +- 연계 문서 현황: + - 선행 Discovery: `docs/discovery/dcy-010_20260702_backend-core-layering-mcp-adapter-realignment.md` (Phase 0~3 foundations/persistence/read-only 추출을 확정한 confirmed Discovery, REQ-013/REQ-014 생성) + - 관련 blueprint: `.hermes/plans/2026-07-02_jarca-ledger-backend-refactor-blueprint.md` (Phase 0~8 리팩터링 청사진, 이번 Discovery는 Phase 4~8 절을 근거로 함) + - 관련 baseline 문서: `docs/project-structure.md`, `docs/runtime-flows.md` + - 관련 project board: GitHub Project 4(Jarca) Todo 항목 (Phase 4 transactional write services, Phase 5 import pipeline application/infrastructure 분리, Phase 6 advanced finance workflow services, Phase 7 MCP/Streamlit adapter surface 축소, Phase 8 optional HTTP adapter 준비) +- 해석: + - dcy-010은 blueprint Phase 0~3(behavioral baseline 고정 → skeleton/compat seam → persistence 분리 → read-only application service 추출)을 첫 배치로 확정했고, BAT-010 구현으로 `service.py`/`server.py` shim과 Streamlit gateway seam이 이미 도입되었다. 다만 Phase 4 이후의 write/import/advanced workflow 세분화와 adapter surface 추가 축소는 명시적으로 Out of Scope로 남겨 별도 후속 주기(REQ·Batch·GitHub Project TODO)로 추적하기로 했다. + - 이번 Discovery는 그 후속 주기를 독립적으로 정의한다. project board Todo와 blueprint Phase 4~8을 근거로, 우선순위를 Phase 4~7(write → import → advanced workflow → adapter narrowing)에 두되, 이미 착수된 seam을 전제로 남은 coupling을 더 좁히는 방향으로 다룬다. Phase 8(optional HTTP adapter)은 이번 주기에서는 구현하지 않고 Deferred 후보로만 남긴다. staged refactor·contract preservation·additive migration·text-first UI seam·no destructive migration 원칙은 dcy-010과 동일하게 유지한다. + +# 2. 요구사항 판정 결과 +이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다. + +- 판정: 신규 +- 근거 + - dcy-010은 Phase 0~3을 In Scope로 확정하고 Phase 4+를 명시적으로 Out of Scope로 분리(`# 5. 범위 경계`)했으므로, Phase 4~7 write/import/advanced workflow/adapter narrowing은 dcy-010의 미세 조정이 아니라 문제 정의·핵심 사용자 시나리오·성공 기준이 다른 독립 후속 주기다(SKILL 규칙 2의 `새 Discovery 생성` 조건: 후속 반복을 독립 주기로 추적). + - 대상 슬라이스가 mutation-heavy(트랜잭션/계좌/규칙 CRUD, import mutation, cross-transfer/reconciliation/asset)이고 adapter 표면 축소까지 포함하므로, dcy-010의 read-only 우선 배치와 리스크 프로파일·검증 전략이 실질적으로 다르다. 같은 문서에 계속 누적하면 책임 경계가 흐려진다. + +> **판정이 `완전 중복`인 경우**: 이하 섹션 작성을 중단하고 `# 9. 파일 처리 결과`에 중복 판정 사유와 참조 문서 경로를 기록한 뒤 문서를 닫는다. REQ 초안 작성으로 넘기지 않는다. + +# 3. 문제점의 요약 +문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다. + +- 현재 상태 + - dcy-010/REQ-013·REQ-014와 BAT-010 구현으로 Phase 0~3 배치(behavioral baseline, skeleton/compat seam, persistence 분리, read-only application service)가 이미 반영되어 `jarca_ledger_mcp/service.py`는 40줄 compatibility facade, `jarca_ledger_mcp/server.py`는 6줄 shim, `dashboard/app.py`는 `adapters/streamlit/service_gateway`를 통하는 read gateway 형태로 정리되었다. + - 그러나 write/import/advanced workflow의 핵심 로직은 여전히 대형 application modules, broad gateway, compatibility surface 뒤에 넓게 남아 있다. 특히 `application/import_transfer_services.py`는 1,500줄대 규모로 import mutation·cross-transfer·reconciliation 일부를 함께 품고 있어 다음 단계의 세분화 대상이 명확하다. + - project board의 Phase 4~7 Todo는 BAT-010 이전 표현(예: MCP/Streamlit seam 도입)을 일부 포함하지만, 현재 저장소 기준으로는 “초기 seam 도입”이 아니라 “남은 write/import/advanced coupling과 broad adapter surface를 추가 축소”하는 후속 작업으로 재해석해야 한다. + - baseline 문서(`docs/project-structure.md`, `docs/runtime-flows.md`)는 dcy-010에서 Phase 0~3 구조까지는 반영했지만, Phase 4~7의 finer-grained write/import/advanced 서비스 경계와 추가 adapter narrowing이 반영되면 다시 갱신 갭이 발생한다. + - GitHub Project 4(Jarca) 보드 Todo에 Phase 4~8이 후속 작업으로 등록되어 있어, 이 후속 주기를 Discovery로 정식화할 필요가 있다. +- 기대 상태 + - write 경로(계좌/트랜잭션/예산/카테고리·규칙 mutation)가 현재의 broad compatibility/application surface에서 더 잘게 쪼개진 `application/*_service.py`와 대응 repository 뒤로 이동해 validation·orchestration·transaction boundary·audit tagging 책임이 명시된다. + - import pipeline이 `application/imports_service.py`와 `infrastructure/parsers/*`로 더 선명하게 분리되어 parser(파싱/정규화)와 import orchestration(dedupe/분류 기본값/batch 생성/persistence/후속 reconciliation)이 독립적으로 테스트 가능해지고, import 증빙 필드가 그대로 보존된다. + - cross-transfer/reconciliation/asset/net worth/workbench 등 domain-heavy 워크플로우가 전용 application service로 추가 분리되어 import/write 서비스와 책임 경계가 겹치지 않는다. + - MCP/Streamlit seam은 이미 존재하므로, 다음 목표는 top-level shim과 broad gateway를 유지한 채 호출 표면을 더 좁혀 MCP tool registration/contract helper와 Streamlit page-domain gateway를 분리하는 것이다. + - 결과적으로 compatibility facade/shim은 계속 얇게 유지되고, HTTP 등 신규 adapter가 로직 재배치 없이 `application/*` 위에 추가 가능한 상태가 된다. +- 이해관계자 + - 문서 작성자(AI agent) 및 리팩터링 실행 subagent + - 리뷰어/승인자(Hermes 및 저장소 소유자 `진환 고`) + - MCP/Streamlit 사용자(마이그레이션 전 구간에서 동작·계약 유지가 보장되어야 하는 소비자) +- 제약사항 + - 마이그레이션 전 구간에서 MCP tool 이름/주요 응답 필드/카테고리·현금흐름 의미/import 증빙 필드(`raw_summary`/`counterparty`/`raw_payload`/`external_id`/`import_batch_id`)/additive-only migration/Streamlit 페이지 동작·순서가 호환 계약으로 유지되어야 한다(contract preservation). + - big-bang rewrite 금지, staged refactor로 한 번에 하나의 도메인 슬라이스만 이관한다. + - 금융 데이터 특성상 destructive migration은 승인 없이 금지하고 fixture/복사 DB만 사용한다(additive migration, no destructive migration). + - Streamlit UI seam은 text-first(좁은 gateway 호출 기반)로 유지하며 UI에 SQL/deep domain 규칙을 두지 않는다. + - Phase 4~7은 dcy-010(Phase 0~3) 산출물이 seam으로 존재함을 전제로 하므로 선행 배치 완료·부분 완료 상태에 따라 착수 시점 의존성이 있다. + +## 주요 사용자 시나리오 +사용자 또는 운영자가 실제로 어떤 상황에서 이 변경을 필요로 하는지 대표 시나리오를 적는다. + +- 개발자가 트랜잭션 생성/수정/삭제 또는 카테고리 규칙 mutation 로직을 고칠 때, 3,379줄 service.py 대신 `application/transactions_service.py`·`category_rules_service.py`와 대응 repository만 열어 변경·테스트하려는 상황. +- 운영자가 특정 은행 PDF/엑셀 import 파싱 이슈를 진단할 때, parser(`infrastructure/parsers/*`)와 import orchestration(`imports_service`)이 분리되어 있어 파싱 오류를 mutation 경로가 아닌 parser 경계로 국소화하려는 상황. +- 개발자가 cross-transfer 매칭이나 asset snapshot/net worth 계산 같은 domain-heavy 로직을 확장할 때, 전용 service에서 규칙을 이해·테스트하고 monolith 회귀 위험을 피하려는 상황. +- 향후 HTTP/FastAPI adapter를 추가하려는 개발자가 write/import/advanced 로직을 다시 이동시키지 않고 좁은 gateway와 `application/*` 위에 adapter 패키지만 얹으려는 상황. + +# 4. 이번에 정의할 변경 +이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. 기능, 구조, 규칙 변경을 함께 포함해도 된다. + +- Phase 4 — transactional write service 분리: 계좌/트랜잭션/예산/카테고리·규칙 mutation을 `application/{accounts,transactions,budgets,category_rules}_service.py`와 대응 repository 뒤로 이관하고, service.py는 해당 함수에 대해 delegator/facade로 남긴다. +- Phase 5 — import pipeline application/infrastructure 분리: import preview/import/rollback 및 parser orchestration을 `application/imports_service.py`와 `infrastructure/parsers/*`로 분리하고, parser는 파싱/정규화만, imports_service는 dedupe·분류 기본값·batch 생성·persistence·후속 reconciliation을 담당하며 import 증빙 필드를 보존한다. +- Phase 6 — advanced finance workflow service 분리: cross-transfer·reconciliation·asset/snapshot/net worth·uncategorized workbench 워크플로우를 `application/{cross_transfer,reconciliation,assets}_service.py`로 이관한다. +- Phase 7 — MCP/Streamlit adapter surface 추가 축소: 이미 도입된 `adapters/mcp/server.py`·`adapters/streamlit/service_gateway.py` seam을 전제로, MCP registration helper/contract helper와 Streamlit page-domain gateway를 더 잘게 분리하고 `dashboard/app.py`의 broad gateway 의존을 단계적으로 좁힌다. +- baseline 문서 갱신 요구: Phase 4~7의 새 write/import/advanced 서비스와 adapter 경계·호출 흐름을 반영해 `docs/project-structure.md`와 `docs/runtime-flows.md`를 갱신한다. +- 단계적 완료 기준 확정: Phase 4~7 완료 시 service.py/db.py가 blueprint §11 기준 호환 facade/shim(~400~600줄 이하) 수준으로 축소되는 것을 이번 주기의 Definition of Done 방향으로 둔다. +- Phase 8 취급 결정: optional HTTP adapter 준비(`adapters/http/*`)는 이번 주기 우선순위에서 제외하고 Deferred 후보로만 다룬다. + +## 영향 범위 +변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다. + +- 코드 영향: + - 신규 `jarca_ledger_mcp/application/{accounts,transactions,budgets,category_rules,imports,cross_transfer,reconciliation,assets}_service.py` + - 신규 `jarca_ledger_mcp/infrastructure/parsers/{kb_bank_pdf,toss_bank_pdf,toss_securities_pdf,seoulpay_xlsx}.py` 및 대응 repository(트랜잭션/예산/규칙/import_batches/assets/snapshots) + - 신규 `jarca_ledger_mcp/adapters/mcp/{server,tool_contracts}.py`, `jarca_ledger_mcp/adapters/streamlit/service_gateway.py` + - `jarca_ledger_mcp/service.py`·`db.py`·`server.py`의 facade/shim 축소, `dashboard/app.py`의 gateway 전환, `pyproject.toml` 패키지 discovery/entrypoint 정합성 유지 + - 테스트를 repository/application-service/adapter 계층(Layer A/B/C)으로 재배치 및 신규 추가 +- 문서 영향: + - `docs/project-structure.md`, `docs/runtime-flows.md` 갱신 요구 + - 신규 Discovery(`dcy-011`) 및 `docs/discovery/index.md` 행 추가, dcy-010의 `후속 Discovery 참조` 갱신 +- 운영 영향: + - 마이그레이션 중 MCP/Streamlit 동작·계약 유지 필요, entrypoint 호환 유지로 배포 절차 자체 변경 없음 + - destructive migration 금지, DB 안전 정책(복사/fixture DB) 유지 + +# 5. 요구사항 목록 +실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다. + +## 기능 요구사항 +- FR-1 (Phase 4): 계좌/트랜잭션/예산/카테고리·규칙 write 함수(`create/update/delete_ledger_transaction`, `create/update_ledger_account`, `create_ledger_budget`, `create/delete_ledger_category`, `create/update/delete_category_rule`, `preview/apply_categorization*`, `preview/apply_category_rule*`)를 `application/*_service.py`와 대응 repository로 이관하고, service.py는 delegator facade로 남겨 기존 호출부와 MCP/Streamlit 계약이 깨지지 않게 한다. +- FR-2 (Phase 5): import preview/import/rollback(`preview_ledger_csv_import`, `preview/import_kb_bank_pdf`, `preview/import_seoulpay_xlsx`, `preview/import_toss_bank_pdf`, `preview/import_toss_securities_pdf`, `list_ledger_import_batches`, `rollback_ledger_import_batch`)을 `application/imports_service.py`와 `infrastructure/parsers/*`로 분리하되, parser는 파싱/정규화만 담당하고 import 증빙 필드(`raw_summary`/`counterparty`/`raw_payload`/`external_id`/`import_batch_id`)를 변경 없이 보존한다. +- FR-3 (Phase 6): cross-transfer(`preview_cross_account_transfers`, `run_cross_account_transfer_match`, `list_cross_transfer_review_queue`), reconciliation(`get_balance_reconciliation_report`), asset/net worth(`list/create/update/deactivate_manual_asset`, `preview/close/create_asset_snapshot`, `get_asset_snapshot_report`, `get_net_worth_timeseries`, `get_asset_liquidity_report`), workbench(`list_uncategorized_transactions`, `get_uncategorized_merchant_summary`, `get_uncategorized_workbench`)를 전용 application service로 이관한다. +- FR-4 (Phase 7): 이미 존재하는 `adapters/mcp/server.py`와 `adapters/streamlit/service_gateway.py`를 출발점으로 삼아 MCP registration/helper·contract helper를 adapter 내부로 더 정리하고, Streamlit이 page/domain별 좁은 gateway에만 의존하도록 `dashboard/app.py`의 broad gateway 사용을 단계적으로 축소한다. +- FR-5 (baseline): Phase 4~7의 새 계층 구조와 호출 흐름 갱신 요구를 `docs/project-structure.md`와 `docs/runtime-flows.md`에 반영한다. + +## 비기능 요구사항 +성능, 운영성, 일관성, 자동화 안정성 등 기능 외 요구사항을 적는다. + +- NFR-1 (contract preservation): MCP tool 이름, 주요 응답 필드(`success`, `count`, `items`, `error`, `validation`, `matched_count` 등), 카테고리·현금흐름 의미, import 증빙 필드, additive-only migration, Streamlit 페이지 동작·순서가 의도적 버전 변경·승인 없이 바뀌지 않는다. +- NFR-2 (staged safety): 각 phase는 독립적으로 검증 가능해야 하고(pytest 회귀 + py_compile), 한 번에 하나의 도메인 슬라이스만 이관해 blast radius를 최소화하며, write/import/advanced 순서로 mutation 위험이 낮은 것부터 이관한다. +- NFR-3 (DB safety / additive migration): 리팩터링 중 fixture/복사 DB만 사용하고 승인 없는 destructive migration을 수행하지 않으며, schema 변경이 필요하면 additive column 방식만 사용한다. +- NFR-4 (test layering): 이관과 함께 테스트를 Layer A(repository)/Layer B(application-service)/Layer C(adapter contract)로 이동·보강해 검증이 monolith service.py 테스트에만 의존하지 않게 한다. + +## 범위 경계 +이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다. + +- In Scope + - blueprint Phase 4~7 우선: transactional write service 분리 → import pipeline application/infrastructure 분리 → advanced finance workflow service 분리 → MCP/Streamlit adapter surface 축소 + - Phase 4~7 이관에 필요한 repository/parser/adapter 모듈과 대응 테스트(Layer A/B/C) 재배치 + - baseline 문서(project-structure/runtime-flows) 갱신 요구 + - staged refactor·contract preservation·additive migration·text-first UI seam·no destructive migration 원칙 유지 +- Out of Scope + - Phase 8 optional HTTP adapter(`adapters/http/*`) 실제 구현 — 이번 주기 우선순위 밖이며 Deferred 후보로만 유지한다. + - 즉시 FastAPI/HTTP 전환 강제 + - Postgres/원격 DB 이관 + - 멀티유저/권한 체계 도입 + - 원격 운영(remote ops) 재설계 + - Phase 0~3 재작업(선행 dcy-010/REQ-013·REQ-014 범위이며 본 주기에서 다시 정의하지 않음) + - 이번 Discovery는 하나로 유지하되, REQ/Batch는 Phase별·도메인별로 분할한다. + +# 6. 리스크/가정 목록 +불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다. + +## 리스크 +- R-1 (High): write/import mutation 경로 이관 중 계약 drift — 트랜잭션/규칙/import 응답 형태나 증빙 필드가 조용히 바뀌면 데이터 무결성과 소비자 계약이 깨질 수 있다. + - 완화 방안: 이관 전 MCP tool contract 및 응답 shape 회귀 테스트(dcy-010 Phase 0 baseline 및 dcy-007 응답 계약)를 기준으로 두고, import 증빙 필드 보존을 명시적 테스트로 고정한 뒤 슬라이스 단위로 이관한다. +- R-2 (High): advanced workflow(cross-transfer/reconciliation/asset)의 숨은 결합 — domain-heavy 로직이 공유 helper에 의존해 이관 시 예상치 못한 호출부가 깨질 수 있다. + - 완화 방안: blueprint 실행 순서(read → write → import → advanced)를 지켜 repository/base service가 검증된 뒤에 이관하고, helper는 첫 소비자와 함께 이동하며 characterization 테스트를 선행한다. +- R-3 (Medium): Streamlit adapter 축소 시 hot-reload/직접 import 취약성 — `dashboard/app.py`의 넓은 `service.*` 의존을 좁은 gateway로 바꾸는 과정에서 페이지가 깨질 수 있다. + - 완화 방안: `service_gateway.py`를 도입해 페이지 단위로 점진 이관하고, hot-reload helper는 domain/application이 아닌 adapter-specific 코드로 이동하며 페이지/서비스 통합 테스트를 확보한다. +- R-4 (Medium): 선행 배치(dcy-010 Phase 0~3) 미완료 상태에서 Phase 4 착수 시 seam 부재로 이관 비용 증가. + - 완화 방안: Phase 4 착수 전 Phase 0~3 seam(compat facade, persistence 분리, read-only service) 완료 여부를 REQ/Batch planning의 진입 조건으로 확인한다. + +## 가정 +아직 확정되지 않았지만 현재 문서 작성의 기반이 되는 전제를 적는다. + +- A-1: dcy-010/REQ-013·REQ-014로 정의된 Phase 0~3 배치가 이번 후속 주기 착수 시점까지 seam(compat facade, sqlite 분리, read-only application service)을 제공한다. +- A-2: 기존 pytest 스위트(transaction/import/rules/dashboard/asset snapshot 회귀 등)와 dcy-007 MCP 응답 계약 테스트가 동작하며 Phase 4~7 회귀 검증의 기준으로 사용할 수 있다. +- A-3: 현재 SQLite schema가 additive-oriented이므로 destructive 변경 없이 write/import/advanced 슬라이스 이관을 진행할 수 있다. +- A-4: GitHub Project 4(Jarca) 보드 Todo의 Phase 4~8 항목과 blueprint Phase 4~8 절이 동일한 후속 작업 정의를 가리킨다(요청 원문과 blueprint로 교차 확인). + +## 오픈 질문 +답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다. + +- OQ-1: Phase 4~7을 하나의 후속 REQ/Batch 주기로 묶을지, Phase별(또는 write / import / advanced+adapter) 하위 배치로 분할해 추적할지? + - 상태: Answered + - 처리 방안: 사용자 결정에 따라 Discovery는 하나로 유지하고, REQ/Batch는 Phase별·도메인별로 분할한다. + - 종료 조건: `# 5. 범위 경계`와 `# 10. 사용자 결정 필요 항목 요약`에 배치 분할 방침이 반영되었으므로 종료한다. +- OQ-2: Phase 8 optional HTTP adapter 준비를 이번 후속 주기의 Out of Scope로 완전히 제외할지, Deferred(다음 주기 예약) 후보로 명시적으로 남길지? + - 상태: Answered + - 처리 방안: 사용자 결정에 따라 이번 주기에서는 구현하지 않고 Deferred 후보로만 남긴다. + - 종료 조건: `# 1. 계획 상태 요약`, `# 4. 이번에 정의할 변경`, `# 5. 범위 경계`에 Deferred 취급이 반영되었으므로 종료한다. + +### 오픈 질문 처리 기준 +- `Open`: 아직 답이 없고 다음 단계 진행에 영향이 있는 상태다. 문서에 유지한다. +- `Answered`: 답이 확정된 상태다. 관련 본문/요구사항에 반영한 뒤 오픈 질문 목록에서는 제거하거나 resolved 기록으로 이동한다. +- `Deferred`: 지금 단계에서 결정하지 않고 다음 단계 또는 후속 이슈로 넘기는 상태다. 넘길 대상과 이유를 반드시 적는다. +- `Dropped`: 더 이상 유효하지 않은 질문이다. 삭제 사유를 짧게 남기고 목록에서 제거한다. +- 처리 원칙 1: 질문만 남기지 말고 반드시 `상태`, `처리 방안`, `종료 조건`을 함께 적는다. +- 처리 원칙 2: 답이 나온 질문은 본문 반영 없이 단순 삭제하지 않는다. 반영 위치를 먼저 갱신한다. +- 처리 원칙 3: High 영향 질문은 `Deferred` 처리 후 처리 방안에 REQ 인계 추적 방식을 명시한다. + +# 7. 초기 성공 기준 +이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다. + +- S-1: Phase 4~7 전 구간에서 MCP와 Streamlit이 동작하며, 기존/대체 회귀 테스트와 MCP tool contract 테스트가 통과한다(계약 무변경). +- S-2: Phase 4~7 이관 완료 시 write/import/advanced 로직이 더 잘게 나뉜 `application/*`, `infrastructure/parsers/*`, 전용 advanced workflow services로 이동하고, compatibility facade/shim은 얇게 유지되며 broad application modules의 책임 밀도가 유의미하게 낮아진다. +- S-3: `dashboard/app.py`가 broad `service_gateway` 또는 legacy compatibility surface 대신 더 좁은 page/domain gateway에 의존하고, MCP adapter도 registration/helper·contract helper가 분리된 구조를 갖는다. +- S-4: 각 도메인 영역이 자체 application service와 persistence 경계를 갖고 `docs/project-structure.md`·`docs/runtime-flows.md`가 Phase 4~7 계층 구조를 반영하도록 갱신되어, 이후 REQ/Batch 산출물과 향후 HTTP adapter가 baseline 문서를 참조할 수 있다. + +## 측정 방식 및 데이터 출처 +각 성공 기준을 어떻게 측정할지와 어떤 데이터나 문서를 근거로 쓸지 적는다. + +- S-1 측정 방식: 각 phase 종료 시 pytest 회귀 스위트 + MCP tool contract 테스트 전부 통과 확인 / 데이터 출처: `tests/` 실행 결과, `docs/discovery/dcy-007` 응답 계약, dcy-010 Phase 0 baseline +- S-2 측정 방식: 대형 application module의 라인 수/책임 축소, write/import/advanced 함수가 더 세분화된 `application/*`·`infrastructure/parsers/*`·전용 workflow services로 이동했는지 확인 / 데이터 출처: 리팩터링 후 소스 파일, blueprint §5(Phase 4~7)·§11 DoD +- S-3 측정 방식: `dashboard/app.py`의 broad gateway 직접 호출 수 감소와 page/domain gateway 사용 여부, MCP adapter 내부 helper 분리 여부 확인 / 데이터 출처: `dashboard/app.py`, `jarca_ledger_mcp/adapters/*` +- S-4 측정 방식: 도메인별 application service/repository 존재 여부 및 baseline 문서 diff 확인 / 데이터 출처: `jarca_ledger_mcp/{application,infrastructure,adapters}/*`, `docs/project-structure.md`, `docs/runtime-flows.md` + +# 8. REQ로 넘기기 전 확인 체크 +REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다. + +- [x] 범위(In Scope/Out of Scope) 확정 + - 기준: Phase 4~7 우선 In Scope, Phase 8은 Deferred 후보로만 유지, Discovery는 하나로 두되 REQ/Batch는 Phase별·도메인별로 분할한다는 방침이 문서에 반영되어 있다. +- [x] 성공 지표의 측정 방식/데이터 출처 확정 + - 기준: S-2의 DoD를 broad application module 책임 축소와 compatibility facade/shim 유지로 채택하고 baseline 테스트 목록은 REQ/planning에서 계측 대상으로 명시한다. +- [ ] High 영향 리스크 우선순위 및 해소 책임자 지정 + - 기준: R-1(계약 drift), R-2(advanced 결합)의 characterization/contract 테스트 선행 책임자는 REQ/Batch planning에서 지정한다. Discovery confirm의 차단 사유는 아니다. +- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인 + - 기준: OQ 목록에 `Open` 상태 항목이 0개이며, 남은 DATA 항목은 REQ/planning의 세부 실행자료로만 이관된다. +- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정 + - 기준: 승인자 `Justin Ko`가 Confirmed By로 기록된다. + +# 9. 파일 처리 결과 +이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다. + +- 처리 결과: 생성 +- 생성 경로: docs/discovery/dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing.md +- 참조 문서: + - docs/discovery/dcy-010_20260702_backend-core-layering-mcp-adapter-realignment.md (선행 Discovery, Phase 0~3, 본 문서가 후속 주기) + - .hermes/plans/2026-07-02_jarca-ledger-backend-refactor-blueprint.md (근거 blueprint, Phase 4~8) + - stage-pilot/templates/project-structure.md (baseline 갱신 논리 템플릿 참조) + - stage-pilot/templates/runtime-flows.md (baseline 갱신 논리 템플릿 참조) + +# 10. 사용자 결정 필요 항목 요약 +사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다. + +## DECIDE +- 없음 — Phase 8 optional HTTP adapter 준비는 이번 주기에서 구현하지 않고 Deferred 후보로만 남긴다. +- 없음 — dcy-011 Discovery는 하나로 유지하고, 후속 REQ/Batch는 Phase별·도메인별로 분할한다. + +## CONFIRM +- 없음 — Phase 4~7 이관 동안 MCP tool 이름/주요 응답 필드/import 증빙 필드/Streamlit 페이지 동작을 호환 계약으로 동결한다. +- 없음 — Phase 4 착수 전 dcy-010(Phase 0~3) seam 완료를 진입 조건으로 둔다. + +## DATA +- {{DATA_BASELINE_TEST_SET}} — Phase 4~7 회귀 검증에 사용할 현재 통과 pytest 스위트 목록과 커버 대상 MCP tool 집합(REQ/planning 산출물에서 명시). + +# 11. Discovery Freeze +REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다. + +## 섹션 참조 +- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록) +- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록) +- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준) +- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크) +- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약) + +## Freeze 플래그 +- Handoff Decision: 승인 +- Handoff Rationale: Phase 4~7 후속 주기 Discovery가 현재 저장소 상태와 정합하도록 보정되었고, 사용자 승인에 따라 Phase 8은 Deferred 후보로만 남기며, Discovery는 하나로 유지하되 REQ/Batch는 Phase별·도메인별로 분할한다는 방침이 확정되었다. 계약 동결·additive migration·text-first UI seam·Phase 0~3 선행 seam 진입 조건도 문서에 반영되었고, 남은 `DATA_BASELINE_TEST_SET`은 REQ/planning 단계에서 계측할 비차단 운영 데이터로 정리되었다. +- Ready for REQ Drafting: true +- Confirmed By: Justin Ko +- Confirmed At (KST): 2026-07-03 07:23 KST diff --git a/docs/discovery/index.md b/docs/discovery/index.md index 976128a..bb6fed5 100644 --- a/docs/discovery/index.md +++ b/docs/discovery/index.md @@ -16,3 +16,4 @@ | 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-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 | +| dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing | 20260703 | Jarca Ledger 백엔드 리팩터링 Phase 4+ 후속 주기 (write service 분리 → import pipeline 분리 → advanced finance workflow 분리 → MCP/Streamlit adapter 표면 축소) | [Discovery](./dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing.md) | confirmed | diff --git a/docs/srs/Non-Functional/req-015_transactional-write-service-split-compatibility.md b/docs/srs/Non-Functional/req-015_transactional-write-service-split-compatibility.md new file mode 100644 index 0000000..f0a29b2 --- /dev/null +++ b/docs/srs/Non-Functional/req-015_transactional-write-service-split-compatibility.md @@ -0,0 +1,86 @@ +# REQ-015 Transactional write service split and compatibility-preserving domain extraction + +- Status: Implemented +- Type: Non-Functional +- Priority: High +- Owner: 렛저장인 +- Source Discovery: `docs/discovery/dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing.md` +- Batch: BAT-011 +- Created At (KST): 2026-07-03 07:29 KST +- Approved At (KST): 2026-07-03 07:30 KST +- Implemented At (KST): 2026-07-03 07:39 KST + +## 1. 배경 + +`dcy-011` confirmed Discovery는 BAT-010 이후 top-level facade/shim과 read-only seam은 이미 도입됐지만, transaction/account/budget mutation과 일부 write-oriented orchestration이 여전히 broad application module에 섞여 있다고 판단했다. 특히 `transaction_report_services.py`, `system_services.py`, `asset_account_services.py`, `categorization_services.py`에 흩어진 write 경로는 이후 import/advanced workflow 분리의 선행 기반이므로, contract를 유지한 채 domain-oriented write service로 먼저 좁혀야 한다. + +## 2. Intent + +이 REQ의 목적은 기능 계약을 바꾸지 않고 transactional write 경로를 `accounts`, `transactions`, `budgets`, `category rules/categories` 성격의 전용 application service로 분리해 write 책임을 더 국소화하는 것이다. 결과적으로 기존 MCP/Streamlit/service facade 호출은 그대로 유지하면서도 이후 import/advanced workflow 분리가 더 좁은 변경 단위로 이어질 수 있어야 한다. + +## 3. 범위 + +### In Scope +- account write 경로(`create_ledger_account`, `update_ledger_account`)를 전용 write service/module로 정리한다. +- transaction write 경로(`create_ledger_transaction`, `update_ledger_transaction`, `delete_ledger_transaction`)를 전용 write service/module로 정리한다. +- budget write 경로(`create_ledger_budget`)를 전용 write service/module로 정리한다. +- category rule/category mutation 경로(`create/update/delete_category_rule`, `create/delete_ledger_category`)를 전용 write service/module로 정리한다. +- 기존 `service.py`, MCP adapter, Streamlit gateway, dashboard call sites는 기존 함수명과 envelope contract를 유지한 채 새 write service를 재노출한다. +- 관련 테스트와 batch evidence를 통해 contract preservation과 regression safety를 검증한다. + +### Out of Scope +- import preview/import/rollback parser/application split. +- cross-transfer, reconciliation, asset/net worth, uncategorized workbench의 전용 service 분리. +- HTTP adapter 추가. +- response schema, tool names, dashboard IA 변경. +- destructive migration. + +## 4. Requirement + +### FR-1. Domain-oriented transactional write service extraction +- account, transaction, budget, category rule/category mutation 구현은 기존 broad application module에서 더 좁은 write-oriented application module로 이동해야 한다. +- 분리 후에도 top-level `service.py` public API와 adapter 호출 signature는 유지돼야 한다. +- 새 module 경계는 최소한 accounts / transactions / budgets / category-rules 성격을 드러내야 한다. + +### FR-2. Compatibility-preserving delegation +- 기존 `jarca_ledger_mcp/application/*` 모듈이 직접 구현을 계속 품지 않더라도, 기존 import surface와 테스트/monkeypatch 관성은 유지돼야 한다. +- `jarca_ledger_mcp/service.py`, `jarca_ledger_mcp/adapters/mcp/server.py`, `jarca_ledger_mcp/adapters/streamlit/service_gateway.py`, `dashboard/app.py`는 호출부 변경 없이 동작해야 한다. +- 성공/실패 envelope(`success`, `error_code`, `transaction`, `account`, `budget`, `rule`, `category`, `deleted` 등)는 의도적으로 변경되지 않아야 한다. + +### FR-3. Regression-safe write boundary proof +- 기존 pytest 회귀와 contract regression은 write service 이동 후에도 PASS 해야 한다. +- write path가 분리된 뒤에도 ledger category/category rule/account/transaction 관련 mutation smoke가 유지돼야 한다. +- batch implementation/verification 문서는 변경 파일, 검증 명령, 잔여 리스크를 실제 실행 결과로 남겨야 한다. + +## 5. Acceptance Criteria + +- AC-1: `jarca_ledger_mcp/application/` 아래에 transactional write 경로를 담당하는 신규 module이 생성되고, account/transaction/budget/category rule/category mutation 구현이 그쪽으로 이동한다. +- AC-2: 기존 `service.py` public API, MCP tools, Streamlit gateway, dashboard 호출부는 signature와 envelope contract를 유지한다. +- AC-3: full pytest 또는 해당 회귀 subset, contract-sensitive tests가 PASS 한다. +- AC-4: batch implementation/verification 문서가 실제 변경 파일과 검증 결과를 반영한다. + +## 6. Impacted Area + +- `jarca_ledger_mcp/application/__init__.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/categorization_services.py` +- `jarca_ledger_mcp/service.py` +- `jarca_ledger_mcp/adapters/mcp/server.py` +- `jarca_ledger_mcp/adapters/streamlit/service_gateway.py` +- `tests/test_service.py` +- `tests/test_category_rule_filters.py` +- `tests/test_contract_regression.py` + +## 7. Notes + +- 이 REQ는 behavior change보다 package boundary와 maintainability 향상을 다루므로 `Non-Functional`로 관리한다. +- dcy-011 전체는 multi-batch 후속 주기이므로, 이번 REQ는 Phase 4 첫 구현 슬라이스로 다룬다. +- import/advanced workflow 분리는 후속 REQ/BAT에서 계속 진행한다. + +## 8. Change Log + +- 2026-07-03 07:29 KST: dcy-011 confirmed 범위를 바탕으로 Phase 4 첫 구현 슬라이스용 Proposed 초안을 생성. +- 2026-07-03 07:30 KST: 사용자 요청에 따라 dcy-011의 첫 delivery 입력으로 승인하고 BAT-011 구현 대상으로 확정. +- 2026-07-03 07:39 KST: write-oriented application modules 분리, full pytest PASS, MCP tool discovery 56, service export provenance 검증을 근거로 Implemented로 전환. diff --git a/docs/srs/index.md b/docs/srs/index.md index 49732af..04457c6 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -33,7 +33,7 @@ ## Next Requirement ID -- Current: `REQ-015` +- Current: `REQ-016` - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. ## Requirement Register @@ -54,6 +54,7 @@ | 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) | +| REQ-015 | Transactional write service split and compatibility-preserving domain extraction | Non-Functional | Implemented | High | 렛저장인 | [req-015_transactional-write-service-split-compatibility.md](Non-Functional/req-015_transactional-write-service-split-compatibility.md) | ## Recent Change Log Summary @@ -80,3 +81,5 @@ - 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로 전환했다. +- 2026-07-03 07:30 KST: dcy-011 confirmed 범위를 `REQ-015` Proposed로 생성하고 첫 delivery 입력으로 Approved 상태로 전환했다. +- 2026-07-03 07:39 KST: BAT-011 write-service split 구현과 full pytest/MCP discovery evidence를 근거로 `REQ-015`를 Implemented로 전환했다. diff --git a/jarca_ledger_mcp/application/__init__.py b/jarca_ledger_mcp/application/__init__.py index 201fc91..ffbeaa4 100644 --- a/jarca_ledger_mcp/application/__init__.py +++ b/jarca_ledger_mcp/application/__init__.py @@ -1,6 +1,10 @@ from . import shared +from .accounts_write_services import * from .asset_account_services import * +from .budgets_write_services import * +from .category_rules_write_services import * from .categorization_services import * from .import_transfer_services import * from .system_services import * +from .transactions_write_services import * from .transaction_report_services import * diff --git a/jarca_ledger_mcp/application/accounts_write_services.py b/jarca_ledger_mcp/application/accounts_write_services.py new file mode 100644 index 0000000..14070c5 --- /dev/null +++ b/jarca_ledger_mcp/application/accounts_write_services.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from .shared import * + + +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 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)) diff --git a/jarca_ledger_mcp/application/asset_account_services.py b/jarca_ledger_mcp/application/asset_account_services.py index 9846f5c..f2c9095 100644 --- a/jarca_ledger_mcp/application/asset_account_services.py +++ b/jarca_ledger_mcp/application/asset_account_services.py @@ -490,35 +490,4 @@ def get_asset_liquidity_report(db_path: str | None = None) -> dict[str, Any]: ) -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)) +from .accounts_write_services import update_ledger_account diff --git a/jarca_ledger_mcp/application/budgets_write_services.py b/jarca_ledger_mcp/application/budgets_write_services.py new file mode 100644 index 0000000..1929a58 --- /dev/null +++ b/jarca_ledger_mcp/application/budgets_write_services.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from .shared import * + + +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)) diff --git a/jarca_ledger_mcp/application/categorization_services.py b/jarca_ledger_mcp/application/categorization_services.py index 881bfce..698a411 100644 --- a/jarca_ledger_mcp/application/categorization_services.py +++ b/jarca_ledger_mcp/application/categorization_services.py @@ -67,27 +67,13 @@ def _rule_matches(rule: dict[str, Any], tx: dict[str, Any]) -> bool: 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)) +from .category_rules_write_services import ( + create_category_rule, + create_ledger_category, + delete_category_rule, + delete_ledger_category, + update_category_rule, +) def seed_default_category_rules(overwrite: bool = False, db_path: str | None = None) -> dict[str, Any]: @@ -112,59 +98,8 @@ def list_category_rules(active_only: bool = True, db_path: str | None = None) -> 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()] @@ -628,22 +563,7 @@ def infer_cashflow_kind(category: str, subcategory: str = "") -> str: 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]: @@ -681,23 +601,4 @@ def list_ledger_categories(active_only: bool = True, include_usage: bool = True, 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) + diff --git a/jarca_ledger_mcp/application/category_rules_write_services.py b/jarca_ledger_mcp/application/category_rules_write_services.py new file mode 100644 index 0000000..184e8ae --- /dev/null +++ b/jarca_ledger_mcp/application/category_rules_write_services.py @@ -0,0 +1,145 @@ +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"} + + +def infer_cashflow_kind(category: str, subcategory: str = "") -> str: + if category == "수입": + return "income" + if category == "이체": + if subcategory in {"현금/계좌이동", "저축이동", "투자이동", "토스증권이체"}: + return "transfer" + return "exclude" + if category == "투자": + if subcategory in {"배당", "이자"}: + return "income" + return "exclude" + if category == "금융": + if subcategory == "대출상환": + return "debt_payment" + if subcategory in {"카드대금", "수수료"}: + return "exclude" + return "exclude" + if category == "미분류": + return "unknown" + return "expense" + + +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 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 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 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) diff --git a/jarca_ledger_mcp/application/system_services.py b/jarca_ledger_mcp/application/system_services.py index 514742f..3642076 100644 --- a/jarca_ledger_mcp/application/system_services.py +++ b/jarca_ledger_mcp/application/system_services.py @@ -166,22 +166,7 @@ def get_ledger_backup_status(file_path: str, include_hash: bool = True, include_ 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)) +from .accounts_write_services import create_ledger_account 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" diff --git a/jarca_ledger_mcp/application/transaction_report_services.py b/jarca_ledger_mcp/application/transaction_report_services.py index d4fc95e..b609205 100644 --- a/jarca_ledger_mcp/application/transaction_report_services.py +++ b/jarca_ledger_mcp/application/transaction_report_services.py @@ -2,30 +2,8 @@ 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)) +from .budgets_write_services import create_ledger_budget +from .transactions_write_services import create_ledger_transaction, delete_ledger_transaction, update_ledger_transaction 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=[] @@ -136,53 +114,7 @@ def get_balance_reconciliation_report(account_id: str = "", baseline_balance: in }) 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=[] diff --git a/jarca_ledger_mcp/application/transactions_write_services.py b/jarca_ledger_mcp/application/transactions_write_services.py new file mode 100644 index 0000000..b7712cb --- /dev/null +++ b/jarca_ledger_mcp/application/transactions_write_services.py @@ -0,0 +1,66 @@ +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 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) diff --git a/jarca_ledger_mcp/service.py b/jarca_ledger_mcp/service.py index 8c7766c..90b085f 100644 --- a/jarca_ledger_mcp/service.py +++ b/jarca_ledger_mcp/service.py @@ -3,11 +3,15 @@ from __future__ import annotations import sys from types import ModuleType +from .application import accounts_write_services as _accounts_write_services from .application import asset_account_services as _asset_account_services +from .application import budgets_write_services as _budgets_write_services +from .application import category_rules_write_services as _category_rules_write_services from .application import categorization_services as _categorization_services from .application import import_transfer_services as _import_transfer_services from .application import shared as _shared from .application import system_services as _system_services +from .application import transactions_write_services as _transactions_write_services from .application import transaction_report_services as _transaction_report_services from .application.asset_account_services import * from .application.categorization_services import * @@ -21,11 +25,15 @@ _current_kst_datetime = _shared._current_kst_datetime _PROXY_MODULES = ( _shared, + _accounts_write_services, _system_services, _asset_account_services, + _budgets_write_services, + _category_rules_write_services, _transaction_report_services, _import_transfer_services, _categorization_services, + _transactions_write_services, )