Implement BAT-011 transactional write service split

This commit is contained in:
Jarca
2026-07-03 07:42:16 +09:00
parent 7ac25a6cf7
commit ac78e94571
21 changed files with 844 additions and 228 deletions
@@ -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.
@@ -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은 의도적으로 변경하지 않았다.
@@ -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
@@ -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.
@@ -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
+1
View File
@@ -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/) |
@@ -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. 계획 상태 요약
@@ -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
+1
View File
@@ -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 |
@@ -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로 전환.
+4 -1
View File
@@ -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로 전환했다.
+4
View File
@@ -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 *
@@ -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))
@@ -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
@@ -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))
@@ -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)
@@ -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)
@@ -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"
@@ -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=[]
@@ -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)
+8
View File
@@ -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,
)