From 7ac25a6cf7ae0db9f844b0fa0c095d07bdf1ce05 Mon Sep 17 00:00:00 2001 From: Jarca Date: Fri, 3 Jul 2026 07:03:17 +0900 Subject: [PATCH] refactor(jarca-ledger): implement BAT-010 backend core layering --- .gitignore | 1 + dashboard/app.py | 2 +- .../design.md | 50 + .../implementation.md | 61 + .../index.md | 37 + .../planning.md | 34 + .../verification.md | 45 + docs/batches/index.md | 1 + ...erations-hub-liquidity-monthly-cashflow.md | 4 +- ...d-core-layering-mcp-adapter-realignment.md | 219 ++ docs/discovery/index.md | 1 + docs/project-structure.md | 116 +- docs/runtime-flows.md | 122 +- ...realignment-contract-preserving-gateway.md | 84 + ...astructure-service-facade-compatibility.md | 87 + docs/srs/index.md | 8 +- jarca_ledger_mcp/adapters/__init__.py | 0 jarca_ledger_mcp/adapters/mcp/__init__.py | 1 + jarca_ledger_mcp/adapters/mcp/server.py | 296 ++ .../adapters/streamlit/__init__.py | 1 + .../adapters/streamlit/service_gateway.py | 44 + jarca_ledger_mcp/application/__init__.py | 6 + .../application/asset_account_services.py | 524 +++ .../application/categorization_services.py | 703 ++++ .../application/import_transfer_services.py | 1513 ++++++++ jarca_ledger_mcp/application/shared.py | 199 + .../application/system_services.py | 190 + .../transaction_report_services.py | 272 ++ jarca_ledger_mcp/db.py | 332 +- jarca_ledger_mcp/infrastructure/__init__.py | 0 .../infrastructure/sqlite/__init__.py | 3 + .../infrastructure/sqlite/connection.py | 39 + .../infrastructure/sqlite/migrations.py | 114 + .../infrastructure/sqlite/schema.py | 190 + jarca_ledger_mcp/server.py | 292 +- jarca_ledger_mcp/service.py | 3414 +---------------- 36 files changed, 4892 insertions(+), 4113 deletions(-) create mode 100644 docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/design.md create mode 100644 docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/implementation.md create mode 100644 docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/index.md create mode 100644 docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/planning.md create mode 100644 docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/verification.md create mode 100644 docs/discovery/dcy-010_20260702_backend-core-layering-mcp-adapter-realignment.md create mode 100644 docs/srs/Interface/req-014_mcp-streamlit-adapter-seam-realignment-contract-preserving-gateway.md create mode 100644 docs/srs/Non-Functional/req-013_backend-core-layering-sqlite-infrastructure-service-facade-compatibility.md create mode 100644 jarca_ledger_mcp/adapters/__init__.py create mode 100644 jarca_ledger_mcp/adapters/mcp/__init__.py create mode 100644 jarca_ledger_mcp/adapters/mcp/server.py create mode 100644 jarca_ledger_mcp/adapters/streamlit/__init__.py create mode 100644 jarca_ledger_mcp/adapters/streamlit/service_gateway.py create mode 100644 jarca_ledger_mcp/application/__init__.py create mode 100644 jarca_ledger_mcp/application/asset_account_services.py create mode 100644 jarca_ledger_mcp/application/categorization_services.py create mode 100644 jarca_ledger_mcp/application/import_transfer_services.py create mode 100644 jarca_ledger_mcp/application/shared.py create mode 100644 jarca_ledger_mcp/application/system_services.py create mode 100644 jarca_ledger_mcp/application/transaction_report_services.py create mode 100644 jarca_ledger_mcp/infrastructure/__init__.py create mode 100644 jarca_ledger_mcp/infrastructure/sqlite/__init__.py create mode 100644 jarca_ledger_mcp/infrastructure/sqlite/connection.py create mode 100644 jarca_ledger_mcp/infrastructure/sqlite/migrations.py create mode 100644 jarca_ledger_mcp/infrastructure/sqlite/schema.py diff --git a/.gitignore b/.gitignore index adc2fa4..3ac54b4 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ __pycache__/ *.csv tmp_* .DS_Store +.worktrees/ diff --git a/dashboard/app.py b/dashboard/app.py index e971d15..354b15d 100644 --- a/dashboard/app.py +++ b/dashboard/app.py @@ -15,7 +15,7 @@ import plotly.express as px import plotly.io as pio import streamlit as st -from jarca_ledger_mcp import service +from jarca_ledger_mcp.adapters.streamlit import service_gateway as service from jarca_ledger_mcp.db import resolve_db_path st.set_page_config(page_title="Jarca Ledger", page_icon="๐Ÿ’ฐ", layout="wide") diff --git a/docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/design.md b/docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/design.md new file mode 100644 index 0000000..193040a --- /dev/null +++ b/docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/design.md @@ -0,0 +1,50 @@ +# BAT-010 Design + +## Layering design + +- `jarca_ledger_mcp/application/shared.py` + - ๊ณตํ†ต helper, validation, clock, dedupe, parser imports, shared policy๋ฅผ ๋ณด์œ ํ•œ๋‹ค. +- `jarca_ledger_mcp/application/system_services.py` + - health/status/backup/account create-list ๋“ฑ system/persistence-adjacent service๋ฅผ ๋‹ด๋‹นํ•œ๋‹ค. +- `jarca_ledger_mcp/application/asset_account_services.py` + - account update, manual asset, snapshot, liquidity/net-worth read/write helper๋ฅผ ๋‹ด๋‹นํ•œ๋‹ค. +- `jarca_ledger_mcp/application/transaction_report_services.py` + - transaction CRUD, budgets, monthly/category/fixed-cost/reporting helper๋ฅผ ๋‹ด๋‹นํ•œ๋‹ค. +- `jarca_ledger_mcp/application/import_transfer_services.py` + - import preview/import, self-transfer/cancel reconciliation, cross-transfer matching์„ ๋‹ด๋‹นํ•œ๋‹ค. +- `jarca_ledger_mcp/application/categorization_services.py` + - category rules, preview/apply, uncategorized workbench, ledger categories๋ฅผ ๋‹ด๋‹นํ•œ๋‹ค. + +## Persistence design + +- `jarca_ledger_mcp/infrastructure/sqlite/connection.py` + - DB path resolution, sqlite connect, `db()` context manager๋ฅผ ๋‹ด๋‹นํ•œ๋‹ค. +- `jarca_ledger_mcp/infrastructure/sqlite/schema.py` + - schema version, DDL, additive column maps๋ฅผ ๋ณด์œ ํ•œ๋‹ค. +- `jarca_ledger_mcp/infrastructure/sqlite/migrations.py` + - idempotent/additive migration๊ณผ `init_db()`๋ฅผ ๋‹ด๋‹นํ•œ๋‹ค. +- top-level `jarca_ledger_mcp/db.py`๋Š” compatibility shim์œผ๋กœ ์œ ์ง€ํ•œ๋‹ค. + +## Adapter / gateway design + +- `jarca_ledger_mcp/adapters/mcp/server.py` + - ์‹ค์ œ FastMCP registration ์œ„์น˜. +- top-level `jarca_ledger_mcp/server.py` + - CLI entrypoint shim. +- `jarca_ledger_mcp/adapters/streamlit/service_gateway.py` + - dashboard๊ฐ€ ์‹ค์ œ๋กœ ์‚ฌ์šฉํ•˜๋Š” service surface๋งŒ ์žฌ๋…ธ์ถœํ•œ๋‹ค. +- `dashboard/app.py` + - gateway import๋ฅผ ํ†ตํ•ด backend core๋ฅผ ํ˜ธ์ถœํ•œ๋‹ค. + +## Compatibility design + +- top-level `service.py`๋Š” public API๋ฅผ ๊ทธ๋Œ€๋กœ ์žฌ๋…ธ์ถœํ•œ๋‹ค. +- monkeypatch ๊ด€์„ฑ์„ ์œ„ํ•ด `service._current_kst_datetime` ๋ณ€๊ฒฝ์ด shared/application modules์— ์ „ํŒŒ๋˜๋„๋ก module proxy๋ฅผ ์œ ์ง€ํ•œ๋‹ค. +- ๊ธฐ์กด tests/import callers๋Š” `from jarca_ledger_mcp import service` / `from jarca_ledger_mcp.db import init_db` ๊ฒฝ๋กœ๋ฅผ ๊ทธ๋Œ€๋กœ ์“ด๋‹ค. + +## Verification design + +- `python -m py_compile`๋กœ ์ƒˆ package tree syntax check. +- `pytest -q`๋กœ full regression ๋ฐ AppTest ํฌํ•จ ํšŒ๊ท€ ๊ฒ€์ฆ. +- `hermes mcp test jarca-ledger`๋กœ MCP tool discovery/registration ํ™•์ธ. +- Browser smoke๋กœ synthetic fixture DB ๊ธฐ๋ฐ˜ `์š”์•ฝ`, `์ž์‚ฐ/๋งˆ๊ฐ` ๋ Œ๋” ๋ฐ console JS error ์œ ๋ฌด ํ™•์ธ. diff --git a/docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/implementation.md b/docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/implementation.md new file mode 100644 index 0000000..06585ef --- /dev/null +++ b/docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/implementation.md @@ -0,0 +1,61 @@ +# BAT-010 Implementation + +## Plan summary + +BAT-010 implements the confirmed dcy-010 Phase 0~3 scope by extracting application services, SQLite infrastructure, and thin MCP/Streamlit adapter seams while preserving legacy import paths and response contracts. + +## Changed files + +- `jarca_ledger_mcp/service.py` +- `jarca_ledger_mcp/db.py` +- `jarca_ledger_mcp/server.py` +- `jarca_ledger_mcp/application/__init__.py` +- `jarca_ledger_mcp/application/shared.py` +- `jarca_ledger_mcp/application/system_services.py` +- `jarca_ledger_mcp/application/asset_account_services.py` +- `jarca_ledger_mcp/application/transaction_report_services.py` +- `jarca_ledger_mcp/application/import_transfer_services.py` +- `jarca_ledger_mcp/application/categorization_services.py` +- `jarca_ledger_mcp/infrastructure/__init__.py` +- `jarca_ledger_mcp/infrastructure/sqlite/__init__.py` +- `jarca_ledger_mcp/infrastructure/sqlite/connection.py` +- `jarca_ledger_mcp/infrastructure/sqlite/schema.py` +- `jarca_ledger_mcp/infrastructure/sqlite/migrations.py` +- `jarca_ledger_mcp/adapters/__init__.py` +- `jarca_ledger_mcp/adapters/mcp/__init__.py` +- `jarca_ledger_mcp/adapters/mcp/server.py` +- `jarca_ledger_mcp/adapters/streamlit/__init__.py` +- `jarca_ledger_mcp/adapters/streamlit/service_gateway.py` +- `dashboard/app.py` +- `docs/discovery/dcy-010_20260702_backend-core-layering-mcp-adapter-realignment.md` +- `docs/project-structure.md` +- `docs/runtime-flows.md` +- `docs/srs/index.md` +- `docs/srs/Non-Functional/req-013_backend-core-layering-sqlite-infrastructure-service-facade-compatibility.md` +- `docs/srs/Interface/req-014_mcp-streamlit-adapter-seam-realignment-contract-preserving-gateway.md` +- `docs/batches/index.md` +- `docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/index.md` +- `docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/planning.md` +- `docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/design.md` +- `docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/implementation.md` +- `docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/verification.md` + +## Execution log + +- 2026-07-03 00:15 KST: BAT-010 standard batch scaffold and REQ-013/REQ-014 ๋ฌธ์„œ๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ์Šน์ธ ์ƒํƒœ๋กœ ์ •๋ฆฌํ–ˆ๋‹ค. +- 2026-07-03 00:17 KST: `service.py` giant implementation์„ application modules๋กœ ๋ถ„๋ฆฌํ•˜๊ณ  top-level facade compatibility / monkeypatch proxy๋ฅผ ์ถ”๊ฐ€ํ–ˆ๋‹ค. +- 2026-07-03 00:17 KST: `db.py` responsibilities๋ฅผ `infrastructure/sqlite`๋กœ ๋ถ„๋ฆฌํ•˜๊ณ  ๊ธฐ์กด init/commit/rollback semantics๋ฅผ ์œ ์ง€ํ•˜๋Š” shim์„ ์ถ”๊ฐ€ํ–ˆ๋‹ค. +- 2026-07-03 00:18 KST: MCP adapter package, Streamlit gateway, baseline docs๋ฅผ ๋ฐ˜์˜ํ•˜๊ณ  regression fixes(`SCHEMA_VERSION import`, `db()` init semantics, underscore helper imports, service monkeypatch proxy)๋ฅผ ์ •๋ฆฌํ–ˆ๋‹ค. + +## Validation + +- `python -m py_compile jarca_ledger_mcp/*.py jarca_ledger_mcp/application/*.py jarca_ledger_mcp/infrastructure/sqlite/*.py jarca_ledger_mcp/adapters/mcp/*.py jarca_ledger_mcp/adapters/streamlit/*.py dashboard/app.py` โ†’ PASS +- `pytest -q` โ†’ PASS (`42 passed, 4 skipped`) +- `hermes mcp test jarca-ledger` โ†’ PASS (`Connected`, `Tools discovered: 56`) +- Browser smoke: synthetic fixture DB(`/tmp/fixture.sqlite3`)๋กœ Streamlit `์š”์•ฝ`๊ณผ `์ž์‚ฐ/๋งˆ๊ฐ` ํŽ˜์ด์ง€ ๋ Œ๋” ํ™•์ธ, console JS errors ์—†์Œ. + +## Remaining risks / follow-up notes + +- Phase 4+ write/import/advanced workflow์˜ ๋” ์„ธ๋ฐ€ํ•œ domain/service ๋ถ„๋ฆฌ๋Š” ์•„์ง TODO๋‹ค. +- Streamlit gateway๋Š” ํ˜„์žฌ ํ•„์š”ํ•œ surface๋งŒ ๋…ธ์ถœํ•˜์ง€๋งŒ, ํ›„์† phase์—์„œ page/domain ๊ธฐ์ค€์œผ๋กœ ๋” ์„ธ๋ถ„ํ™”ํ•  ์ˆ˜ ์žˆ๋‹ค. +- application modules๋Š” ์—ฌ์ „ํžˆ ํฐ ํŽธ์ด๋ฏ€๋กœ ํ›„์† phase์—์„œ deeper repository/use-case split์ด ํ•„์š”ํ•˜๋‹ค. diff --git a/docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/index.md b/docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/index.md new file mode 100644 index 0000000..7e33ea2 --- /dev/null +++ b/docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/index.md @@ -0,0 +1,37 @@ +# BAT-010 Backend core layering (Phase 0~3) + +- Profile: standard +- Status: release-candidate +- Included REQ: REQ-013, REQ-014 +- Discovery: dcy-010 +- Owner: ๋ ›์ €์žฅ์ธ +- Date (KST): 2026-07-03 + +## ๋ชฉํ‘œ + +REQ-013/REQ-014์— ๋”ฐ๋ผ `service.py` giant implementation๊ณผ `db.py` persistence ์ง‘์ค‘์„ application / infrastructure / adapter seam์œผ๋กœ ๋ถ„๋ฆฌํ•œ๋‹ค. ๋™์‹œ์— top-level facade/shim ํ˜ธํ™˜, MCP tool contract, Streamlit ํ•ต์‹ฌ ํ™”๋ฉด, additive migration discipline์„ ์œ ์ง€ํ•ด Phase 4+ ๋ถ„๋ฆฌ์˜ ์•ˆ์ „ํ•œ ๊ธฐ๋ฐ˜์„ ๋งŒ๋“ ๋‹ค. + +## ์‚ฐ์ถœ๋ฌผ + +- `jarca_ledger_mcp/application/` phase-0~3 service modules + shared helpers +- `jarca_ledger_mcp/infrastructure/sqlite/` connection/schema/migration split +- `jarca_ledger_mcp/adapters/mcp/server.py` + top-level `server.py` shim +- `jarca_ledger_mcp/adapters/streamlit/service_gateway.py` + dashboard import seam ์ •๋ ฌ +- `service.py` / `db.py` compatibility facade & shim +- full pytest / MCP discovery / browser smoke evidence +- `docs/project-structure.md`, `docs/runtime-flows.md` baseline updates +- BAT-010 planning/design/implementation/verification documents + +## ๋ฒ”์œ„ ์ œํ•œ + +- Phase 4+ transactional write/import/advanced workflow ๋ถ„๋ฆฌ +- ์‹ ๊ทœ HTTP adapter ์ถ”๊ฐ€ +- response envelope, tool names, dashboard IA ์žฌ์„ค๊ณ„ +- destructive DB migration + +## ๋ฌธ์„œ + +- `planning.md`: Phase 0~3 delivery plan, risks, validation gate +- `design.md`: layering/seam/shim design +- `implementation.md`: changed files, execution log, validation commands +- `verification.md`: AC mapping, PASS/FAIL, REQ Implemented decision diff --git a/docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/planning.md b/docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/planning.md new file mode 100644 index 0000000..4928bc7 --- /dev/null +++ b/docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/planning.md @@ -0,0 +1,34 @@ +# BAT-010 Planning + +## Scope + +- Implement REQ-013: application/infrastructure split, SQLite persistence extraction, service/db compatibility facade. +- Implement REQ-014: MCP adapter split, Streamlit service gateway, contract-preserving verification. +- Update architecture baseline docs to reflect the new layering and remaining Phase 4+ TODOs. +- Use synthetic/temp data only for verification evidence. + +## Dependencies + +- dcy-010 confirmed Discovery and approved/implemented REQ-013/REQ-014. +- Existing BAT-009 runtime behavior, response contract expectations, and Streamlit dashboard pages. +- Additive/idempotent SQLite migration discipline from REQ-002. + +## Work plan + +1. Split `service.py` implementation into application modules and preserve top-level facade compatibility. +2. Split `db.py` responsibilities into `infrastructure/sqlite` and preserve existing import paths. +3. Introduce MCP adapter package and Streamlit service gateway. +4. Update baseline docs (`docs/project-structure.md`, `docs/runtime-flows.md`). +5. Run py_compile, full pytest, MCP discovery, and browser smoke against synthetic fixture DB. +6. If evidence satisfies ACs, mark BAT-010 release-candidate and REQ-013/REQ-014 Implemented. + +## Design Gate + +- Result: standard batch ์œ ์ง€. +- Reason: package boundary ๋ณ€ํ™”, compatibility shim, MCP adapter seam, Streamlit browser verification, baseline docs๊ฐ€ ํ•จ๊ป˜ ๋ณ€๊ฒฝ๋˜๋ฏ€๋กœ planning/design/verification depth๊ฐ€ ํ•„์š”ํ•˜๋‹ค. + +## Risks and mitigations + +- Hidden helper coupling after module split: mitigate by full pytest, monkeypatch compatibility, and targeted smoke on boundary-sensitive flows. +- DB initialization drift after infrastructure split: mitigate by preserving init/commit/rollback semantics and verifying schema initialization through tests. +- MCP/dashboard contract drift: mitigate by `hermes mcp test jarca-ledger`, Streamlit AppTest/full pytest, and browser smoke on key pages. diff --git a/docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/verification.md b/docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/verification.md new file mode 100644 index 0000000..59add04 --- /dev/null +++ b/docs/batches/bat-010_20260703_backend-core-layering-phase-0-3/verification.md @@ -0,0 +1,45 @@ +# BAT-010 Verification + +## Verification summary + +Status: PASS + +Evidence was collected against synthetic/temp data only. No production ledger DB mutation or real financial files were used. + +## Acceptance criteria mapping + +### REQ-013 + +- AC-1: PASS โ€” `jarca_ledger_mcp/application/` ์•„๋ž˜ shared/system/asset-account/transaction-report/import-transfer/categorization modules๊ฐ€ ์ƒ์„ฑ๋๋‹ค. +- AC-2: PASS โ€” `jarca_ledger_mcp/infrastructure/sqlite/` ์•„๋ž˜ connection/schema/migrations split์ด ์ƒ์„ฑ๋๊ณ  top-level `db.py` shim์ด ์œ ์ง€๋œ๋‹ค. +- AC-3: PASS โ€” `jarca_ledger_mcp/service.py`๋Š” compatibility facade๋กœ ์ถ•์†Œ๋์œผ๋ฉฐ ํ˜„์žฌ 40 lines ์ˆ˜์ค€์ด๋‹ค(์‹ค์งˆ ๊ตฌํ˜„์€ application modules๋กœ ์ด๋™). +- AC-4: PASS โ€” `pytest -q` full regression PASS (`42 passed, 4 skipped`), ๊ธฐ์กด `service`/`db` import paths ์œ ์ง€. +- AC-5: PASS โ€” `docs/project-structure.md`์™€ `docs/runtime-flows.md`๊ฐ€ ์ƒˆ ๊ณ„์ธต ๊ตฌ์กฐ์™€ shim/gateway ํ๋ฆ„์„ ๋ฐ˜์˜ํ•œ๋‹ค. + +### REQ-014 + +- AC-1: PASS โ€” `jarca_ledger_mcp/adapters/mcp/server.py` ๋„์ž… ํ›„ `hermes mcp test jarca-ledger` PASS, tools discovered 56. +- AC-2: PASS โ€” `jarca_ledger_mcp/adapters/streamlit/service_gateway.py` ๋„์ž… ํ›„ `dashboard/app.py`๊ฐ€ gateway import๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค. +- AC-3: PASS โ€” full pytest PASS๋กœ Streamlit AppTest / contract regression ํฌํ•จ ํšŒ๊ท€๊ฐ€ ์œ ์ง€๋œ๋‹ค. +- AC-4: PASS โ€” local browser smoke์—์„œ `์š”์•ฝ`๊ณผ `์ž์‚ฐ/๋งˆ๊ฐ` ํŽ˜์ด์ง€ ๋ Œ๋”๋ฅผ ํ™•์ธํ–ˆ๊ณ  console JS errors๊ฐ€ ์—†์—ˆ๋‹ค. +- AC-5: PASS โ€” baseline docs์™€ BAT-010 implementation/verification ๋ฌธ์„œ๊ฐ€ ์ƒˆ adapter seam์„ ๋ฐ˜์˜ํ•œ๋‹ค. + +## Requirement state decision + +- REQ-013: Implemented. +- REQ-014: Implemented. + +## Validation evidence + +- `python -m py_compile jarca_ledger_mcp/*.py jarca_ledger_mcp/application/*.py jarca_ledger_mcp/infrastructure/sqlite/*.py jarca_ledger_mcp/adapters/mcp/*.py jarca_ledger_mcp/adapters/streamlit/*.py dashboard/app.py` โ†’ PASS +- `pytest -q` โ†’ PASS (`42 passed, 4 skipped`) +- `hermes mcp test jarca-ledger` โ†’ PASS (`Connected`, `Tools discovered: 56`) +- Browser smoke on Streamlit `์š”์•ฝ` / `์ž์‚ฐ/๋งˆ๊ฐ` โ†’ PASS, JS errors ์—†์Œ + +## Remaining issues + +### Hard blockers +- None. + +### Non-blocking follow-up +- Phase 4+ transactional write services, import pipeline deeper split, advanced workflow separation, narrower adapter surfaces remain tracked as GitHub Project TODO items. diff --git a/docs/batches/index.md b/docs/batches/index.md index 70cf76e..7bdf7b6 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -17,3 +17,4 @@ | BAT-007 | standard | release-candidate | REQ-007, REQ-008 | dcy-007 | [bat-007_20260630_mcp-response-contract-ci-versioning](bat-007_20260630_mcp-response-contract-ci-versioning/) | | BAT-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/) | diff --git a/docs/discovery/dcy-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow.md b/docs/discovery/dcy-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow.md index c28dd81..5041be3 100644 --- a/docs/discovery/dcy-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow.md +++ b/docs/discovery/dcy-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow.md @@ -5,9 +5,9 @@ - ๋ฌธ์„œ ID: dcy-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow - ์ด์Šˆ๋ช…: Jarca Ledger ๋Œ€์‹œ๋ณด๋“œ ์šด์˜ ํ—ˆ๋ธŒ ์‚ฌ์–‘ ์ •๋ฆฝ (์ด๋ฒˆ ๋‹ฌ ํ˜„๊ธˆํ๋ฆ„ + ์ž์‚ฐ ์œ ๋™์„ฑ + ์šด์˜ ๋ฏธ์ฒ˜๋ฆฌ) - ์ž‘์„ฑ ์‹œ๊ฐ(KST): 2026-06-29 14:02 KST -- ๋งˆ์ง€๋ง‰ ๊ฐฑ์‹  ์‹œ๊ฐ(KST): 2026-06-29 20:03 KST +- ๋งˆ์ง€๋ง‰ ๊ฐฑ์‹  ์‹œ๊ฐ(KST): 2026-06-30 20:43 KST - ๋Œ€์ฒด๋จ: ์—†์Œ -- ํ›„์† Discovery ์ฐธ์กฐ: ์—†์Œ +- ํ›„์† Discovery ์ฐธ์กฐ: docs/discovery/dcy-008_20260630_asset-snapshot-manual-valuation-system.md - ์ƒ์„ฑ๋œ REQ ์ฐธ์กฐ: REQ-005 (`docs/srs/Interface/req-005_dashboard-operations-hub-liquidity-monthly-cashflow.md`) # 1. ๊ณ„ํš ์ƒํƒœ ์š”์•ฝ 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 new file mode 100644 index 0000000..c84f1e5 --- /dev/null +++ b/docs/discovery/dcy-010_20260702_backend-core-layering-mcp-adapter-realignment.md @@ -0,0 +1,219 @@ +# 0. ๋ฌธ์„œ ์ƒํƒœ +์ด ๋ฌธ์„œ์˜ ํ˜„์žฌ ์ƒํƒœ์™€ ์‹๋ณ„ ์ •๋ณด๋ฅผ ๊ธฐ๋กํ•œ๋‹ค. ๋ฌธ์„œ ์ถ”์ ๊ณผ ์Šน์ธ ํ๋ฆ„์— ํ•„์š”ํ•œ ์ตœ์†Œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ์ ๋Š”๋‹ค. + +- ์ƒํƒœ: confirmed +- ๋ฌธ์„œ ID: dcy-010_20260702_backend-core-layering-mcp-adapter-realignment +- ์ด์Šˆ๋ช…: Jarca Ledger ๋ฐฑ์—”๋“œ ์ฝ”์–ด ๋ถ„๋ฆฌ ๋ฐ layered ์•„ํ‚คํ…์ฒ˜(application/domain/infrastructure/adapters) ์žฌํŽธ, MCP adapter ์žฌ์ •๋ ฌ +- ์ž‘์„ฑ ์‹œ๊ฐ(KST): 2026-07-02 23:32 +- ๋งˆ์ง€๋ง‰ ๊ฐฑ์‹  ์‹œ๊ฐ(KST): 2026-07-02 23:56 +- ๋Œ€์ฒด๋จ: ์—†์Œ +- ํ›„์† Discovery ์ฐธ์กฐ: ์—†์Œ +- ์ƒ์„ฑ๋œ REQ ์ฐธ์กฐ: `REQ-013`, `REQ-014` + +# 1. ๊ณ„ํš ์ƒํƒœ ์š”์•ฝ +ํ˜„์žฌ Discovery๊ฐ€ ์–ด๋–ค ๋ฐฐ๊ฒฝ์—์„œ ์ž‘์„ฑ๋˜๊ณ  ์žˆ๋Š”์ง€, ์—ฐ๊ณ„ ๋ฌธ์„œ๊ฐ€ ์žˆ๋Š”์ง€, ์ง€๊ธˆ ์‹œ์ ์˜ ํ•ด์„์ด ๋ฌด์—‡์ธ์ง€ ์งง๊ฒŒ ์š”์•ฝํ•œ๋‹ค. + +- ์—ฐ๊ณ„ ๋ฌธ์„œ ํ˜„ํ™ฉ: + - ๊ด€๋ จ blueprint: `.hermes/plans/2026-07-02_jarca-ledger-backend-refactor-blueprint.md` (๊ทผ๊ฑฐ ๋ฆฌํŒฉํ„ฐ๋ง ์ฒญ์‚ฌ์ง„) + - ๊ด€๋ จ baseline ๋ฌธ์„œ: `docs/project-structure.md`, `docs/runtime-flows.md` +- ํ•ด์„: + - `jarca_ledger_mcp/service.py`๊ฐ€ ์•ฝ 3,378์ค„๋กœ ๋น„๋Œ€ํ•ด์ ธ MCP/Streamlit/SQLite ์ ‘๊ทผ/import/reporting/reconciliation/asset ์›Œํฌํ”Œ๋กœ์šฐ๊ฐ€ ํ•˜๋‚˜์˜ ํ•จ์ˆ˜ ๋„ค์ž„์ŠคํŽ˜์ด์Šค์— ๊ณผ๊ฒฐํ•ฉ๋œ ์ƒํƒœ์ด๋ฉฐ, ํ–ฅํ›„ HTTP ๋“ฑ ์ธํ„ฐํŽ˜์ด์Šค ํ™•์žฅ ์ „์— layered backend ๊ฒฝ๊ณ„ ๋„์ž…์ด ํ•„์š”ํ•˜๋‹ค. + - ์ด๋ฒˆ Discovery๋Š” MCP๋ฅผ ์ œ๊ฑฐํ•˜์ง€ ์•Š๊ณ  adapter๋กœ ์žฌ์ •์˜ํ•˜๋ฉด์„œ application/domain/infrastructure/adapters ๊ฒฝ๊ณ„๋กœ ๋‹จ๊ณ„์ (strangler-fig) ์žฌํŽธํ•˜๋Š” ๊ตฌ์กฐ ๊ฐœ์„  ์ฃผ๊ธฐ๋ฅผ ์‹ ๊ทœ๋กœ ์‹œ์ž‘ํ• ์ง€ ํŒ์ •ํ•˜๊ณ , baseline ๋ฌธ์„œ(project-structure/runtime-flows) ๊ฐฑ์‹  ์š”๊ตฌ๊นŒ์ง€ ํฌํ•จํ•œ ์ดˆ์•ˆ์„ ๋งŒ๋“ ๋‹ค. + +# 2. ์š”๊ตฌ์‚ฌํ•ญ ํŒ์ • ๊ฒฐ๊ณผ +์ด๋ฒˆ ์š”์ฒญ์ด ๊ธฐ์กด ์š”๊ตฌ์‚ฌํ•ญ๊ณผ ๋น„๊ตํ•ด ์‹ ๊ทœ์ธ์ง€, ์œ ์‚ฌํ•œ์ง€, ์ค‘๋ณต์ธ์ง€ ํŒ๋‹จํ•˜๊ณ  ๊ทธ ๊ทผ๊ฑฐ๋ฅผ ๋‚จ๊ธด๋‹ค. + +- ํŒ์ •: ์‹ ๊ทœ +- ๊ทผ๊ฑฐ + - ๊ธฐ์กด dcy-001~009๋Š” ๊ธฐ๋Šฅ/์šด์˜ ์›Œํฌํ”Œ๋กœ์šฐ(๋ถ„๋ฅ˜ยทํ™˜๊ธ‰, ๋ฐฑ์—…ยท๋ณต๊ตฌ, ์ž”์•ก๋Œ€์กฐ, E2E evidence, ๋Œ€์‹œ๋ณด๋“œ, CI, MCP ์‘๋‹ต ๊ณ„์•ฝ, ์ž์‚ฐ ์Šค๋ƒ…์ƒท/CRUD)๋ฅผ ๋‹ค๋ฃจ๋ฉฐ, service.py ๋ถ„ํ•ด์™€ layered backend ์•„ํ‚คํ…์ฒ˜ ์žฌํŽธ์ด๋ผ๋Š” ๊ตฌ์กฐ ๋ฆฌํŒฉํ„ฐ๋ง ์ฃผ์ œ๋ฅผ ๋‹ค๋ฃฌ Discovery๋Š” ์—†๋‹ค. + - dcy-007(MCP response contract)์€ ์‘๋‹ต ๊ณ„์•ฝ์˜ ์•ˆ์ •์„ฑ์„ ๋‹ค๋ฃจ์ง€๋งŒ ๋‚ด๋ถ€ ๊ณ„์ธต ๊ตฌ์กฐ ๋ถ„ํ•ด๋‚˜ persistence ๋ถ„๋ฆฌ๋ฅผ ์ •์˜ํ•˜์ง€ ์•Š์•„ ๋ฌธ์ œ ์ •์˜ยท๋ฒ”์œ„ยท์„ฑ๊ณต ๊ธฐ์ค€์ด ์‹ค์งˆ์ ์œผ๋กœ ๋‹ค๋ฅด๋‹ค. + +> **ํŒ์ •์ด `์™„์ „ ์ค‘๋ณต`์ธ ๊ฒฝ์šฐ**: ์ดํ•˜ ์„น์…˜ ์ž‘์„ฑ์„ ์ค‘๋‹จํ•˜๊ณ  `# 9. ํŒŒ์ผ ์ฒ˜๋ฆฌ ๊ฒฐ๊ณผ`์— ์ค‘๋ณต ํŒ์ • ์‚ฌ์œ ์™€ ์ฐธ์กฐ ๋ฌธ์„œ ๊ฒฝ๋กœ๋ฅผ ๊ธฐ๋กํ•œ ๋’ค ๋ฌธ์„œ๋ฅผ ๋‹ซ๋Š”๋‹ค. REQ ์ดˆ์•ˆ ์ž‘์„ฑ์œผ๋กœ ๋„˜๊ธฐ์ง€ ์•Š๋Š”๋‹ค. + +# 3. ๋ฌธ์ œ์ ์˜ ์š”์•ฝ +๋ฌธ์ œ์˜ ํ˜„์žฌ ์ƒํƒœ, ๊ธฐ๋Œ€ ์ƒํƒœ, ๊ด€๋ จ ์ดํ•ด๊ด€๊ณ„์ž, ์ œ์•ฝ์‚ฌํ•ญ์„ ์ •๋ฆฌํ•œ๋‹ค. Discovery์˜ ํ•ต์‹ฌ ๋งฅ๋ฝ์„ ๊ฐ€์žฅ ๋จผ์ € ์ดํ•ดํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•œ๋‹ค. + +- ํ˜„์žฌ ์ƒํƒœ + - `jarca_ledger_mcp/service.py`(์•ฝ 3,378์ค„, blueprint ๊ธฐ์ค€ ์•ฝ 126 ํ•จ์ˆ˜)๊ฐ€ ์‚ฌ์‹ค์ƒ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ „์ฒด ์—ญํ• ์„ ํ•˜๋ฉฐ ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ยท์˜ค์ผ€์ŠคํŠธ๋ ˆ์ด์…˜ยทSQLยทmutation ์•ˆ์ „ยทimportยทreportingยทcross-transfer๊ฐ€ ํ•œ ๋ชจ๋“ˆ์— ์ง‘์ค‘๋˜์–ด ์žˆ๋‹ค. + - `jarca_ledger_mcp/server.py`(์•ฝ 296์ค„, ์•ฝ 57๊ฐœ MCP tool wrapper)์™€ `dashboard/app.py`(์•ฝ 1,811์ค„)๊ฐ€ ๋ชจ๋‘ `from jarca_ledger_mcp import service`๋กœ ๋™์ผํ•œ ๋„“์€ ํ•จ์ˆ˜ ๋„ค์ž„์ŠคํŽ˜์ด์Šค์— ์ง์ ‘ ๊ฒฐํ•ฉ๋˜์–ด ์žˆ๋‹ค. + - `jarca_ledger_mcp/db.py`(์•ฝ 333์ค„)์— schemaยทconnectionยทadditive migration helper๊ฐ€ ํ•œ ํŒŒ์ผ์— ์„ž์—ฌ ์žˆ์–ด persistence ๊ฒฝ๊ณ„๊ฐ€ ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™๊ณผ ๋ถ„๋ฆฌ๋˜์–ด ์žˆ์ง€ ์•Š๋‹ค. + - baseline ๋ฌธ์„œ๋Š” ์ด๋ฏธ ์กด์žฌํ•œ๋‹ค(`docs/project-structure.md`, `docs/runtime-flows.md`). ๋‹ค๋งŒ ์ด๋ฒˆ ๊ตฌ์กฐ ์žฌํŽธ์€ ํŒจํ‚ค์ง€ ๋ ˆ์ด์•„์›ƒ๊ณผ ๋Ÿฐํƒ€์ž„ ํ˜ธ์ถœ ํ๋ฆ„์„ ํฌ๊ฒŒ ๋ฐ”๊พธ๋ฏ€๋กœ ๋‘ baseline ๋ฌธ์„œ์˜ ๊ฐฑ์‹  ๊ฐญ์ด ๋ฐœ์ƒํ•œ๋‹ค. +- ๊ธฐ๋Œ€ ์ƒํƒœ + - MCP๋Š” ์œ ์ง€ํ•˜๋˜ `adapters/mcp`๋กœ ์œ„์น˜๋ฅผ ์žฌ์ •์˜ํ•˜๊ณ , Streamlit์€ `adapters/streamlit` gateway๋ฅผ ํ†ตํ•ด ์ข์€ ํ‘œ๋ฉด์—๋งŒ ์˜์กดํ•˜๋Š” thin adapter๊ฐ€ ๋œ๋‹ค. + - domain/application/infrastructure/adapters ๊ฒฝ๊ณ„๊ฐ€ ๋ช…์‹œ๋˜๊ณ , service.py๋Š” ํ˜ธํ™˜ facade๋กœ ์ถ•์†Œ๋˜๊ฑฐ๋‚˜ ํ˜ธ์ถœ๋ถ€ ์ด๊ด€ ํ›„ ์ œ๊ฑฐ๋˜๋ฉฐ, ๊ฐ ๋„๋ฉ”์ธ ์˜์—ญ์ด ๋…๋ฆฝ์ ์œผ๋กœ ์ดํ•ดยทํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•ด์ง„๋‹ค. +- ์ดํ•ด๊ด€๊ณ„์ž + - ๋ฌธ์„œ ์ž‘์„ฑ์ž(AI agent) ๋ฐ ๋ฆฌํŒฉํ„ฐ๋ง ์‹คํ–‰ subagent + - ๋ฆฌ๋ทฐ์–ด/์Šน์ธ์ž(Hermes ๋ฐ ์ €์žฅ์†Œ ์†Œ์œ ์ž) + - MCP/Streamlit ์‚ฌ์šฉ์ž(๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ค‘ ๋™์ž‘ ์œ ์ง€๊ฐ€ ๋ณด์žฅ๋˜์–ด์•ผ ํ•˜๋Š” ์†Œ๋น„์ž) +- ์ œ์•ฝ์‚ฌํ•ญ + - ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ „ ๊ตฌ๊ฐ„์—์„œ MCP tool ์ด๋ฆ„/์ฃผ์š” ์‘๋‹ต ํ•„๋“œ/์นดํ…Œ๊ณ ๋ฆฌยทํ˜„๊ธˆํ๋ฆ„ ์˜๋ฏธ/import ์ฆ๋น™ ํ•„๋“œ/additive-only migration/Streamlit ํŽ˜์ด์ง€ ๋™์ž‘์ด ํ˜ธํ™˜ ๊ณ„์•ฝ์œผ๋กœ ์œ ์ง€๋˜์–ด์•ผ ํ•œ๋‹ค. + - big-bang rewrite ๊ธˆ์ง€, ๊ธˆ์œต ๋ฐ์ดํ„ฐ ํŠน์„ฑ์ƒ destructive migration์€ ์Šน์ธ ์—†์ด ๊ธˆ์ง€ํ•˜๊ณ  fixture/๋ณต์‚ฌ DB๋งŒ ์‚ฌ์šฉํ•œ๋‹ค. + +## ์ฃผ์š” ์‚ฌ์šฉ์ž ์‹œ๋‚˜๋ฆฌ์˜ค +์‚ฌ์šฉ์ž ๋˜๋Š” ์šด์˜์ž๊ฐ€ ์‹ค์ œ๋กœ ์–ด๋–ค ์ƒํ™ฉ์—์„œ ์ด ๋ณ€๊ฒฝ์„ ํ•„์š”๋กœ ํ•˜๋Š”์ง€ ๋Œ€ํ‘œ ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ ์ ๋Š”๋‹ค. + +- ๊ฐœ๋ฐœ์ž๊ฐ€ ํŠน์ • ๋„๋ฉ”์ธ(์˜ˆ: ์ž์‚ฐ ์Šค๋ƒ…์ƒท) ๋กœ์ง์„ ์ˆ˜์ •ํ•  ๋•Œ 3,378์ค„ service.py ์ „์ฒด๋ฅผ ํƒ์ƒ‰ํ•˜์ง€ ์•Š๊ณ  ํ•ด๋‹น application service์™€ repository๋งŒ ์—ด์–ด ๋ณ€๊ฒฝยทํ…Œ์ŠคํŠธํ•˜๋ ค๋Š” ์ƒํ™ฉ. +- ์šด์˜์ž๊ฐ€ DB I/O/integrity ์žฅ์• ๋ฅผ ์ง„๋‹จํ•  ๋•Œ, persistence ๊ณ„์ธต์ด ๋ถ„๋ฆฌ๋˜์–ด ์žˆ์–ด ๋ฌธ์ œ๋ฅผ ์ „์ฒด ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๊ฒฝ๋กœ๊ฐ€ ์•„๋‹Œ infrastructure/sqlite ๊ฒฝ๊ณ„๋กœ ๊ตญ์†Œํ™”ํ•˜๋ ค๋Š” ์ƒํ™ฉ. +- ํ–ฅํ›„ HTTP/FastAPI adapter๋ฅผ ์ถ”๊ฐ€ํ•˜๋ ค๋Š” ๊ฐœ๋ฐœ์ž๊ฐ€ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ๋‹ค์‹œ ์ด๋™์‹œํ‚ค์ง€ ์•Š๊ณ  `application/*` ์œ„์— adapter ํŒจํ‚ค์ง€๋งŒ ์ถ”๊ฐ€ํ•˜๋ ค๋Š” ์ƒํ™ฉ. + +# 4. ์ด๋ฒˆ์— ์ •์˜ํ•  ๋ณ€๊ฒฝ +์ด๋ฒˆ Discovery์—์„œ ๋‹ค๋ฃจ๋Š” ํ•ต์‹ฌ ๋ณ€๊ฒฝ ํ•ญ๋ชฉ์„ ๋ชฉ๋ก์œผ๋กœ ์ ๋Š”๋‹ค. ๊ธฐ๋Šฅ, ๊ตฌ์กฐ, ๊ทœ์น™ ๋ณ€๊ฒฝ์„ ํ•จ๊ป˜ ํฌํ•จํ•ด๋„ ๋œ๋‹ค. + +- service.py ๋ถ„ํ•ด: ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ domain(types/invariants/validation), application(use case/orchestration), infrastructure(sqlite repo/parsers)๋กœ ์ด๊ด€ํ•˜๊ณ  service.py๋Š” ์ž„์‹œ ํ˜ธํ™˜ facade๋กœ ์ถ•์†Œํ•œ๋‹ค. +- persistence ๋ถ„๋ฆฌ: db.py์˜ schema/connection/migration์„ `infrastructure/sqlite`๋กœ ๋ถ„๋ฆฌํ•˜๊ณ  ํ…Œ์ด๋ธ”/use-case๋ณ„ repository๋ฅผ ๋„์ž…ํ•œ๋‹ค. +- MCP/Streamlit thin adapterํ™”: MCP tool ๋“ฑ๋ก์„ `adapters/mcp/server.py`๋กœ ์ด๋™ํ•ด top-level server.py๋Š” shim์œผ๋กœ ๋‚จ๊ธฐ๊ณ , Streamlit์€ `adapters/streamlit/service_gateway.py`์˜ ์ข์€ gateway ํ˜ธ์ถœ๋กœ ์ „ํ™˜ํ•œ๋‹ค. +- baseline ๋ฌธ์„œ ๊ฐฑ์‹  ์š”๊ตฌ: ์ƒˆ ํŒจํ‚ค์ง€ ๋ ˆ์ด์•„์›ƒ๊ณผ ๋Ÿฐํƒ€์ž„ ํ˜ธ์ถœ ๊ฒฝ๋กœ ๋ณ€ํ™”๋ฅผ ๋ฐ˜์˜ํ•ด `docs/project-structure.md`์™€ `docs/runtime-flows.md`๋ฅผ ๊ฐฑ์‹ ํ•œ๋‹ค. +- ๋‹จ๊ณ„์  migration ์›์น™ ํ™•์ •: strangler-fig ์ˆœ์„œ(foundations โ†’ read-only/reporting โ†’ write CRUD โ†’ import โ†’ cross-transfer/reconciliation/assets โ†’ adapter narrowing)๋กœ blast radius๋ฅผ ์ตœ์†Œํ™”ํ•œ๋‹ค. + +## ์˜ํ–ฅ ๋ฒ”์œ„ +๋ณ€๊ฒฝ์ด ๋ฏธ์น  ์ฝ”๋“œ/๋ฌธ์„œ/์šด์˜ ๋ฒ”์œ„๋ฅผ ๋ช…์‹œํ•ด REQ ์ •์ œ์™€ batch planning์—์„œ ์ถ”์ • ๋ˆ„๋ฝ์„ ์ค„์ธ๋‹ค. + +- ์ฝ”๋“œ ์˜ํ–ฅ: + - `jarca_ledger_mcp/service.py`, `db.py`, `server.py` ๋ถ„ํ•ด ๋ฐ ํ˜ธํ™˜ facade/shimํ™” + - ์‹ ๊ทœ ํŒจํ‚ค์ง€ `jarca_ledger_mcp/{domain,application,infrastructure,adapters}/*` ๋„์ž… + - `dashboard/app.py`์˜ `service.*` ๊ด‘๋ฒ”์œ„ ์˜์กด์„ gateway ํ˜ธ์ถœ๋กœ ์ถ•์†Œ, `pyproject.toml` ํŒจํ‚ค์ง€ discovery/entrypoint ์ •ํ•ฉ์„ฑ ์œ ์ง€ + - ํ…Œ์ŠคํŠธ๋ฅผ repository/application-service/adapter ๊ณ„์ธต์œผ๋กœ ์žฌ๋ฐฐ์น˜(Layer A/B/C) +- ๋ฌธ์„œ ์˜ํ–ฅ: + - `docs/project-structure.md`, `docs/runtime-flows.md` ๊ฐฑ์‹  ์š”๊ตฌ + - ์‹ ๊ทœ Discovery ๋ฐ `docs/discovery/index.md` ํ–‰ ์ถ”๊ฐ€ +- ์šด์˜ ์˜ํ–ฅ: + - ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ค‘ MCP/Streamlit ๋™์ž‘ ์œ ์ง€ ํ•„์š”, ๋ฐฐํฌ ์ ˆ์ฐจ ์ž์ฒด ๋ณ€๊ฒฝ์€ ์—†์Œ(entrypoint ํ˜ธํ™˜ ์œ ์ง€) + - destructive migration ๊ธˆ์ง€, DB ์•ˆ์ „ ์ •์ฑ…(๋ณต์‚ฌ/fixture DB) ์œ ์ง€ + +# 5. ์š”๊ตฌ์‚ฌํ•ญ ๋ชฉ๋ก +์‹ค์ œ๋กœ ๊ตฌํ˜„๊ณผ ๊ฒ€์ฆ์œผ๋กœ ์ด์–ด์งˆ ์š”๊ตฌ์‚ฌํ•ญ์„ ๊ธฐ๋Šฅ/๋น„๊ธฐ๋Šฅ์œผ๋กœ ๋‚˜๋ˆ„์–ด ์ •๋ฆฌํ•˜๊ณ , ๋ฒ”์œ„ ๊ฒฝ๊ณ„๋ฅผ ๋ช…ํ™•ํžˆ ์ ๋Š”๋‹ค. + +## ๊ธฐ๋Šฅ ์š”๊ตฌ์‚ฌํ•ญ +- FR-1: `jarca_ledger_mcp`์— domain/application/infrastructure/adapters ๊ฒฝ๊ณ„๋ฅผ ๋„์ž…ํ•˜๊ณ  service.py์˜ ๋กœ์ง์„ ๋„๋ฉ”์ธ๋ณ„ application service์™€ repository๋กœ ์ด๊ด€ํ•˜๋˜, service.py๋Š” ํ˜ธํ™˜ facade๋กœ ๋‚จ๊ฒจ ๊ธฐ์กด ํ˜ธ์ถœ๋ถ€๊ฐ€ ๊นจ์ง€์ง€ ์•Š๊ฒŒ ํ•œ๋‹ค. +- FR-2: db.py์˜ schema/connection/additive migration์„ `infrastructure/sqlite`(connection/schema/migrations/row_mapping + ํ…Œ์ด๋ธ”๋ณ„ repo)๋กœ ๋ถ„๋ฆฌํ•˜๊ณ , db.py๋Š” ํ˜ธํ™˜ shim์œผ๋กœ ์œ ์ง€ํ•œ๋‹ค. +- FR-3: MCP tool ๋“ฑ๋ก์„ `adapters/mcp`๋กœ ์ด๋™ํ•ด top-level `server.py`๋ฅผ thin shim์œผ๋กœ ๋งŒ๋“ค๊ณ , Streamlit์ด `adapters/streamlit/service_gateway.py`์˜ ์ข์€ gateway์—๋งŒ ์˜์กดํ•˜๋„๋ก ์ „ํ™˜ํ•œ๋‹ค. +- FR-4: ๊ตฌ์กฐ/๋Ÿฐํƒ€์ž„ baseline ๊ฐญ์„ ํ•ด์†Œํ•˜๊ธฐ ์œ„ํ•ด `docs/project-structure.md`์™€ `docs/runtime-flows.md`์— ์‹ ๊ทœ ๊ณ„์ธต ๊ตฌ์กฐ์™€ ํ˜ธ์ถœ ํ๋ฆ„ ๊ฐฑ์‹  ์š”๊ตฌ๋ฅผ ๋ฐ˜์˜ํ•œ๋‹ค. + +## ๋น„๊ธฐ๋Šฅ ์š”๊ตฌ์‚ฌํ•ญ +์„ฑ๋Šฅ, ์šด์˜์„ฑ, ์ผ๊ด€์„ฑ, ์ž๋™ํ™” ์•ˆ์ •์„ฑ ๋“ฑ ๊ธฐ๋Šฅ ์™ธ ์š”๊ตฌ์‚ฌํ•ญ์„ ์ ๋Š”๋‹ค. + +- NFR-1: ํ˜ธํ™˜ ๊ณ„์•ฝ ์•ˆ์ •์„ฑ โ€” MCP tool ์ด๋ฆ„, ์ฃผ์š” ์‘๋‹ต ํ•„๋“œ(`success`, `count`, `items`, `error`, `validation`, `matched_count` ๋“ฑ), ์นดํ…Œ๊ณ ๋ฆฌยทํ˜„๊ธˆํ๋ฆ„ ์˜๋ฏธ, import ์ฆ๋น™ ํ•„๋“œ(`raw_summary`/`counterparty`/`raw_payload`/`external_id`/`import_batch_id`), additive-only migration, Streamlit ํŽ˜์ด์ง€ ๋™์ž‘/์ˆœ์„œ๊ฐ€ ์˜๋„์  ๋ฒ„์ „ ๋ณ€๊ฒฝยท์Šน์ธ ์—†์ด ๋ฐ”๋€Œ์ง€ ์•Š๋Š”๋‹ค. +- NFR-2: ๋‹จ๊ณ„์  ์•ˆ์ „์„ฑ โ€” ๊ฐ phase๋Š” ๋…๋ฆฝ์ ์œผ๋กœ ๊ฒ€์ฆ ๊ฐ€๋Šฅํ•ด์•ผ ํ•˜๊ณ (pytest ํšŒ๊ท€ + py_compile), ํ•œ ๋ฒˆ์— ํ•˜๋‚˜์˜ ๋„๋ฉ”์ธ ์Šฌ๋ผ์ด์Šค๋งŒ ์ด๊ด€ํ•ด blast radius๋ฅผ ์ตœ์†Œํ™”ํ•œ๋‹ค. +- NFR-3: DB ์•ˆ์ „์„ฑ โ€” ๋ฆฌํŒฉํ„ฐ๋ง ์ค‘ fixture/๋ณต์‚ฌ DB๋งŒ ์‚ฌ์šฉํ•˜๊ณ  ์Šน์ธ ์—†๋Š” destructive migration์„ ์ˆ˜ํ–‰ํ•˜์ง€ ์•Š๋Š”๋‹ค. + +## ๋ฒ”์œ„ ๊ฒฝ๊ณ„ +์ด๋ฒˆ ๋ฐ˜๋ณต์—์„œ ๋ฐ˜๋“œ์‹œ ํฌํ•จํ•  ๋ฒ”์œ„์™€ ๋ช…์‹œ์ ์œผ๋กœ ์ œ์™ธํ•  ๋ฒ”์œ„๋ฅผ ๊ตฌ๋ถ„ํ•œ๋‹ค. + +- In Scope + - blueprint ๊ธฐ์ค€ Phase 0~3 ์šฐ์„ : behavioral baseline ๊ณ ์ • โ†’ ์ƒˆ skeleton/compat seam ๋„์ž… โ†’ persistence ๋ถ„๋ฆฌ โ†’ read-only application service ์ถ”์ถœ + - baseline ๋ฌธ์„œ(project-structure/runtime-flows) ๊ฐฑ์‹  ์š”๊ตฌ + - ๋‹จ๊ณ„์ (strangler-fig) migration ์›์น™ ์ •์˜ +- Out of Scope + - ์ฆ‰์‹œ FastAPI/HTTP ์ „ํ™˜(ํ–ฅํ›„ adapter ์ค€๋น„๋งŒ, ๊ฐ•์ œํ•˜์ง€ ์•Š์Œ) + - Postgres/์›๊ฒฉ DB ์ด๊ด€ + - ๋ฉ€ํ‹ฐ์œ ์ €/๊ถŒํ•œ ์ฒด๊ณ„ ๋„์ž… + - ์›๊ฒฉ ์šด์˜(remote ops) ์žฌ์„ค๊ณ„ + - Phase 4 ์ดํ›„์˜ write/import/advanced workflow/adapter narrowing ๊ตฌํ˜„(๋ณ„๋„ ํ›„์† REQยทBatchยทGitHub Project TODO๋กœ ์ถ”์ ) + +# 6. ๋ฆฌ์Šคํฌ/๊ฐ€์ • ๋ชฉ๋ก +๋ถˆํ™•์‹ค์„ฑ๊ณผ ์ „์ œ ์กฐ๊ฑด์„ ๋ถ„๋ฆฌํ•ด์„œ ์ ๋Š”๋‹ค. ๋ฆฌ์Šคํฌ๋Š” ์˜ํ–ฅ๊ณผ ์™„ํ™” ๋ฐฉ์•ˆ์„, ๊ฐ€์ •์€ ํ˜„์žฌ ํŒ๋‹จ์˜ ์ „์ œ๋ฅผ ๊ธฐ๋กํ•œ๋‹ค. + +## ๋ฆฌ์Šคํฌ +- R-1 (High): service.py ๋‚ด๋ถ€ ์ˆจ์€ ๊ฒฐํ•ฉ โ€” ๊ณต์œ  helper ์ด๊ด€ ์‹œ ์˜ˆ์ƒ์น˜ ๋ชปํ•œ ํ˜ธ์ถœ๋ถ€๊ฐ€ ๊นจ์งˆ ์ˆ˜ ์žˆ๋‹ค. + - ์™„ํ™” ๋ฐฉ์•ˆ: ๋ฆฌํŒฉํ„ฐ๋ง ์ „์— characterization/contract ํšŒ๊ท€ ํ…Œ์ŠคํŠธ๋ฅผ ๋จผ์ € ์ถ”๊ฐ€ํ•˜๊ณ , helper๋Š” ์ฒซ ์†Œ๋น„์ž์™€ ํ•จ๊ป˜ ์ด๋™ํ•œ๋‹ค. +- R-2 (High): MCP/Streamlit ๊ณ„์•ฝ drift โ€” tool ์‘๋‹ต ํ˜•ํƒœ๋‚˜ ํŽ˜์ด์ง€ ๋™์ž‘์ด ์กฐ์šฉํžˆ ๋ฐ”๋€” ์ˆ˜ ์žˆ๋‹ค. + - ์™„ํ™” ๋ฐฉ์•ˆ: ๋ฆฌํŒฉํ„ฐ๋ง ์ „ ์‘๋‹ต ํ˜•ํƒœ(shape) ํ…Œ์ŠคํŠธ์™€ Streamlit ํŽ˜์ด์ง€/์„œ๋น„์Šค ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ๋ฅผ ํ™•๋ณดํ•˜๊ณ  adapter gateway๋กœ ํŽ˜์ด์ง€ ๋‹จ์œ„ ์ ์ง„ ์ด๊ด€ํ•œ๋‹ค. +- R-3 (Medium): ์‹ ๊ทœ repository ๊ฐ„ SQL/row mapping ์ค‘๋ณต์œผ๋กœ ์œ ์ง€๋ณด์ˆ˜์„ฑ ์ €ํ•˜. + - ์™„ํ™” ๋ฐฉ์•ˆ: row mapping๊ณผ ๊ณตํ†ต predicate๋ฅผ ์ค‘์•™ํ™”(`row_mapping.py`)ํ•˜๊ณ  repository ๊ทœ์น™(๋ฉ”์‹œ์ง€ ํฌ๋งคํŒ… ๊ธˆ์ง€, dict/dataclass ๋ฐ˜ํ™˜)์„ ๊ฐ•์ œํ•œ๋‹ค. + +## ๊ฐ€์ • +์•„์ง ํ™•์ •๋˜์ง€ ์•Š์•˜์ง€๋งŒ ํ˜„์žฌ ๋ฌธ์„œ ์ž‘์„ฑ์˜ ๊ธฐ๋ฐ˜์ด ๋˜๋Š” ์ „์ œ๋ฅผ ์ ๋Š”๋‹ค. + +- A-1: ๊ธฐ์กด pytest ์Šค์œ„ํŠธ(transaction/import/rules/dashboard ํšŒ๊ท€ ๋“ฑ)๊ฐ€ ๋™์ž‘ํ•˜๋ฉฐ baseline ๊ณ ์ •๊ณผ ํšŒ๊ท€ ๊ฒ€์ฆ์˜ ๊ธฐ์ค€์œผ๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค. +- A-2: ํ˜„์žฌ SQLite schema๊ฐ€ additive-oriented์ด๋ฏ€๋กœ destructive ๋ณ€๊ฒฝ ์—†์ด ๊ณ„์ธต ๋ถ„๋ฆฌ๋ฅผ ์ง„ํ–‰ํ•  ์ˆ˜ ์žˆ๋‹ค. +- A-3: MCP๋Š” ์ด๋ฏธ spirit ์ƒ adapter(server.py๊ฐ€ ๋Œ€๋ถ€๋ถ„ thin wrapper)์ด๋ฏ€๋กœ adapter ์žฌ์ •๋ ฌ์ด ํฐ ๋™์ž‘ ๋ณ€๊ฒฝ ์—†์ด ๊ฐ€๋Šฅํ•˜๋‹ค. + +## ์˜คํ”ˆ ์งˆ๋ฌธ +๋‹ต์„ ์•„์ง ๋ชจ๋ฅด๋Š” ํ•ญ๋ชฉ์„ ์ ๋Š”๋‹ค. ์ถ”์ •์œผ๋กœ ๋ฎ์ง€ ๋ง๊ณ  REQ ์ดˆ์•ˆ ์ž‘์„ฑ ์ด์ „์— ํ™•์ธํ•  ์งˆ๋ฌธ์œผ๋กœ ๋‚จ๊ธด๋‹ค. + +- OQ-1: ์ด๋ฒˆ ๋ฆฌํŒฉํ„ฐ๋ง์„ ์ „์ฒด phase(1~7)๋ฅผ ํ•œ ์ฃผ๊ธฐ๋กœ ๋‹ค๋ฃฐ์ง€, blueprint Batch A(Phase 0~3)๋งŒ ์ฒซ ๋ฐฐ์น˜๋กœ ์ถ•์†Œํ•ด ์‹œ์ž‘ํ• ์ง€? + - ์ƒํƒœ: Answered + - ์ฒ˜๋ฆฌ ๋ฐฉ์•ˆ: ์‚ฌ์šฉ์ž ์Šน์ธ์— ๋”ฐ๋ผ Discovery ๋ฒ”์œ„๋ฅผ Phase 0~3 ์šฐ์„  ๋ฐฐ์น˜๋กœ ๊ณ ์ •ํ•˜๊ณ , Phase 4+๋Š” GitHub Project TODO์™€ ํ›„์† REQ/Batch๋กœ ๋ถ„๋ฆฌ ์ถ”์ ํ•œ๋‹ค. + - ์ข…๋ฃŒ ์กฐ๊ฑด: `# 5. ์š”๊ตฌ์‚ฌํ•ญ ๋ชฉ๋ก > ๋ฒ”์œ„ ๊ฒฝ๊ณ„`์— 1์ฐจ ๋ฒ”์œ„๊ฐ€ Phase 0~3์œผ๋กœ ๋ฐ˜์˜๋˜์—ˆ์œผ๋ฏ€๋กœ ์ข…๋ฃŒํ•œ๋‹ค. +- OQ-2: ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์™„๋ฃŒ ํŒ์ • ๊ธฐ์ค€(service.py ๋ชฉํ‘œ ๋ผ์ธ ์ˆ˜ ~400~600์ค„ ์ถ•์†Œ vs ์™„์ „ ์ œ๊ฑฐ) ์ค‘ ์–ด๋А ๊ฒƒ์„ Definition of Done์œผ๋กœ ์ฑ„ํƒํ• ์ง€? + - ์ƒํƒœ: Answered + - ์ฒ˜๋ฆฌ ๋ฐฉ์•ˆ: ์‚ฌ์šฉ์ž ์Šน์ธ์— ๋”ฐ๋ผ 1์ฐจ ์™„๋ฃŒ ๊ธฐ์ค€์„ service.py ํ˜ธํ™˜ facade ์ˆ˜์ค€(~400~600์ค„) ์ถ•์†Œ๋กœ ์ฑ„ํƒํ•˜๊ณ , ์™„์ „ ์ œ๊ฑฐ๋Š” ํ›„์† ๋‹จ๊ณ„ ์ตœ์ ํ™”๋กœ ๋‚จ๊ธด๋‹ค. + - ์ข…๋ฃŒ ์กฐ๊ฑด: ์„ฑ๊ณต ๊ธฐ์ค€ S-2์™€ REQ handoff ๊ธฐ์ค€์— facade ์ถ•์†Œ DoD๊ฐ€ ๋ฐ˜์˜๋˜์—ˆ์œผ๋ฏ€๋กœ ์ข…๋ฃŒํ•œ๋‹ค. + +### ์˜คํ”ˆ ์งˆ๋ฌธ ์ฒ˜๋ฆฌ ๊ธฐ์ค€ +- `Open`: ์•„์ง ๋‹ต์ด ์—†๊ณ  ๋‹ค์Œ ๋‹จ๊ณ„ ์ง„ํ–‰์— ์˜ํ–ฅ์ด ์žˆ๋Š” ์ƒํƒœ๋‹ค. ๋ฌธ์„œ์— ์œ ์ง€ํ•œ๋‹ค. +- `Answered`: ๋‹ต์ด ํ™•์ •๋œ ์ƒํƒœ๋‹ค. ๊ด€๋ จ ๋ณธ๋ฌธ/์š”๊ตฌ์‚ฌํ•ญ์— ๋ฐ˜์˜ํ•œ ๋’ค ์˜คํ”ˆ ์งˆ๋ฌธ ๋ชฉ๋ก์—์„œ๋Š” ์ œ๊ฑฐํ•˜๊ฑฐ๋‚˜ resolved ๊ธฐ๋ก์œผ๋กœ ์ด๋™ํ•œ๋‹ค. +- `Deferred`: ์ง€๊ธˆ ๋‹จ๊ณ„์—์„œ ๊ฒฐ์ •ํ•˜์ง€ ์•Š๊ณ  ๋‹ค์Œ ๋‹จ๊ณ„ ๋˜๋Š” ํ›„์† ์ด์Šˆ๋กœ ๋„˜๊ธฐ๋Š” ์ƒํƒœ๋‹ค. ๋„˜๊ธธ ๋Œ€์ƒ๊ณผ ์ด์œ ๋ฅผ ๋ฐ˜๋“œ์‹œ ์ ๋Š”๋‹ค. +- `Dropped`: ๋” ์ด์ƒ ์œ ํšจํ•˜์ง€ ์•Š์€ ์งˆ๋ฌธ์ด๋‹ค. ์‚ญ์ œ ์‚ฌ์œ ๋ฅผ ์งง๊ฒŒ ๋‚จ๊ธฐ๊ณ  ๋ชฉ๋ก์—์„œ ์ œ๊ฑฐํ•œ๋‹ค. +- ์ฒ˜๋ฆฌ ์›์น™ 1: ์งˆ๋ฌธ๋งŒ ๋‚จ๊ธฐ์ง€ ๋ง๊ณ  ๋ฐ˜๋“œ์‹œ `์ƒํƒœ`, `์ฒ˜๋ฆฌ ๋ฐฉ์•ˆ`, `์ข…๋ฃŒ ์กฐ๊ฑด`์„ ํ•จ๊ป˜ ์ ๋Š”๋‹ค. +- ์ฒ˜๋ฆฌ ์›์น™ 2: ๋‹ต์ด ๋‚˜์˜จ ์งˆ๋ฌธ์€ ๋ณธ๋ฌธ ๋ฐ˜์˜ ์—†์ด ๋‹จ์ˆœ ์‚ญ์ œํ•˜์ง€ ์•Š๋Š”๋‹ค. ๋ฐ˜์˜ ์œ„์น˜๋ฅผ ๋จผ์ € ๊ฐฑ์‹ ํ•œ๋‹ค. +- ์ฒ˜๋ฆฌ ์›์น™ 3: High ์˜ํ–ฅ ์งˆ๋ฌธ์€ `Deferred` ์ฒ˜๋ฆฌ ํ›„ ์ฒ˜๋ฆฌ ๋ฐฉ์•ˆ์— REQ ์ธ๊ณ„ ์ถ”์  ๋ฐฉ์‹์„ ๋ช…์‹œํ•œ๋‹ค. + +# 7. ์ดˆ๊ธฐ ์„ฑ๊ณต ๊ธฐ์ค€ +์ด ๋ณ€๊ฒฝ์ด ์„ฑ๊ณตํ–ˆ๋‹ค๊ณ  ํŒ๋‹จํ•  ๊ธฐ์ค€์„ ์ธก์ • ๊ฐ€๋Šฅํ•˜๊ฒŒ ์ •์˜ํ•œ๋‹ค. ๊ฐ€๋Šฅํ•˜๋ฉด ๋ฐ์ดํ„ฐ ์ถœ์ฒ˜๊นŒ์ง€ ์—ฐ๊ฒฐํ•œ๋‹ค. + +- S-1: ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ „ ๊ตฌ๊ฐ„์—์„œ MCP์™€ Streamlit์ด ๋™์ž‘ํ•˜๋ฉฐ ๊ธฐ์กด/๋Œ€์ฒด ํšŒ๊ท€ ํ…Œ์ŠคํŠธ๊ฐ€ ํ†ต๊ณผํ•œ๋‹ค. +- S-2: 1์ฐจ ๋ฆฌํŒฉํ„ฐ๋ง ์™„๋ฃŒ ์‹œ service.py๊ฐ€ ํ˜ธํ™˜ glue/facade ์ˆ˜์ค€(blueprint ยง11 ๊ธฐ์ค€ ~400~600์ค„)์œผ๋กœ ์ถ•์†Œ๋˜๊ณ , db.py๋Š” shim ๋˜๋Š” ์–‡์€ compatibility entry๋กœ ์ถ•์†Œ๋œ๋‹ค. +- S-3: ๊ฐ ์ฃผ์š” ๋„๋ฉ”์ธ ์˜์—ญ์ด ์ž์ฒด application service์™€ persistence ๊ฒฝ๊ณ„๋ฅผ ๊ฐ–๊ณ , `docs/project-structure.md`ยท`docs/runtime-flows.md`๊ฐ€ ์‹ ๊ทœ ๊ณ„์ธต ๊ตฌ์กฐ๋ฅผ ๋ฐ˜์˜ํ•˜๋„๋ก ๊ฐฑ์‹ ๋˜์–ด ์ดํ›„ REQ/Batch ์‚ฐ์ถœ๋ฌผ์ด baseline ๋ฌธ์„œ๋ฅผ ์ฐธ์กฐํ•  ์ˆ˜ ์žˆ๋‹ค. + +## ์ธก์ • ๋ฐฉ์‹ ๋ฐ ๋ฐ์ดํ„ฐ ์ถœ์ฒ˜ +๊ฐ ์„ฑ๊ณต ๊ธฐ์ค€์„ ์–ด๋–ป๊ฒŒ ์ธก์ •ํ• ์ง€์™€ ์–ด๋–ค ๋ฐ์ดํ„ฐ๋‚˜ ๋ฌธ์„œ๋ฅผ ๊ทผ๊ฑฐ๋กœ ์“ธ์ง€ ์ ๋Š”๋‹ค. + +- S-1 ์ธก์ • ๋ฐฉ์‹: ๊ฐ phase ์ข…๋ฃŒ ์‹œ pytest ํšŒ๊ท€ ์Šค์œ„ํŠธ + MCP tool contract ํ…Œ์ŠคํŠธ ์ „๋ถ€ ํ†ต๊ณผ ํ™•์ธ / ๋ฐ์ดํ„ฐ ์ถœ์ฒ˜: `tests/` ์‹คํ–‰ ๊ฒฐ๊ณผ, `docs/discovery/dcy-007` ์‘๋‹ต ๊ณ„์•ฝ +- S-2 ์ธก์ • ๋ฐฉ์‹: `wc -l jarca_ledger_mcp/service.py`, `db.py` ๋ผ์ธ ์ˆ˜์™€ import ์˜์กด์„ฑ ๊ทธ๋ž˜ํ”„๋ฅผ ํ™•์ธํ•˜๊ณ  1์ฐจ ๋ชฉํ‘œ๋ฅผ facade ์ˆ˜์ค€ ์ถ•์†Œ๋กœ ๊ฒ€์ฆ / ๋ฐ์ดํ„ฐ ์ถœ์ฒ˜: ๋ฆฌํŒฉํ„ฐ๋ง ํ›„ ์†Œ์Šค ํŒŒ์ผ, blueprint ยง11 DoD +- S-3 ์ธก์ • ๋ฐฉ์‹: ๊ฐ ๋„๋ฉ”์ธ application service/repository ์กด์žฌ ์—ฌ๋ถ€ ๋ฐ baseline ๋ฌธ์„œ diff ํ™•์ธ / ๋ฐ์ดํ„ฐ ์ถœ์ฒ˜: `jarca_ledger_mcp/{application,infrastructure}/*`, `docs/project-structure.md`, `docs/runtime-flows.md` + +# 8. REQ๋กœ ๋„˜๊ธฐ๊ธฐ ์ „ ํ™•์ธ ์ฒดํฌ +REQ ์ดˆ์•ˆ ์ž‘์„ฑ ๋‹จ๊ณ„๋กœ ๋„˜๊ธฐ๊ธฐ ์ „์— ๋ฌด์—‡์ด ํ™•์ •๋˜์–ด์•ผ ํ•˜๋Š”์ง€ ํ™•์ธํ•œ๋‹ค. ๋ฏธ์™„๋ฃŒ ํ•ญ๋ชฉ์€ ํ›„์† ๊ฒฐ์ •์ด ํ•„์š”ํ•จ์„ ๋œปํ•œ๋‹ค. + +- [x] ๋ฒ”์œ„(In Scope/Out of Scope) ํ™•์ • + - ๊ธฐ์ค€: ์‚ฌ์šฉ์ž ์Šน์ธ์— ๋”ฐ๋ผ Phase 0~3 ์šฐ์„  ๋ฒ”์œ„์™€ Out of Scope ํ•ญ๋ชฉ์ด ๋ฌธ์„œ์— ๋ฐ˜์˜๋˜์–ด ์žˆ์œผ๋ฉฐ, Phase 4+๋Š” ๋ณ„๋„ TODO/ํ›„์† REQ๋กœ ์ถ”์ ํ•œ๋‹ค. +- [x] ์„ฑ๊ณต ์ง€ํ‘œ์˜ ์ธก์ • ๋ฐฉ์‹/๋ฐ์ดํ„ฐ ์ถœ์ฒ˜ ํ™•์ • + - ๊ธฐ์ค€: S-2์˜ 1์ฐจ DoD๋ฅผ service.py facade ์ˆ˜์ค€ ์ถ•์†Œ๋กœ ์ฑ„ํƒํ–ˆ๊ณ , ์„ธ๋ถ€ baseline ํ…Œ์ŠคํŠธ ๋ชฉ๋ก์€ REQ/Phase 0 planning์—์„œ ๊ณ„์ธก ๋Œ€์ƒ์œผ๋กœ ๋ช…์‹œํ•œ๋‹ค. +- [x] High ์˜ํ–ฅ ๋ฆฌ์Šคํฌ ์šฐ์„ ์ˆœ์œ„ ๋ฐ ํ•ด์†Œ ์ฑ…์ž„์ž ์ง€์ • + - ๊ธฐ์ค€: R-1, R-2๋Š” REQ/Batch planning ๋‹จ๊ณ„์—์„œ characterization/contract ํ…Œ์ŠคํŠธ ์„ ํ–‰ ์ฑ…์ž„์ž๋ฅผ ์ง€์ •ํ•˜๋Š” ๋น„์ฐจ๋‹จ ํ•ญ๋ชฉ์œผ๋กœ ์ •๋ฆฌ๋˜์—ˆ๊ณ , Discovery ๋ฒ”์œ„ยท์„ฑ๊ณต ๊ธฐ์ค€ยท๊ฒ€์ฆ ์‹œ๋‚˜๋ฆฌ์˜ค ํ™•์ •์€ ์™„๋ฃŒ๋˜์—ˆ๋‹ค. +- [x] ๋ชจ๋“  ์˜คํ”ˆ ์งˆ๋ฌธ(OQ) `Open` ์ƒํƒœ ์—†์Œ ํ™•์ธ + - ๊ธฐ์ค€: OQ ๋ชฉ๋ก์˜ ํ•ญ๋ชฉ์ด ๋ชจ๋‘ `Answered`์ด๊ฑฐ๋‚˜ `Deferred` ์ฒ˜๋ฆฌ ์™„๋ฃŒ +- [x] Freeze ํ™•์ • ๋‹ด๋‹น์ž(CONFIRMED_BY) ์ง€์ • + - ๊ธฐ์ค€: ์‚ฌ์šฉ์ž ์Šน์ธ์— ๋”ฐ๋ผ ์ €์žฅ์†Œ ์†Œ์œ ์ž `์ง„ํ™˜ ๊ณ `๊ฐ€ Confirmed By๋กœ ๊ธฐ๋ก๋˜์—ˆ๋‹ค. + +# 9. ํŒŒ์ผ ์ฒ˜๋ฆฌ ๊ฒฐ๊ณผ +์ด Discovery ๋ฌธ์„œ ์ƒ์„ฑ/๊ฐฑ์‹  ๊ฒฐ๊ณผ์™€ ์ฐธ์กฐ ๋ฌธ์„œ๋ฅผ ๊ธฐ๋กํ•œ๋‹ค. ์ดํ›„ ์ถ”์ ๊ณผ ๋ฆฌ๋ทฐ ์‹œ ๊ทผ๊ฑฐ๋กœ ์‚ฌ์šฉํ•œ๋‹ค. + +- ์ฒ˜๋ฆฌ ๊ฒฐ๊ณผ: ๊ฐฑ์‹  +- ์ƒ์„ฑ ๊ฒฝ๋กœ: docs/discovery/dcy-010_20260702_backend-core-layering-mcp-adapter-realignment.md +- ์ฐธ์กฐ ๋ฌธ์„œ: + - .hermes/plans/2026-07-02_jarca-ledger-backend-refactor-blueprint.md (๊ทผ๊ฑฐ blueprint) + - stage-pilot/templates/project-structure.md (baseline ๊ฐฑ์‹  ๋…ผ๋ฆฌ ํ…œํ”Œ๋ฆฟ ์ฐธ์กฐ) + - stage-pilot/templates/runtime-flows.md (baseline ๊ฐฑ์‹  ๋…ผ๋ฆฌ ํ…œํ”Œ๋ฆฟ ์ฐธ์กฐ) + +# 10. ์‚ฌ์šฉ์ž ๊ฒฐ์ • ํ•„์š” ํ•ญ๋ชฉ ์š”์•ฝ +์‚ฌ์šฉ์ž๋‚˜ ์Šน์ธ์ž๊ฐ€ ๊ฒฐ์ •ํ•ด์•ผ ํ•˜๋Š” ํ•ญ๋ชฉ๋งŒ ๋ชจ์•„ ์ •๋ฆฌํ•œ๋‹ค. ๊ฒฐ์ •, ํ™•์ธ, ๋ฐ์ดํ„ฐ ์š”์ฒญ์„ ๊ตฌ๋ถ„ํ•ด์„œ ์ ๋Š”๋‹ค. + +## DECIDE +- ์—†์Œ โ€” ์ด๋ฒˆ ๋ฐ˜๋ณต ๋ฒ”์œ„๋Š” blueprint ๊ธฐ์ค€ Phase 0~3 ์šฐ์„ ์œผ๋กœ ๊ณ ์ •ํ•œ๋‹ค. +- ์—†์Œ โ€” 1์ฐจ ์™„๋ฃŒ ๊ธฐ์ค€์€ service.py๋ฅผ ~400~600์ค„ ํ˜ธํ™˜ facade ์ˆ˜์ค€์œผ๋กœ ์ถ•์†Œํ•˜๋Š” ๊ฒƒ์œผ๋กœ ์ฑ„ํƒํ•œ๋‹ค. + +## CONFIRM +- ์—†์Œ โ€” MCP tool ์ด๋ฆ„/์ฃผ์š” ์‘๋‹ต ํ•„๋“œ/import ์ฆ๋น™ ํ•„๋“œ/Streamlit ํŽ˜์ด์ง€ ๋™์ž‘์€ ์ด๋ฒˆ ๋ฆฌํŒฉํ„ฐ๋ง ๋™์•ˆ ํ˜ธํ™˜ ๊ณ„์•ฝ์œผ๋กœ ๋™๊ฒฐํ•œ๋‹ค. + +## DATA +- REQ ๋‹จ๊ณ„ ๊ณ„์ธก: Phase 0 baseline ๊ณ ์ •์šฉ ํ˜„์žฌ ํ†ต๊ณผ pytest ์Šค์œ„ํŠธ ๋ชฉ๋ก๊ณผ ์ปค๋ฒ„ ๋Œ€์ƒ MCP tool ์ง‘ํ•ฉ์€ REQ/Phase 0 planning ์‚ฐ์ถœ๋ฌผ์—์„œ ๋ช…์‹œํ•œ๋‹ค. + +# 11. Discovery Freeze +REQ ์ดˆ์•ˆ ์ž‘์„ฑ ์ธ๊ณ„ ์‹œ์ ์˜ ํ™•์ • ํ”Œ๋ž˜๊ทธ์™€ ์„น์…˜ ์ฐธ์กฐ๋งŒ ๊ธฐ๋กํ•œ๋‹ค. ๋‚ด์šฉ ์žฌ์ž‘์„ฑ ์—†์ด ์›๋ณธ ์„น์…˜์„ ๋งํฌ๋กœ ๋Œ€์‹ ํ•œ๋‹ค. + +## ์„น์…˜ ์ฐธ์กฐ +- ๋ฒ”์œ„: [# 5. ์š”๊ตฌ์‚ฌํ•ญ ๋ชฉ๋ก > ๋ฒ”์œ„ ๊ฒฝ๊ณ„](#5-์š”๊ตฌ์‚ฌํ•ญ-๋ชฉ๋ก) +- ๋ฆฌ์Šคํฌ/์˜คํ”ˆ ์งˆ๋ฌธ: [# 6. ๋ฆฌ์Šคํฌ/๊ฐ€์ • ๋ชฉ๋ก](#6-๋ฆฌ์Šคํฌ๊ฐ€์ •-๋ชฉ๋ก) +- ์„ฑ๊ณต ๊ธฐ์ค€: [# 7. ์ดˆ๊ธฐ ์„ฑ๊ณต ๊ธฐ์ค€](#7-์ดˆ๊ธฐ-์„ฑ๊ณต-๊ธฐ์ค€) +- ์ธ๊ณ„ ์ „ ํ™•์ธ: [# 8. REQ๋กœ ๋„˜๊ธฐ๊ธฐ ์ „ ํ™•์ธ ์ฒดํฌ](#8-req๋กœ-๋„˜๊ธฐ๊ธฐ-์ „-ํ™•์ธ-์ฒดํฌ) +- ์‚ฌ์šฉ์ž ๊ฒฐ์ • ํ•„์š”: [# 10. ์‚ฌ์šฉ์ž ๊ฒฐ์ • ํ•„์š” ํ•ญ๋ชฉ ์š”์•ฝ](#10-์‚ฌ์šฉ์ž-๊ฒฐ์ •-ํ•„์š”-ํ•ญ๋ชฉ-์š”์•ฝ) + +## Freeze ํ”Œ๋ž˜๊ทธ +- Handoff Decision: ์Šน์ธ +- Handoff Rationale: ์‚ฌ์šฉ์ž ์Šน์ธ์— ๋”ฐ๋ผ 1์ฐจ ๋ฒ”์œ„๋ฅผ Phase 0~3์œผ๋กœ ์ถ•์†Œํ•˜๊ณ  1์ฐจ ์™„๋ฃŒ ๊ธฐ์ค€์„ service.py facade ์ˆ˜์ค€ ์ถ•์†Œ๋กœ ํ™•์ •ํ–ˆ๋‹ค. ํ˜ธํ™˜ ๊ณ„์•ฝ ๋™๊ฒฐ ์ •์ฑ…๋„ ๋ฐ˜์˜๋˜์—ˆ๊ณ , ๋‚จ์€ baseline ํ…Œ์ŠคํŠธ ์ƒ์„ธ ๋ชฉ๋ก๊ณผ ๋ฆฌ์Šคํฌ ์ฑ…์ž„์ž ์ง€์ •์€ REQ/Phase 0 planning ๋‹จ๊ณ„ ๋น„์ฐจ๋‹จ ๊ณ„์ธกยทํ• ๋‹น ํ•ญ๋ชฉ์œผ๋กœ ์ •๋ฆฌ๋˜์—ˆ๋‹ค. +- Ready for REQ Drafting: true +- Confirmed By: ์ง„ํ™˜ ๊ณ  +- Confirmed At (KST): 2026-07-02 23:56 KST + diff --git a/docs/discovery/index.md b/docs/discovery/index.md index 99ce66e..976128a 100644 --- a/docs/discovery/index.md +++ b/docs/discovery/index.md @@ -15,3 +15,4 @@ | dcy-007_20260630_mcp-response-contract-ci-contract-versioning | 20260630 | MCP response contract / CI contract versioning ์ •๋ฆฝ | [Discovery](./dcy-007_20260630_mcp-response-contract-ci-contract-versioning.md) | confirmed | | dcy-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 | diff --git a/docs/project-structure.md b/docs/project-structure.md index 7a1e20a..340c253 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -4,21 +4,21 @@ - Baseline Seed: `.stage-pilot/bootstrap/baseline.yaml` - Status: draft - Owner: Jarca / project maintainer -- Last Updated (KST): 2026-06-30 -- Source Discovery / Batch: bootstrap-baseline, BAT-006, BAT-007 +- Last Updated (KST): 2026-07-03 +- Source Discovery / Batch: bootstrap-baseline, BAT-006, BAT-007, BAT-010 ## Project Summary -- Jarca ๊ฐ€๊ณ„๋ถ€ ์ „์šฉ MCP ์„œ๋ฒ„์™€ Streamlit ๋Œ€์‹œ๋ณด๋“œ๋กœ ๊ณ„์ขŒ/์นด๋“œ ๊ฑฐ๋ž˜, ์˜ˆ์‚ฐ, ๋ถ„๋ฅ˜ ๊ทœ์น™, import, ํ˜„๊ธˆํ๋ฆ„ ๋ถ„์„์„ SQLite์— ์ €์žฅํ•˜๊ณ  ์šด์˜ํ•œ๋‹ค. +- Jarca ๊ฐ€๊ณ„๋ถ€ ์ „์šฉ MCP ์„œ๋ฒ„์™€ Streamlit ๋Œ€์‹œ๋ณด๋“œ๋กœ ๊ณ„์ขŒ/์นด๋“œ ๊ฑฐ๋ž˜, ์˜ˆ์‚ฐ, ๋ถ„๋ฅ˜ ๊ทœ์น™, import, ์ž์‚ฐ/์Šค๋ƒ…์ƒท, ํ˜„๊ธˆํ๋ฆ„ ๋ถ„์„์„ SQLite์— ์ €์žฅํ•˜๊ณ  ์šด์˜ํ•œ๋‹ค. ## Primary Domain -- Korean household finance ledger and transaction reconciliation +- Korean household finance ledger, reconciliation, and household asset tracking ## Tech Stack - Python >=3.11 - FastMCP / MCP server - SQLite -- Streamlit dashboard -- pandas / plotly dashboard dependencies +- Streamlit +- pandas / plotly - PyMuPDF, openpyxl, msoffcrypto-tool - pytest - ruff @@ -53,9 +53,28 @@ jarca-ledger-mcp/ โ”‚ โ””โ”€โ”€ app.py โ”œโ”€โ”€ jarca_ledger_mcp/ โ”‚ โ”œโ”€โ”€ __init__.py -โ”‚ โ”œโ”€โ”€ db.py -โ”‚ โ”œโ”€โ”€ service.py -โ”‚ โ”œโ”€โ”€ server.py +โ”‚ โ”œโ”€โ”€ service.py # compatibility facade +โ”‚ โ”œโ”€โ”€ db.py # compatibility shim +โ”‚ โ”œโ”€โ”€ server.py # compatibility shim / CLI entrypoint +โ”‚ โ”œโ”€โ”€ application/ +โ”‚ โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ”‚ โ”œโ”€โ”€ shared.py +โ”‚ โ”‚ โ”œโ”€โ”€ system_services.py +โ”‚ โ”‚ โ”œโ”€โ”€ asset_account_services.py +โ”‚ โ”‚ โ”œโ”€โ”€ transaction_report_services.py +โ”‚ โ”‚ โ”œโ”€โ”€ import_transfer_services.py +โ”‚ โ”‚ โ””โ”€โ”€ categorization_services.py +โ”‚ โ”œโ”€โ”€ infrastructure/ +โ”‚ โ”‚ โ””โ”€โ”€ sqlite/ +โ”‚ โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ”‚ โ”œโ”€โ”€ connection.py +โ”‚ โ”‚ โ”œโ”€โ”€ schema.py +โ”‚ โ”‚ โ””โ”€โ”€ migrations.py +โ”‚ โ”œโ”€โ”€ adapters/ +โ”‚ โ”‚ โ”œโ”€โ”€ mcp/ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ server.py +โ”‚ โ”‚ โ””โ”€โ”€ streamlit/ +โ”‚ โ”‚ โ””โ”€โ”€ service_gateway.py โ”‚ โ”œโ”€โ”€ kb_bank_pdf.py โ”‚ โ”œโ”€โ”€ toss_bank_pdf.py โ”‚ โ”œโ”€โ”€ toss_securities_pdf.py @@ -64,7 +83,9 @@ jarca-ledger-mcp/ โ”‚ โ”œโ”€โ”€ contract_snapshots/ โ”‚ โ”‚ โ””โ”€โ”€ response_contract_v1_0.json โ”‚ โ”œโ”€โ”€ test_contract_regression.py +โ”‚ โ”œโ”€โ”€ test_dashboard_app.py โ”‚ โ”œโ”€โ”€ test_service.py +โ”‚ โ”œโ”€โ”€ test_asset_snapshots.py โ”‚ โ”œโ”€โ”€ test_categorization.py โ”‚ โ”œโ”€โ”€ test_category_rule_filters.py โ”‚ โ”œโ”€โ”€ test_cross_account_transfer_matching.py @@ -93,60 +114,72 @@ jarca-ledger-mcp/ ``` ## Package / Module Responsibilities -- `jarca_ledger_mcp/db.py` - - SQLite connection, schema initialization, migrations, row conversion, and database path resolution. - `jarca_ledger_mcp/service.py` - - Domain service layer for accounts, transactions, categories, category rules, imports, reports, asset liquidity/net-worth aggregation, transfer matching, and manual link operations. + - Legacy-compatible top-level service facade. Public call sites continue to import from here while implementation lives in application modules. +- `jarca_ledger_mcp/db.py` + - Legacy-compatible top-level DB shim. Public call sites continue to import `init_db`, `db`, `resolve_db_path` from here. - `jarca_ledger_mcp/server.py` + - Legacy-compatible MCP entrypoint shim that delegates to `adapters.mcp.server`. +- `jarca_ledger_mcp/application/shared.py` + - Shared helpers, validation, KST clock, dedupe policy, parser imports, and cross-domain constants. +- `jarca_ledger_mcp/application/system_services.py` + - Health/status/backup/account create-list/update and persistence-adjacent service behavior. +- `jarca_ledger_mcp/application/asset_account_services.py` + - Account mutation, manual asset, asset snapshot, liquidity/net-worth logic. +- `jarca_ledger_mcp/application/transaction_report_services.py` + - Transaction CRUD, budgets, monthly/category/fixed-cost/investable cashflow reports. +- `jarca_ledger_mcp/application/import_transfer_services.py` + - Import preview/import, self-transfer/cancel matching, cross-account transfer workflow. +- `jarca_ledger_mcp/application/categorization_services.py` + - Category rules, categorization preview/apply, uncategorized workbench, ledger categories. +- `jarca_ledger_mcp/infrastructure/sqlite/connection.py` + - DB path resolution, sqlite connection setup, context manager semantics. +- `jarca_ledger_mcp/infrastructure/sqlite/schema.py` + - Schema version, DDL, additive column definitions. +- `jarca_ledger_mcp/infrastructure/sqlite/migrations.py` + - Additive/idempotent migration routines and DB initialization. +- `jarca_ledger_mcp/adapters/mcp/server.py` - FastMCP tool registration layer that exposes service functions to Hermes/MCP clients. -- `jarca_ledger_mcp/kb_bank_pdf.py` - - KB ๊ตญ๋ฏผ์€ํ–‰ encrypted PDF transaction parsing. -- `jarca_ledger_mcp/toss_bank_pdf.py` - - Toss Bank transaction-history PDF parsing. -- `jarca_ledger_mcp/toss_securities_pdf.py` - - Toss Securities transaction-history PDF parsing and transaction preview conversion. -- `jarca_ledger_mcp/seoulpay_xlsx.py` - - SeoulPay encrypted XLSX parsing and preview conversion. +- `jarca_ledger_mcp/adapters/streamlit/service_gateway.py` + - Narrow dashboard-facing service surface used by `dashboard/app.py`. +- Parser modules (`kb_bank_pdf.py`, `toss_bank_pdf.py`, `toss_securities_pdf.py`, `seoulpay_xlsx.py`) + - Source-file parsing and preview conversion only. - `dashboard/app.py` - - Streamlit operator UI for the operations hub summary, transaction filtering, uncategorized triage, rule management, review queues, categories, accounts/liquidity bucket editing, and import operations. + - Streamlit operator UI for the operations hub summary, ์ž์‚ฐ/๋งˆ๊ฐ, ๊ฑฐ๋ž˜๋‚ด์—ญ, ํ™•์ธํ•„์š” ํ, ๋ฏธ๋ถ„๋ฅ˜, ๋ถ„๋ฅ˜ ๊ทœ์น™, ์นดํ…Œ๊ณ ๋ฆฌ, ๊ณ„์ขŒ, Import pages. - `tests/` - - Regression tests for service behavior, parser behavior, categorization, category rules, cross-account transfer matching, ledger category semantics, MCP response contract compatibility, and Streamlit AppTest dashboard smoke. -- `tests/contract_snapshots/` - - Representative MCP response contract metadata/schemas. Version `1.0` is used by `tests/test_contract_regression.py` and intentionally asserts required subset/types so optional additive fields remain compatible. + - Regression tests for facade compatibility, service behavior, parser behavior, categorization, transfer matching, MCP contract compatibility, and Streamlit AppTest smoke. - `.github/workflows/ci.yml` - GitHub Actions workflow for `pull_request`, `push` to `main`, and `workflow_dispatch`; stable required gate jobs are `pytest-regression`, `dashboard-app-test`, `dashboard-http-smoke`, `db-safety-smoke`, `contract-regression`, and `lint`. - `scripts/ci_fixture_db.py` - - Synthetic fixture DB builder for CI/AppTest/smoke checks. Refuses the default operating DB path and seeds only synthetic ledger data. + - Synthetic fixture DB builder for CI/AppTest/browser smoke. - `scripts/ci_dashboard_http_smoke.py` - - CI HTTP smoke wrapper that creates a synthetic fixture DB, starts Streamlit on loopback, polls HTTP 200 readiness, and cleans up the process with status-only JSON output. + - Synthetic-db Streamlit readiness smoke wrapper. - `scripts/ci_db_safety_smoke.py` - - CI DB safety wrapper that runs restore smoke through temp synthetic source/backup/target DB paths only and emits status-only JSON. + - Synthetic-db backup/restore safety smoke wrapper. - `scripts/streamlit_browser_evidence.py` - - Local browser evidence helper that creates a synthetic fixture DB, starts Streamlit with that DB, and captures Chrome screenshot/DOM snapshot artifacts for QC. + - Local browser evidence helper using synthetic/validation DBs only. - `scripts/ledger_restore_smoke.py` - - Script-only restore smoke helper for validation DBs; restore remains outside the MCP tool surface. + - Script-only restore smoke helper; restore remains outside the MCP tool surface. ## Dependency Rules -- `server.py` should remain a thin tool adapter and delegate business logic to `service.py`. +- `adapters/mcp/server.py` should remain a thin tool adapter and delegate business logic to `service.py` facade/application modules. +- `dashboard/app.py` should call `adapters/streamlit/service_gateway.py`, not giant implementation modules directly. +- `service.py` / `db.py` / `server.py` shims preserve public import paths while new implementation lives under `application/`, `infrastructure/`, `adapters/`. - Parser modules should parse source documents into structured records and avoid direct UI responsibilities. -- `dashboard/app.py` may call service functions but should not duplicate core classification/report semantics. - DB schema migrations should be additive and idempotent by default. ## Shared Boundaries - `cashflow_kind` is the boundary between actual household income/expense and account movement/exclusion semantics. -- `liquidity_bucket` is the account-level boundary for asset liquidity/composition/debt/net-worth views; `dashboard/app.py` displays it and `service.py` computes it from `accounts.last_balance`. +- `liquidity_bucket` is the account/manual-asset boundary for liquidity/composition/debt/net-worth views. - Import evidence fields (`source`, `external_id`, `raw_summary`, `raw_payload`, `counterparty`, `import_batch_id`) are audit boundaries and should be preserved. - Manual reconciliation is represented through memo tags and narrow transaction updates, not broad rewrites. - Browser evidence artifacts are generated from synthetic/validation DBs and should not embed real financial identifiers or raw source payloads. -- CI gate fixtures are generated at runtime under temp space by `scripts/ci_fixture_db.py`; CI and AppTest must not read the default operating DB path. -- MCP contract regression fixtures use pytest `tmp_path` synthetic DBs and committed schema metadata only; they must not read the operating DB or expose runtime `contract_version` fields. -- Branch protection required-check contract is documented in `docs/interface-contract.md` and represented by stable workflow job names. Applying branch protection remains an external GitHub repository setting. +- CI gate fixtures are generated at runtime under temp space by `scripts/ci_fixture_db.py`; CI/AppTest/browser smoke must not read the default operating DB path. ## Current Gaps / Planned Changes -- Local browser evidence capture exists for core Streamlit pages; browser screenshot/visual regression remains local evidence rather than a required CI gate. -- CI workflow now covers pytest/AppTest/HTTP smoke/DB safety smoke plus BAT-007 contract-regression/lint required-check jobs; branch protection enforcement remains a repository setting outside this codebase. -- Long-term policy for accounts without downloadable statements remains partially operational/documentation work. -- Separate automatic asset snapshots/manual asset tables are not present; BAT-005 uses account rows as the manual asset/liability source. +- Application modules are now separated by phase-0~3 domain seams, but deeper repository/use-case split remains future Phase 4+ work. +- Streamlit gateway is intentionally narrow for current pages; further page/domain-specific gateways may follow in later phases. +- HTTP adapter and remote ops re-architecture remain out of scope. ## Update Triggers - Update this document when package boundaries change. @@ -157,5 +190,6 @@ jarca-ledger-mcp/ - 2026-06-26 22:24 KST: Created by `bootstrap-baseline` from repository observation. - 2026-06-29 KST: REQ-004/BAT-004 ๊ธฐ์ค€์œผ๋กœ `scripts/streamlit_browser_evidence.py` ๋ฐ browser evidence artifact ๊ตฌ์กฐ ์ถ”๊ฐ€. - 2026-06-29 KST: REQ-005/BAT-005 ๊ธฐ์ค€์œผ๋กœ dashboard operations hub์™€ service asset liquidity aggregation ์ฑ…์ž„ ์ถ”๊ฐ€. -- 2026-06-30 KST: REQ-006/BAT-006 ๊ธฐ์ค€์œผ๋กœ GitHub Actions CI workflow, synthetic fixture DB builder, AppTest/HTTP/DB safety smoke wrappers, CI-vs-local-browser evidence ๊ฒฝ๊ณ„ ์ถ”๊ฐ€. -- 2026-06-30 KST: REQ-007/REQ-008/BAT-007 ๊ธฐ์ค€์œผ๋กœ MCP response contract snapshot/test ์œ„์น˜์™€ `contract-regression`/`lint` required-check job ๊ณ„์•ฝ ์ถ”๊ฐ€. +- 2026-06-30 KST: REQ-006/BAT-006 ๊ธฐ์ค€์œผ๋กœ GitHub Actions CI workflow, synthetic fixture DB builder, AppTest/HTTP/DB safety smoke wrappers ์ถ”๊ฐ€. +- 2026-06-30 KST: REQ-007/REQ-008/BAT-007 ๊ธฐ์ค€์œผ๋กœ MCP response contract snapshot/test ์œ„์น˜์™€ required-check job ๊ณ„์•ฝ ์ถ”๊ฐ€. +- 2026-07-03 KST: REQ-013/REQ-014/BAT-010 ๊ธฐ์ค€์œผ๋กœ application / infrastructure / adapter layering, compatibility facade/shim, Streamlit gateway ๊ตฌ์กฐ๋ฅผ ๋ฐ˜์˜ํ–ˆ๋‹ค. diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index 8ea266e..826ab9c 100644 --- a/docs/runtime-flows.md +++ b/docs/runtime-flows.md @@ -4,19 +4,19 @@ - Baseline Seed: `.stage-pilot/bootstrap/baseline.yaml` - Status: draft - Owner: Jarca / project maintainer -- Last Updated (KST): 2026-06-30 -- Source Discovery / Batch: bootstrap-baseline, BAT-006, BAT-007, BAT-008 +- Last Updated (KST): 2026-07-03 +- Source Discovery / Batch: bootstrap-baseline, BAT-006, BAT-007, BAT-008, BAT-010 - Primary Runtime: mixed ## Project Summary -- Jarca ๊ฐ€๊ณ„๋ถ€ ์ „์šฉ MCP ์„œ๋ฒ„์™€ Streamlit ๋Œ€์‹œ๋ณด๋“œ๋กœ ๊ณ„์ขŒ/์นด๋“œ ๊ฑฐ๋ž˜, ์˜ˆ์‚ฐ, ๋ถ„๋ฅ˜ ๊ทœ์น™, import, ํ˜„๊ธˆํ๋ฆ„ ๋ถ„์„์„ SQLite์— ์ €์žฅํ•˜๊ณ  ์šด์˜ํ•œ๋‹ค. +- Jarca ๊ฐ€๊ณ„๋ถ€ ์ „์šฉ MCP ์„œ๋ฒ„์™€ Streamlit ๋Œ€์‹œ๋ณด๋“œ๋กœ ๊ณ„์ขŒ/์นด๋“œ ๊ฑฐ๋ž˜, ์˜ˆ์‚ฐ, ๋ถ„๋ฅ˜ ๊ทœ์น™, import, ํ˜„๊ธˆํ๋ฆ„ ๋ถ„์„, ์ž์‚ฐ/์Šค๋ƒ…์ƒท ์šด์˜์„ SQLite์— ์ €์žฅํ•˜๊ณ  ์ œ๊ณตํ•œ๋‹ค. ## Purpose - This document is the current execution-flow baseline for the project. - It is a cross-cutting reference document for Discovery, REQ, Batch Design, Batch Verification, and implementation updates. ## Scope -- Covers representative command, MCP, dashboard, import, and reconciliation flows. +- Covers representative command, MCP, dashboard, import, reconciliation, snapshot, and verification flows. - Does not duplicate full acceptance criteria or low-level implementation details. ## Covered Entry Points @@ -27,56 +27,48 @@ ## Shared Components - SQLite database at `~/.local/share/jarca/ledger.sqlite3` by default. -- `jarca_ledger_mcp.service` as the central domain/service layer. +- `jarca_ledger_mcp/service.py` compatibility facade over application modules. +- `jarca_ledger_mcp/infrastructure/sqlite/*` for DB path resolution, schema, migration, and connection lifecycle. +- `jarca_ledger_mcp/adapters/mcp/server.py` for MCP registration. +- `jarca_ledger_mcp/adapters/streamlit/service_gateway.py` for dashboard-facing service calls. - Parser modules for KB/Toss/SeoulPay sources. -- Category and `cashflow_kind` semantics shared by reports and UI. ## Flow: `MCP server tool call` -1. Hermes MCP client invokes a tool exposed by `jarca_ledger_mcp.server`. -2. `server.py` forwards arguments to the matching `service.py` function. -3. `service.py` opens the SQLite database path from env/default, validates inputs, and performs the operation. -4. Service returns a dictionary payload with success data or error information. -5. Hermes surfaces the tool result to the user or uses it in a follow-up workflow. +1. Hermes MCP client invokes a tool exposed by `jarca_ledger_mcp.server` / `jarca_ledger_mcp.adapters.mcp.server`. +2. MCP adapter forwards arguments to the matching `jarca_ledger_mcp.service` facade function. +3. Service facade routes into the relevant application module. +4. Application code opens SQLite through `infrastructure/sqlite` helpers, validates inputs, and performs the operation. +5. Application returns a dictionary payload with success data or error information. +6. Hermes surfaces the tool result to the user or uses it in a follow-up workflow. ## Flow: `Streamlit dashboard operation` 1. Operator starts `streamlit run dashboard/app.py --server.address 0.0.0.0 --server.port 8501`. 2. Dashboard reads the configured/default SQLite database. -3. User navigates to ์š”์•ฝ, ๊ฑฐ๋ž˜๋‚ด์—ญ, ํ™•์ธํ•„์š” ํ, ๋ฏธ๋ถ„๋ฅ˜, ๋ถ„๋ฅ˜ ๊ทœ์น™, ์นดํ…Œ๊ณ ๋ฆฌ, ๊ณ„์ขŒ, or Import pages. -4. UI calls service functions for reads, updates, imports, categorization, and manual linking. -5. UI displays success/error status and refreshes visible tables. +3. User navigates to ์š”์•ฝ, ์ž์‚ฐ/๋งˆ๊ฐ, ๊ฑฐ๋ž˜๋‚ด์—ญ, ํ™•์ธํ•„์š” ํ, ๋ฏธ๋ถ„๋ฅ˜, ๋ถ„๋ฅ˜ ๊ทœ์น™, ์นดํ…Œ๊ณ ๋ฆฌ, ๊ณ„์ขŒ, or Import pages. +4. UI calls `jarca_ledger_mcp.adapters.streamlit.service_gateway` rather than giant implementation modules directly. +5. Gateway delegates to the service facade/application modules and returns data to the page. +6. UI displays success/error status and refreshes visible tables. ## Flow: `Dashboard operations hub overview` 1. Operator opens the `์š”์•ฝ` page. -2. Dashboard resolves the KST current month and calls `get_monthly_cashflow_report(month)` using `transactions.transaction_date` and `ledger_categories.cashflow_kind` semantics. +2. Dashboard calls `get_monthly_cashflow_report(month)` using `transactions.transaction_date` and `ledger_categories.cashflow_kind` semantics. 3. Dashboard calls `get_asset_liquidity_report`, which uses the shared valuation helper: account-backed assets from `accounts.last_balance` plus active `manual_assets` rows when present. 4. If `manual_assets` and `asset_snapshots` are empty, the report keeps the REQ-005 account-only fallback and preserves existing totals/keys. 5. Dashboard renders sections in REQ-005 priority order: this-month actual income/expense/net/savings rate, asset liquidity, asset composition, budget/spend, operational warnings. -6. Missing `last_balance` or `manual_assets.valuation_amount` values are excluded from asset totals and surfaced as warnings; transaction sums are not used to estimate non-transaction assets. +6. Missing `last_balance` or `manual_assets.valuation_amount` values are excluded from asset totals and surfaced as warnings. -## Flow: `CI validation gates` -1. GitHub Actions starts `.github/workflows/ci.yml` on `pull_request`, `push` to `main`, or `workflow_dispatch`. -2. `lint` installs dev dependencies and runs `ruff check .` with the conservative BAT-007 required rule set. -3. `contract-regression` installs dev dependencies and runs `pytest tests/test_contract_regression.py`; the test uses synthetic pytest temp DBs and `tests/contract_snapshots/response_contract_v1_0.json` metadata for health/status, list, report, and mutation success envelope scopes. -4. `pytest-regression` installs the project with dev/dashboard extras and runs `pytest --ignore=tests/test_dashboard_app.py` so service/parser/regression failures fail the workflow. -5. `dashboard-app-test` runs `pytest tests/test_dashboard_app.py`; the AppTest test creates a synthetic fixture DB through `scripts/ci_fixture_db.py`, sets `JARCA_LEDGER_DB_PATH`, and asserts core dashboard pages render without Streamlit exceptions. -6. `dashboard-http-smoke` runs `scripts/ci_dashboard_http_smoke.py`; the wrapper creates a temp fixture DB, starts Streamlit on loopback, polls for HTTP 200, then terminates the process. -7. `db-safety-smoke` runs `scripts/ci_db_safety_smoke.py`; the wrapper creates temp source/backup/target DB files and invokes `scripts/ledger_restore_smoke.py` without touching the operating DB path. -8. Any required job failure fails the workflow. Stable required-check job names are `pytest-regression`, `dashboard-app-test`, `dashboard-http-smoke`, `db-safety-smoke`, `contract-regression`, and `lint`; actual branch protection toggle enforcement is configured outside the repository. -9. CI logs are status-oriented and synthetic-data-only; they must not contain real account identifiers, raw payloads, statement filenames, balances from the operating DB, or passwords. - -## Flow: `Asset snapshot and net-worth read model` -1. Month-end manual close records item-level rows in `asset_snapshots` from the live authority sources: account `last_balance` rows and `manual_assets` valuations. BAT-008 stores the schema and service helper but does not expose snapshot creation as an MCP mutation. -2. `get_asset_snapshot_report(basis_date)` first checks for snapshot rows on the requested basis date. -3. If snapshot rows exist, the report computes bucket totals, total assets, total liabilities, and net worth from the stored rows. -4. If no snapshot rows exist, the report falls back to live authority sources (`accounts.last_balance` + active `manual_assets`) and marks `source='live_authority'`. -5. `get_net_worth_timeseries(start_date, end_date, months)` reads distinct stored snapshot basis dates and applies the same asset-buckets-minus-loan formula to every point. -6. `list_manual_assets` exposes current manual asset/liability rows for read-only MCP consumers. Manual asset CRUD and snapshot creation MCP tools remain future scope. +## Flow: `Asset snapshot and net-worth workflow` +1. Operator can open `์ž์‚ฐ/๋งˆ๊ฐ` in Streamlit or call snapshot tools through MCP. +2. `preview_asset_snapshot_close(basis_date)` computes authority-source items from `accounts.last_balance` + active `manual_assets` without DB side effects. +3. `close_asset_snapshot(basis_date, source_note, memo, correction_reason)` writes append-only snapshot rows with correction metadata when re-closing the same basis date. +4. `get_asset_snapshot_report(basis_date)` first checks stored snapshot rows; if none exist, it falls back to live authority sources and marks `source='live_authority'`. +5. `get_net_worth_timeseries(start_date, end_date, months)` reads stored snapshot basis dates and applies the same asset-buckets-minus-loan formula to every point. ## Flow: `Import preview and import` -1. Operator chooses or supplies a supported source file: KB Bank PDF, Toss Bank PDF, Toss Securities PDF, SeoulPay XLSX, or CSV. +1. Operator chooses a supported source file: KB Bank PDF, Toss Bank PDF, Toss Securities PDF, SeoulPay XLSX, or CSV. 2. Preview function parses the file and reports counts, sample rows, duplicate status, and classification preview. -3. On explicit import, service creates/fetches the target account, inserts non-duplicate transactions, writes raw provenance, and records an import batch. -4. Reports and dashboard views read the inserted rows using category and cashflow semantics. +3. On explicit import, application code creates/fetches the target account, inserts non-duplicate transactions, writes provenance, and records an import batch. +4. Reports and dashboard views read the inserted rows using shared category/cashflow semantics. 5. If an import is wrong, rollback uses import batch data and soft-deletes by default. ## Flow: `Categorization rule lifecycle` @@ -86,7 +78,7 @@ 4. Category `cashflow_kind` controls actual cashflow/report inclusion. ## Flow: `Cross-account and cancel/original reconciliation` -1. Preview functions search candidate pairs by amount, date, direction, account/source compatibility, and text similarity. +1. Preview helpers search candidate pairs by amount, date, direction, account/source compatibility, and text similarity. 2. Operator reviews candidates in the dashboard or runs the automatic matcher where confidence rules are satisfied. 3. Matched self-transfers become `์ด์ฒด / ๋ณธ์ธ๊ณ„์ขŒ์ด๋™`; investment movements become `์ด์ฒด / ํˆฌ์ž์ด๋™`; ambiguous rows remain `ํ™•์ธํ•„์š”`. 4. Cancel/refund rows can be linked to original expense rows; memo tags preserve manual link evidence. @@ -97,26 +89,23 @@ 3. Risky import, rollback, categorization apply, reconciliation, or migration work is tested first against a synthetic fixture DB or repo-external validation DB copy selected through `JARCA_LEDGER_DB_PATH`. 4. Backup list/status/hash checks use `list_ledger_backups` and `get_ledger_backup_status`. 5. Restore execution is not an MCP flow. Restore smoke uses `scripts/ledger_restore_smoke.py` or the runbook and requires explicit approval before overwriting an existing target DB. -6. Verification evidence must not include full transaction dumps, full merchant/counterparty dumps, account identifiers, raw_payload, original financial file contents, or passwords. -## Flow: `Statement-unavailable manual balance reconciliation` -1. Operator follows the dcy-002 safety standard first: capture DB status, create or confirm a recent backup, and run preview/reconcile on a validation DB before operating on the production DB. -2. Operator marks accounts with `statement_status`: `manual_only` for statement-unavailable accounts, `partial_import` for accounts that require manual supplementation, or `import_supported` where normal statement import is sufficient. -3. Operator records manual evidence transactions with `source='manual'`, positive amount, date, account, and non-sensitive memo/source context. -4. Operator runs `get_balance_reconciliation_report` from MCP/service or the dashboard account page. If `baseline_at` is `YYYY-MM`, the snapshot time is that month end at 23:59 KST; ad-hoc reconciliation uses the user-supplied snapshot timestamp. -5. The report compares `baseline_balance`/`last_balance` with the latest `balance_after` if available, otherwise with net signed account transactions. Absolute tolerance is 1,000 KRW; 0.1% of baseline is reference-only. -6. `OK` rows need no immediate triage. `REVIEW_NEEDED` rows present manual decision candidates: `๊ณ„์ขŒ์ด๋™`, `์‹ค์ง€์ถœ`, `์‹ค์ˆ˜์ž…`, `ํˆฌ์ž์ด๋™`, `๋ถ„์„์ œ์™ธ`, `ํ™•์ธํ•„์š”`. -7. This flow does not run automatic scheduling, notifications, restore, or auto-confirming mutation. +## Flow: `CI validation gates` +1. GitHub Actions starts `.github/workflows/ci.yml` on `pull_request`, `push` to `main`, or `workflow_dispatch`. +2. `lint` runs `ruff check .` with the conservative required rule set. +3. `contract-regression` runs `pytest tests/test_contract_regression.py` with synthetic pytest temp DBs and committed contract snapshots. +4. `pytest-regression` runs the non-dashboard regression suite. +5. `dashboard-app-test` runs `pytest tests/test_dashboard_app.py` using `scripts/ci_fixture_db.py` synthetic DB setup. +6. `dashboard-http-smoke` starts Streamlit against a synthetic DB and polls readiness. +7. `db-safety-smoke` runs restore smoke through temp source/backup/target DB paths only. +8. Any required job failure fails the workflow. Stable required-check job names remain `pytest-regression`, `dashboard-app-test`, `dashboard-http-smoke`, `db-safety-smoke`, `contract-regression`, and `lint`. ## Flow: `Streamlit browser evidence capture` -1. Operator runs `scripts/streamlit_browser_evidence.py` from the repository root. -2. The script creates a synthetic fixture DB under the evidence output directory and refuses to use the default operating DB path. -3. Streamlit starts with `JARCA_LEDGER_DB_PATH=` on a local loopback port. -4. `/usr/bin/google-chrome-stable` runs in headless mode through Chrome DevTools Protocol and opens target dashboard pages such as `๋ถ„๋ฅ˜ ๊ทœ์น™`, `ํ™•์ธํ•„์š” ํ`, and `Import`. -5. After page title text is rendered, the script stores PNG screenshots, DOM snapshots, and `manifest.json` in the evidence directory. -6. UI/dashboard-impacting batches must attach browser screenshot/snapshot evidence. Non-UI batches may treat it as optional, and docs-only batches do not require browser evidence. -7. If Chrome/browser capture is unavailable, AppTest + HTTP 200 fallback evidence is allowed only as `PASS_WITH_NOTES` with the environment failure and remaining browser-evidence item recorded. -8. Browser screenshot/DOM evidence is not a required GitHub Actions gate in BAT-006. Run it locally for visual/layout-sensitive dashboard changes, while non-visual backend/CI changes can rely on pytest/AppTest/HTTP/DB safety gates. +1. Operator runs `scripts/streamlit_browser_evidence.py` or starts Streamlit against a synthetic fixture DB. +2. The script/flow refuses to use the default operating DB path for evidence capture. +3. Browser smoke opens key pages such as `์š”์•ฝ`, `์ž์‚ฐ/๋งˆ๊ฐ`, `๋ถ„๋ฅ˜ ๊ทœ์น™`, `ํ™•์ธํ•„์š” ํ`, and `Import`. +4. Verification records rendered headings/content and browser console JS error absence. +5. UI/dashboard-impacting batches must attach browser screenshot/DOM evidence or equivalent browser smoke notes. ## Flow Constraints - Preview before broad import or categorization mutation where supported. @@ -125,20 +114,14 @@ - Avoid destructive migrations without a backup/approval plan. - Before broad DB mutation, capture DB status evidence and create or confirm a recent backup. - Restore execution is script/runbook only and must not be exposed as an MCP tool. -- Manual balance reconciliation must remain preview/report-first unless a future REQ approves persistent review routing and confirmation mutation. -- Streamlit browser evidence must use synthetic/validation DBs only and must not screenshot/log real account numbers, balances, source financial filenames, passwords, full merchant/counterparty dumps, or raw payloads. -- CI gates must use synthetic/temp DBs only. The default operating DB path (`~/.local/share/jarca/ledger.sqlite3`) is forbidden for AppTest, HTTP smoke, and DB safety smoke wrappers. -- Contract regression must use synthetic/temp DBs only and must not expose runtime contract-version fields in MCP success payloads unless a future approved REQ changes the policy. -- Branch protection required-check drift should be audited against the six stable workflow job names documented in `docs/interface-contract.md`; changing a job name requires an explicit REQ/BAT note because it can break external repository settings. -- Dashboard and MCP asset valuation reports must use the shared service valuation policy: `accounts.last_balance` plus `manual_assets`, asset buckets minus loan, and missing valuation exclusion. -- Snapshot read tools are read-only in BAT-008. Snapshot creation and manual asset CRUD MCP mutation tools require a future REQ. -- Sensitive passwords are runtime inputs and must not be committed. +- Streamlit browser evidence must use synthetic/validation DBs only and must not screenshot/log real account numbers, source financial filenames, passwords, full merchant/counterparty dumps, or raw payloads. +- CI gates must use synthetic/temp DBs only. The default operating DB path is forbidden for AppTest, HTTP smoke, and DB safety smoke wrappers. +- Top-level `service.py`, `db.py`, and `server.py` import paths remain compatibility surfaces until a future approved REQ retires them. ## Current Gaps / Planned Changes -- Browser-level evidence is available locally through `scripts/streamlit_browser_evidence.py`; it remains outside required CI gates by design. -- Contract-regression and lint gates are now part of the required-check contract for CI; full MCP tool-surface snapshot coverage remains deferred beyond the representative health/status/list/report/mutation scope. -- Dedicated reconciliation workbench and persistent review-decision queue are deferred beyond REQ-003. -- Separate asset snapshot/manual asset tables and automatic market/real-estate valuation are deferred beyond REQ-005. +- Browser-level evidence exists locally and remains outside required CI gates by design. +- Phase 4+ write/import/advanced workflow service thinning and narrower adapters are deferred to GitHub Project TODO items. +- HTTP adapter and remote ops re-architecture remain deferred. ## Update Triggers - Update this document when a command entry point changes. @@ -151,5 +134,6 @@ - 2026-06-29 KST: REQ-003/BAT-003 ๊ธฐ์ค€์œผ๋กœ statement-unavailable ์ˆ˜๋™ ์ž”์•ก๋Œ€์กฐ flow ์ถ”๊ฐ€. - 2026-06-29 KST: REQ-004/BAT-004 ๊ธฐ์ค€์œผ๋กœ Streamlit browser evidence capture flow ๋ฐ UI evidence gate ๊ธฐ์ค€ ์ถ”๊ฐ€. - 2026-06-29 KST: REQ-005/BAT-005 ๊ธฐ์ค€์œผ๋กœ dashboard operations hub ๋ฐ `liquidity_bucket`/`last_balance` ์ž์‚ฐ ์œ ๋™์„ฑ flow ์ถ”๊ฐ€. -- 2026-06-30 KST: REQ-006/BAT-006 ๊ธฐ์ค€์œผ๋กœ GitHub Actions CI validation gates, synthetic fixture DB boundary, AppTest/HTTP/DB safety smoke flow, local browser evidence boundary ์ถ”๊ฐ€. -- 2026-06-30 KST: REQ-007/REQ-008/BAT-007 ๊ธฐ์ค€์œผ๋กœ `contract-regression`/`lint` CI required-check flow, contract snapshot scope, branch protection drift boundary ์ถ”๊ฐ€. +- 2026-06-30 KST: REQ-006/BAT-006 ๊ธฐ์ค€์œผ๋กœ GitHub Actions CI validation gates, synthetic fixture DB boundary, AppTest/HTTP/DB safety smoke flow ์ถ”๊ฐ€. +- 2026-06-30 KST: REQ-007/REQ-008/BAT-007 ๊ธฐ์ค€์œผ๋กœ contract-regression/lint CI required-check flow ์ถ”๊ฐ€. +- 2026-07-03 KST: REQ-013/REQ-014/BAT-010 ๊ธฐ์ค€์œผ๋กœ service facade โ†’ application, db shim โ†’ infrastructure/sqlite, MCP/Streamlit adapter seam flow๋ฅผ ๋ฐ˜์˜ํ–ˆ๋‹ค. diff --git a/docs/srs/Interface/req-014_mcp-streamlit-adapter-seam-realignment-contract-preserving-gateway.md b/docs/srs/Interface/req-014_mcp-streamlit-adapter-seam-realignment-contract-preserving-gateway.md new file mode 100644 index 0000000..a7890b4 --- /dev/null +++ b/docs/srs/Interface/req-014_mcp-streamlit-adapter-seam-realignment-contract-preserving-gateway.md @@ -0,0 +1,84 @@ +# REQ-014 MCP/Streamlit adapter seam realignment and contract-preserving gateway + +- Status: Implemented +- Type: Interface +- Priority: High +- Owner: ๋ ›์ €์žฅ์ธ +- Source Discovery: `docs/discovery/dcy-010_20260702_backend-core-layering-mcp-adapter-realignment.md` +- Batch: BAT-010 +- Created At (KST): 2026-07-03 00:15 KST +- Approved At (KST): 2026-07-03 00:15 KST +- Implemented At (KST): 2026-07-03 00:18 KST + +## 1. ๋ฐฐ๊ฒฝ + +`dcy-010`์€ MCP server์™€ Streamlit dashboard๊ฐ€ giant service namespace์— ์ง์ ‘ ๊ฒฐํ•ฉ๋ผ ์žˆ์–ด ๋‚ด๋ถ€ ๊ตฌ์กฐ ๊ฐœ์„ ์ด ๊ณง external contract drift๋กœ ์ด์–ด์งˆ ์œ„ํ—˜์ด ํฌ๋‹ค๊ณ  ํŒ๋‹จํ–ˆ๋‹ค. Phase 0~3์—์„œ๋Š” adapter๋ฅผ ์–‡๊ฒŒ ๋งŒ๋“ค๋˜, tool names/response envelope/dashboard ํ•ต์‹ฌ ํŽ˜์ด์ง€๋ฅผ ์œ ์ง€ํ•˜๋Š” ๊ฒƒ์ด ํ•ต์‹ฌ์ด๋‹ค. + +## 2. Intent + +์ด REQ์˜ ๋ชฉ์ ์€ MCP์™€ Streamlit์„ ์ œ๊ฑฐํ•˜๋Š” ๊ฒƒ์ด ์•„๋‹ˆ๋ผ, backend core ์œ„์— ์–นํžŒ thin adapter๋กœ ์žฌ์ •๋ ฌํ•˜๋Š” ๊ฒƒ์ด๋‹ค. ์ฆ‰ `server.py`์™€ `dashboard/app.py`๊ฐ€ ๋” ์ข์€ seam์„ ํ†ตํ•ด application core๋ฅผ ํ˜ธ์ถœํ•˜๋ฉด์„œ๋„ ๊ธฐ์กด ์‚ฌ์šฉ์ž์˜ ์šด์˜ ๊ฒฝํ—˜๊ณผ ๊ณ„์•ฝ์€ ์œ ์ง€ํ•ด์•ผ ํ•œ๋‹ค. + +## 3. ๋ฒ”์œ„ + +### In Scope +- `jarca_ledger_mcp/adapters/mcp/server.py`๋ฅผ ๋„์ž…ํ•˜๊ณ  top-level `server.py`๋ฅผ compatibility shim์œผ๋กœ ์œ ์ง€ํ•œ๋‹ค. +- `jarca_ledger_mcp/adapters/streamlit/service_gateway.py`๋ฅผ ๋„์ž…ํ•˜๊ณ  dashboard๊ฐ€ ์ด gateway๋ฅผ ํ†ตํ•ด service functions๋ฅผ ํ˜ธ์ถœํ•˜๋„๋ก ์ •๋ ฌํ•œ๋‹ค. +- MCP tool discovery ๊ฒฐ๊ณผ(56 tools)์™€ success envelope contract๋ฅผ ์œ ์ง€ํ•œ๋‹ค. +- Streamlit ํ•ต์‹ฌ ํŽ˜์ด์ง€(`์š”์•ฝ`, `์ž์‚ฐ/๋งˆ๊ฐ` ํฌํ•จ)๊ฐ€ synthetic fixture DB์—์„œ ๋ Œ๋”๋ง๋ผ์•ผ ํ•œ๋‹ค. +- baseline docs์™€ batch verification evidence์— adapter seam์„ ๋ฐ˜์˜ํ•œ๋‹ค. + +### Out of Scope +- ์‹ ๊ทœ MCP ๋„๊ตฌ ์ถ”๊ฐ€๋‚˜ ์‘๋‹ต schema ๋ณ€๊ฒฝ. +- ์ƒˆ๋กœ์šด Streamlit IA/๋””์ž์ธ ์žฌ๊ตฌ์„ฑ. +- HTTP adapter/REST route ์ถ”๊ฐ€. + +## 4. Requirement + +### FR-1. MCP adapter thinness +- ์‹ค์ œ FastMCP registration์€ `jarca_ledger_mcp/adapters/mcp/server.py`์— ์œ„์น˜ํ•ด์•ผ ํ•œ๋‹ค. +- top-level `jarca_ledger_mcp/server.py`๋Š” CLI/script entrypoint ํ˜ธํ™˜์„ ์œ„ํ•œ shim์ด์–ด์•ผ ํ•œ๋‹ค. +- MCP ๋„๊ตฌ๋ช…/์„ค๋ช…/์ธ์ž contract๋Š” ์œ ์ง€๋ผ์•ผ ํ•œ๋‹ค. + +### FR-2. Streamlit gateway seam +- `dashboard/app.py`๋Š” giant service implementation์ด ์•„๋‹ˆ๋ผ ๋ช…์‹œ์  gateway module์„ ํ†ตํ•ด service functions๋ฅผ ์‚ฌ์šฉํ•ด์•ผ ํ•œ๋‹ค. +- gateway๋Š” dashboard๊ฐ€ ์‹ค์ œ๋กœ ํ•„์š”ํ•œ surface๋งŒ ๋…ธ์ถœํ•ด์•ผ ํ•œ๋‹ค. +- dashboard ๋ Œ๋” ๊ฒฐ๊ณผ๋Š” synthetic fixture DB ๊ธฐ์ค€ ๊ธฐ์กด ํ•ต์‹ฌ ํŽ˜์ด์ง€๋ฅผ ๊นจ์ง€ ์•Š์•„์•ผ ํ•œ๋‹ค. + +### FR-3. Contract-preserving verification +- full pytest regression๊ณผ MCP tool discovery๊ฐ€ PASS ํ•ด์•ผ ํ•œ๋‹ค. +- browser-level smoke๋Š” synthetic fixture DB์—์„œ `์š”์•ฝ`๊ณผ ๊ตฌ์กฐ ๋ณ€๊ฒฝ ์˜ํ–ฅ ํŽ˜์ด์ง€(`์ž์‚ฐ/๋งˆ๊ฐ`)๋ฅผ ๋ Œ๋”๋งํ•ด์•ผ ํ•œ๋‹ค. +- ๋ธŒ๋ผ์šฐ์ € ์ฝ˜์†” JS ์—๋Ÿฌ๊ฐ€ ์—†์–ด์•ผ ํ•œ๋‹ค. + +### FR-4. Documentation alignment +- `docs/project-structure.md`์™€ `docs/runtime-flows.md`๋Š” adapter/gateway seam๊ณผ compatibility shim ๊ตฌ์กฐ๋ฅผ ๋ฐ˜์˜ํ•ด์•ผ ํ•œ๋‹ค. +- BAT verification ๋ฌธ์„œ๋Š” synthetic-only evidence์™€ ์ž”์—ฌ Phase 4+ TODO๋ฅผ ๊ธฐ๋กํ•ด์•ผ ํ•œ๋‹ค. + +## 5. Acceptance Criteria + +- AC-1: `jarca_ledger_mcp/adapters/mcp/server.py`์™€ top-level `server.py` shim์ด ์กด์žฌํ•˜๊ณ  `hermes mcp test jarca-ledger`๊ฐ€ PASS ํ•œ๋‹ค. +- AC-2: `jarca_ledger_mcp/adapters/streamlit/service_gateway.py`๊ฐ€ ๋„์ž…๋˜๊ณ  `dashboard/app.py`๊ฐ€ ์ด๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค. +- AC-3: full pytest๊ฐ€ PASS ํ•˜๊ณ  Streamlit AppTest smoke๊ฐ€ ๊ณ„์† PASS ํ•œ๋‹ค. +- AC-4: local browser smoke์—์„œ `์š”์•ฝ`๊ณผ `์ž์‚ฐ/๋งˆ๊ฐ` ํŽ˜์ด์ง€๊ฐ€ ๋ Œ๋”๋ง๋˜๊ณ  console JS errors๊ฐ€ ์—†๋‹ค. +- AC-5: baseline docs์™€ batch evidence ๋ฌธ์„œ๊ฐ€ ์ƒˆ adapter seam์„ ๋ฐ˜์˜ํ•œ๋‹ค. + +## 6. Impacted Area + +- `jarca_ledger_mcp/server.py` +- `jarca_ledger_mcp/adapters/mcp/server.py` +- `jarca_ledger_mcp/adapters/streamlit/service_gateway.py` +- `dashboard/app.py` +- `tests/test_dashboard_app.py` +- `tests/test_contract_regression.py` +- `docs/project-structure.md` +- `docs/runtime-flows.md` + +## 7. Notes + +- ์ด REQ๋Š” MCP/Streamlit surface์™€ operator-facing ํ˜ธ์ถœ ๊ฒฝ๊ณ„๋ฅผ ๋‹ค๋ฃจ๋ฏ€๋กœ `Interface` ํƒ€์ž…์œผ๋กœ ๊ด€๋ฆฌํ•œ๋‹ค. +- gateway๋Š” ํ˜„์žฌ dashboard๊ฐ€ ์‚ฌ์šฉํ•˜๋Š” surface๋งŒ ๋…ธ์ถœํ•˜๋ฉฐ, Phase 4+์—์„œ ๋” ์ข์€ domain-specific gateway๋กœ ์žฌ๋ถ„ํ• ํ•  ์ˆ˜ ์žˆ๋‹ค. + +## 8. Change Log + +- 2026-07-03 00:15 KST: dcy-010 confirmed ๋ฒ”์œ„๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ Proposed ์ดˆ์•ˆ์„ ์ƒ์„ฑ. +- 2026-07-03 00:15 KST: ์‚ฌ์šฉ์ž ์Šน์ธ ๋ฐฉํ–ฅ(๊ณ„์•ฝ ๋™๊ฒฐ, adapter thinness, Phase 0~3 ์šฐ์„ )์— ๋”ฐ๋ผ Approved๋กœ ์ „ํ™˜. +- 2026-07-03 00:18 KST: MCP adapter split, Streamlit gateway ๋„์ž…, browser smoke, MCP discovery, full pytest PASS๋ฅผ ๊ทผ๊ฑฐ๋กœ Implemented๋กœ ์ „ํ™˜. diff --git a/docs/srs/Non-Functional/req-013_backend-core-layering-sqlite-infrastructure-service-facade-compatibility.md b/docs/srs/Non-Functional/req-013_backend-core-layering-sqlite-infrastructure-service-facade-compatibility.md new file mode 100644 index 0000000..d89d5cd --- /dev/null +++ b/docs/srs/Non-Functional/req-013_backend-core-layering-sqlite-infrastructure-service-facade-compatibility.md @@ -0,0 +1,87 @@ +# REQ-013 Backend core layering / SQLite infrastructure split and service facade compatibility + +- Status: Implemented +- Type: Non-Functional +- Priority: High +- Owner: ๋ ›์ €์žฅ์ธ +- Source Discovery: `docs/discovery/dcy-010_20260702_backend-core-layering-mcp-adapter-realignment.md` +- Batch: BAT-010 +- Created At (KST): 2026-07-03 00:15 KST +- Approved At (KST): 2026-07-03 00:15 KST +- Implemented At (KST): 2026-07-03 00:18 KST + +## 1. ๋ฐฐ๊ฒฝ + +`dcy-010` confirmed Discovery๋Š” `jarca_ledger_mcp/service.py`์™€ `jarca_ledger_mcp/db.py`์— ๊ณผ๋„ํ•˜๊ฒŒ ์ง‘์ค‘๋œ ์ฑ…์ž„ ๋•Œ๋ฌธ์— MCP adapter, Streamlit dashboard, SQLite persistence๊ฐ€ ํ•˜๋‚˜์˜ giant module ์ง‘ํ•ฉ์— ๊ฐ•๊ฒฐํ•ฉ๋ผ ์žˆ๋‹ค๊ณ  ํŒ๋‹จํ–ˆ๋‹ค. Phase 0~3 ์šฐ์„  ๋ฒ”์œ„์—์„œ๋Š” ๊ธฐ๋Šฅ ๊ณ„์•ฝ์„ ๋ฐ”๊พธ์ง€ ์•Š๊ณ ๋„ backend core์˜ ์ตœ์†Œ layering์„ ์„ธ์›Œ ์ดํ›„ write/import/advanced workflow ๋ถ„๋ฆฌ๋ฅผ ์•ˆ์ „ํ•˜๊ฒŒ ์ด์–ด๊ฐˆ ์ˆ˜ ์žˆ์–ด์•ผ ํ•œ๋‹ค. + +## 2. Intent + +์ด REQ์˜ ๋ชฉ์ ์€ ์„œ๋น„์Šค/DB ์ฝ”๋“œ๋ฅผ ํ•œ ๋ฒˆ์— ๋‹ค์‹œ ์“ฐ๋Š” ๊ฒƒ์ด ์•„๋‹ˆ๋ผ, ๊ธฐ์กด ๋™์ž‘์„ ์œ ์ง€ํ•œ ์ฑ„ `application` / `infrastructure/sqlite` / compatibility facade ๊ฒฝ๊ณ„๋ฅผ ๋„์ž…ํ•ด ์ฝ”๋“œ ํƒ์ƒ‰์„ฑ, ๋ณ€๊ฒฝ ์•ˆ์ „์„ฑ, ํ›„์† ๋ถ„๋ฆฌ ๊ฐ€๋Šฅ์„ฑ์„ ํ™•๋ณดํ•˜๋Š” ๊ฒƒ์ด๋‹ค. + +## 3. ๋ฒ”์œ„ + +### In Scope +- `jarca_ledger_mcp/service.py`๋ฅผ compatibility facade๋กœ ์ถ•์†Œํ•˜๊ณ  ์‹ค์ œ ๊ตฌํ˜„์„ application ๋ชจ๋“ˆ๋กœ ๋ถ„๋ฆฌํ•œ๋‹ค. +- ๊ณตํ†ต helper/์ •์ฑ…์„ `jarca_ledger_mcp/application/shared.py`๋กœ ์ •๋ฆฌํ•œ๋‹ค. +- SQLite connection/schema/migration ์ฑ…์ž„์„ `jarca_ledger_mcp/infrastructure/sqlite/`๋กœ ์ด๋™ํ•œ๋‹ค. +- ๊ธฐ์กด `jarca_ledger_mcp/db.py` import surface๋Š” compatibility shim์œผ๋กœ ์œ ์ง€ํ•œ๋‹ค. +- additive/idempotent migration discipline๊ณผ ๊ธฐ์กด ํ…Œ์ŠคํŠธ contract๋ฅผ ์œ ์ง€ํ•œ๋‹ค. +- `docs/project-structure.md`, `docs/runtime-flows.md`์— ์ƒˆ ๊ฒฝ๊ณ„๋ฅผ ๋ฐ˜์˜ํ•œ๋‹ค. + +### Out of Scope +- FastAPI/HTTP adapter ์ถ”๊ฐ€. +- write/import/advanced workflow์˜ ๋ณธ๊ฒฉ์ ์ธ Phase 4+ domain ๋ถ„๋ฆฌ. +- ๋ฐ์ดํ„ฐ ๋ชจ๋ธ semantics ๋ณ€๊ฒฝ, ์‘๋‹ต envelope ๋ณ€๊ฒฝ, destructive migration. + +## 4. Requirement + +### FR-1. Application service decomposition +- ๊ธฐ์กด `service.py`์˜ ๊ธฐ๋Šฅ ๊ตฌํ˜„์€ ์ตœ์†Œํ•œ `system`, `asset/account`, `transaction/report`, `import/transfer`, `categorization` ์„ฑ๊ฒฉ์˜ application ๋ชจ๋“ˆ๋กœ ๋ถ„๋ฆฌ๋ผ์•ผ ํ•œ๋‹ค. +- shared helper์™€ ๊ณตํ†ต ์ •์ฑ…(`cashflow_kind`, KST clock, dedupe, validation helpers)์€ application shared module๋กœ ์ด๋™ํ•ด์•ผ ํ•œ๋‹ค. +- ๋ถ„๋ฆฌ ํ›„์—๋„ ๊ธฐ์กด `from jarca_ledger_mcp import service` ์‚ฌ์šฉ์ž๋Š” ๋™์ผ ํ•จ์ˆ˜๋ช…์„ ๊ณ„์† ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•œ๋‹ค. + +### FR-2. SQLite infrastructure split +- ๊ธฐ์กด `db.py`์˜ connection/schema/migration ์ฑ…์ž„์€ `jarca_ledger_mcp/infrastructure/sqlite/`๋กœ ์ด๋™ํ•ด์•ผ ํ•œ๋‹ค. +- top-level `db.py`๋Š” ๊ธฐ์กด import ๊ฒฝ๋กœ๋ฅผ ์œ ์ง€ํ•˜๋Š” compatibility shim์ด์–ด์•ผ ํ•œ๋‹ค. +- `db()` context manager๋Š” ๊ธฐ์กด์ฒ˜๋Ÿผ ์ดˆ๊ธฐํ™”/commit/rollback semantics๋ฅผ ์œ ์ง€ํ•ด์•ผ ํ•œ๋‹ค. +- ์Šคํ‚ค๋งˆ migration์€ additive/idempotent ์›์น™์„ ์œ ์ง€ํ•ด์•ผ ํ•œ๋‹ค. + +### FR-3. Service facade compatibility +- `jarca_ledger_mcp/service.py`๋Š” thin facade์—ฌ์•ผ ํ•˜๋ฉฐ ๊ตฌํ˜„ ๋””ํ…Œ์ผ ๋Œ€์‹  application ๋ชจ๋“ˆ์„ ์žฌ๋…ธ์ถœํ•ด์•ผ ํ•œ๋‹ค. +- ๊ธฐ์กด pytest/service call sites์™€ monkeypatch ๊ด€์„ฑ(`_current_kst_datetime`)์€ ๊นจ์ง€์ง€ ์•Š์•„์•ผ ํ•œ๋‹ค. +- facade ์ถ•์†Œ ๊ฒฐ๊ณผ๋Š” ๋” ์ด์ƒ giant implementation file์ด ์•„๋‹ˆ์–ด์•ผ ํ•œ๋‹ค. + +### FR-4. Baseline documentation alignment +- `docs/project-structure.md`๋Š” ์ƒˆ package tree์™€ ์ฑ…์ž„ ๊ฒฝ๊ณ„๋ฅผ ๋ฐ˜์˜ํ•ด์•ผ ํ•œ๋‹ค. +- `docs/runtime-flows.md`๋Š” service facade, Streamlit gateway, MCP adapter, SQLite infrastructure flow๋ฅผ ๋ฐ˜์˜ํ•ด์•ผ ํ•œ๋‹ค. +- ๋ฌธ์„œ๋Š” Phase 4+๊ฐ€ GitHub Project TODO๋กœ ๋‚จ์•„ ์žˆ์Œ์„ ๋“œ๋Ÿฌ๋‚ด์•ผ ํ•œ๋‹ค. + +## 5. Acceptance Criteria + +- AC-1: `jarca_ledger_mcp/application/` ์•„๋ž˜์— phase-0~3 ๋ฒ”์œ„์˜ application service modules์™€ shared module์ด ์ƒ์„ฑ๋œ๋‹ค. +- AC-2: `jarca_ledger_mcp/infrastructure/sqlite/` ์•„๋ž˜์— connection/schema/migration ๋ถ„๋ฆฌ๊ฐ€ ์ƒ์„ฑ๋˜๊ณ  top-level `db.py` shim์ด ๋™์ž‘ํ•œ๋‹ค. +- AC-3: `jarca_ledger_mcp/service.py`๋Š” compatibility facade๋กœ ์ถ•์†Œ๋˜๊ณ  600์ค„ ์ดํ•˜๋ฅผ ์œ ์ง€ํ•œ๋‹ค. +- AC-4: ์ „์ฒด pytest regression์ด PASS ํ•˜๋ฉฐ ๊ธฐ์กด service/db import surface๊ฐ€ ์œ ์ง€๋œ๋‹ค. +- AC-5: `docs/project-structure.md`, `docs/runtime-flows.md`๊ฐ€ ์ƒˆ ๊ณ„์ธต ๊ตฌ์กฐ๋ฅผ ๋ฐ˜์˜ํ•œ๋‹ค. + +## 6. Impacted Area + +- `jarca_ledger_mcp/service.py` +- `jarca_ledger_mcp/db.py` +- `jarca_ledger_mcp/application/*` +- `jarca_ledger_mcp/infrastructure/sqlite/*` +- `tests/test_service.py` +- `tests/test_asset_snapshots.py` +- `docs/project-structure.md` +- `docs/runtime-flows.md` + +## 7. Notes + +- ์ด REQ๋Š” ์ฃผ๋กœ maintainability/architecture boundary์™€ compatibility behavior๋ฅผ ๋‹ค๋ฃจ๋ฏ€๋กœ `Non-Functional`๋กœ ๊ด€๋ฆฌํ•œ๋‹ค. +- write/import/advanced workflow์˜ deeper split์€ dcy-010์—์„œ ๋ณ„๋„ Phase 4+ TODO๋กœ ๋‚จ๊ฒผ๋‹ค. + +## 8. Change Log + +- 2026-07-03 00:15 KST: dcy-010 confirmed ๋ฒ”์œ„๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ Proposed ์ดˆ์•ˆ์„ ์ƒ์„ฑ. +- 2026-07-03 00:15 KST: ์‚ฌ์šฉ์ž ์Šน์ธ ๋ฐฉํ–ฅ(Phase 0~3 ์šฐ์„ , facade ์ถ•์†Œ, ๊ณ„์•ฝ ๋™๊ฒฐ)์— ๋”ฐ๋ผ Approved๋กœ ์ „ํ™˜. +- 2026-07-03 00:18 KST: application/infrastructure split, facade/shim compatibility, full pytest PASS, MCP tool discovery, browser smoke evidence๋ฅผ ๊ทผ๊ฑฐ๋กœ Implemented๋กœ ์ „ํ™˜. diff --git a/docs/srs/index.md b/docs/srs/index.md index c9d2bab..49732af 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -28,12 +28,12 @@ | **Integration** | ์™ธ๋ถ€ ์‹œ์Šคํ…œ ์—ฐ๋™, ํ”Œ๋Ÿฌ๊ทธ์ธ, ํ™•์žฅ | KB/ํ† ์Šค/์„œ์šธํŽ˜์ด import | | **Interface** | ์‚ฌ์šฉ์ž ์ƒํ˜ธ์ž‘์šฉ ๋ฐฉ์‹ | MCP tool, Streamlit UI | | **Migration** | ๋ฐ์ดํ„ฐ ์ด์ „, ๋ฒ„์ „ ์—…๊ทธ๋ ˆ์ด๋“œ | SQLite schema migration | -| **Non-Functional** | ์„ฑ๋Šฅ, ๋ณด์•ˆ, ํ™•์žฅ์„ฑ, ํ˜ธํ™˜์„ฑ, ์‹ ๋ขฐ์„ฑ | ๊ฐœ์ธ์ •๋ณด/๊ธˆ์œต์ •๋ณด ๋ณดํ˜ธ, UX ์„ฑ๋Šฅ | +| **Non-Functional** | ์„ฑ๋Šฅ, ๋ณด์•ˆ, ํ™•์žฅ์„ฑ, ํ˜ธํ™˜์„ฑ, ์‹ ๋ขฐ์„ฑ | ๊ฐœ์ธ์ •๋ณด/๊ธˆ์œต์ •๋ณด ๋ณดํ˜ธ, UX ์„ฑ๋Šฅ, ์•„ํ‚คํ…์ฒ˜ ์œ ์ง€๋ณด์ˆ˜์„ฑ | | **Testing** | ํ…Œ์ŠคํŠธ ์ „๋žต, QA ์š”๊ตฌ์‚ฌํ•ญ, ๊ฒ€์ฆ ๋ฐฉ์‹ | parser regression, report validation | ## Next Requirement ID -- Current: `REQ-013` +- Current: `REQ-015` - Rule: `Requirement Register`์— ์žˆ๋Š” ๊ฐ€์žฅ ํฐ REQ ๋ฒˆํ˜ธ๋ณด๋‹ค ์ •ํ™•ํžˆ 1 ์ปค์•ผ ํ•œ๋‹ค. ## Requirement Register @@ -52,6 +52,8 @@ | REQ-010 | Asset snapshot/net worth read MCP and dashboard alignment | Interface | Implemented | High | ๋ ›์ €์žฅ์ธ | [req-010_asset-snapshot-net-worth-read-mcp-and-dashboard-alignment.md](Interface/req-010_asset-snapshot-net-worth-read-mcp-and-dashboard-alignment.md) | | REQ-011 | Manual asset CRUD / month-end snapshot write model | Data | Implemented | High | ๋ ›์ €์žฅ์ธ | [req-011_manual-asset-crud-month-end-snapshot-write-model.md](Data/req-011_manual-asset-crud-month-end-snapshot-write-model.md) | | REQ-012 | Manual asset CRUD / snapshot close MCP and operator dashboard alignment | Interface | Implemented | High | ๋ ›์ €์žฅ์ธ | [req-012_manual-asset-crud-snapshot-close-mcp-and-operator-dashboard-alignment.md](Interface/req-012_manual-asset-crud-snapshot-close-mcp-and-operator-dashboard-alignment.md) | +| REQ-013 | Backend core layering / SQLite infrastructure split and service facade compatibility | Non-Functional | Implemented | High | ๋ ›์ €์žฅ์ธ | [req-013_backend-core-layering-sqlite-infrastructure-service-facade-compatibility.md](Non-Functional/req-013_backend-core-layering-sqlite-infrastructure-service-facade-compatibility.md) | +| REQ-014 | MCP/Streamlit adapter seam realignment and contract-preserving gateway | Interface | Implemented | High | ๋ ›์ €์žฅ์ธ | [req-014_mcp-streamlit-adapter-seam-realignment-contract-preserving-gateway.md](Interface/req-014_mcp-streamlit-adapter-seam-realignment-contract-preserving-gateway.md) | ## Recent Change Log Summary @@ -76,3 +78,5 @@ - 2026-07-01 09:00 KST: dcy-009 confirmed ๋ฒ”์œ„๋ฅผ `REQ-011`(manual asset CRUD / month-end snapshot write model)๊ณผ `REQ-012`(manual asset CRUD / snapshot close MCP and operator dashboard alignment) Proposed ์ƒํƒœ๋กœ ์ƒ์„ฑํ–ˆ๋‹ค. - 2026-07-01 09:02 KST: `REQ-011`, `REQ-012`๋ฅผ ์Šน์ธ ๊ฒŒ์ดํŠธ ์žฌ๊ฒ€ํ†  ํ›„ Approved๋กœ ์ „ํ™˜ํ–ˆ๋‹ค. - 2026-07-01 10:52 KST: BAT-009 ๊ตฌํ˜„ยท๊ฒ€์ฆ evidence, full pytest PASS, MCP tool discovery, browser smoke๋ฅผ ๊ทผ๊ฑฐ๋กœ `REQ-011`, `REQ-012`๋ฅผ Implemented๋กœ ์ „ํ™˜ํ–ˆ๋‹ค. +- 2026-07-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๋กœ ์ „ํ™˜ํ–ˆ๋‹ค. diff --git a/jarca_ledger_mcp/adapters/__init__.py b/jarca_ledger_mcp/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jarca_ledger_mcp/adapters/mcp/__init__.py b/jarca_ledger_mcp/adapters/mcp/__init__.py new file mode 100644 index 0000000..3a0d35b --- /dev/null +++ b/jarca_ledger_mcp/adapters/mcp/__init__.py @@ -0,0 +1 @@ +from .server import main, mcp diff --git a/jarca_ledger_mcp/adapters/mcp/server.py b/jarca_ledger_mcp/adapters/mcp/server.py new file mode 100644 index 0000000..9be3a3e --- /dev/null +++ b/jarca_ledger_mcp/adapters/mcp/server.py @@ -0,0 +1,296 @@ +from __future__ import annotations + +from pathlib import Path +from dotenv import load_dotenv +from mcp.server.fastmcp import FastMCP + +from ... import service + +load_dotenv(Path(__file__).resolve().parents[3] / ".env") +mcp = FastMCP("jarca-ledger-mcp") + +@mcp.tool() +def ledger_health_check() -> dict: + """Verify the Jarca Ledger MCP server and SQLite database are reachable.""" + return service.health_check() + +@mcp.tool() +def get_ledger_db_status(include_hash: bool = True, include_integrity: bool = True) -> dict: + """Return non-sensitive DB status evidence: path, size, mtime, checksum, table row counts, and aggregate totals.""" + return service.get_ledger_db_status(include_hash=include_hash, include_integrity=include_integrity) + +@mcp.tool() +def create_ledger_backup(backup_dir: str = "", retention_days: int = 30, retention_count: int = 20) -> dict: + """Create a timestamped ledger DB backup. Restore is intentionally not exposed as an MCP tool.""" + return service.create_ledger_backup(backup_dir=backup_dir or None, retention_days=retention_days, retention_count=retention_count) + +@mcp.tool() +def list_ledger_backups(backup_dir: str = "", include_hash: bool = False, limit: int = 50) -> dict: + """List ledger DB backups with metadata-only evidence.""" + return service.list_ledger_backups(backup_dir=backup_dir or None, include_hash=include_hash, limit=limit) + +@mcp.tool() +def get_ledger_backup_status(file_path: str, include_hash: bool = True, include_integrity: bool = True) -> dict: + """Return metadata-only status and integrity evidence for one backup file.""" + return service.get_ledger_backup_status(file_path, include_hash=include_hash, include_integrity=include_integrity) + +@mcp.tool() +def create_ledger_account(name: str, type: str = "bank", institution: str = "", currency: str = "KRW", active: bool = True, memo: str = "", account_number_masked: str = "", holder_name: str = "", last_balance: int | None = None, last_synced_at: str = "", statement_status: str = "unknown", liquidity_bucket: str = "other") -> dict: + """Create an account/card/cash/investment/loan ledger account.""" + return service.create_ledger_account(name, type, institution, currency, active, memo, account_number_masked, holder_name, last_balance, last_synced_at, statement_status, liquidity_bucket) + +@mcp.tool() +def list_ledger_accounts(active_only: bool = True) -> dict: + """List ledger accounts and cards.""" + return service.list_ledger_accounts(active_only) + +@mcp.tool() +def update_ledger_account(account_id: str, name: str | None = None, type: str | None = None, institution: str | None = None, currency: str | None = None, active: bool | None = None, memo: str | None = None, account_number_masked: str | None = None, holder_name: str | None = None, last_balance: int | None = None, last_synced_at: str | None = None, statement_status: str | None = None, liquidity_bucket: str | None = None) -> dict: + """Update selected account fields, including statement_status/liquidity_bucket for manual reconciliation.""" + return service.update_ledger_account(account_id, name=name, type=type, institution=institution, currency=currency, active=active, memo=memo, account_number_masked=account_number_masked, holder_name=holder_name, last_balance=last_balance, last_synced_at=last_synced_at, statement_status=statement_status, liquidity_bucket=liquidity_bucket) + +@mcp.tool() +def get_asset_liquidity_report() -> dict: + """Return asset liquidity, debt, and net worth from shared account/manual valuation sources.""" + return service.get_asset_liquidity_report() + +@mcp.tool() +def list_manual_assets(active_only: bool = True) -> dict: + """List manual non-transaction assets/liabilities used for net-worth valuation.""" + return service.list_manual_assets(active_only=active_only) + +@mcp.tool() +def create_manual_asset(name: str, valuation_amount: int, valuation_date: str, liquidity_bucket: str, source_note: str, memo: str = "", currency: str = "KRW", active: bool = True) -> dict: + """Create a manual non-transaction asset/liability valuation row.""" + return service.create_manual_asset(name, valuation_amount, valuation_date, liquidity_bucket, source_note=source_note, memo=memo, currency=currency, active=active) + +@mcp.tool() +def update_manual_asset(asset_id: str, name: str | None = None, valuation_amount: int | None = None, valuation_date: str | None = None, liquidity_bucket: str | None = None, source_note: str | None = None, memo: str | None = None, currency: str | None = None, active: bool | None = None) -> dict: + """Update selected fields on a manual asset/liability valuation row.""" + return service.update_manual_asset(asset_id, name=name, valuation_amount=valuation_amount, valuation_date=valuation_date, liquidity_bucket=liquidity_bucket, source_note=source_note, memo=memo, currency=currency, active=active) + +@mcp.tool() +def deactivate_manual_asset(asset_id: str, hard_delete: bool = False) -> dict: + """Deactivate a manual asset/liability by default; hard delete only for exceptional cleanup.""" + return service.deactivate_manual_asset(asset_id, hard_delete=hard_delete) + +@mcp.tool() +def preview_asset_snapshot_close(basis_date: str = "") -> dict: + """Preview a month-end asset snapshot close without mutating the database.""" + return service.preview_asset_snapshot_close(basis_date=basis_date) + +@mcp.tool() +def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "") -> dict: + """Close an asset snapshot for a basis date; same-date re-close appends correction rows.""" + return service.close_asset_snapshot(basis_date=basis_date, source_note=source_note, memo=memo, correction_reason=correction_reason) + +@mcp.tool() +def get_asset_snapshot_report(basis_date: str = "") -> dict: + """Return basis-date asset snapshot/net-worth report with live-authority fallback.""" + return service.get_asset_snapshot_report(basis_date=basis_date) + +@mcp.tool() +def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: int = 12) -> dict: + """Return net-worth timeseries from stored asset snapshot rows.""" + return service.get_net_worth_timeseries(start_date=start_date, end_date=end_date, months=months) + +@mcp.tool() +def get_balance_reconciliation_report(account_id: str = "", baseline_balance: int | None = None, baseline_at: str = "", source_note: str = "", memo: str = "", tolerance: int = 1000) -> dict: + """Preview balance reconciliation for manual_only/partial_import accounts without mutating the DB.""" + return service.get_balance_reconciliation_report(account_id, baseline_balance, baseline_at, source_note, memo, tolerance) + +@mcp.tool() +def create_ledger_transaction(transaction_date: str = "", type: str = "expense", amount: int = 0, category: str = "", subcategory: str = "", merchant: str = "", description: str = "", account_id: str = "", payment_method: str = "", person: str = "", project: str = "", tags: list[str] | str | None = None, is_fixed: bool = False, currency: str = "KRW", posted_date: str = "", source: str = "manual", external_id: str = "", memo: str = "", dedupe: bool = True) -> dict: + """Create an income, expense, transfer, refund, or adjustment transaction. Amount is positive KRW minor unit; type determines direction.""" + return service.create_ledger_transaction(transaction_date, type, amount, category, subcategory, merchant, description, account_id, payment_method, person, project, tags, is_fixed, currency, posted_date, source, external_id, memo, dedupe) + +@mcp.tool() +def list_ledger_transactions(start_date: str = "", end_date: str = "", type: str = "", category: str = "", person: str = "", account_id: str = "", include_deleted: bool = False, limit: int = 100) -> dict: + """List ledger transactions with date/category/person/account filters.""" + return service.list_ledger_transactions(start_date, end_date, type, category, person, account_id, include_deleted, limit) + +@mcp.tool() +def update_ledger_transaction(transaction_id: str, transaction_date: str | None = None, type: str | None = None, amount: int | None = None, category: str | None = None, subcategory: str | None = None, merchant: str | None = None, description: str | None = None, account_id: str | None = None, payment_method: str | None = None, person: str | None = None, project: str | None = None, tags: list[str] | str | None = None, is_fixed: bool | None = None, memo: str | None = None, status: str | None = None) -> dict: + """Update selected fields on a ledger transaction.""" + return service.update_ledger_transaction(transaction_id, transaction_date=transaction_date, type=type, amount=amount, category=category, subcategory=subcategory, merchant=merchant, description=description, account_id=account_id, payment_method=payment_method, person=person, project=project, tags=tags, is_fixed=is_fixed, memo=memo, status=status) + +@mcp.tool() +def delete_ledger_transaction(transaction_id: str, hard_delete: bool = False) -> dict: + """Soft-delete a ledger transaction by default, or permanently delete with hard_delete=true.""" + return service.delete_ledger_transaction(transaction_id, hard_delete) + +@mcp.tool() +def create_ledger_budget(month: str, category: str, amount: int, subcategory: str = "", person: str = "", currency: str = "KRW", memo: str = "") -> dict: + """Create or update a monthly category/person budget.""" + return service.create_ledger_budget(month, category, amount, subcategory, person, currency, memo) + +@mcp.tool() +def list_ledger_budgets(month: str = "") -> dict: + """List ledger budgets, optionally for a month YYYY-MM.""" + return service.list_ledger_budgets(month) + +@mcp.tool() +def get_monthly_cashflow_report(month: str = "") -> dict: + """Return income, expenses, net cashflow, and savings rate for a month.""" + return service.get_monthly_cashflow_report(month) + +@mcp.tool() +def get_category_spending_report(month: str = "", start_date: str = "", end_date: str = "", person: str = "") -> dict: + """Return category/subcategory spending for a month or date range.""" + return service.get_category_spending_report(month, start_date, end_date, person) + +@mcp.tool() +def get_fixed_cost_report(month: str = "") -> dict: + """Return fixed expense transactions and total fixed cost for a month.""" + return service.get_fixed_cost_report(month) + +@mcp.tool() +def get_budget_vs_actual_report(month: str = "") -> dict: + """Compare monthly budgets against actual spending.""" + return service.get_budget_vs_actual_report(month) + +@mcp.tool() +def get_investable_cashflow_report(month: str = "", minimum_cash_buffer: int = 0, planned_extra_expense: int = 0) -> dict: + """Estimate cashflow available for investing after buffer and planned extra expenses.""" + return service.get_investable_cashflow_report(month, minimum_cash_buffer, planned_extra_expense) + +@mcp.tool() +def preview_ledger_csv_import(file_path: str, source: str = "generic") -> dict: + """Preview a UTF-8/UTF-8-BOM CSV import before saving transactions.""" + return service.preview_ledger_csv_import(file_path, source) + +@mcp.tool() +def preview_kb_bank_pdf_import(file_path: str, password: str, preview_limit: int = 20) -> dict: + """Preview transactions from a password-protected KB Kookmin Bank account-history PDF without saving them.""" + return service.preview_kb_bank_pdf_import(file_path, password, preview_limit) + +@mcp.tool() +def import_kb_bank_pdf(file_path: str, password: str, account_id: str = "", skip_duplicates: bool = True, skip_validation: bool = False) -> dict: + """Import transactions from a KB Kookmin Bank account-history PDF into the ledger database.""" + return service.import_kb_bank_pdf(file_path, password, account_id, skip_duplicates, skip_validation=skip_validation) + +@mcp.tool() +def preview_seoulpay_xlsx_import(file_path: str, password: str = "", preview_limit: int = 20) -> dict: + """Preview transactions from a SeoulPay usage-history XLSX before saving them.""" + return service.preview_seoulpay_xlsx_import(file_path, password, preview_limit) + +@mcp.tool() +def import_seoulpay_xlsx(file_path: str, password: str = "", account_id: str = "", skip_duplicates: bool = True) -> dict: + """Import transactions from a SeoulPay usage-history XLSX into the ledger database.""" + return service.import_seoulpay_xlsx(file_path, password, account_id, skip_duplicates) + +@mcp.tool() +def preview_toss_securities_pdf_import(file_path: str, preview_limit: int = 20) -> dict: + """Preview transactions from a Toss Securities transaction-history PDF without saving them.""" + return service.preview_toss_securities_pdf_import(file_path, preview_limit) + +@mcp.tool() +def import_toss_securities_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True) -> dict: + """Import transactions from a Toss Securities transaction-history PDF into the ledger database.""" + return service.import_toss_securities_pdf(file_path, account_id, skip_duplicates) + +@mcp.tool() +def preview_toss_bank_pdf_import(file_path: str, preview_limit: int = 20) -> dict: + """Preview transactions from a Toss Bank transaction-history PDF without saving them.""" + return service.preview_toss_bank_pdf_import(file_path, preview_limit) + +@mcp.tool() +def import_toss_bank_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True) -> dict: + """Import transactions from a Toss Bank transaction-history PDF into the ledger database.""" + return service.import_toss_bank_pdf(file_path, account_id, skip_duplicates) + +@mcp.tool() +def list_ledger_import_batches(source: str = "", include_rolled_back: bool = True, limit: int = 50) -> dict: + """List CSV/PDF/API import batches.""" + return service.list_ledger_import_batches(source, include_rolled_back, limit) + +@mcp.tool() +def rollback_ledger_import_batch(import_batch_id: str, hard_delete: bool = False) -> dict: + """Roll back all transactions created by one import batch. Soft-deletes by default.""" + return service.rollback_ledger_import_batch(import_batch_id, hard_delete) + +@mcp.tool() +def create_category_rule(match_field: str, pattern: str, category: str, subcategory: str = "", transaction_type: str = "", match_type: str = "contains", priority: int = 100, person: str = "", is_fixed: bool | None = None, confidence: float = 1.0, memo: str = "", amount_min: int | None = None, amount_max: int | None = None, day_of_month: int | None = None) -> dict: + """Create or update a ledger transaction categorization rule.""" + return service.create_category_rule(match_field, pattern, category, subcategory, transaction_type, match_type, priority, person, is_fixed, confidence, memo, amount_min, amount_max, day_of_month) + +@mcp.tool() +def seed_default_category_rules(overwrite: bool = False) -> dict: + """Seed default Jarca category rules for common Korean household ledger patterns.""" + return service.seed_default_category_rules(overwrite) + +@mcp.tool() +def list_category_rules(active_only: bool = True) -> dict: + """List transaction categorization rules.""" + return service.list_category_rules(active_only) + +@mcp.tool() +def update_category_rule(rule_id: str, match_field: str, pattern: str, category: str, subcategory: str = "", transaction_type: str = "", match_type: str = "contains", priority: int = 100, person: str = "", is_fixed: bool | None = None, confidence: float = 1.0, memo: str = "", amount_min: int | None = None, amount_max: int | None = None, day_of_month: int | None = None, active: bool = True) -> dict: + """Update an existing ledger transaction categorization rule.""" + return service.update_category_rule(rule_id, match_field, pattern, category, subcategory, transaction_type, match_type, priority, person, is_fixed, confidence, memo, amount_min, amount_max, day_of_month, active) + +@mcp.tool() +def delete_category_rule(rule_id: str) -> dict: + """Delete a transaction categorization rule.""" + return service.delete_category_rule(rule_id) + +@mcp.tool() +def preview_categorization(limit: int = 1000, only_uncategorized: bool = True) -> dict: + """Preview category-rule matches before writing categories to transactions.""" + return service.preview_categorization(limit, only_uncategorized) + +@mcp.tool() +def apply_categorization_rules(limit: int = 1000, only_uncategorized: bool = True) -> dict: + """Apply active category rules to matching ledger transactions.""" + return service.apply_categorization_rules(limit, only_uncategorized) + +@mcp.tool() +def list_uncategorized_transactions(limit: int = 100, min_amount: int = 0) -> dict: + """List active transactions that still have no category.""" + return service.list_uncategorized_transactions(limit, min_amount) + +@mcp.tool() +def get_uncategorized_merchant_summary(limit: int = 50) -> dict: + """Summarize uncategorized transactions by merchant/raw_summary/type for user review.""" + return service.get_uncategorized_merchant_summary(limit) + +@mcp.tool() +def create_ledger_category(category: str, subcategory: str = "", sort_order: int = 100, memo: str = "", cashflow_kind: str = "") -> dict: + """Create or reactivate a ledger category/subcategory used by the dashboard and transactions.""" + return service.create_ledger_category(category, subcategory, sort_order, memo, cashflow_kind) + +@mcp.tool() +def seed_default_ledger_categories(overwrite: bool = False) -> dict: + """Seed default ledger categories and subcategories.""" + return service.seed_default_ledger_categories(overwrite) + +@mcp.tool() +def list_ledger_categories(active_only: bool = True, include_usage: bool = True) -> dict: + """List ledger categories and subcategories, optionally with usage counts.""" + return service.list_ledger_categories(active_only, include_usage) + +@mcp.tool() +def list_cross_transfer_review_queue(limit: int = 300) -> dict: + """List KBโ†”ํ† ์Šค ๊ณ„์ขŒ ์ด์ฒด ํ™•์ธํ•„์š” ํ ํ•ญ๋ชฉ.""" + return service.list_cross_transfer_review_queue(limit) + +@mcp.tool() +def preview_cross_account_transfers(limit: int = 500) -> dict: + """Preview KBโ†”ํ† ์Šค์€ํ–‰ ์ด์ฒด ๋งค์นญ ํ›„๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.""" + return service.preview_cross_account_transfers(limit) + +@mcp.tool() +def run_cross_account_transfer_match(limit: int = 500) -> dict: + """Run KBโ†”ํ† ์Šค์€ํ–‰ ์ž๋™ ์ด์ฒด ๋งค์นญ ๋ฐ ํƒœ๊ทธ/๋ถ„๋ฅ˜ ๋ฐ˜์˜์„ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค.""" + return service.run_cross_account_transfer_match(limit) + +@mcp.tool() +def delete_ledger_category(category: str, subcategory: str = "", hard_delete: bool = False, force: bool = False) -> dict: + """Deactivate or delete a ledger category/subcategory.""" + return service.delete_ledger_category(category, subcategory, hard_delete, force) + +def main() -> None: + mcp.run() + +if __name__ == "__main__": + main() diff --git a/jarca_ledger_mcp/adapters/streamlit/__init__.py b/jarca_ledger_mcp/adapters/streamlit/__init__.py new file mode 100644 index 0000000..444c7ed --- /dev/null +++ b/jarca_ledger_mcp/adapters/streamlit/__init__.py @@ -0,0 +1 @@ +from . import service_gateway diff --git a/jarca_ledger_mcp/adapters/streamlit/service_gateway.py b/jarca_ledger_mcp/adapters/streamlit/service_gateway.py new file mode 100644 index 0000000..f57fa23 --- /dev/null +++ b/jarca_ledger_mcp/adapters/streamlit/service_gateway.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from ...service import ( + apply_categorization_rules, + apply_uncategorized_group_recommendation, + apply_uncategorized_recommendation, + close_asset_snapshot, + create_category_rule, + create_ledger_category, + create_manual_asset, + deactivate_manual_asset, + delete_ledger_category, + find_cancel_original_candidates, + find_self_transfer_candidates, + get_asset_liquidity_report, + get_balance_reconciliation_report, + get_budget_vs_actual_report, + get_monthly_cashflow_report, + health_check, + import_toss_bank_pdf, + import_toss_securities_pdf, + link_cancel_to_original, + link_self_transfer_pair, + list_category_rules, + list_cross_transfer_review_queue, + list_ledger_accounts, + list_ledger_backups, + list_ledger_categories, + list_ledger_import_batches, + list_manual_assets, + list_uncategorized_transactions, + preview_asset_snapshot_close, + preview_categorization, + preview_cross_account_transfers, + preview_toss_bank_pdf_import, + preview_toss_securities_pdf_import, + rollback_ledger_import_batch, + run_cross_account_transfer_match, + seed_default_category_rules, + seed_default_ledger_categories, + update_ledger_account, + update_ledger_transaction, + update_manual_asset, +) diff --git a/jarca_ledger_mcp/application/__init__.py b/jarca_ledger_mcp/application/__init__.py new file mode 100644 index 0000000..201fc91 --- /dev/null +++ b/jarca_ledger_mcp/application/__init__.py @@ -0,0 +1,6 @@ +from . import shared +from .asset_account_services import * +from .categorization_services import * +from .import_transfer_services import * +from .system_services import * +from .transaction_report_services import * diff --git a/jarca_ledger_mcp/application/asset_account_services.py b/jarca_ledger_mcp/application/asset_account_services.py new file mode 100644 index 0000000..9846f5c --- /dev/null +++ b/jarca_ledger_mcp/application/asset_account_services.py @@ -0,0 +1,524 @@ +from __future__ import annotations + +from .shared import * + +def _valuation_policy() -> dict[str, Any]: + return { + "authority_sources": ["accounts.last_balance", "manual_assets"], + "snapshot_role": "historical_record_layer", + "formula": "asset_buckets - loan", + "asset_buckets": ASSET_BUCKETS, + "liability_buckets": LIABILITY_BUCKETS, + "missing_valuation_policy": "exclude_without_transaction_inference", + } + + +def _account_valuation_items(conn: sqlite3.Connection) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]: + rows = conn.execute("SELECT * FROM accounts WHERE active=1 ORDER BY name").fetchall() + included: list[dict[str, Any]] = [] + excluded: list[dict[str, Any]] = [] + invalid: list[dict[str, Any]] = [] + for row in rows: + account = row_to_dict(row) + raw_bucket = str(account.get("liquidity_bucket") or "other") + try: + bucket = normalize_liquidity_bucket(raw_bucket) + except ValueError: + bucket = "other" + invalid.append({"id": account.get("id", ""), "name": account.get("name", ""), "liquidity_bucket": raw_bucket}) + base = { + "id": account.get("id", ""), + "source_id": account.get("id", ""), + "source_type": "account", + "authority_source": "accounts.last_balance", + "name": account.get("name", ""), + "type": account.get("type", ""), + "liquidity_bucket": bucket, + "valuation_date": account.get("last_synced_at", ""), + "last_synced_at": account.get("last_synced_at", ""), + "source_note": account.get("memo", ""), + "memo": account.get("memo", ""), + } + balance = account.get("last_balance") + if balance is None: + excluded.append({**base, "reason": "missing_last_balance"}) + continue + amount = int(balance) + included.append({**base, "amount": amount, "last_balance": amount}) + return included, excluded, invalid + + +def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool = True) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + where = "WHERE active=1" if active_only else "" + rows = conn.execute(f"SELECT * FROM manual_assets {where} ORDER BY name").fetchall() + included: list[dict[str, Any]] = [] + excluded: list[dict[str, Any]] = [] + for row in rows: + asset = row_to_dict(row) + try: + bucket = normalize_liquidity_bucket(asset.get("liquidity_bucket")) + except ValueError: + bucket = "other" + base = { + "id": asset.get("id", ""), + "source_id": asset.get("id", ""), + "source_type": "manual_asset", + "authority_source": "manual_assets", + "name": asset.get("name", ""), + "liquidity_bucket": bucket, + "valuation_date": asset.get("valuation_date", ""), + "source_note": asset.get("source_note", ""), + "memo": asset.get("memo", ""), + "active": asset.get("active", True), + } + value = asset.get("valuation_amount") + if value is None: + excluded.append({**base, "reason": "missing_valuation_amount"}) + continue + included.append({**base, "amount": int(value), "valuation_amount": int(value)}) + return included, excluded + + +def _snapshot_rows_for_latest_run(conn: sqlite3.Connection, basis_date: str) -> tuple[list[sqlite3.Row], dict[str, Any]]: + rows = conn.execute( + "SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC", + (basis_date,), + ).fetchall() + if not rows: + return [], {} + run_rows = [row for row in rows if "snapshot_run_id" in row.keys() and row["snapshot_run_id"]] + if not run_rows: + return rows, {"snapshot_run_id": "", "is_correction": False, "correction_of_run_id": "", "correction_reason": "", "total_snapshot_rows_for_basis_date": len(rows)} + latest = sorted(run_rows, key=lambda row: (int(row["_rowid"]), row["closed_at"] or row["created_at"] or "", row["snapshot_run_id"] or ""))[-1] + latest_run_id = latest["snapshot_run_id"] + selected = [row for row in run_rows if row["snapshot_run_id"] == latest_run_id] + metadata = { + "snapshot_run_id": latest_run_id, + "is_correction": bool(latest["is_correction"]), + "correction_of_run_id": latest["correction_of_run_id"] or "", + "correction_reason": latest["correction_reason"] or "", + "closed_at": latest["closed_at"] or latest["created_at"] or "", + "total_snapshot_rows_for_basis_date": len(rows), + } + return selected, metadata + + +def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str, Any]]: + rows, _metadata = _snapshot_rows_for_latest_run(conn, basis_date) + items: list[dict[str, Any]] = [] + for row in rows: + snap = row_to_dict(row) + try: + bucket = normalize_liquidity_bucket(snap.get("liquidity_bucket")) + except ValueError: + bucket = "other" + items.append( + { + "id": snap.get("id", ""), + "source_id": snap.get("source_id", ""), + "source_type": snap.get("source_type", ""), + "authority_source": "asset_snapshots", + "valuation_source": snap.get("valuation_source", ""), + "name": snap.get("name", ""), + "liquidity_bucket": bucket, + "amount": int(snap.get("amount") or 0), + "valuation_date": snap.get("basis_date", ""), + "source_note": snap.get("source_note", ""), + "memo": snap.get("memo", ""), + "snapshot_run_id": snap.get("snapshot_run_id", ""), + "is_correction": bool(snap.get("is_correction", False)), + "correction_of_run_id": snap.get("correction_of_run_id", ""), + "correction_reason": snap.get("correction_reason", ""), + } + ) + return items + + +def _summarize_valuation_items(included_items: list[dict[str, Any]], excluded_items: list[dict[str, Any]] | None = None) -> dict[str, Any]: + bucket_totals = {bucket: 0 for bucket in ASSET_BUCKET_ORDER} + bucket_counts = {bucket: 0 for bucket in ASSET_BUCKET_ORDER} + for item in included_items: + bucket = normalize_liquidity_bucket(item.get("liquidity_bucket")) + amount = int(item.get("amount") or 0) + bucket_counts[bucket] += 1 + bucket_totals[bucket] += amount + total_assets = sum(bucket_totals[bucket] for bucket in ASSET_BUCKETS) + total_liabilities = sum(abs(bucket_totals[bucket]) for bucket in LIABILITY_BUCKETS) + net_worth = total_assets - total_liabilities + buckets = [] + for bucket in ASSET_BUCKET_ORDER: + amount = bucket_totals[bucket] + denominator = total_liabilities if bucket in LIABILITY_BUCKETS else total_assets + buckets.append( + { + "liquidity_bucket": bucket, + "amount": amount, + "account_count": sum(1 for item in included_items if item.get("source_type") == "account" and item.get("liquidity_bucket") == bucket), + "manual_asset_count": sum(1 for item in included_items if item.get("source_type") == "manual_asset" and item.get("liquidity_bucket") == bucket), + "item_count": bucket_counts[bucket], + "asset_share": round(amount / total_assets, 4) if bucket not in LIABILITY_BUCKETS and total_assets else None, + "liability_share": round(abs(amount) / total_liabilities, 4) if bucket in LIABILITY_BUCKETS and total_liabilities else None, + "share": round(abs(amount) / denominator, 4) if denominator else None, + } + ) + return { + "buckets": buckets, + "bucket_totals": bucket_totals, + "total_assets": total_assets, + "total_liabilities": total_liabilities, + "net_worth": net_worth, + "included_items": included_items, + "excluded_items": excluded_items or [], + "valuation_policy": _valuation_policy(), + } + + +def create_manual_asset(name: str, valuation_amount: int | None, valuation_date: str, liquidity_bucket: str, source_note: str = "", memo: str = "", currency: str = "KRW", active: bool = True, db_path: str | None = None) -> dict[str, Any]: + if not name.strip(): + return fail("validation_error", "manual asset name is required") + try: + bucket = normalize_liquidity_bucket(liquidity_bucket) + amount = int(valuation_amount) if valuation_amount is not None else None + vdate = normalize_date(valuation_date, default_today=True) + except Exception as e: + return fail("validation_error", str(e)) + ts = now_iso() + asset_id = new_id("asset") + with db(db_path) as conn: + conn.execute( + """INSERT INTO manual_assets(id,name,liquidity_bucket,valuation_amount,valuation_date,currency,source_note,memo,active,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?)""", + (asset_id, name.strip(), bucket, amount, vdate, currency, source_note, memo, int(active), ts, ts), + ) + row = conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone() + return ok(manual_asset={**row_to_dict(row), "authority_source": "manual_assets"}) + + +def list_manual_assets(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]: + where = "WHERE active=1" if active_only else "" + with db(db_path) as conn: + rows = conn.execute(f"SELECT * FROM manual_assets {where} ORDER BY name").fetchall() + assets = [{**row_to_dict(row), "authority_source": "manual_assets"} for row in rows] + return ok(manual_assets=assets, count=len(assets), valuation_policy=_valuation_policy()) + + +def update_manual_asset(asset_id: str, name: str | None = None, valuation_amount: int | None = None, valuation_date: str | None = None, liquidity_bucket: str | None = None, source_note: str | None = None, memo: str | None = None, currency: str | None = None, active: bool | None = None, db_path: str | None = None) -> dict[str, Any]: + allowed_updates: list[str] = [] + params: list[Any] = [] + try: + if name is not None: + if not name.strip(): + return fail("validation_error", "manual asset name is required") + allowed_updates.append("name=?") + params.append(name.strip()) + if valuation_amount is not None: + allowed_updates.append("valuation_amount=?") + params.append(int(valuation_amount)) + if valuation_date is not None: + allowed_updates.append("valuation_date=?") + params.append(normalize_date(valuation_date, default_today=True)) + if liquidity_bucket is not None: + allowed_updates.append("liquidity_bucket=?") + params.append(normalize_liquidity_bucket(liquidity_bucket)) + if source_note is not None: + allowed_updates.append("source_note=?") + params.append(source_note) + if memo is not None: + allowed_updates.append("memo=?") + params.append(memo) + if currency is not None: + allowed_updates.append("currency=?") + params.append(currency or "KRW") + if active is not None: + allowed_updates.append("active=?") + params.append(int(bool(active))) + except Exception as e: + return fail("validation_error", str(e)) + if not allowed_updates: + return fail("validation_error", "no fields to update") + allowed_updates.append("updated_at=?") + params.extend([now_iso(), asset_id]) + with db(db_path) as conn: + cur = conn.execute(f"UPDATE manual_assets SET {', '.join(allowed_updates)} WHERE id=?", params) + if cur.rowcount == 0: + return fail("not_found", "manual asset not found") + row = conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone() + return ok(manual_asset={**row_to_dict(row), "authority_source": "manual_assets"}) + + +def deactivate_manual_asset(asset_id: str, hard_delete: bool = False, db_path: str | None = None) -> dict[str, Any]: + with db(db_path) as conn: + if hard_delete: + cur = conn.execute("DELETE FROM manual_assets WHERE id=?", (asset_id,)) + else: + cur = conn.execute("UPDATE manual_assets SET active=0, updated_at=? WHERE id=?", (now_iso(), asset_id)) + if cur.rowcount == 0: + return fail("not_found", "manual asset not found") + row = None if hard_delete else conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone() + payload = {"manual_asset_id": asset_id, "deactivated": True, "hard_delete": hard_delete} + if row is not None: + payload["manual_asset"] = {**row_to_dict(row), "authority_source": "manual_assets"} + return ok(**payload) + + +def _snapshot_preview_from_conn(conn: sqlite3.Connection, basis: str) -> dict[str, Any]: + account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn) + manual_items, manual_excluded = _manual_asset_valuation_items(conn) + included = account_items + manual_items + excluded = account_excluded + manual_excluded + summary = _summarize_valuation_items(included, excluded) + existing_rows = conn.execute("SELECT COUNT(*) FROM asset_snapshots WHERE basis_date=?", (basis,)).fetchone()[0] + warnings: list[dict[str, Any]] = [] + for item in excluded: + warnings.append({"code": item.get("reason", "excluded_item"), "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "name": item.get("name", "")}) + for item in included: + item_date = str(item.get("valuation_date") or "")[:10] + if item_date and item_date < basis: + warnings.append({"code": "stale_valuation", "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "name": item.get("name", ""), "valuation_date": item_date, "basis_date": basis}) + for item in invalid_bucket_accounts: + warnings.append({"code": "invalid_liquidity_bucket", **item}) + if existing_rows: + warnings.append({"code": "existing_snapshot_rows", "basis_date": basis, "snapshot_row_count": int(existing_rows), "policy": "append_only_correction_required"}) + return { + "basis_date": basis, + "source": "live_authority", + "is_closed_snapshot": False, + "snapshot_count": 0, + "existing_snapshot_row_count": int(existing_rows), + "would_be_correction": bool(existing_rows), + "warnings": warnings, + **summary, + } + + +def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = None) -> dict[str, Any]: + try: + basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat() + except Exception as e: + return fail("validation_error", str(e)) + with db(db_path) as conn: + preview = _snapshot_preview_from_conn(conn, basis) + return ok(**preview) + + +def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "", db_path: str | None = None) -> dict[str, Any]: + try: + basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat() + except Exception as e: + return fail("validation_error", str(e)) + ts = now_iso() + run_id = new_id("snaprun") + with db(db_path) as conn: + preview = _snapshot_preview_from_conn(conn, basis) + included = preview["included_items"] + latest_rows, latest_meta = _snapshot_rows_for_latest_run(conn, basis) + is_correction = bool(preview["existing_snapshot_row_count"]) + if is_correction and not correction_reason.strip(): + return fail("validation_error", "correction_reason is required when closing a basis_date that already has snapshot rows", basis_date=basis, existing_snapshot_row_count=preview["existing_snapshot_row_count"]) + correction_of_run_id = latest_meta.get("snapshot_run_id", "") if latest_rows else "" + snapshot_ids: list[str] = [] + for item in included: + snapshot_id = new_id("snap") + row_note = source_note or item.get("source_note", "") or "month-end close" + row_memo = memo or item.get("memo", "") + conn.execute( + """INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,snapshot_run_id,correction_of_run_id,correction_reason,is_correction,closed_at,created_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + snapshot_id, + basis, + item.get("source_type", ""), + item.get("source_id", item.get("id", "")), + item.get("name", ""), + int(item.get("amount") or 0), + item.get("currency", "KRW"), + normalize_liquidity_bucket(item.get("liquidity_bucket")), + item.get("authority_source", item.get("source_type", "")), + row_note, + row_memo, + run_id, + correction_of_run_id, + correction_reason.strip(), + int(is_correction), + ts, + ts, + ), + ) + snapshot_ids.append(snapshot_id) + report_items = _snapshot_items(conn, basis) + summary = _summarize_valuation_items(report_items, []) + return ok( + basis_date=basis, + source="snapshot", + is_closed_snapshot=True, + snapshot_run_id=run_id, + correction_of_run_id=correction_of_run_id, + correction_reason=correction_reason.strip(), + is_correction=is_correction, + closed_at=ts, + inserted_count=len(snapshot_ids), + inserted_snapshot_ids=snapshot_ids, + warnings=preview["warnings"], + **summary, + ) + + +def create_asset_snapshot(basis_date: str, source_type: str, source_id: str, name: str, amount: int | None, liquidity_bucket: str, valuation_source: str = "", source_note: str = "", memo: str = "", currency: str = "KRW", db_path: str | None = None) -> dict[str, Any]: + if source_type not in {"account", "manual_asset"}: + return fail("validation_error", "source_type must be one of ['account', 'manual_asset']") + if not name.strip(): + return fail("validation_error", "snapshot item name is required") + try: + basis = normalize_date(basis_date, default_today=True) + bucket = normalize_liquidity_bucket(liquidity_bucket) + amount_int = int(amount) if amount is not None else None + except Exception as e: + return fail("validation_error", str(e)) + ts = now_iso() + snapshot_id = new_id("snap") + with db(db_path) as conn: + conn.execute( + """INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,created_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""", + (snapshot_id, basis, source_type, source_id, name.strip(), amount_int, currency, bucket, valuation_source or source_type, source_note, memo, ts), + ) + row = conn.execute("SELECT * FROM asset_snapshots WHERE id=?", (snapshot_id,)).fetchone() + return ok(asset_snapshot=row_to_dict(row)) + + +def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None) -> dict[str, Any]: + basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat() + with db(db_path) as conn: + rows, close_metadata = _snapshot_rows_for_latest_run(conn, basis) + snapshots = _snapshot_items(conn, basis) if rows else [] + if snapshots: + summary = _summarize_valuation_items(snapshots, []) + return ok( + basis_date=basis, + source="asset_snapshots", + is_closed_snapshot=True, + snapshot_count=len(snapshots), + close_metadata=close_metadata, + **summary, + ) + account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn) + manual_items, manual_excluded = _manual_asset_valuation_items(conn) + included = account_items + manual_items + excluded = account_excluded + manual_excluded + summary = _summarize_valuation_items(included, excluded) + return ok( + basis_date=basis, + source="live_authority", + is_closed_snapshot=False, + snapshot_count=0, + invalid_bucket_accounts=invalid_bucket_accounts, + **summary, + ) + + +def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: int = 12, db_path: str | None = None) -> dict[str, Any]: + try: + months_int = max(1, min(int(months), 120)) + end_norm = normalize_date(end_date, default_today=True) if end_date else current_kst_date().isoformat() + if start_date: + start_norm = normalize_date(start_date) + else: + end_dt = date.fromisoformat(end_norm) + start_month = end_dt.month - months_int + 1 + year = end_dt.year + (start_month - 1) // 12 + month = (start_month - 1) % 12 + 1 + start_norm = date(year, month, 1).isoformat() + except Exception as e: + return fail("validation_error", str(e)) + with db(db_path) as conn: + basis_rows = conn.execute( + """SELECT DISTINCT basis_date FROM asset_snapshots + WHERE basis_date>=? AND basis_date<=? + ORDER BY basis_date ASC""", + (start_norm, end_norm), + ).fetchall() + points = [] + for row in basis_rows: + basis = row["basis_date"] + items = _snapshot_items(conn, basis) + summary = _summarize_valuation_items(items, []) + points.append( + { + "basis_date": basis, + "total_assets": summary["total_assets"], + "total_liabilities": summary["total_liabilities"], + "net_worth": summary["net_worth"], + "source": "asset_snapshots", + "item_count": len(items), + } + ) + return ok(start_date=start_norm, end_date=end_norm, months=months_int, points=points, count=len(points), valuation_policy=_valuation_policy()) + + +def get_asset_liquidity_report(db_path: str | None = None) -> dict[str, Any]: + """Summarize assets/liabilities from shared valuation authority sources. + + Existing REQ-005 keys are preserved. When manual_assets is empty, totals and + source remain equivalent to the original accounts.last_balance behavior. + """ + with db(db_path) as conn: + account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn) + manual_items, manual_excluded = _manual_asset_valuation_items(conn) + included = account_items + manual_items + excluded = account_excluded + manual_excluded + summary = _summarize_valuation_items(included, excluded) + source_accounts = [item for item in included if item.get("source_type") == "account"] + source = "accounts.last_balance" if not manual_items and not manual_excluded else "accounts.last_balance+manual_assets" + return ok( + buckets=summary["buckets"], + accounts=source_accounts, + manual_assets=manual_items, + total_assets=summary["total_assets"], + total_liabilities=summary["total_liabilities"], + net_worth=summary["net_worth"], + missing_balance_accounts=[ + {"id": item.get("id", ""), "name": item.get("name", ""), "liquidity_bucket": item.get("liquidity_bucket", "")} + for item in account_excluded + ], + missing_valuation_items=excluded, + invalid_bucket_accounts=invalid_bucket_accounts, + manual_asset_count=len(manual_items), + included_items=included, + excluded_items=excluded, + valuation_policy=summary["valuation_policy"], + source=source, + ) + + +def update_ledger_account(account_id: str, db_path: str | None = None, **fields: Any) -> dict[str, Any]: + allowed = {"name", "type", "institution", "currency", "active", "memo", "account_number_masked", "holder_name", "last_balance", "last_synced_at", "statement_status", "liquidity_bucket"} + updates: list[str] = [] + params: list[Any] = [] + for k, v in fields.items(): + if k not in allowed or v is None: + continue + try: + if k == "type" and v not in VALID_ACCOUNT_TYPES: + return fail("validation_error", f"type must be one of {sorted(VALID_ACCOUNT_TYPES)}") + if k == "statement_status": + v = normalize_statement_status(str(v)) + if k == "liquidity_bucket": + v = normalize_liquidity_bucket(str(v)) + if k == "active": + v = int(bool(v)) + if k == "last_balance" and v != "": + v = int(v) + except Exception as e: + return fail("validation_error", str(e)) + updates.append(f"{k}=?") + params.append(v) + if not updates: + return fail("validation_error", "no fields to update") + updates.append("updated_at=?") + params.extend([now_iso(), account_id]) + with db(db_path) as conn: + cur = conn.execute(f"UPDATE accounts SET {', '.join(updates)} WHERE id=?", params) + if cur.rowcount == 0: + return fail("not_found", "account not found") + row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone() + return ok(account=row_to_dict(row)) diff --git a/jarca_ledger_mcp/application/categorization_services.py b/jarca_ledger_mcp/application/categorization_services.py new file mode 100644 index 0000000..881bfce --- /dev/null +++ b/jarca_ledger_mcp/application/categorization_services.py @@ -0,0 +1,703 @@ +from __future__ import annotations + +from .shared import * + +RULE_FIELDS = {"merchant", "counterparty", "description", "payment_method", "raw_summary", "branch", "memo"} +RULE_MATCH_TYPES = {"contains", "equals", "regex", "starts_with", "ends_with"} + +DEFAULT_CATEGORY_RULES: list[dict[str, Any]] = [ + {"priority": 10, "match_field": "merchant", "pattern": "๊ธ‰์—ฌ", "transaction_type": "income", "category": "์ˆ˜์ž…", "subcategory": "๊ธ‰์—ฌ"}, + {"priority": 10, "match_field": "merchant", "pattern": "์ด์ž", "transaction_type": "income", "category": "์ˆ˜์ž…", "subcategory": "์ด์ž"}, + {"priority": 10, "match_field": "merchant", "pattern": "ํ™˜๊ธ‰", "transaction_type": "income", "category": "์ˆ˜์ž…", "subcategory": "ํ™˜๊ธ‰"}, + {"priority": 20, "match_field": "raw_summary", "match_type": "equals", "pattern": "๊ตญ๋ฏผ์นด๋“œ", "transaction_type": "expense", "category": "๊ธˆ์œต", "subcategory": "์นด๋“œ๋Œ€๊ธˆ", "is_fixed": 1}, + {"priority": 20, "match_field": "merchant", "pattern": "KB์นด๋“œ์ถœ๊ธˆ", "transaction_type": "expense", "category": "๊ธˆ์œต", "subcategory": "์นด๋“œ๋Œ€๊ธˆ", "is_fixed": 1}, + {"priority": 30, "match_field": "merchant", "pattern": "SKํ…”๋ ˆ์ฝค", "transaction_type": "expense", "category": "ํ†ต์‹ ", "subcategory": "ํœด๋Œ€ํฐ", "is_fixed": 1}, + {"priority": 30, "match_field": "merchant", "pattern": "KTํ†ต์‹ ", "transaction_type": "expense", "category": "ํ†ต์‹ ", "subcategory": "ํ†ต์‹ ์š”๊ธˆ", "is_fixed": 1}, + {"priority": 40, "match_field": "merchant", "pattern": "ํ•™์›", "transaction_type": "expense", "category": "๊ต์œก", "subcategory": "ํ•™์›"}, + {"priority": 40, "match_field": "merchant", "pattern": "๊ต์Šต์†Œ", "transaction_type": "expense", "category": "๊ต์œก", "subcategory": "์˜ˆ์ฒด๋Šฅ"}, + {"priority": 40, "match_field": "merchant", "pattern": "์—ฐ์„ธ๋Œ€ํ•™๊ต", "transaction_type": "expense", "category": "์˜๋ฃŒ", "subcategory": "๋ณ‘์›"}, + {"priority": 40, "match_field": "merchant", "pattern": "๋ณ‘์›", "transaction_type": "expense", "category": "์˜๋ฃŒ", "subcategory": "๋ณ‘์›"}, + {"priority": 40, "match_field": "merchant", "pattern": "์˜์›", "transaction_type": "expense", "category": "์˜๋ฃŒ", "subcategory": "๋ณ‘์›"}, + {"priority": 40, "match_field": "merchant", "pattern": "์น˜๊ณผ", "transaction_type": "expense", "category": "์˜๋ฃŒ", "subcategory": "์น˜๊ณผ"}, + {"priority": 40, "match_field": "merchant", "pattern": "์•ฝ๊ตญ", "transaction_type": "expense", "category": "์˜๋ฃŒ", "subcategory": "์•ฝ๊ตญ"}, + {"priority": 50, "match_field": "merchant", "pattern": "์ด๋งˆํŠธ", "transaction_type": "expense", "category": "์‹๋น„", "subcategory": "๋งˆํŠธ"}, + {"priority": 50, "match_field": "merchant", "pattern": "์˜ค์•„์‹œ์Šค๋งˆ์ผ“", "transaction_type": "expense", "category": "์‹๋น„", "subcategory": "๋งˆํŠธ"}, + {"priority": 50, "match_field": "merchant", "pattern": "์ƒํ˜‘", "transaction_type": "expense", "category": "์‹๋น„", "subcategory": "๋งˆํŠธ"}, + {"priority": 50, "match_field": "merchant", "pattern": "์Šคํƒ€๋ฒ…์Šค", "transaction_type": "expense", "category": "์‹๋น„", "subcategory": "์นดํŽ˜"}, + {"priority": 50, "match_field": "merchant", "pattern": "๋ฉ”๊ฐ€MGC", "transaction_type": "expense", "category": "์‹๋น„", "subcategory": "์นดํŽ˜"}, + {"priority": 50, "match_field": "merchant", "pattern": "๋งค๋จธ๋“œ", "transaction_type": "expense", "category": "์‹๋น„", "subcategory": "์นดํŽ˜"}, + {"priority": 50, "match_field": "merchant", "pattern": "์ปคํ”ผ", "transaction_type": "expense", "category": "์‹๋น„", "subcategory": "์นดํŽ˜"}, + {"priority": 60, "match_field": "merchant", "pattern": "KFC", "transaction_type": "expense", "category": "์‹๋น„", "subcategory": "์™ธ์‹"}, + {"priority": 60, "match_field": "merchant", "pattern": "์Šค์‹œ", "transaction_type": "expense", "category": "์‹๋น„", "subcategory": "์™ธ์‹"}, + {"priority": 60, "match_field": "merchant", "pattern": "์Œ€๊ตญ์ˆ˜", "transaction_type": "expense", "category": "์‹๋น„", "subcategory": "์™ธ์‹"}, + {"priority": 60, "match_field": "merchant", "pattern": "์งœ์žฅ", "transaction_type": "expense", "category": "์‹๋น„", "subcategory": "์™ธ์‹"}, + {"priority": 80, "match_field": "raw_summary", "match_type": "equals", "pattern": "์Šค๋งˆํŠธ์ถœ๊ธˆ", "category": "์ด์ฒด", "subcategory": "ํ˜„๊ธˆ/๊ณ„์ขŒ์ด๋™"}, + {"priority": 80, "match_field": "raw_summary", "match_type": "equals", "pattern": "ATM์ž…๊ธˆ", "category": "์ด์ฒด", "subcategory": "ํ˜„๊ธˆ/๊ณ„์ขŒ์ด๋™"}, +] + + +def _rule_matches(rule: dict[str, Any], tx: dict[str, Any]) -> bool: + if rule.get("transaction_type") and rule.get("transaction_type") != tx.get("type", ""): + return False + amount = int(tx.get("amount") or 0) + if rule.get("amount_min") is not None and amount < int(rule["amount_min"]): + return False + if rule.get("amount_max") is not None and amount > int(rule["amount_max"]): + return False + if rule.get("day_of_month") is not None: + tx_date = tx.get("transaction_datetime") or tx.get("transaction_date") or "" + try: + if datetime.fromisoformat(str(tx_date)[:10]).day != int(rule["day_of_month"]): + return False + except ValueError: + return False + value = str(tx.get(rule["match_field"], "") or "") + pattern = str(rule.get("pattern", "")) + match_type = rule.get("match_type") or "contains" + if match_type == "contains": + return pattern.lower() in value.lower() + if match_type == "equals": + return value == pattern + if match_type == "starts_with": + return value.startswith(pattern) + if match_type == "ends_with": + return value.endswith(pattern) + if match_type == "regex": + return re.search(pattern, value) is not None + return False + + +def create_category_rule(match_field: str, pattern: str, category: str, subcategory: str = "", transaction_type: str = "", match_type: str = "contains", priority: int = 100, person: str = "", is_fixed: bool | None = None, confidence: float = 1.0, memo: str = "", amount_min: int | None = None, amount_max: int | None = None, day_of_month: int | None = None, db_path: str | None = None) -> dict[str, Any]: + if match_field not in RULE_FIELDS: + return fail("validation_error", f"match_field must be one of {sorted(RULE_FIELDS)}") + if match_type not in RULE_MATCH_TYPES: + return fail("validation_error", f"match_type must be one of {sorted(RULE_MATCH_TYPES)}") + if transaction_type and transaction_type not in VALID_TRANSACTION_TYPES: + return fail("validation_error", f"transaction_type must be empty or one of {sorted(VALID_TRANSACTION_TYPES)}") + if day_of_month is not None and not 1 <= int(day_of_month) <= 31: + return fail("validation_error", "day_of_month must be between 1 and 31") + if amount_min is not None and amount_max is not None and int(amount_min) > int(amount_max): + return fail("validation_error", "amount_min must be <= amount_max") + if not pattern or not category: + return fail("validation_error", "pattern and category are required") + ts=now_iso(); rule_id=new_id("rule") + with db(db_path) as conn: + conn.execute("""INSERT INTO category_rules(id,priority,match_field,match_type,pattern,amount_min,amount_max,day_of_month,transaction_type,category,subcategory,person,is_fixed,confidence,active,memo,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(match_field,match_type,pattern,transaction_type,category,subcategory,person) DO UPDATE SET priority=excluded.priority,amount_min=excluded.amount_min,amount_max=excluded.amount_max,day_of_month=excluded.day_of_month,is_fixed=excluded.is_fixed,confidence=excluded.confidence,active=1,memo=excluded.memo,updated_at=excluded.updated_at""", + (rule_id, int(priority), match_field, match_type, pattern, None if amount_min is None else int(amount_min), None if amount_max is None else int(amount_max), None if day_of_month is None else int(day_of_month), transaction_type, category, subcategory, person, None if is_fixed is None else int(is_fixed), float(confidence), 1, memo, ts, ts)) + row=conn.execute("SELECT * FROM category_rules WHERE match_field=? AND match_type=? AND pattern=? AND transaction_type=? AND category=? AND subcategory=? AND person=?", (match_field, match_type, pattern, transaction_type, category, subcategory, person)).fetchone() + return ok(rule=row_to_dict(row)) + + +def seed_default_category_rules(overwrite: bool = False, db_path: str | None = None) -> dict[str, Any]: + created=0; skipped=0 + with db(db_path) as conn: + if overwrite: + conn.execute("UPDATE category_rules SET active=0 WHERE memo LIKE 'default:%'") + for rule in DEFAULT_CATEGORY_RULES: + memo = "default: initial Jarca ledger categorization" + res=create_category_rule(memo=memo, db_path=db_path, **rule) + if res.get("success"): + created += 1 + else: + skipped += 1 + return ok(created_or_updated=created, skipped=skipped) + + +def list_category_rules(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]: + sql="SELECT * FROM category_rules" + (" WHERE active=1" if active_only else "") + " ORDER BY priority, id" + with db(db_path) as conn: + rows=conn.execute(sql).fetchall() + return ok(rules=[row_to_dict(r) for r in rows], count=len(rows)) + + +def update_category_rule(rule_id: str, match_field: str, pattern: str, category: str, subcategory: str = "", transaction_type: str = "", match_type: str = "contains", priority: int = 100, person: str = "", is_fixed: bool | None = None, confidence: float = 1.0, memo: str = "", amount_min: int | None = None, amount_max: int | None = None, day_of_month: int | None = None, active: bool = True, db_path: str | None = None) -> dict[str, Any]: + if match_field not in RULE_FIELDS: + return fail("validation_error", f"match_field must be one of {sorted(RULE_FIELDS)}") + if match_type not in RULE_MATCH_TYPES: + return fail("validation_error", f"match_type must be one of {sorted(RULE_MATCH_TYPES)}") + if transaction_type and transaction_type not in VALID_TRANSACTION_TYPES: + return fail("validation_error", f"transaction_type must be empty or one of {sorted(VALID_TRANSACTION_TYPES)}") + if day_of_month is not None and not 1 <= int(day_of_month) <= 31: + return fail("validation_error", "day_of_month must be between 1 and 31") + if amount_min is not None and amount_max is not None and int(amount_min) > int(amount_max): + return fail("validation_error", "amount_min must be <= amount_max") + if not pattern or not category: + return fail("validation_error", "pattern and category are required") + ts = now_iso() + with db(db_path) as conn: + existing = conn.execute("SELECT * FROM category_rules WHERE id=?", (rule_id,)).fetchone() + if not existing: + return fail("not_found", f"category rule not found: {rule_id}") + duplicate = conn.execute( + "SELECT id FROM category_rules WHERE match_field=? AND match_type=? AND pattern=? AND transaction_type=? AND category=? AND subcategory=? AND person=? AND id<>?", + (match_field, match_type, pattern, transaction_type, category, subcategory, person, rule_id), + ).fetchone() + if duplicate: + return fail("conflict", f"another rule already exists with the same unique fields: {duplicate['id']}") + conn.execute( + """UPDATE category_rules + SET priority=?, match_field=?, match_type=?, pattern=?, amount_min=?, amount_max=?, day_of_month=?, + transaction_type=?, category=?, subcategory=?, person=?, is_fixed=?, confidence=?, active=?, memo=?, updated_at=? + WHERE id=?""", + ( + int(priority), match_field, match_type, pattern, + None if amount_min is None else int(amount_min), + None if amount_max is None else int(amount_max), + None if day_of_month is None else int(day_of_month), + transaction_type, category, subcategory, person, + None if is_fixed is None else int(is_fixed), + float(confidence), int(bool(active)), memo, ts, rule_id, + ), + ) + row = conn.execute("SELECT * FROM category_rules WHERE id=?", (rule_id,)).fetchone() + return ok(rule=row_to_dict(row)) + + +def delete_category_rule(rule_id: str, db_path: str | None = None) -> dict[str, Any]: + if not rule_id: + return fail("validation_error", "rule_id is required") + with db(db_path) as conn: + existing = conn.execute("SELECT * FROM category_rules WHERE id=?", (rule_id,)).fetchone() + if not existing: + return fail("not_found", f"category rule not found: {rule_id}") + conn.execute("DELETE FROM category_rules WHERE id=?", (rule_id,)) + return ok(deleted_rule_id=rule_id) + + +def _load_active_rules(conn: Any) -> list[dict[str, Any]]: + return [row_to_dict(r) for r in conn.execute("SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id").fetchall()] + + +def _categorization_preview_data(limit: int = 1000, only_uncategorized: bool = True, db_path: str | None = None) -> dict[str, Any]: + with db(db_path) as conn: + rules = _load_active_rules(conn) + where = "WHERE status='active'" + (" AND category=''" if only_uncategorized else "") + rows = [row_to_dict(r) for r in conn.execute( + f"SELECT * FROM transactions {where} ORDER BY transaction_datetime, transaction_date LIMIT ?", + (max(1, min(int(limit), 5000)),), + ).fetchall()] + matches = [] + unmatched = [] + for tx in rows: + match = next((r for r in rules if _rule_matches(r, tx)), None) + if match: + matches.append({ + "transaction_id": tx["id"], + "merchant": tx["merchant"], + "type": tx["type"], + "amount": tx["amount"], + "transaction_datetime": tx.get("transaction_datetime", ""), + "category": match["category"], + "subcategory": match["subcategory"], + "person": match["person"], + "is_fixed": match["is_fixed"], + "rule_id": match["id"], + "rule_pattern": match["pattern"], + }) + else: + unmatched.append({ + "transaction_id": tx["id"], + "merchant": tx["merchant"], + "raw_summary": tx.get("raw_summary", ""), + "type": tx["type"], + "amount": tx["amount"], + "transaction_datetime": tx.get("transaction_datetime", ""), + }) + by_category = {} + for m in matches: + key = f"{m['category']}/{m['subcategory']}" if m['subcategory'] else m['category'] + by_category.setdefault(key, {"count": 0, "amount": 0}) + by_category[key]["count"] += 1 + by_category[key]["amount"] += int(m["amount"]) + return { + "rows": rows, + "matches": matches, + "unmatched": unmatched, + "by_category": by_category, + } + + +def preview_categorization(limit: int = 1000, only_uncategorized: bool = True, db_path: str | None = None) -> dict[str, Any]: + data = _categorization_preview_data(limit, only_uncategorized, db_path) + return ok( + scanned=len(data["rows"]), + matched_count=len(data["matches"]), + unmatched_count=len(data["unmatched"]), + by_category=data["by_category"], + matches=data["matches"][:100], + unmatched=data["unmatched"][:100], + ) + + + +def _category_rule_match_rows(rule_id: str, limit: int = 10000, db_path: str | None = None) -> dict[str, Any]: + if not rule_id: + return fail("validation_error", "rule_id is required") + row_limit = max(1, min(int(limit), 50000)) + with db(db_path) as conn: + rule_row = conn.execute("SELECT * FROM category_rules WHERE id=?", (rule_id,)).fetchone() + if not rule_row: + return fail("not_found", f"category rule not found: {rule_id}") + rule = row_to_dict(rule_row) + rows = [row_to_dict(r) for r in conn.execute( + """SELECT * FROM transactions + WHERE status='active' + ORDER BY transaction_datetime DESC, transaction_date DESC, created_at DESC + LIMIT ?""", + (row_limit,), + ).fetchall()] + + matched = [] + uncategorized = [] + for tx in rows: + if not _rule_matches(rule, tx): + continue + item = { + "transaction_id": tx["id"], + "transaction_date": tx.get("transaction_date", ""), + "transaction_datetime": tx.get("transaction_datetime", ""), + "type": tx.get("type", ""), + "amount": tx.get("amount"), + "merchant": tx.get("merchant", ""), + "raw_summary": tx.get("raw_summary", ""), + "counterparty": tx.get("counterparty", ""), + "current_category": tx.get("category", "") or "", + "current_subcategory": tx.get("subcategory", "") or "", + "new_category": rule.get("category", ""), + "new_subcategory": rule.get("subcategory", ""), + "rule_id": rule_id, + "rule_pattern": rule.get("pattern", ""), + } + matched.append(item) + if not (tx.get("category") or ""): + uncategorized.append(item) + return ok(rule=rule, scanned=len(rows), matched=matched, uncategorized=uncategorized) + + +def preview_category_rule_matches(rule_id: str, limit: int = 10000, sample_limit: int = 3, db_path: str | None = None) -> dict[str, Any]: + data = _category_rule_match_rows(rule_id, limit, db_path) + if not data.get("success"): + return data + sample_n = max(1, min(int(sample_limit), 20)) + return ok( + rule=data["rule"], + scanned=data["scanned"], + matched_count=len(data["matched"]), + uncategorized_matched_count=len(data["uncategorized"]), + sample=data["matched"][:sample_n], + ) + + +def apply_category_rule(rule_id: str, limit: int = 10000, db_path: str | None = None) -> dict[str, Any]: + data = _category_rule_match_rows(rule_id, limit, db_path) + if not data.get("success"): + return data + rule = data["rule"] + ts = now_iso() + applied = 0 + with db(db_path) as conn: + for m in data["uncategorized"]: + conn.execute( + """UPDATE transactions + SET category=?, subcategory=?, + person=CASE WHEN ?<>'' THEN ? ELSE person END, + is_fixed=CASE WHEN ? IS NULL THEN is_fixed ELSE ? END, + updated_at=? + WHERE id=? AND status='active' AND COALESCE(category,'')=''""", + ( + rule.get("category", ""), rule.get("subcategory", ""), + rule.get("person", ""), rule.get("person", ""), + rule.get("is_fixed"), rule.get("is_fixed"), + ts, m["transaction_id"], + ), + ) + applied += 1 + return ok( + applied_count=applied, + matched_count=len(data["matched"]), + uncategorized_matched_count=len(data["uncategorized"]), + rule=rule, + ) + +def apply_categorization_rules(limit: int = 1000, only_uncategorized: bool = True, db_path: str | None = None) -> dict[str, Any]: + data = _categorization_preview_data(limit, only_uncategorized, db_path) + preview = ok( + scanned=len(data["rows"]), + matched_count=len(data["matches"]), + unmatched_count=len(data["unmatched"]), + by_category=data["by_category"], + matches=data["matches"][:100], + unmatched=data["unmatched"][:100], + ) + applied = 0 + ts = now_iso() + with db(db_path) as conn: + for m in data["matches"]: + conn.execute("UPDATE transactions SET category=?, subcategory=?, person=CASE WHEN ?<>'' THEN ? ELSE person END, is_fixed=CASE WHEN ? IS NULL THEN is_fixed ELSE ? END, updated_at=? WHERE id=?", (m["category"], m["subcategory"], m["person"], m["person"], m["is_fixed"], m["is_fixed"], ts, m["transaction_id"])) + applied += 1 + return ok(applied_count=applied, preview=preview) + + +def list_uncategorized_transactions(limit: int = 100, min_amount: int = 0, db_path: str | None = None) -> dict[str, Any]: + with db(db_path) as conn: + rows=conn.execute("""SELECT * FROM transactions WHERE status='active' AND COALESCE(category,'')='' AND amount>=? ORDER BY amount DESC, transaction_datetime DESC LIMIT ?""", (int(min_amount), max(1,min(int(limit),1000)))).fetchall() + return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows)) + + +def get_uncategorized_merchant_summary(limit: int = 50, db_path: str | None = None) -> dict[str, Any]: + with db(db_path) as conn: + rows=conn.execute("""SELECT merchant, raw_summary, type, COUNT(*) count, SUM(amount) total_amount, MAX(amount) max_amount + FROM transactions WHERE status='active' AND COALESCE(category,'')='' + GROUP BY merchant, raw_summary, type ORDER BY total_amount DESC LIMIT ?""", (max(1,min(int(limit),500)),)).fetchall() + return ok(items=[row_to_dict(r) for r in rows], count=len(rows)) + + +def _recommend_category_for_transaction(tx: dict[str, Any], rules: list[dict[str, Any]], history: dict[tuple[str, str], list[dict[str, Any]]]) -> dict[str, Any]: + """Recommend a category for one transaction using deterministic, explainable signals.""" + for rule in rules: + try: + if _rule_matches(rule, tx): + return { + "category": rule.get("category", ""), + "subcategory": rule.get("subcategory", ""), + "confidence": round(float(rule.get("confidence") or 1.0), 2), + "source": "rule", + "reason": f"ํ™œ์„ฑ ๊ทœ์น™ `{rule.get('pattern', '')}` ๋งค์นญ", + "rule_id": rule.get("id", ""), + } + except re.error: + continue + + key = (tx.get("merchant", "") or "", tx.get("type", "") or "") + candidates = history.get(key, []) + if candidates: + best = candidates[0] + count = int(best.get("count") or 0) + confidence = min(0.9, 0.55 + min(count, 5) * 0.07) + return { + "category": best.get("category", ""), + "subcategory": best.get("subcategory", ""), + "confidence": round(confidence, 2), + "source": "history", + "reason": f"๊ฐ™์€ ์‚ฌ์šฉ์ฒ˜์˜ ๊ธฐ์กด ๋ถ„๋ฅ˜ {count}๊ฑด", + "rule_id": "", + } + + return {"category": "", "subcategory": "", "confidence": 0.0, "source": "none", "reason": "์ถ”์ฒœ ๊ทผ๊ฑฐ ์—†์Œ", "rule_id": ""} + + +def get_uncategorized_workbench( + limit: int = 100, + min_amount: int = 0, + sort_by: str = "amount_desc", + db_path: str | None = None, +) -> dict[str, Any]: + row_limit = max(1, min(int(limit), 1000)) + minimum_amount = int(min_amount) + order_by_map = { + "amount_desc": "amount DESC, transaction_datetime DESC, transaction_date DESC, created_at DESC", + "date_desc": "transaction_datetime DESC, transaction_date DESC, created_at DESC, amount DESC", + } + order_by = order_by_map.get(sort_by, order_by_map["amount_desc"]) + with db(db_path) as conn: + tx_rows = [row_to_dict(r) for r in conn.execute( + f"""SELECT id, transaction_date, transaction_datetime, type, amount, currency, merchant, raw_summary, + counterparty, account_id, payment_method, person, is_fixed, source, memo, created_at + FROM transactions + WHERE status='active' AND COALESCE(category,'')='' AND amount>=? + ORDER BY {order_by} + LIMIT ?""", + (minimum_amount, row_limit), + ).fetchall()] + total_row = conn.execute( + """SELECT COUNT(*) AS count, COALESCE(SUM(amount),0) AS total_amount, + COUNT(DISTINCT COALESCE(NULLIF(merchant,''), raw_summary, counterparty, '')) AS merchant_group_count + FROM transactions + WHERE status='active' AND COALESCE(category,'')='' AND amount>=?""", + (minimum_amount,), + ).fetchone() + history_rows = [row_to_dict(r) for r in conn.execute( + """SELECT merchant, type, category, subcategory, COUNT(*) AS count, + SUM(amount) AS total_amount, MAX(transaction_date) AS latest_date + FROM transactions + WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>'' + GROUP BY merchant, type, category, subcategory + ORDER BY merchant, type, count DESC, total_amount DESC""" + ).fetchall()] + rules = [row_to_dict(r) for r in conn.execute( + "SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id" + ).fetchall()] + + history: dict[tuple[str, str], list[dict[str, Any]]] = {} + for row in history_rows: + history.setdefault((row.get("merchant", "") or "", row.get("type", "") or ""), []).append(row) + + txs: list[dict[str, Any]] = [] + groups: dict[tuple[str, str, str], dict[str, Any]] = {} + for tx in tx_rows: + rec = _recommend_category_for_transaction(tx, rules, history) + public_tx = { + **tx, + "recommendation": rec, + "recommended_category": rec.get("category", ""), + "recommended_subcategory": rec.get("subcategory", ""), + "recommendation_confidence": rec.get("confidence", 0.0), + "recommendation_reason": rec.get("reason", ""), + "recommendation_source": rec.get("source", "none"), + } + txs.append(public_tx) + gkey = (tx.get("merchant", "") or "", tx.get("raw_summary", "") or "", tx.get("type", "") or "") + group = groups.setdefault(gkey, { + "merchant": gkey[0], + "raw_summary": gkey[1], + "type": gkey[2], + "count": 0, + "total_amount": 0, + "max_amount": 0, + "latest_date": "", + "sample_transaction_id": tx.get("id", ""), + "recommendation": rec, + }) + group["count"] += 1 + group["total_amount"] += int(tx.get("amount") or 0) + group["max_amount"] = max(int(group["max_amount"] or 0), int(tx.get("amount") or 0)) + date_value = tx.get("transaction_datetime") or tx.get("transaction_date") or "" + if date_value > (group.get("latest_date") or ""): + group["latest_date"] = date_value + if not group["recommendation"].get("category") and rec.get("category"): + group["recommendation"] = rec + + group_items = sorted(groups.values(), key=lambda g: (int(g["count"]), int(g["total_amount"])), reverse=True) + recommendable_count = sum(1 for tx in txs if tx.get("recommended_category")) + repeated_group_count = sum(1 for g in group_items if int(g.get("count") or 0) >= 2) + total = row_to_dict(total_row) if total_row else {"count": 0, "total_amount": 0, "merchant_group_count": 0} + return ok( + summary={ + "uncategorized_count": int(total.get("count") or 0), + "uncategorized_total_amount": int(total.get("total_amount") or 0), + "merchant_group_count": int(total.get("merchant_group_count") or 0), + "recommendable_count": recommendable_count, + "repeated_group_count": repeated_group_count, + }, + groups=group_items, + transactions=txs, + count=len(txs), + ) + + +def apply_uncategorized_recommendation(transaction_id: str, db_path: str | None = None) -> dict[str, Any]: + if not transaction_id: + return fail("validation_error", "transaction_id is required") + with db(db_path) as conn: + row = conn.execute("SELECT * FROM transactions WHERE id=? AND status='active'", (transaction_id,)).fetchone() + if not row: + return fail("not_found", "transaction not found") + tx = row_to_dict(row) + if tx.get("category"): + return fail("validation_error", "transaction is already categorized") + rules = [row_to_dict(r) for r in conn.execute("SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id").fetchall()] + history_rows = [row_to_dict(r) for r in conn.execute( + """SELECT merchant, type, category, subcategory, COUNT(*) AS count, SUM(amount) AS total_amount + FROM transactions + WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>'' + GROUP BY merchant, type, category, subcategory + ORDER BY count DESC, total_amount DESC""" + ).fetchall()] + history: dict[tuple[str, str], list[dict[str, Any]]] = {} + for h in history_rows: + history.setdefault((h.get("merchant", "") or "", h.get("type", "") or ""), []).append(h) + rec = _recommend_category_for_transaction(tx, rules, history) + if not rec.get("category"): + return fail("no_recommendation", "์ถ”์ฒœ ๋ถ„๋ฅ˜๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค") + cur = conn.execute( + """UPDATE transactions + SET category=?, subcategory=?, updated_at=? + WHERE id=? AND status='active' AND COALESCE(category,'')=''""", + (rec.get("category", ""), rec.get("subcategory", ""), now_iso(), transaction_id), + ) + return ok(applied_count=cur.rowcount, transaction_id=transaction_id, recommendation=rec) + + +def apply_uncategorized_group_recommendation(merchant: str, raw_summary: str = "", transaction_type: str = "", db_path: str | None = None) -> dict[str, Any]: + with db(db_path) as conn: + sample = conn.execute( + """SELECT * FROM transactions + WHERE status='active' AND COALESCE(category,'')='' AND merchant=? AND raw_summary=? AND type=? + ORDER BY amount DESC, transaction_datetime DESC, transaction_date DESC LIMIT 1""", + (merchant, raw_summary, transaction_type), + ).fetchone() + if not sample: + return fail("not_found", "uncategorized group not found") + tx = row_to_dict(sample) + rules = [row_to_dict(r) for r in conn.execute("SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id").fetchall()] + history_rows = [row_to_dict(r) for r in conn.execute( + """SELECT merchant, type, category, subcategory, COUNT(*) AS count, SUM(amount) AS total_amount + FROM transactions + WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>'' + GROUP BY merchant, type, category, subcategory + ORDER BY count DESC, total_amount DESC""" + ).fetchall()] + history: dict[tuple[str, str], list[dict[str, Any]]] = {} + for h in history_rows: + history.setdefault((h.get("merchant", "") or "", h.get("type", "") or ""), []).append(h) + rec = _recommend_category_for_transaction(tx, rules, history) + if not rec.get("category"): + return fail("no_recommendation", "์ถ”์ฒœ ๋ถ„๋ฅ˜๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค") + cur = conn.execute( + """UPDATE transactions + SET category=?, subcategory=?, updated_at=? + WHERE status='active' AND COALESCE(category,'')='' AND merchant=? AND raw_summary=? AND type=?""", + (rec.get("category", ""), rec.get("subcategory", ""), now_iso(), merchant, raw_summary, transaction_type), + ) + return ok(applied_count=cur.rowcount, merchant=merchant, raw_summary=raw_summary, type=transaction_type, recommendation=rec) + + +DEFAULT_LEDGER_CATEGORIES: list[dict[str, Any]] = [ + {"category": "์ˆ˜์ž…", "subcategory": "๊ธ‰์—ฌ", "sort_order": 10}, + {"category": "์ˆ˜์ž…", "subcategory": "์ด์ž", "sort_order": 11}, + {"category": "์ˆ˜์ž…", "subcategory": "ํ™˜๊ธ‰", "sort_order": 12}, + {"category": "์ˆ˜์ž…", "subcategory": "๋ฐฐ๋‹น", "sort_order": 13}, + {"category": "์ˆ˜์ž…", "subcategory": "๊ธฐํƒ€์ˆ˜์ž…", "sort_order": 19}, + {"category": "์ด์ฒด", "subcategory": "๋ณธ์ธ๊ณ„์ขŒ์ด๋™", "sort_order": 20}, + {"category": "์ด์ฒด", "subcategory": "ํ˜„๊ธˆ์ž…์ถœ๊ธˆ", "sort_order": 21}, + {"category": "์ด์ฒด", "subcategory": "๊ฐ€์กฑ์†ก๊ธˆ", "sort_order": 22}, + {"category": "์ด์ฒด", "subcategory": "ํˆฌ์ž์ด๋™", "sort_order": 23}, + {"category": "์ด์ฒด", "subcategory": "ํ™•์ธํ•„์š”", "sort_order": 29}, + {"category": "์‹๋น„", "subcategory": "๋งˆํŠธ", "sort_order": 30}, + {"category": "์‹๋น„", "subcategory": "์™ธ์‹", "sort_order": 31}, + {"category": "์‹๋น„", "subcategory": "์นดํŽ˜", "sort_order": 32}, + {"category": "์‹๋น„", "subcategory": "๋ฐฐ๋‹ฌ", "sort_order": 33}, + {"category": "์ฃผ๊ฑฐ", "subcategory": "๊ด€๋ฆฌ๋น„", "sort_order": 40}, + {"category": "์ฃผ๊ฑฐ", "subcategory": "์ „๊ธฐ", "sort_order": 41}, + {"category": "์ฃผ๊ฑฐ", "subcategory": "๊ฐ€์Šค", "sort_order": 42}, + {"category": "์ฃผ๊ฑฐ", "subcategory": "์ˆ˜๋„", "sort_order": 43}, + {"category": "์ฃผ๊ฑฐ", "subcategory": "์ธํ„ฐ๋„ท", "sort_order": 44}, + {"category": "ํ†ต์‹ ", "subcategory": "ํœด๋Œ€ํฐ", "sort_order": 50}, + {"category": "ํ†ต์‹ ", "subcategory": "์ธํ„ฐ๋„ท", "sort_order": 51}, + {"category": "ํ†ต์‹ ", "subcategory": "ํ†ต์‹ ์š”๊ธˆ", "sort_order": 52}, + {"category": "๊ตํ†ต", "subcategory": "๋Œ€์ค‘๊ตํ†ต", "sort_order": 60}, + {"category": "๊ตํ†ต", "subcategory": "ํƒ์‹œ", "sort_order": 61}, + {"category": "๊ตํ†ต", "subcategory": "์ฃผ์œ ", "sort_order": 62}, + {"category": "๊ตํ†ต", "subcategory": "์ฃผ์ฐจ", "sort_order": 63}, + {"category": "๊ตํ†ต", "subcategory": "์ฐจ๋Ÿ‰์ •๋น„", "sort_order": 64}, + {"category": "๊ต์œก", "subcategory": "ํ•™์›", "sort_order": 70}, + {"category": "๊ต์œก", "subcategory": "๊ต์žฌ", "sort_order": 71}, + {"category": "๊ต์œก", "subcategory": "ํ•™๊ต", "sort_order": 72}, + {"category": "๊ต์œก", "subcategory": "์˜ˆ์ฒด๋Šฅ", "sort_order": 73}, + {"category": "์˜๋ฃŒ", "subcategory": "๋ณ‘์›", "sort_order": 80}, + {"category": "์˜๋ฃŒ", "subcategory": "์•ฝ๊ตญ", "sort_order": 81}, + {"category": "์˜๋ฃŒ", "subcategory": "์น˜๊ณผ", "sort_order": 82}, + {"category": "์˜๋ฃŒ", "subcategory": "๋ณดํ—˜", "sort_order": 83}, + {"category": "์ƒํ™œ", "subcategory": "์ƒํ•„ํ’ˆ", "sort_order": 90}, + {"category": "์ƒํ™œ", "subcategory": "์˜๋ฅ˜", "sort_order": 91}, + {"category": "์ƒํ™œ", "subcategory": "๋ฏธ์šฉ", "sort_order": 92}, + {"category": "์ƒํ™œ", "subcategory": "์„ธํƒ", "sort_order": 93}, + {"category": "๋ฌธํ™”/์—ฌ๊ฐ€", "subcategory": "์—ฌํ–‰", "sort_order": 100}, + {"category": "๋ฌธํ™”/์—ฌ๊ฐ€", "subcategory": "์ˆ™๋ฐ•", "sort_order": 101}, + {"category": "๋ฌธํ™”/์—ฌ๊ฐ€", "subcategory": "๊ณต์—ฐ", "sort_order": 102}, + {"category": "๋ฌธํ™”/์—ฌ๊ฐ€", "subcategory": "์ทจ๋ฏธ", "sort_order": 103}, + {"category": "๋ฌธํ™”/์—ฌ๊ฐ€", "subcategory": "์Šคํฌ์ธ ", "sort_order": 104}, + {"category": "๊ธˆ์œต", "subcategory": "์นด๋“œ๋Œ€๊ธˆ", "sort_order": 110}, + {"category": "๊ธˆ์œต", "subcategory": "๋Œ€์ถœ์ƒํ™˜", "sort_order": 111}, + {"category": "๊ธˆ์œต", "subcategory": "์ˆ˜์ˆ˜๋ฃŒ", "sort_order": 112}, + {"category": "๊ธˆ์œต", "subcategory": "๋ณดํ—˜๋ฃŒ", "sort_order": 113}, + {"category": "๊ฐ€์กฑ/์šฉ๋ˆ", "subcategory": "๋ถ€๋ชจ๋‹˜", "sort_order": 120}, + {"category": "๊ฐ€์กฑ/์šฉ๋ˆ", "subcategory": "์ž๋…€", "sort_order": 121}, + {"category": "๊ฐ€์กฑ/์šฉ๋ˆ", "subcategory": "๊ฐ€์กฑ์†ก๊ธˆ", "sort_order": 122}, + {"category": "๋ฏธ๋ถ„๋ฅ˜", "subcategory": "ํ™•์ธํ•„์š”", "sort_order": 999}, +] + + +def infer_cashflow_kind(category: str, subcategory: str = "") -> str: + if category == "์ˆ˜์ž…": + return "income" + if category == "์ด์ฒด": + if subcategory == "ํˆฌ์ž์ด๋™": + return "investment" + return "transfer" + if category == "๊ธˆ์œต": + if subcategory == "๋Œ€์ถœ์ƒํ™˜": + return "debt_payment" + if subcategory in {"์นด๋“œ๋Œ€๊ธˆ", "์ˆ˜์ˆ˜๋ฃŒ"}: + return "exclude" + return "exclude" + if category == "๋ฏธ๋ถ„๋ฅ˜": + return "unknown" + return "expense" + + +def create_ledger_category(category: str, subcategory: str = "", sort_order: int = 100, memo: str = "", cashflow_kind: str = "", db_path: str | None = None) -> dict[str, Any]: + category = (category or "").strip() + subcategory = (subcategory or "").strip() + cashflow_kind = (cashflow_kind or infer_cashflow_kind(category, subcategory)).strip() + if not category: + return fail("validation_error", "category is required") + if cashflow_kind not in VALID_CASHFLOW_KINDS: + return fail("validation_error", f"cashflow_kind must be one of {sorted(VALID_CASHFLOW_KINDS)}") + ts = now_iso(); category_id = new_id("cat") + with db(db_path) as conn: + conn.execute("""INSERT INTO ledger_categories(id,category,subcategory,cashflow_kind,active,sort_order,memo,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?) + ON CONFLICT(category, subcategory) DO UPDATE SET active=1, cashflow_kind=excluded.cashflow_kind, sort_order=excluded.sort_order, memo=excluded.memo, updated_at=excluded.updated_at""", + (category_id, category, subcategory, cashflow_kind, 1, int(sort_order), memo, ts, ts)) + row = conn.execute("SELECT * FROM ledger_categories WHERE category=? AND subcategory=?", (category, subcategory)).fetchone() + return ok(category=row_to_dict(row)) + + +def seed_default_ledger_categories(overwrite: bool = False, db_path: str | None = None) -> dict[str, Any]: + if overwrite: + with db(db_path) as conn: + conn.execute("UPDATE ledger_categories SET active=0 WHERE memo LIKE 'default:%'") + created = 0 + for item in DEFAULT_LEDGER_CATEGORIES: + res = create_ledger_category(memo="default: Jarca initial category", db_path=db_path, **item) + if res.get("success"): + created += 1 + return ok(created_or_updated=created) + + +def list_ledger_categories(active_only: bool = True, include_usage: bool = True, db_path: str | None = None) -> dict[str, Any]: + clauses = [] + if active_only: + clauses.append("active=1") + where = " WHERE " + " AND ".join(clauses) if clauses else "" + with db(db_path) as conn: + rows = conn.execute(f"SELECT * FROM ledger_categories{where} ORDER BY sort_order, category, subcategory").fetchall() + items = [row_to_dict(r) for r in rows] + if include_usage: + usage_rows = conn.execute("""SELECT category, subcategory, COUNT(*) AS transaction_count, SUM(amount) AS total_amount + FROM transactions WHERE status='active' + GROUP BY category, subcategory""").fetchall() + usage = {(r["category"], r["subcategory"]): {"transaction_count": int(r["transaction_count"] or 0), "total_amount": int(r["total_amount"] or 0)} for r in usage_rows} + for item in items: + item.update(usage.get((item["category"], item["subcategory"]), {"transaction_count": 0, "total_amount": 0})) + grouped: dict[str, list[str]] = {} + for item in items: + grouped.setdefault(item["category"], []) + if item["subcategory"] and item["subcategory"] not in grouped[item["category"]]: + grouped[item["category"]].append(item["subcategory"]) + return ok(categories=items, grouped=grouped, count=len(items)) + + +def delete_ledger_category(category: str, subcategory: str = "", hard_delete: bool = False, force: bool = False, db_path: str | None = None) -> dict[str, Any]: + category = (category or "").strip() + subcategory = (subcategory or "").strip() + if not category: + return fail("validation_error", "category is required") + with db(db_path) as conn: + row = conn.execute("SELECT * FROM ledger_categories WHERE category=? AND subcategory=?", (category, subcategory)).fetchone() + if not row: + return fail("not_found", "category not found") + tx_count = conn.execute("SELECT COUNT(*) FROM transactions WHERE status='active' AND category=? AND subcategory=?", (category, subcategory)).fetchone()[0] + budget_count = conn.execute("SELECT COUNT(*) FROM budgets WHERE category=? AND subcategory=?", (category, subcategory)).fetchone()[0] + rule_count = conn.execute("SELECT COUNT(*) FROM category_rules WHERE active=1 AND category=? AND subcategory=?", (category, subcategory)).fetchone()[0] + in_use = int(tx_count or 0) + int(budget_count or 0) + int(rule_count or 0) + if in_use and hard_delete and not force: + return fail("category_in_use", "category is referenced; use soft delete or force=true", transaction_count=tx_count, budget_count=budget_count, rule_count=rule_count) + if hard_delete: + conn.execute("DELETE FROM ledger_categories WHERE id=?", (row["id"],)) + else: + conn.execute("UPDATE ledger_categories SET active=0, updated_at=? WHERE id=?", (now_iso(), row["id"])) + return ok(category=category, subcategory=subcategory, deleted=True, hard_delete=hard_delete, transaction_count=tx_count, budget_count=budget_count, rule_count=rule_count) diff --git a/jarca_ledger_mcp/application/import_transfer_services.py b/jarca_ledger_mcp/application/import_transfer_services.py new file mode 100644 index 0000000..7db4414 --- /dev/null +++ b/jarca_ledger_mcp/application/import_transfer_services.py @@ -0,0 +1,1513 @@ +from __future__ import annotations + +from .shared import * +from .shared import _append_memo_tag, _memo_has_tag, _normalize_counterparty_for_match, _party_match_score + +def preview_ledger_csv_import(file_path: str, source: str = "generic", db_path: str | None = None) -> dict[str, Any]: + path=Path(file_path).expanduser() + if not path.exists(): + return fail("not_found", f"CSV file not found: {path}") + with path.open("r", encoding="utf-8-sig", newline="") as f: + rows=list(csv.DictReader(f)) + preview=[] + for r in rows[:20]: + preview.append(r) + return ok(source=source, filename=str(path), row_count=len(rows), preview=preview, required_columns=["transaction_date/date", "amount", "type optional", "category optional", "merchant/description optional"]) + + +def preview_kb_bank_pdf_import(file_path: str, password: str, preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]: + try: + parsed = parse_kb_bank_pdf(file_path, password) + except FileNotFoundError as e: + return fail("not_found", str(e)) + except ValueError as e: + return fail("pdf_password_error", str(e)) + except Exception as e: + return fail("pdf_parse_error", str(e)) + transactions = parsed["transactions"] + validation = parsed["validation"] + source_hash = parsed["source_hash"] + duplicate_count = 0 + with db(db_path) as conn: + for tx in transactions: + dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, tx.counterparty, "", tx.external_id(source_hash)) + found = conn.execute("SELECT 1 FROM transactions WHERE dedupe_key=?", (dedupe_key,)).fetchone() + duplicate_count += 1 if found else 0 + preview = [transaction_preview(tx, source_hash) for tx in transactions[: max(1, min(int(preview_limit), 100))]] + return ok( + source="kb_bank_pdf", + file_path=parsed["file_path"], + source_hash=source_hash, + page_count=parsed["page_count"], + metadata=parsed["metadata"], + row_count=len(transactions), + duplicate_count=duplicate_count, + validation=validation, + preview=preview, + ) + + +def _find_or_create_kb_account(conn: Any, metadata: dict[str, Any], account_id: str = "") -> str: + if account_id: + row = conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone() + if row: + return account_id + raise ValueError(f"account_id not found: {account_id}") + account_number_masked = metadata.get("account_number_masked") or "" + row = None + if account_number_masked: + row = conn.execute( + "SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1", + ("KB๊ตญ๋ฏผ์€ํ–‰", account_number_masked), + ).fetchone() + if row: + return row["id"] + ts = now_iso() + new_account_id = new_id("acct") + account_name = metadata.get("account_name") or "KB๊ตญ๋ฏผ์€ํ–‰ ๊ณ„์ขŒ" + display_name = f"KB๊ตญ๋ฏผ์€ํ–‰ {account_name} {account_number_masked}".strip() + conn.execute( + """INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + new_account_id, + display_name, + "bank", + "KB๊ตญ๋ฏผ์€ํ–‰", + "KRW", + 1, + "๊ตญ๋ฏผ์€ํ–‰ ๊ฑฐ๋ž˜๋‚ด์—ญ PDF import๋กœ ์ž๋™ ์ƒ์„ฑ", + account_number_masked, + metadata.get("holder_name") or "", + metadata.get("final_balance"), + ts, + ts, + ts, + ), + ) + return new_account_id + + +def import_kb_bank_pdf(file_path: str, password: str, account_id: str = "", skip_duplicates: bool = True, db_path: str | None = None, skip_validation: bool = False) -> dict[str, Any]: + try: + parsed = parse_kb_bank_pdf(file_path, password) + except FileNotFoundError as e: + return fail("not_found", str(e)) + except ValueError as e: + return fail("pdf_password_error", str(e)) + except Exception as e: + return fail("pdf_parse_error", str(e)) + transactions = parsed["transactions"] + validation = parsed["validation"] + if not validation.get("ok") and not skip_validation: + return fail("balance_validation_failed", "KB Bank PDF balance flow validation failed", validation=validation) + source_hash = parsed["source_hash"] + metadata = parsed["metadata"] + ts = now_iso() + batch_id = new_id("imp") + inserted = 0 + skipped_duplicates = 0 + inserted_ids: list[str] = [] + try: + with db(db_path) as conn: + linked_account_id = _find_or_create_kb_account(conn, metadata, account_id) + conn.execute( + """INSERT INTO import_batches(id,source,filename,imported_at,row_count,status,memo) + VALUES(?,?,?,?,?,?,?)""", + ( + batch_id, + "kb_bank_pdf", + parsed["file_path"], + ts, + 0, + "active", + json.dumps( + { + "source_hash": source_hash, + "page_count": parsed["page_count"], + "metadata": metadata, + "validation": validation, + "parsed_row_count": len(transactions), + }, + ensure_ascii=False, + ), + ), + ) + for tx in transactions: + tx_id = new_id("tx") + external_id = tx.external_id(source_hash) + dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, tx.counterparty, linked_account_id, external_id) + payload = tx.raw_payload() + description = f"๊ตญ๋ฏผ์€ํ–‰ PDF import; ์ ์š”={tx.raw_summary}; ๊ฑฐ๋ž˜์ ={tx.branch}; ์ž”์•ก={tx.balance_after}" + try: + 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,import_batch_id,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + tx_id, + tx.transaction_date, + "", + tx.type, + tx.amount, + "KRW", + "", + "", + tx.counterparty, + description, + linked_account_id, + tx.raw_summary, + "", + "", + normalize_tags(["kb_bank_pdf", tx.raw_summary] if tx.raw_summary else ["kb_bank_pdf"]), + 0, + "kb_bank_pdf", + batch_id, + external_id, + dedupe_key, + tx.remittance_memo, + tx.transaction_datetime, + tx.counterparty, + tx.balance_after, + tx.branch, + tx.raw_summary, + payload, + ts, + ts, + ), + ) + inserted += 1 + inserted_ids.append(tx_id) + except Exception as e: + if "UNIQUE constraint failed" in str(e) and skip_duplicates: + skipped_duplicates += 1 + continue + raise + conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id)) + if transactions: + conn.execute( + "UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?", + (transactions[-1].balance_after, ts, ts, linked_account_id), + ) + except ValueError as e: + return fail("validation_error", str(e)) + except Exception as e: + return fail("db_error", str(e)) + return ok( + import_batch_id=batch_id, + account_id=linked_account_id, + source="kb_bank_pdf", + parsed_row_count=len(transactions), + inserted_count=inserted, + skipped_duplicate_count=skipped_duplicates, + inserted_transaction_ids=inserted_ids[:20], + validation=validation, + metadata=metadata, + ) + + +def _find_or_create_seoulpay_account(conn: Any, metadata: dict[str, Any], account_id: str = "") -> str: + if account_id: + row = conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone() + if row: + return account_id + raise ValueError(f"account_id not found: {account_id}") + holder_name = metadata.get("holder_name") or "" + row = conn.execute( + "SELECT id FROM accounts WHERE institution=? AND name=? AND active=1 ORDER BY created_at LIMIT 1", + ("์„œ์šธํŽ˜์ด", f"์„œ์šธํŽ˜์ด ์ด์šฉ๋‚ด์—ญ {holder_name}".strip()), + ).fetchone() + if row: + return row["id"] + ts = now_iso() + new_account_id = new_id("acct") + display_name = f"์„œ์šธํŽ˜์ด ์ด์šฉ๋‚ด์—ญ {holder_name}".strip() + conn.execute( + """INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + new_account_id, + display_name, + "cash", + "์„œ์šธํŽ˜์ด", + "KRW", + 1, + "์„œ์šธํŽ˜์ด ์ด์šฉ๋‚ด์—ญ XLSX import๋กœ ์ž๋™ ์ƒ์„ฑ", + "", + holder_name, + None, + ts, + ts, + ts, + ), + ) + return new_account_id + + +def _classify_seoulpay_transaction(tx: Any) -> tuple[str, str, str, bool]: + merchant = (tx.merchant or "").strip() + text = f"{merchant} {tx.transaction_kind} {tx.payment_kind}" + if tx.type == "refund": + return "์ˆ˜์ž…", "ํ™˜๊ธ‰", merchant, False + if "๋•ก๊ฒจ์š”" in text or "๋ฐฐ๋‹ฌ" in text: + return "์‹๋น„", "๋ฐฐ๋‹ฌ", merchant, False + if any(keyword in text for keyword in ("๊ต์Šต์†Œ", "ํ•™์›", "๋ฐ”์ด์˜ฌ๋ฆฐ", "ํ”ผ์•„๋…ธ")): + return "๊ต์œก", "ํ•™์›", merchant, False + if any(keyword in text for keyword in ("๊น€์˜๋ชจ", "๋ฒ ์ด์ปค๋ฆฌ", "์ œ๊ณผ", "์นดํŽ˜", "์ปคํ”ผ")): + return "์‹๋น„", "์นดํŽ˜", merchant, False + if "์˜จ๋ผ์ธ" in text: + return "์‡ผํ•‘", "์˜จ๋ผ์ธ", merchant, False + return "์‡ผํ•‘", "์ƒํ™œ์šฉํ’ˆ", merchant, False + + +def _find_or_create_toss_bank_account(conn: Any, metadata: dict[str, Any], account_id: str = "") -> str: + if account_id: + row = conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone() + if row: + return account_id + raise ValueError(f"account_id not found: {account_id}") + account_number_masked = metadata.get("account_number_masked") or "" + row = None + if account_number_masked: + row = conn.execute( + "SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1", + ("ํ† ์Šค๋ฑ…ํฌ", account_number_masked), + ).fetchone() + if row: + return row["id"] + ts = now_iso() + new_account_id = new_id("acct") + account_name = metadata.get("account_name") or "ํ† ์Šค๋ฑ…ํฌ ๊ณ„์ขŒ" + display_name = f"ํ† ์Šค๋ฑ…ํฌ {account_name} {account_number_masked}".strip() + conn.execute( + """INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + new_account_id, + display_name, + "bank", + "ํ† ์Šค๋ฑ…ํฌ", + "KRW", + 1, + "ํ† ์Šค๋ฑ…ํฌ ๊ฑฐ๋ž˜๋‚ด์—ญ PDF import๋กœ ์ž๋™ ์ƒ์„ฑ", + account_number_masked, + metadata.get("holder_name") or "", + metadata.get("final_balance"), + ts, + ts, + ts, + ), + ) + return new_account_id + + +def _classify_toss_bank_transaction(tx: Any, owner_name: str = "") -> tuple[str, str, str, bool]: + action = (tx.action or "").strip() + counterparty = (tx.counterparty or "").strip() + owner_name = (owner_name or "").strip() + if "์ด์ž" in action: + return "์ˆ˜์ž…", "์ด์ž", counterparty, False + if "์บ์‹œ๋ฐฑ" in action: + return "์ˆ˜์ž…", "์บ์‹œ๋ฐฑ", counterparty, False + if "์ฒดํฌ์นด๋“œ๊ฒฐ์ œ" in action: + return "์‹๋น„", "์นด๋“œ๊ฒฐ์ œ", counterparty, False + transfer_like = { + "์ด์ฒด", + "๋‚ด๊ณ„์ขŒ๊ฐ„์ž๋™์ด์ฒด", + "์˜คํ”ˆ๋ฑ…ํ‚น", + "ํŽŒ๋ฑ…ํ‚น์ถœ๊ธˆ", + "ํŽŒ๋ฑ…ํ‚น์ž…๊ธˆ", + "์ ๊ธˆ ๊ฐœ์„ค", + "์ถœ๊ธˆ", + "์ž…๊ธˆ", + "ATM์ถœ๊ธˆ", + } + if any(keyword in action for keyword in transfer_like): + if counterparty and owner_name and counterparty != owner_name: + return "์ด์ฒด", "ํ™•์ธํ•„์š”", counterparty, False + return "์ด์ฒด", "๋ณธ์ธ๊ณ„์ขŒ์ด๋™", counterparty, False + if "์บ์‹œ๋ฐฑ" in action: + return "์ˆ˜์ž…", "์บ์‹œ๋ฐฑ", counterparty, False + return "์ด์ฒด", "ํ™•์ธํ•„์š”", counterparty, False + + +def _build_cross_transfer_index(transactions: list[dict[str, Any]]) -> dict[tuple[str, int, str], list[dict[str, Any]]]: + index: dict[tuple[str, int, str], list[dict[str, Any]]] = {} + for tx in transactions: + if tx.get("status") != "active": + continue + key = (tx["transaction_date"], int(tx["amount"]), tx["type"]) + index.setdefault(key, []).append(tx) + return index + + +def _fetch_transfer_candidates(db_conn: Any, source: str) -> list[dict[str, Any]]: + rows = db_conn.execute( + """ + SELECT t.id, t.transaction_datetime, t.transaction_date, t.type, t.amount, t.category, t.subcategory, + t.counterparty, t.memo, t.person, t.account_id, t.payment_method, t.status, + a.holder_name, t.merchant + FROM transactions t + LEFT JOIN accounts a ON a.id = t.account_id + WHERE t.source=? + AND t.type IN ('income','expense') + AND ( + t.category='์ด์ฒด' + OR t.subcategory IN ('ํ™•์ธํ•„์š”', '๋ณธ์ธ๊ณ„์ขŒ์ด๋™', 'ํˆฌ์ž์ด๋™') + OR t.payment_method IN ('๋‚ด๊ณ„์ขŒ๊ฐ„์ž๋™์ด์ „', '๋‚ด๊ณ„์ขŒ๊ฐ„์ž๋™์ด์ฒด', '์˜คํ”ˆ๋ฑ…ํ‚น', 'ํŽŒ๋ฑ…ํ‚น์ถœ๊ธˆ', 'ํŽŒ๋ฑ…ํ‚น์ž…๊ธˆ', '์ž…๊ธˆ', '์ถœ๊ธˆ', 'ATM์ถœ๊ธˆ', '์ ๊ธˆ๊ฐœ์„ค') + OR t.merchant LIKE '%์ด์ฒด%' + ) + """, + (source,), + ).fetchall() + return [dict(r) for r in rows] + + +def _parse_tx_datetime(value: str | None) -> datetime | None: + if not value: + return None + text = value.strip() + for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"): + try: + return datetime.strptime(text, fmt) + except ValueError: + continue + try: + return datetime.fromisoformat(text) + except ValueError: + return None + + +def _self_transfer_direction_score(source_type: str, candidate_type: str) -> int: + if source_type == "expense": + if candidate_type == "income": + return 3 + if candidate_type == "transfer": + return 2 + elif source_type == "income": + if candidate_type == "expense": + return 3 + if candidate_type == "transfer": + return 2 + elif source_type == "transfer": + if candidate_type in {"income", "expense"}: + return 2 + if candidate_type == "transfer": + return 1 + return 0 + + +def _normalize_cancel_lookup_text(value: str) -> str: + if not value: + return "" + text = value.lower().strip() + replacements = [ + "kb์ฒดํฌ์ทจ์†Œ", "์Šน์ธ์ทจ์†Œ", "์ทจ์†Œ", "ํ™˜๋ถˆ", "ํ™˜๊ธ‰", "refund", "reversal", + "์นด๋“œ์ž…๊ธˆ", "์นด๋“œํ™˜๊ธ‰", "๋งค์ถœ์ทจ์†Œ", "์ž…๊ธˆ", "์ถœ๊ธˆ", "์ฒดํฌ์นด๋“œ", "์‹ ์šฉ์นด๋“œ", + ] + for token in replacements: + text = text.replace(token, " ") + text = re.sub(r"\s+", " ", text) + return _normalize_counterparty_for_match(text) + + +def _cancel_channel(value: str) -> str: + text = (value or "").lower() + if any(token in text for token in ("์นด๋“œ", "check", "credit")): + return "card" + if any(token in text for token in ("์ด์ฒด", "์˜คํ”ˆ๋ฑ…ํ‚น", "์ถœ๊ธˆ", "์ž…๊ธˆ", "bank")): + return "bank" + return "other" + + +def _is_cancel_like_transaction(tx: dict[str, Any]) -> bool: + if str(tx.get("type") or "") == "refund": + return True + combined = " ".join( + str(tx.get(key) or "") for key in ("merchant", "raw_summary", "payment_method", "memo", "description") + ).lower() + keywords = ("์ทจ์†Œ", "ํ™˜๋ถˆ", "ํ™˜๊ธ‰", "refund", "reversal", "์นด๋“œ์ž…๊ธˆ", "๋งค์ถœ์ทจ์†Œ") + return str(tx.get("type") or "") == "income" and any(token in combined for token in keywords) + + +def find_cancel_original_candidates(transaction_id: str, limit: int = 20, lookback_days: int = 365, db_path: str | None = None) -> dict[str, Any]: + limit = max(1, min(int(limit), 50)) + lookback_days = max(1, min(int(lookback_days), 730)) + with db(db_path) as conn: + source_row = conn.execute( + """ + SELECT t.*, a.name AS account_name, a.institution, a.holder_name + FROM transactions t + LEFT JOIN accounts a ON a.id = t.account_id + WHERE t.id=? + """, + (transaction_id,), + ).fetchone() + if not source_row: + return fail("not_found", f"transaction not found: {transaction_id}") + source = row_to_dict(source_row) + explicit_cancel_like = _is_cancel_like_transaction(source) + source_type = str(source.get("type") or "") + if source_type not in {"income", "refund"} and not explicit_cancel_like: + return fail("not_cancel_like", "cancel/original lookup is intended for ์ทจ์†Œยทํ™˜๊ธ‰์„ฑ ๊ฑฐ๋ž˜") + + source_date = normalize_date(source.get("transaction_date"), default_today=False) + source_dt = _parse_tx_datetime(source.get("transaction_datetime")) + source_day = date.fromisoformat(source_date) + start_date = date.fromordinal(source_day.toordinal() - lookback_days).isoformat() + source_text = " ".join(str(source.get(key) or "") for key in ("merchant", "counterparty", "raw_summary", "payment_method")) + source_norm_text = _normalize_cancel_lookup_text(source_text) + source_channel = _cancel_channel(str(source.get("payment_method") or "") + " " + str(source.get("raw_summary") or "") + " " + str(source.get("merchant") or "")) + candidate_rows = conn.execute( + """ + SELECT t.*, a.name AS account_name, a.institution, a.holder_name + FROM transactions t + LEFT JOIN accounts a ON a.id = t.account_id + WHERE t.status='active' + AND t.id<>? + AND t.amount=? + AND t.transaction_date BETWEEN ? AND ? + AND t.type IN ('expense', 'adjustment') + ORDER BY t.transaction_date DESC, t.transaction_datetime DESC, t.created_at DESC + """, + (transaction_id, int(source.get("amount") or 0), start_date, source_date), + ).fetchall() + + candidates: list[dict[str, Any]] = [] + for row in candidate_rows: + candidate = row_to_dict(row) + candidate_date = normalize_date(candidate.get("transaction_date"), default_today=False) + candidate_day = date.fromisoformat(candidate_date) + day_delta = (source_day - candidate_day).days + if day_delta < 0: + continue + candidate_dt = _parse_tx_datetime(candidate.get("transaction_datetime")) + time_delta_seconds = abs(int((source_dt - candidate_dt).total_seconds())) if source_dt and candidate_dt else None + same_account = bool(source.get("account_id") and source.get("account_id") == candidate.get("account_id")) + source_same = bool(source.get("source") and source.get("source") == candidate.get("source")) + candidate_text = " ".join(str(candidate.get(key) or "") for key in ("merchant", "counterparty", "raw_summary", "payment_method")) + candidate_norm_text = _normalize_cancel_lookup_text(candidate_text) + merchant_score = 0 + if source_norm_text and candidate_norm_text: + if source_norm_text == candidate_norm_text: + merchant_score = 4 + elif source_norm_text in candidate_norm_text or candidate_norm_text in source_norm_text: + merchant_score = 2 + channel_score = 0 + candidate_channel = _cancel_channel(str(candidate.get("payment_method") or "") + " " + str(candidate.get("raw_summary") or "") + " " + str(candidate.get("merchant") or "")) + if source_channel != "other" and source_channel == candidate_channel: + channel_score = 2 + if not explicit_cancel_like and not (same_account and merchant_score >= 2 and channel_score >= 1): + continue + recency_score = 0 + if day_delta == 0: + recency_score = 5 + elif day_delta <= 7: + recency_score = 4 + elif day_delta <= 30: + recency_score = 3 + elif day_delta <= 90: + recency_score = 2 + else: + recency_score = 1 + time_score = 0 + if time_delta_seconds is not None: + if time_delta_seconds <= 3600: + time_score = 2 + elif time_delta_seconds <= 86400: + time_score = 1 + score = merchant_score + channel_score + recency_score + time_score + (3 if same_account else 0) + (1 if source_same else 0) + reason_bits = [ + f"same_account={int(same_account)}", + f"merchant={merchant_score}", + f"channel={channel_score}", + f"days={day_delta}", + ] + if time_delta_seconds is not None: + reason_bits.append(f"time_delta={time_delta_seconds}s") + candidates.append({ + "id": candidate.get("id"), + "transaction_date": candidate.get("transaction_date"), + "transaction_datetime": candidate.get("transaction_datetime"), + "source": candidate.get("source"), + "type": candidate.get("type"), + "amount": candidate.get("amount"), + "category": candidate.get("category"), + "subcategory": candidate.get("subcategory"), + "merchant": candidate.get("merchant"), + "raw_summary": candidate.get("raw_summary"), + "counterparty": candidate.get("counterparty"), + "account_id": candidate.get("account_id"), + "account_name": candidate.get("account_name"), + "institution": candidate.get("institution"), + "payment_method": candidate.get("payment_method"), + "memo": candidate.get("memo"), + "score": score, + "day_delta": day_delta, + "time_delta_seconds": time_delta_seconds, + "reason": ", ".join(reason_bits), + }) + + candidates.sort(key=lambda item: (-int(item.get("score", 0)), int(item.get("day_delta", 9999)), int(item.get("time_delta_seconds") or 99999999), str(item.get("transaction_datetime") or ""))) + return ok(source_tx=source, count=len(candidates[:limit]), candidates=candidates[:limit], source="cancel_original_candidate_search") + + +def find_self_transfer_candidates(transaction_id: str, limit: int = 20, db_path: str | None = None) -> dict[str, Any]: + limit = max(1, min(int(limit), 50)) + with db(db_path) as conn: + source_row = conn.execute( + """ + SELECT t.*, a.name AS account_name, a.institution, a.holder_name + FROM transactions t + LEFT JOIN accounts a ON a.id = t.account_id + WHERE t.id=? + """, + (transaction_id,), + ).fetchone() + if not source_row: + return fail("not_found", f"transaction not found: {transaction_id}") + + source = row_to_dict(source_row) + source_date = normalize_date(source.get("transaction_date"), default_today=False) + source_dt = _parse_tx_datetime(source.get("transaction_datetime")) + source_holder = _normalize_counterparty_for_match(source.get("holder_name", "")) + anchor_date = date.fromisoformat(source_date) if source_date else current_kst_date() + window_start = anchor_date.toordinal() - 1 + window_end = anchor_date.toordinal() + 1 + candidate_rows = conn.execute( + """ + SELECT t.*, a.name AS account_name, a.institution, a.holder_name + FROM transactions t + LEFT JOIN accounts a ON a.id = t.account_id + WHERE t.status='active' + AND t.id<>? + AND COALESCE(t.account_id, '')<>COALESCE(?, '') + AND t.amount=? + AND t.transaction_date BETWEEN ? AND ? + ORDER BY t.transaction_date, t.transaction_datetime, t.created_at + """, + ( + transaction_id, + source.get("account_id", ""), + int(source.get("amount") or 0), + date.fromordinal(window_start).isoformat(), + date.fromordinal(window_end).isoformat(), + ), + ).fetchall() + + candidates: list[dict[str, Any]] = [] + for row in candidate_rows: + candidate = row_to_dict(row) + direction_score = _self_transfer_direction_score(str(source.get("type", "")), str(candidate.get("type", ""))) + if direction_score == 0: + continue + + candidate_dt = _parse_tx_datetime(candidate.get("transaction_datetime")) + day_delta = abs((date.fromisoformat(candidate.get("transaction_date", source_date)) - date.fromisoformat(source_date)).days) + time_delta_seconds = abs(int((candidate_dt - source_dt).total_seconds())) if candidate_dt and source_dt else None + same_owner = bool(source_holder and source_holder == _normalize_counterparty_for_match(candidate.get("holder_name", ""))) + party_score = max( + _party_match_score(str(source.get("counterparty", "")), str(candidate.get("counterparty", ""))), + _party_match_score(str(source.get("counterparty", "")), str(candidate.get("holder_name", ""))), + _party_match_score(str(source.get("merchant", "")), str(candidate.get("counterparty", ""))), + _party_match_score(str(source.get("merchant", "")), str(candidate.get("holder_name", ""))), + ) + source_gap_score = 1 if source.get("source") != candidate.get("source") else 0 + day_score = 3 if day_delta == 0 else 1 + time_score = 0 + if time_delta_seconds is not None: + if time_delta_seconds <= 120: + time_score = 3 + elif time_delta_seconds <= 3600: + time_score = 2 + elif time_delta_seconds <= 86400: + time_score = 1 + score = direction_score + party_score + source_gap_score + day_score + time_score + (3 if same_owner else 0) + reason_bits = [ + f"direction={direction_score}", + f"party={party_score}", + f"same_owner={int(same_owner)}", + f"same_source={int(source.get('source') == candidate.get('source'))}", + f"day_delta={day_delta}", + ] + if time_delta_seconds is not None: + reason_bits.append(f"time_delta={time_delta_seconds}s") + candidates.append( + { + "id": candidate.get("id"), + "transaction_date": candidate.get("transaction_date"), + "transaction_datetime": candidate.get("transaction_datetime"), + "source": candidate.get("source"), + "type": candidate.get("type"), + "amount": candidate.get("amount"), + "category": candidate.get("category"), + "subcategory": candidate.get("subcategory"), + "merchant": candidate.get("merchant"), + "counterparty": candidate.get("counterparty"), + "account_id": candidate.get("account_id"), + "account_name": candidate.get("account_name"), + "institution": candidate.get("institution"), + "holder_name": candidate.get("holder_name"), + "memo": candidate.get("memo"), + "score": score, + "same_owner": same_owner, + "day_delta": day_delta, + "time_delta_seconds": time_delta_seconds, + "reason": ", ".join(reason_bits), + } + ) + + candidates.sort(key=lambda item: (-int(item.get("score", 0)), int(item.get("day_delta", 99)), int(item.get("time_delta_seconds") or 999999), str(item.get("transaction_datetime") or ""))) + return ok(source_tx=source, count=len(candidates[:limit]), candidates=candidates[:limit], source="self_transfer_candidate_search") + + +def link_self_transfer_pair(source_transaction_id: str, candidate_transaction_id: str, db_path: str | None = None) -> dict[str, Any]: + if not source_transaction_id or not candidate_transaction_id: + return fail("validation_error", "both transaction ids are required") + if source_transaction_id == candidate_transaction_id: + return fail("validation_error", "source and candidate transaction must differ") + + preview = find_self_transfer_candidates(source_transaction_id, limit=50, db_path=db_path) + if not preview.get("success"): + return preview + candidates = preview.get("candidates") or [] + candidate = next((item for item in candidates if item.get("id") == candidate_transaction_id), None) + if candidate is None: + return fail("candidate_not_found", f"candidate not found for source transaction: {candidate_transaction_id}") + + link_tag = f"manual_self_transfer_link:{source_transaction_id}|{candidate_transaction_id}" + ts = now_iso() + with db(db_path) as conn: + source_row = conn.execute("SELECT * FROM transactions WHERE id=?", (source_transaction_id,)).fetchone() + candidate_row = conn.execute("SELECT * FROM transactions WHERE id=?", (candidate_transaction_id,)).fetchone() + if not source_row or not candidate_row: + return fail("not_found", "source or candidate transaction not found") + source_memo = _append_memo_tag(source_row["memo"], link_tag) + candidate_memo = _append_memo_tag(candidate_row["memo"], link_tag) + conn.execute( + "UPDATE transactions SET category='์ด์ฒด', subcategory='๋ณธ์ธ๊ณ„์ขŒ์ด๋™', memo=?, updated_at=? WHERE id=?", + (source_memo, ts, source_transaction_id), + ) + conn.execute( + "UPDATE transactions SET category='์ด์ฒด', subcategory='๋ณธ์ธ๊ณ„์ขŒ์ด๋™', memo=?, updated_at=? WHERE id=?", + (candidate_memo, ts, candidate_transaction_id), + ) + updated_source = conn.execute("SELECT * FROM transactions WHERE id=?", (source_transaction_id,)).fetchone() + updated_candidate = conn.execute("SELECT * FROM transactions WHERE id=?", (candidate_transaction_id,)).fetchone() + + return ok( + source_transaction=row_to_dict(updated_source), + candidate_transaction=row_to_dict(updated_candidate), + link_tag=link_tag, + score=candidate.get("score"), + reason=candidate.get("reason"), + source="manual_self_transfer_link", + ) + + +def link_cancel_to_original(cancel_transaction_id: str, original_transaction_id: str, db_path: str | None = None) -> dict[str, Any]: + if not cancel_transaction_id or not original_transaction_id: + return fail("validation_error", "both transaction ids are required") + if cancel_transaction_id == original_transaction_id: + return fail("validation_error", "cancel and original transaction must differ") + + preview = find_cancel_original_candidates(cancel_transaction_id, limit=50, db_path=db_path) + if not preview.get("success"): + return preview + candidates = preview.get("candidates") or [] + original = next((item for item in candidates if item.get("id") == original_transaction_id), None) + if original is None: + return fail("candidate_not_found", f"original transaction candidate not found: {original_transaction_id}") + + link_tag = f"manual_cancel_link:cancel={cancel_transaction_id}|original={original_transaction_id}" + ts = now_iso() + with db(db_path) as conn: + cancel_row = conn.execute("SELECT * FROM transactions WHERE id=?", (cancel_transaction_id,)).fetchone() + original_row = conn.execute("SELECT * FROM transactions WHERE id=?", (original_transaction_id,)).fetchone() + if not cancel_row or not original_row: + return fail("not_found", "cancel or original transaction not found") + + cancel_memo = _append_memo_tag(cancel_row["memo"], link_tag) + original_memo = _append_memo_tag(original_row["memo"], link_tag) + original_category = original_row["category"] or "์ˆ˜์ž…" + original_subcategory = original_row["subcategory"] or "ํ™˜๊ธ‰" + original_person = original_row["person"] or "" + conn.execute( + "UPDATE transactions SET type='refund', category=?, subcategory=?, person=?, memo=?, updated_at=? WHERE id=?", + (original_category, original_subcategory, original_person, cancel_memo, ts, cancel_transaction_id), + ) + conn.execute( + "UPDATE transactions SET memo=?, updated_at=? WHERE id=?", + (original_memo, ts, original_transaction_id), + ) + updated_cancel = conn.execute("SELECT * FROM transactions WHERE id=?", (cancel_transaction_id,)).fetchone() + updated_original = conn.execute("SELECT * FROM transactions WHERE id=?", (original_transaction_id,)).fetchone() + + return ok( + cancel_transaction=row_to_dict(updated_cancel), + original_transaction=row_to_dict(updated_original), + link_tag=link_tag, + score=original.get("score"), + reason=original.get("reason"), + source="manual_cancel_link", + ) + + +def _tag_memos(conn: Any, tx_id: str, tag: str) -> None: + existing = conn.execute("SELECT memo FROM transactions WHERE id=?", (tx_id,)).fetchone() + if not existing: + return + memo = _append_memo_tag(existing["memo"], tag) + conn.execute("UPDATE transactions SET memo=?, updated_at=? WHERE id=?", (memo, now_iso(), tx_id)) + + +def _run_cross_account_transfer_match(limit: int = 500, dry_run: bool = True, db_path: str | None = None) -> dict[str, Any]: + limit = max(1, min(int(limit), 1000)) + matched_pairs: list[dict[str, Any]] = [] + review_items: list[dict[str, Any]] = [] + matched_tx_ids: set[str] = set() + + with db(db_path) as conn: + kb_rows = _fetch_transfer_candidates(conn, "kb_bank_pdf") + toss_rows = _fetch_transfer_candidates(conn, "toss_bank_pdf") + toss_index = _build_cross_transfer_index(toss_rows) + + for kb in kb_rows: + if kb["status"] != "active": + continue + if _memo_has_tag(kb.get("memo", ""), CROSS_TRANSFER_MATCH_TAG): + continue + required_type = "income" if kb["type"] == "expense" else "expense" + candidates = [ + t + for t in toss_index.get((kb["transaction_date"], int(kb["amount"]), required_type), []) + if t.get("status") == "active" and not _memo_has_tag(t.get("memo", ""), CROSS_TRANSFER_MATCH_TAG) + ] + + if not candidates: + review_items.append( + { + "source_tx_id": kb["id"], + "source": "kb_bank_pdf", + "target_tx_id": "", + "reason": "no_candidate_found", + "type": kb["type"], + "amount": kb["amount"], + "transaction_date": kb["transaction_date"], + "counterparty": kb["counterparty"], + } + ) + continue + + scored = [] + for candidate in candidates: + score = _party_match_score(kb.get("counterparty", ""), candidate.get("counterparty", "")) + scored.append((score, candidate["transaction_datetime"], candidate)) + scored.sort(key=lambda item: (item[0], item[1]), reverse=True) + best_score = scored[0][0] + top = [c for c in scored if c[0] == best_score] + + if best_score == 0 or len(top) != 1: + review_items.append( + { + "source_tx_id": kb["id"], + "source": "kb_bank_pdf", + "target_tx_id": "", + "reason": "ambiguous_or_name_mismatch", + "candidate_count": len(candidates), + "best_name_score": best_score, + "type": kb["type"], + "amount": kb["amount"], + "transaction_date": kb["transaction_date"], + "counterparty": kb["counterparty"], + } + ) + continue + + toss = top[0][2] + if toss["id"] in matched_tx_ids: + review_items.append( + { + "source_tx_id": kb["id"], + "source": "kb_bank_pdf", + "target_tx_id": toss["id"], + "reason": "target_already_matched", + "type": kb["type"], + "amount": kb["amount"], + "transaction_date": kb["transaction_date"], + "counterparty": kb["counterparty"], + } + ) + continue + + kb_party = _normalize_counterparty_for_match(kb.get("counterparty", "")) + toss_party = _normalize_counterparty_for_match(toss.get("counterparty", "")) + owner_candidates = { + _normalize_counterparty_for_match(kb.get("holder_name", "")), + _normalize_counterparty_for_match(toss.get("holder_name", "")), + } + if (kb_party and kb_party in owner_candidates) or (toss_party and toss_party in owner_candidates): + subcategory = "ํˆฌ์ž์ด๋™" + else: + subcategory = "ํ™•์ธํ•„์š”" + + matched_pairs.append( + { + "kb_tx_id": kb["id"], + "toss_tx_id": toss["id"], + "transaction_date": kb["transaction_date"], + "amount": kb["amount"], + "counterparty_kb": kb["counterparty"], + "counterparty_toss": toss["counterparty"], + "subcategory": subcategory, + "name_score": best_score, + "reason": "paired_by_date_amount_party", + } + ) + if not dry_run: + review_tag = f"{CROSS_TRANSFER_MATCH_TAG}kb={kb['id']}|toss={toss['id']}" + conn.execute( + "UPDATE transactions SET category='์ด์ฒด', subcategory=?, memo=?, updated_at=? WHERE id=?", + (subcategory, _append_memo_tag(kb.get("memo", ""), review_tag), now_iso(), kb["id"]), + ) + conn.execute( + "UPDATE transactions SET category='์ด์ฒด', subcategory=?, memo=?, updated_at=? WHERE id=?", + (subcategory, _append_memo_tag(toss.get("memo", ""), review_tag), now_iso(), toss["id"]), + ) + + if not dry_run: + _tag_memos(conn, kb["id"], f"{CROSS_TRANSFER_REVIEW_TAG}paired") + _tag_memos(conn, toss["id"], f"{CROSS_TRANSFER_REVIEW_TAG}paired") + matched_tx_ids.add(kb["id"]) + matched_tx_ids.add(toss["id"]) + + if len(matched_pairs) >= limit: + break + + for review in review_items: + if review["source_tx_id"] in matched_tx_ids: + continue + if not dry_run: + _tag_memos(conn, review["source_tx_id"], f"{CROSS_TRANSFER_REVIEW_TAG}{review['reason']}") + + + return ok( + dry_run=dry_run, + matched_count=len(matched_pairs), + review_count=len(review_items), + matched_pairs=matched_pairs[:limit], + review_items=review_items[:limit], + source="kb_toss_cross_transfer_match", + ) + + +def preview_cross_account_transfers(limit: int = 500, db_path: str | None = None) -> dict[str, Any]: + return _run_cross_account_transfer_match(limit=limit, dry_run=True, db_path=db_path) + + +def run_cross_account_transfer_match(limit: int = 500, db_path: str | None = None) -> dict[str, Any]: + return _run_cross_account_transfer_match(limit=limit, dry_run=False, db_path=db_path) + + +def list_cross_transfer_review_queue(limit: int = 300, db_path: str | None = None) -> dict[str, Any]: + clauses = ["t.status='active'", "t.source IN ('kb_bank_pdf','toss_bank_pdf')"] + clauses.append("t.memo LIKE '%cross_transfer_review:%'") + where = " WHERE " + " AND ".join(clauses) + with db(db_path) as conn: + rows = conn.execute( + f"SELECT * FROM transactions t{where} ORDER BY t.transaction_date DESC, t.created_at DESC LIMIT ?", + (max(1, min(int(limit), 1000)),), + ).fetchall() + return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows), source="cross_transfer_review_queue") + + +def preview_seoulpay_xlsx_import(file_path: str, password: str = "", preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]: + try: + parsed = parse_seoulpay_xlsx(file_path, password=password) + except FileNotFoundError as e: + return fail("not_found", str(e)) + except ValueError as e: + return fail("xlsx_parse_error", str(e)) + except Exception as e: + return fail("xlsx_parse_error", str(e)) + + transactions = parsed["transactions"] + source_hash = parsed["source_hash"] + duplicate_count = 0 + by_category: dict[str, dict[str, int]] = {} + with db(db_path) as conn: + linked_account_id = "" + metadata = parsed["metadata"] + holder_name = metadata.get("holder_name") or "" + if holder_name: + row = conn.execute( + "SELECT id FROM accounts WHERE institution=? AND name=? AND active=1 ORDER BY created_at LIMIT 1", + ("์„œ์šธํŽ˜์ด", f"์„œ์šธํŽ˜์ด ์ด์šฉ๋‚ด์—ญ {holder_name}".strip()), + ).fetchone() + linked_account_id = row["id"] if row else "" + for tx in transactions: + category, subcategory, _, _ = _classify_seoulpay_transaction(tx) + key = f"{category}/{subcategory}" + item = by_category.setdefault(key, {"count": 0, "amount": 0}) + item["count"] += 1 + item["amount"] += int(tx.amount) + merchant = f"์„œ์šธํŽ˜์ด {tx.merchant}".strip() + dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, tx.external_id(source_hash)) + found = conn.execute("SELECT 1 FROM transactions WHERE dedupe_key=?", (dedupe_key,)).fetchone() + duplicate_count += 1 if found else 0 + + preview = [seoulpay_transaction_preview(tx, source_hash) for tx in transactions[: max(1, min(int(preview_limit), 100))]] + return ok( + source="seoulpay_xlsx", + file_path=parsed["file_path"], + source_hash=source_hash, + sheet_name=parsed["sheet_name"], + metadata=parsed["metadata"], + row_count=len(transactions), + duplicate_count=duplicate_count, + validation=parsed["validation"], + by_category=by_category, + preview=preview, + ) + + +def import_seoulpay_xlsx(file_path: str, password: str = "", account_id: str = "", skip_duplicates: bool = True, db_path: str | None = None) -> dict[str, Any]: + try: + parsed = parse_seoulpay_xlsx(file_path, password=password) + except FileNotFoundError as e: + return fail("not_found", str(e)) + except ValueError as e: + return fail("xlsx_parse_error", str(e)) + except Exception as e: + return fail("xlsx_parse_error", str(e)) + + transactions = parsed["transactions"] + validation = parsed["validation"] + if not validation.get("ok"): + return fail("xlsx_parse_error", "SeoulPay XLSX parsing produced invalid transactions", validation=validation) + + source_hash = parsed["source_hash"] + metadata = parsed["metadata"] + ts = now_iso() + batch_id = new_id("imp") + inserted = 0 + skipped_duplicates = 0 + inserted_ids: list[str] = [] + + try: + with db(db_path) as conn: + linked_account_id = _find_or_create_seoulpay_account(conn, metadata, account_id) + conn.execute( + """INSERT INTO import_batches(id,source,filename,imported_at,row_count,status,memo) + VALUES(?,?,?,?,?,?,?)""", + ( + batch_id, + "seoulpay_xlsx", + parsed["file_path"], + ts, + 0, + "active", + json.dumps( + { + "source_hash": source_hash, + "sheet_name": parsed["sheet_name"], + "metadata": metadata, + "validation": validation, + "parsed_row_count": len(transactions), + }, + ensure_ascii=False, + ), + ), + ) + for tx in transactions: + tx_id = new_id("tx") + external_id = tx.external_id(source_hash) + merchant = f"์„œ์šธํŽ˜์ด {tx.merchant}".strip() + category, subcategory, person, is_fixed = _classify_seoulpay_transaction(tx) + dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, external_id) + description = f"์„œ์šธํŽ˜์ด XLSX import; ๊ฑฐ๋ž˜์œ ํ˜•={tx.transaction_kind}; ๊ฒฐ์ œ๊ตฌ๋ถ„={tx.payment_kind}; ๊ฑฐ๋ž˜๋ฒˆํ˜ธ={tx.transaction_no}; ๊ฐ€๋งน์ ID={tx.merchant_id}" + try: + 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,import_batch_id,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + tx_id, + tx.transaction_date, + "", + tx.type, + tx.amount, + "KRW", + category, + subcategory, + merchant, + description, + linked_account_id, + tx.payment_kind, + person, + "", + normalize_tags(["seoulpay_xlsx", tx.channel, tx.payment_kind, tx.transaction_kind]), + int(is_fixed), + "seoulpay_xlsx", + batch_id, + external_id, + dedupe_key, + tx.payment_kind, + tx.transaction_datetime, + tx.merchant, + None, + tx.channel, + tx.transaction_kind, + tx.raw_payload(), + ts, + ts, + ), + ) + inserted += 1 + inserted_ids.append(tx_id) + except Exception as e: + if "UNIQUE constraint failed" in str(e) and skip_duplicates: + skipped_duplicates += 1 + continue + raise + conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id)) + except ValueError as e: + return fail("validation_error", str(e)) + except Exception as e: + return fail("db_error", str(e)) + + return ok( + import_batch_id=batch_id, + account_id=linked_account_id, + source="seoulpay_xlsx", + parsed_row_count=len(transactions), + inserted_count=inserted, + skipped_duplicate_count=skipped_duplicates, + inserted_transaction_ids=inserted_ids[:20], + validation=validation, + metadata=metadata, + ) + + +def preview_toss_bank_pdf_import(file_path: str, preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]: + try: + parsed = parse_toss_bank_pdf(file_path) + except FileNotFoundError as e: + return fail("not_found", str(e)) + except ValueError as e: + return fail("pdf_parse_error", str(e)) + except Exception as e: + return fail("pdf_parse_error", str(e)) + + transactions = parsed["transactions"] + source_hash = parsed["source_hash"] + duplicate_count = 0 + with db(db_path) as conn: + metadata = parsed["metadata"] + account_number_masked = metadata.get("account_number_masked") or "" + linked_account_id = "" + if account_number_masked: + row = conn.execute( + "SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1", + ("ํ† ์Šค๋ฑ…ํฌ", account_number_masked), + ).fetchone() + linked_account_id = row["id"] if row else "" + for tx in transactions: + merchant = f"ํ† ์Šค๋ฑ…ํฌ {tx.action}" + (f" {tx.counterparty}" if tx.counterparty else "") + dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, tx.external_id(source_hash)) + found = conn.execute("SELECT 1 FROM transactions WHERE dedupe_key=?", (dedupe_key,)).fetchone() + duplicate_count += 1 if found else 0 + + preview = [toss_bank_transaction_preview(tx, source_hash) for tx in transactions[: max(1, min(int(preview_limit), 100))]] + by_action: dict[str, dict[str, int]] = {} + for tx in transactions: + item = by_action.setdefault(tx.action, {"count": 0, "amount": 0}) + item["count"] += 1 + item["amount"] += int(tx.amount) + return ok( + source="toss_bank_pdf", + file_path=parsed["file_path"], + source_hash=source_hash, + page_count=parsed["page_count"], + metadata=parsed["metadata"], + row_count=len(transactions), + duplicate_count=duplicate_count, + validation=parsed["validation"], + by_action=by_action, + preview=preview, + ) + + +def import_toss_bank_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True, db_path: str | None = None) -> dict[str, Any]: + try: + parsed = parse_toss_bank_pdf(file_path) + except FileNotFoundError as e: + return fail("not_found", str(e)) + except ValueError as e: + return fail("pdf_parse_error", str(e)) + except Exception as e: + return fail("pdf_parse_error", str(e)) + + transactions = parsed["transactions"] + validation = parsed["validation"] + if not validation.get("ok"): + return fail("pdf_parse_error", "Toss Bank PDF parsing produced no valid transactions", validation=validation) + + source_hash = parsed["source_hash"] + metadata = parsed["metadata"] + ts = now_iso() + batch_id = new_id("imp") + inserted = 0 + skipped_duplicates = 0 + inserted_ids: list[str] = [] + + try: + with db(db_path) as conn: + linked_account_id = _find_or_create_toss_bank_account(conn, metadata, account_id) + conn.execute( + """INSERT INTO import_batches(id,source,filename,imported_at,row_count,status,memo) + VALUES(?,?,?,?,?,?,?)""", + ( + batch_id, + "toss_bank_pdf", + parsed["file_path"], + ts, + 0, + "active", + json.dumps( + { + "source_hash": source_hash, + "page_count": parsed["page_count"], + "metadata": metadata, + "validation": validation, + "parsed_row_count": len(transactions), + }, + ensure_ascii=False, + ), + ), + ) + for tx in transactions: + tx_id = new_id("tx") + external_id = tx.external_id(source_hash) + merchant = f"ํ† ์Šค๋ฑ…ํฌ {tx.action}" + (f" {tx.counterparty}" if tx.counterparty else "") + category, subcategory, person, is_fixed = _classify_toss_bank_transaction(tx, metadata.get("holder_name") or "") + if category == "์ด์ฒด": + tx_type = "transfer" + else: + tx_type = tx.type + dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, external_id) + description = f"ํ† ์Šค๋ฑ…ํฌ PDF import; ๊ตฌ๋ถ„={tx.action}; ์ƒ๋Œ€={tx.counterparty}; ์ž”์•ก={tx.balance_after}" + payload = tx.raw_payload() + try: + 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,import_batch_id,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + tx_id, + tx.transaction_date, + "", + tx_type, + tx.amount, + "KRW", + category, + subcategory, + merchant, + description, + linked_account_id, + tx.action, + person, + "", + normalize_tags(["toss_bank_pdf", tx.action] + ([tx.counterparty] if tx.counterparty else [])), + int(is_fixed), + "toss_bank_pdf", + batch_id, + external_id, + dedupe_key, + tx.action, + tx.transaction_datetime, + tx.counterparty, + tx.balance_after, + "ํ† ์Šค๋ฑ…ํฌ", + tx.raw_summary, + payload, + ts, + ts, + ), + ) + inserted += 1 + inserted_ids.append(tx_id) + except Exception as e: + if "UNIQUE constraint failed" in str(e) and skip_duplicates: + skipped_duplicates += 1 + continue + raise + conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id)) + if transactions: + conn.execute( + "UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?", + (transactions[-1].balance_after, ts, ts, linked_account_id), + ) + except ValueError as e: + return fail("validation_error", str(e)) + except Exception as e: + return fail("db_error", str(e)) + + return ok( + import_batch_id=batch_id, + account_id=linked_account_id, + source="toss_bank_pdf", + parsed_row_count=len(transactions), + inserted_count=inserted, + skipped_duplicate_count=skipped_duplicates, + inserted_transaction_ids=inserted_ids[:20], + validation=validation, + metadata=metadata, + ) + + +def _find_or_create_toss_securities_account(conn: Any, metadata: dict[str, Any], account_id: str = "") -> str: + if account_id: + row = conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone() + if row: + return account_id + raise ValueError(f"account_id not found: {account_id}") + account_number_masked = metadata.get("account_number_masked") or "" + row = None + if account_number_masked: + row = conn.execute( + "SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1", + ("ํ† ์Šค์ฆ๊ถŒ", account_number_masked), + ).fetchone() + if row: + return row["id"] + ts = now_iso() + new_account_id = new_id("acct") + display_name = f"ํ† ์Šค์ฆ๊ถŒ {metadata.get('account_name') or '๊ณ„์ขŒ'} {account_number_masked}".strip() + conn.execute( + """INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + new_account_id, + display_name, + "investment", + "ํ† ์Šค์ฆ๊ถŒ", + "KRW", + 1, + "ํ† ์Šค์ฆ๊ถŒ ๊ฑฐ๋ž˜๋‚ด์—ญ PDF import๋กœ ์ž๋™ ์ƒ์„ฑ", + account_number_masked, + metadata.get("holder_name") or "", + None, + ts, + ts, + ts, + ), + ) + return new_account_id + + +def _classify_toss_transaction(tx: Any, owner_name: str = "") -> tuple[str, str, str, bool]: + action = tx.action + counterparty = (tx.name or "").strip() + owner_name = (owner_name or "").strip() + if "๋ฐฐ๋‹น" in action: + return "์ˆ˜์ž…", "๋ฐฐ๋‹น", counterparty, False + if "์ด์ž" in action: + return "์ˆ˜์ž…", "์ด์ž", counterparty, False + if "ํ™˜๊ธ‰" in action: + return "์ˆ˜์ž…", "ํ™˜๊ธ‰", counterparty, False + if "์ด์ฒด" in action: + # ํ† ์Šค์ฆ๊ถŒ ์ž…์ถœ๊ธˆ์—์„œ ์ƒ๋Œ€๊ฐ€ ๊ณ„์ขŒ ์†Œ์œ ์ž์™€ ๋‹ค๋ฅด๋ฉด ๋ณธ์ธ ํˆฌ์ž ์ด๋™์œผ๋กœ ํ™•์ •ํ•˜์ง€ ์•Š๋Š”๋‹ค. + # ์˜ˆ: ์ด์ฒด์ถœ๊ธˆ(๊ตญ๋ฏผ์€ํ–‰) ์กฐ์ƒ๋ฌธ -> ์™ธ๋ถ€/ํƒ€์ธ ์†ก๊ธˆ ๊ฐ€๋Šฅ์„ฑ์ด ์žˆ์–ด ํ™•์ธํ•„์š”. + if counterparty and owner_name and counterparty != owner_name: + return "์ด์ฒด", "ํ™•์ธํ•„์š”", counterparty, False + return "์ด์ฒด", "ํˆฌ์ž์ด๋™", counterparty, False + return "์ด์ฒด", "ํˆฌ์ž์ด๋™", counterparty, False + + +def preview_toss_securities_pdf_import(file_path: str, preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]: + try: + parsed = parse_toss_securities_pdf(file_path) + except FileNotFoundError as e: + return fail("not_found", str(e)) + except ValueError as e: + return fail("pdf_parse_error", str(e)) + except Exception as e: + return fail("pdf_parse_error", str(e)) + transactions = parsed["transactions"] + source_hash = parsed["source_hash"] + duplicate_count = 0 + with db(db_path) as conn: + metadata = parsed["metadata"] + account_number_masked = metadata.get("account_number_masked") or "" + linked_account_id = "" + if account_number_masked: + row = conn.execute( + "SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1", + ("ํ† ์Šค์ฆ๊ถŒ", account_number_masked), + ).fetchone() + linked_account_id = row["id"] if row else "" + for tx in transactions: + merchant = f"ํ† ์Šค์ฆ๊ถŒ {tx.action}" + (f" {tx.name}" if tx.name else "") + dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, tx.external_id(source_hash)) + found = conn.execute("SELECT 1 FROM transactions WHERE dedupe_key=?", (dedupe_key,)).fetchone() + duplicate_count += 1 if found else 0 + preview = [toss_transaction_preview(tx, source_hash) for tx in transactions[: max(1, min(int(preview_limit), 100))]] + by_action: dict[str, dict[str, int]] = {} + for tx in transactions: + item = by_action.setdefault(tx.action, {"count": 0, "amount": 0}) + item["count"] += 1 + item["amount"] += int(tx.amount) + return ok( + source="toss_securities_pdf", + file_path=parsed["file_path"], + source_hash=source_hash, + page_count=parsed["page_count"], + metadata=parsed["metadata"], + row_count=len(transactions), + duplicate_count=duplicate_count, + validation=parsed["validation"], + by_action=by_action, + preview=preview, + ) + + +def import_toss_securities_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True, db_path: str | None = None) -> dict[str, Any]: + try: + parsed = parse_toss_securities_pdf(file_path) + except FileNotFoundError as e: + return fail("not_found", str(e)) + except ValueError as e: + return fail("pdf_parse_error", str(e)) + except Exception as e: + return fail("pdf_parse_error", str(e)) + transactions = parsed["transactions"] + validation = parsed["validation"] + if not validation.get("ok"): + return fail("pdf_parse_error", "Toss Securities PDF parsing produced no valid transactions", validation=validation) + source_hash = parsed["source_hash"] + metadata = parsed["metadata"] + ts = now_iso() + batch_id = new_id("imp") + inserted = 0 + skipped_duplicates = 0 + inserted_ids: list[str] = [] + try: + with db(db_path) as conn: + linked_account_id = _find_or_create_toss_securities_account(conn, metadata, account_id) + conn.execute( + """INSERT INTO import_batches(id,source,filename,imported_at,row_count,status,memo) + VALUES(?,?,?,?,?,?,?)""", + ( + batch_id, + "toss_securities_pdf", + parsed["file_path"], + ts, + 0, + "active", + json.dumps( + { + "source_hash": source_hash, + "page_count": parsed["page_count"], + "metadata": metadata, + "validation": validation, + "parsed_row_count": len(transactions), + }, + ensure_ascii=False, + ), + ), + ) + for tx in transactions: + tx_id = new_id("tx") + external_id = tx.external_id(source_hash) + merchant = f"ํ† ์Šค์ฆ๊ถŒ {tx.action}" + (f" {tx.name}" if tx.name else "") + category, subcategory, person, is_fixed = _classify_toss_transaction(tx, metadata.get("holder_name") or "") + dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, external_id) + description = f"ํ† ์Šค์ฆ๊ถŒ PDF import; ๊ตฌ๋ถ„={tx.action}; ์ข…๋ชฉ/์ƒ๋Œ€={tx.name}; ์ˆ˜๋Ÿ‰={tx.quantity}; ๋‹จ๊ฐ€={tx.unit_price}; ์ˆ˜์ˆ˜๋ฃŒ={tx.fee}; ์„ธ๊ธˆ={tx.tax}; ์ž”์•ก={tx.balance_after}" + try: + 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,import_batch_id,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + tx_id, + tx.transaction_date, + "", + tx.type, + tx.amount, + "KRW", + category, + subcategory, + merchant, + description, + linked_account_id, + tx.action, + person, + "", + normalize_tags(["toss_securities_pdf", tx.section, tx.action, tx.code] if tx.code else ["toss_securities_pdf", tx.section, tx.action]), + int(is_fixed), + "toss_securities_pdf", + batch_id, + external_id, + dedupe_key, + tx.code, + tx.transaction_date, + tx.name, + tx.balance_after, + "ํ† ์Šค์ฆ๊ถŒ", + tx.action, + tx.raw_payload(), + ts, + ts, + ), + ) + inserted += 1 + inserted_ids.append(tx_id) + except Exception as e: + if "UNIQUE constraint failed" in str(e) and skip_duplicates: + skipped_duplicates += 1 + continue + raise + conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id)) + if transactions: + conn.execute( + "UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?", + (transactions[-1].balance_after, ts, ts, linked_account_id), + ) + except ValueError as e: + return fail("validation_error", str(e)) + except Exception as e: + return fail("db_error", str(e)) + return ok( + import_batch_id=batch_id, + account_id=linked_account_id, + source="toss_securities_pdf", + parsed_row_count=len(transactions), + inserted_count=inserted, + skipped_duplicate_count=skipped_duplicates, + inserted_transaction_ids=inserted_ids[:20], + validation=validation, + metadata=metadata, + ) + + +def list_ledger_import_batches(source: str = "", include_rolled_back: bool = True, limit: int = 50, db_path: str | None = None) -> dict[str, Any]: + clauses=[]; params=[] + if source: + clauses.append("source=?"); params.append(source) + if not include_rolled_back: + clauses.append("status='active'") + where = " WHERE " + " AND ".join(clauses) if clauses else "" + params.append(max(1, min(int(limit), 500))) + with db(db_path) as conn: + rows=conn.execute(f"SELECT * FROM import_batches{where} ORDER BY imported_at DESC LIMIT ?", params).fetchall() + return ok(import_batches=[row_to_dict(r) for r in rows], count=len(rows)) + + +def rollback_ledger_import_batch(import_batch_id: str, hard_delete: bool = False, db_path: str | None = None) -> dict[str, Any]: + with db(db_path) as conn: + batch = conn.execute("SELECT * FROM import_batches WHERE id=?", (import_batch_id,)).fetchone() + if not batch: + return fail("not_found", "import batch not found") + if hard_delete: + cur = conn.execute("DELETE FROM transactions WHERE import_batch_id=?", (import_batch_id,)) + else: + cur = conn.execute("UPDATE transactions SET status='deleted', updated_at=? WHERE import_batch_id=? AND status='active'", (now_iso(), import_batch_id)) + conn.execute("UPDATE import_batches SET status='rolled_back' WHERE id=?", (import_batch_id,)) + return ok(import_batch_id=import_batch_id, affected_transactions=cur.rowcount, hard_delete=hard_delete) diff --git a/jarca_ledger_mcp/application/shared.py b/jarca_ledger_mcp/application/shared.py new file mode 100644 index 0000000..0ed17cc --- /dev/null +++ b/jarca_ledger_mcp/application/shared.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import csv +import hashlib +import json +import re +import sqlite3 +from datetime import date, datetime, timedelta +from pathlib import Path +from typing import Any +from uuid import uuid4 +from zoneinfo import ZoneInfo + +from ..infrastructure.sqlite import db, init_db, resolve_db_path +from ..kb_bank_pdf import parse_kb_bank_pdf, transaction_preview +from ..toss_bank_pdf import parse_toss_bank_pdf, transaction_preview as toss_bank_transaction_preview +from ..toss_securities_pdf import parse_toss_securities_pdf, transaction_preview as toss_transaction_preview +from ..seoulpay_xlsx import parse_seoulpay_xlsx, transaction_preview as seoulpay_transaction_preview + +VALID_TRANSACTION_TYPES = {"income", "expense", "transfer", "refund", "adjustment"} +VALID_ACCOUNT_TYPES = {"bank", "card", "cash", "investment", "loan", "other"} +VALID_STATEMENT_STATUSES = {"unknown", "import_supported", "manual_only", "partial_import"} +VALID_LIQUIDITY_BUCKETS = {"cash", "investment", "deposit", "real_estate", "loan", "other"} +ASSET_BUCKETS = ["cash", "investment", "deposit", "real_estate", "other"] +LIABILITY_BUCKETS = ["loan"] +ASSET_BUCKET_ORDER = [*ASSET_BUCKETS, *LIABILITY_BUCKETS] +RECONCILIATION_TARGET_STATUSES = {"manual_only", "partial_import"} +BALANCE_RECONCILIATION_TOLERANCE_KRW = 1_000 +BALANCE_RECONCILIATION_REFERENCE_RATIO = 0.001 +VALID_CASHFLOW_KINDS = {"income", "expense", "transfer", "investment", "debt_payment", "exclude", "unknown"} +CASHFLOW_KIND_SQL = """COALESCE(lc.cashflow_kind, CASE + WHEN t.category='์ˆ˜์ž…' THEN 'income' + WHEN t.category='์ด์ฒด' AND t.subcategory='ํˆฌ์ž์ด๋™' THEN 'investment' + WHEN t.category='์ด์ฒด' THEN 'transfer' + WHEN t.category='๊ธˆ์œต' AND t.subcategory='๋Œ€์ถœ์ƒํ™˜' THEN 'debt_payment' + WHEN t.category='๊ธˆ์œต' THEN 'exclude' + WHEN t.category='๋ฏธ๋ถ„๋ฅ˜' THEN 'unknown' + WHEN COALESCE(t.category, '')='' THEN 'unknown' + ELSE 'expense' +END)""" + +CROSS_TRANSFER_MATCH_TAG = "cross_transfer_match:" +CROSS_TRANSFER_REVIEW_TAG = "cross_transfer_review:" +DEFAULT_LEDGER_BACKUP_DIR = Path.home() / ".local" / "share" / "jarca" / "backups" / "ledger" +KST = ZoneInfo("Asia/Seoul") +BACKUP_RESTORE_POLICY = { + "mcp_restore_exposed": False, + "restore_requires_explicit_user_approval": True, + "restore_entrypoint": "scripts/ledger_restore_smoke.py ๋˜๋Š” docs/runbooks/ledger-backup-restore.md", +} + + +def _normalize_counterparty_for_match(value: str) -> str: + if not value: + return "" + # normalize party names for fuzzy matching across KB/ํ† ์Šค๋ฑ…ํฌ records + normalized = re.sub(r"\\([^)]*\\)", "", value) + normalized = normalized.lower().strip() + normalized = normalized.replace("ํ† ์Šค๋ฑ…ํฌ", "").replace("ํ† ์Šค", "") + normalized = normalized.replace("kb", "").replace("๊ตญ๋ฏผ์€ํ–‰", "") + normalized = re.sub(r"\\s+", "", normalized) + normalized = re.sub(r"[^0-9๊ฐ€-ํžฃa-zA-Z]", "", normalized) + return normalized + + +def _party_match_score(left: str, right: str) -> int: + left_norm = _normalize_counterparty_for_match(left) + right_norm = _normalize_counterparty_for_match(right) + if not left_norm or not right_norm: + return 0 + if left_norm == right_norm: + return 2 + if left_norm in right_norm or right_norm in left_norm: + return 1 + return 0 + + +def _append_memo_tag(raw_memo: str, tag: str) -> str: + memo = (raw_memo or "").strip() + if not memo: + return tag + if tag in memo: + return memo + return f"{memo}; {tag}" + + +def _memo_has_tag(raw_memo: str, tag_prefix: str) -> bool: + memo = (raw_memo or "").strip() + return tag_prefix in memo + + +def _current_kst_datetime() -> datetime: + return datetime.now(KST).replace(microsecond=0) + + +def current_kst_date() -> date: + return _current_kst_datetime().date() + + +def current_kst_month() -> str: + return current_kst_date().strftime("%Y-%m") + + +def now_iso() -> str: + return _current_kst_datetime().isoformat() + +def new_id(prefix: str) -> str: + return f"{prefix}_{uuid4().hex[:12]}" + +def ok(**data: Any) -> dict[str, Any]: + return {"success": True, **data} + +def fail(error_code: str, message: str, **data: Any) -> dict[str, Any]: + return {"success": False, "error_code": error_code, "error": message, **data} + +def row_to_dict(row: Any) -> dict[str, Any]: + d = dict(row) + if "tags" in d: + try: + d["tags"] = json.loads(d["tags"] or "[]") + except json.JSONDecodeError: + d["tags"] = [] + for k in ("active", "is_fixed", "is_correction"): + if k in d: + d[k] = bool(d[k]) + return d + +def normalize_date(value: str | None, default_today: bool = False) -> str: + if not value: + if default_today: + return current_kst_date().isoformat() + raise ValueError("date is required") + return date.fromisoformat(value[:10]).isoformat() + +def normalize_month(value: str | None) -> str: + if not value: + return current_kst_month() + if re.fullmatch(r"\d{4}-\d{2}", value): + return value + return date.fromisoformat(value[:10]).strftime("%Y-%m") + + +def normalize_statement_status(value: str | None) -> str: + status = (value or "unknown").strip() or "unknown" + if status not in VALID_STATEMENT_STATUSES: + raise ValueError(f"statement_status must be one of {sorted(VALID_STATEMENT_STATUSES)}") + return status + + +def normalize_liquidity_bucket(value: str | None) -> str: + bucket = (value or "other").strip() or "other" + if bucket not in VALID_LIQUIDITY_BUCKETS: + raise ValueError(f"liquidity_bucket must be one of {sorted(VALID_LIQUIDITY_BUCKETS)}") + return bucket + + +def normalize_snapshot_at(value: str | None, default_now: bool = True) -> str: + if not value: + if default_now: + return now_iso() + raise ValueError("baseline_at is required") + raw = value.strip() + if re.fullmatch(r"\d{4}-\d{2}", raw): + month_start = date.fromisoformat(raw + "-01") + if month_start.month == 12: + next_month = date(month_start.year + 1, 1, 1) + else: + next_month = date(month_start.year, month_start.month + 1, 1) + last_day = next_month - timedelta(days=1) + return f"{last_day.isoformat()}T23:59:00+09:00" + if "T" not in raw and len(raw) == 10: + return f"{normalize_date(raw)}T23:59:00+09:00" + datetime.fromisoformat(raw.replace("Z", "+00:00")) + return raw + +def normalize_tags(tags: Any) -> str: + if tags is None or tags == "": + return "[]" + if isinstance(tags, str): + parts = [p.strip() for p in re.split(r"[,#]", tags) if p.strip()] + return json.dumps(parts, ensure_ascii=False) + if isinstance(tags, list): + return json.dumps([str(x).strip() for x in tags if str(x).strip()], ensure_ascii=False) + raise ValueError("tags must be a list or comma-separated string") + + +def _month_range(month: str) -> tuple[str, str]: + m = normalize_month(month) + start = date.fromisoformat(f"{m}-01") + next_month = date(start.year + (start.month // 12), ((start.month % 12) + 1), 1) + return start.isoformat(), next_month.isoformat() + + +def make_dedupe_key(transaction_date: str, amount: int, merchant: str = "", account_id: str = "", external_id: str = "") -> str: + if external_id: + raw = f"external:{external_id}" + else: + raw = f"{transaction_date}|{amount}|{merchant.strip().lower()}|{account_id.strip().lower()}" + return hashlib.sha256(raw.encode("utf-8")).hexdigest() diff --git a/jarca_ledger_mcp/application/system_services.py b/jarca_ledger_mcp/application/system_services.py new file mode 100644 index 0000000..514742f --- /dev/null +++ b/jarca_ledger_mcp/application/system_services.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +from .shared import * +from .shared import _current_kst_datetime, _month_range + +def health_check(db_path: str | None = None) -> dict[str, Any]: + path = init_db(db_path) + with db(path) as conn: + schema_version = conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] + tx_count = conn.execute("SELECT COUNT(*) FROM transactions WHERE status='active'").fetchone()[0] + return ok(server="jarca-ledger-mcp", db_path=path, schema_version=schema_version, active_transactions=tx_count) + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _file_metadata(path: Path, include_hash: bool = True) -> dict[str, Any]: + stat = path.stat() + metadata: dict[str, Any] = { + "path": str(path.resolve()), + "size_bytes": stat.st_size, + "mtime": datetime.fromtimestamp(stat.st_mtime).replace(microsecond=0).isoformat(), + } + if include_hash: + metadata["sha256"] = _sha256_file(path) + return metadata + + +def _readonly_sqlite_connection(path: Path) -> sqlite3.Connection: + conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True) + conn.row_factory = sqlite3.Row + return conn + + +def _safe_table_counts(conn: sqlite3.Connection) -> dict[str, int]: + counts: dict[str, int] = {} + for table in ("accounts", "transactions", "budgets", "import_batches", "category_rules", "ledger_categories", "manual_assets", "asset_snapshots"): + exists = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone() + if exists: + counts[table] = int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]) + return counts + + +def _safe_monthly_aggregates(conn: sqlite3.Connection) -> dict[str, Any]: + current_month = current_kst_month() + start, next_month = _month_range(current_month) + rows = conn.execute( + f"""SELECT {CASHFLOW_KIND_SQL} AS cashflow_kind, SUM(t.amount) AS total, COUNT(*) AS count + FROM transactions t + LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory + WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date dict[str, Any]: + status: dict[str, Any] = {"db_path": str(path.resolve()), "exists": path.exists()} + if not path.exists(): + return status + status.update(_file_metadata(path, include_hash=include_hash)) + try: + with _readonly_sqlite_connection(path) as conn: + if include_integrity: + status["integrity_check"] = str(conn.execute("PRAGMA integrity_check").fetchone()[0]) + status["table_counts"] = _safe_table_counts(conn) + status["aggregate_totals"] = _safe_monthly_aggregates(conn) + except Exception as e: + status["integrity_check"] = "error" + status["error"] = str(e) + return status + + +def get_ledger_db_status(db_path: str | None = None, include_hash: bool = True, include_integrity: bool = True) -> dict[str, Any]: + """Return non-sensitive metadata evidence for the selected ledger DB.""" + path = resolve_db_path(db_path) + if not path.exists(): + return fail("not_found", f"ledger DB not found: {path}", db_path=str(path), exists=False) + return ok(**_ledger_db_status_for_path(path, include_hash=include_hash, include_integrity=include_integrity)) + + +def _resolve_backup_dir(backup_dir: str | None = None) -> Path: + return Path(backup_dir).expanduser().resolve() if backup_dir else DEFAULT_LEDGER_BACKUP_DIR.resolve() + + +def _backup_file_metadata(path: Path, include_hash: bool = False) -> dict[str, Any]: + metadata = _file_metadata(path, include_hash=include_hash) + metadata["name"] = path.name + metadata["restore_policy"] = BACKUP_RESTORE_POLICY + return metadata + + +def _apply_backup_retention(backup_dir: Path, retention_days: int = 30, retention_count: int = 20) -> list[str]: + backups = sorted(backup_dir.glob("*.sqlite3"), key=lambda p: p.stat().st_mtime, reverse=True) + keep_count = max(1, int(retention_count)) + # Retention comparison needs sub-second precision for retention_days=0: + # the timestamp used for backup names is second-truncated, but filesystem mtimes + # can identify files created just before retention runs as older than "now". + cutoff = datetime.now(KST) - timedelta(days=max(0, int(retention_days))) + pruned: list[str] = [] + for idx, path in enumerate(backups): + mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=KST) + should_keep = idx < keep_count or mtime >= cutoff + if should_keep: + continue + path.unlink() + pruned.append(str(path.resolve())) + return pruned + + +def create_ledger_backup(db_path: str | None = None, backup_dir: str | None = None, retention_days: int = 30, retention_count: int = 20) -> dict[str, Any]: + """Create a timestamped SQLite backup and return metadata-only evidence.""" + source_path = resolve_db_path(db_path) + if not source_path.exists(): + return fail("not_found", f"ledger DB not found: {source_path}", db_path=str(source_path), exists=False) + backup_root = _resolve_backup_dir(backup_dir) + backup_root.mkdir(parents=True, exist_ok=True) + timestamp = _current_kst_datetime().strftime("%Y%m%d%H%M%S%f") + backup_path = backup_root / f"ledger_{timestamp}.sqlite3" + try: + # Use SQLite's online backup API so WAL-mode databases are copied consistently. + with sqlite3.connect(f"file:{source_path}?mode=ro", uri=True) as source_conn: + with sqlite3.connect(backup_path) as backup_conn: + source_conn.backup(backup_conn) + except Exception: + if backup_path.exists(): + backup_path.unlink() + raise + source_status = _ledger_db_status_for_path(source_path, include_hash=True, include_integrity=True) + backup_status = _ledger_db_status_for_path(backup_path, include_hash=True, include_integrity=True) + pruned_paths = _apply_backup_retention(backup_root, retention_days=retention_days, retention_count=retention_count) + return ok( + source_db=source_status, + backup=backup_status, + backup_dir=str(backup_root), + retention={"retention_days": int(retention_days), "retention_count": int(retention_count), "pruned_paths": pruned_paths}, + restore_policy=BACKUP_RESTORE_POLICY, + ) + + +def list_ledger_backups(backup_dir: str | None = None, include_hash: bool = False, limit: int = 50) -> dict[str, Any]: + backup_root = _resolve_backup_dir(backup_dir) + if not backup_root.exists(): + return ok(backup_dir=str(backup_root), backups=[], count=0, restore_policy=BACKUP_RESTORE_POLICY) + max_items = max(1, min(int(limit), 500)) + files = sorted(backup_root.glob("*.sqlite3"), key=lambda p: p.stat().st_mtime, reverse=True)[:max_items] + backups = [_backup_file_metadata(path, include_hash=include_hash) for path in files] + return ok(backup_dir=str(backup_root), backups=backups, count=len(backups), restore_policy=BACKUP_RESTORE_POLICY) + + +def get_ledger_backup_status(file_path: str, include_hash: bool = True, include_integrity: bool = True) -> dict[str, Any]: + path = Path(file_path).expanduser().resolve() + if not path.exists(): + return fail("not_found", f"backup file not found: {path}", path=str(path), exists=False) + return ok(backup=_ledger_db_status_for_path(path, include_hash=include_hash, include_integrity=include_integrity), restore_policy=BACKUP_RESTORE_POLICY) + +def create_ledger_account(name: str, type: str = "bank", institution: str = "", currency: str = "KRW", active: bool = True, memo: str = "", account_number_masked: str = "", holder_name: str = "", last_balance: int | None = None, last_synced_at: str = "", statement_status: str = "unknown", liquidity_bucket: str = "other", db_path: str | None = None) -> dict[str, Any]: + if not name.strip(): + return fail("validation_error", "account name is required") + if type not in VALID_ACCOUNT_TYPES: + return fail("validation_error", f"type must be one of {sorted(VALID_ACCOUNT_TYPES)}") + try: + statement_status_norm = normalize_statement_status(statement_status) + liquidity_bucket_norm = normalize_liquidity_bucket(liquidity_bucket) + except Exception as e: + return fail("validation_error", str(e)) + ts = now_iso(); account_id = new_id("acct") + with db(db_path) as conn: + conn.execute("""INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,statement_status,liquidity_bucket,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (account_id, name.strip(), type, institution, currency, int(active), memo, account_number_masked, holder_name, last_balance, last_synced_at, statement_status_norm, liquidity_bucket_norm, ts, ts)) + row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone() + return ok(account=row_to_dict(row)) + +def list_ledger_accounts(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]: + sql = "SELECT * FROM accounts" + (" WHERE active=1" if active_only else "") + " ORDER BY name" + with db(db_path) as conn: + rows = conn.execute(sql).fetchall() + return ok(accounts=[row_to_dict(r) for r in rows], count=len(rows)) diff --git a/jarca_ledger_mcp/application/transaction_report_services.py b/jarca_ledger_mcp/application/transaction_report_services.py new file mode 100644 index 0000000..d4fc95e --- /dev/null +++ b/jarca_ledger_mcp/application/transaction_report_services.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +from .shared import * + +def create_ledger_transaction(transaction_date: str = "", type: str = "expense", amount: int | float = 0, category: str = "", subcategory: str = "", merchant: str = "", description: str = "", account_id: str = "", payment_method: str = "", person: str = "", project: str = "", tags: Any = None, is_fixed: bool = False, currency: str = "KRW", posted_date: str = "", source: str = "manual", external_id: str = "", memo: str = "", dedupe: bool = True, transaction_datetime: str = "", counterparty: str = "", balance_after: int | None = None, branch: str = "", raw_summary: str = "", raw_payload: str = "", db_path: str | None = None) -> dict[str, Any]: + try: + tx_date = normalize_date(transaction_date, default_today=True) + posted = normalize_date(posted_date, default_today=False) if posted_date else "" + amount_int = int(amount) + tags_json = normalize_tags(tags) + except Exception as e: + return fail("validation_error", str(e)) + if type not in VALID_TRANSACTION_TYPES: + return fail("validation_error", f"type must be one of {sorted(VALID_TRANSACTION_TYPES)}") + if amount_int <= 0: + return fail("validation_error", "amount must be a positive integer in minor units; type determines direction") + dedupe_key = make_dedupe_key(tx_date, amount_int, merchant, account_id, external_id) if dedupe else "" + ts = now_iso(); tx_id = new_id("tx") + try: + with db(db_path) as conn: + conn.execute("""INSERT INTO transactions(id,transaction_date,posted_date,type,amount,currency,category,subcategory,merchant,description,account_id,payment_method,person,project,tags,is_fixed,source,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (tx_id, tx_date, posted, type, amount_int, currency, category, subcategory, merchant, description, account_id, payment_method, person, project, tags_json, int(is_fixed), source, external_id, dedupe_key, memo, transaction_datetime, counterparty, balance_after, branch, raw_summary, raw_payload, ts, ts)) + row = conn.execute("SELECT * FROM transactions WHERE id=?", (tx_id,)).fetchone() + except Exception as e: + if "UNIQUE constraint failed" in str(e): + return fail("duplicate_transaction", "transaction appears to be duplicated", dedupe_key=dedupe_key) + return fail("db_error", str(e)) + return ok(transaction=row_to_dict(row)) + +def list_ledger_transactions(start_date: str = "", end_date: str = "", type: str = "", category: str = "", person: str = "", account_id: str = "", include_deleted: bool = False, limit: int = 100, db_path: str | None = None) -> dict[str, Any]: + clauses=[]; params=[] + if not include_deleted: + clauses.append("status='active'") + for field, value in [("type", type), ("category", category), ("person", person), ("account_id", account_id)]: + if value: + clauses.append(f"{field}=?"); params.append(value) + if start_date: + clauses.append("transaction_date>=?"); params.append(normalize_date(start_date)) + if end_date: + clauses.append("transaction_date<=?"); params.append(normalize_date(end_date)) + where = " WHERE " + " AND ".join(clauses) if clauses else "" + sql = f"SELECT * FROM transactions{where} ORDER BY transaction_date DESC, created_at DESC LIMIT ?" + params.append(max(1, min(int(limit), 1000))) + with db(db_path) as conn: + rows = conn.execute(sql, params).fetchall() + return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows)) + + +def _transaction_signed_amount(tx_type: str, amount: int) -> int: + if tx_type in {"income", "refund", "adjustment"}: + return int(amount) + if tx_type in {"expense", "transfer"}: + return -int(amount) + return 0 + + +def _account_balance_row(conn: sqlite3.Connection, account_id: str, baseline_at: str) -> dict[str, Any]: + date_cutoff = normalize_date(baseline_at[:10]) + rows = conn.execute( + """SELECT type, amount, balance_after, transaction_date, transaction_datetime, created_at + FROM transactions + WHERE status='active' AND account_id=? AND transaction_date<=? + ORDER BY transaction_date ASC, transaction_datetime ASC, created_at ASC""", + (account_id, date_cutoff), + ).fetchall() + signed_total = sum(_transaction_signed_amount(row["type"], int(row["amount"] or 0)) for row in rows) + latest_balance_row = next((row for row in reversed(rows) if row["balance_after"] is not None), None) + if latest_balance_row is not None: + calculated_balance = int(latest_balance_row["balance_after"]) + basis = "latest_transaction_balance_after" + basis_at = latest_balance_row["transaction_datetime"] or latest_balance_row["transaction_date"] + else: + calculated_balance = int(signed_total) + basis = "net_transaction_delta_without_opening_balance" + basis_at = baseline_at + return { + "calculated_balance": calculated_balance, + "calculation_basis": basis, + "calculation_basis_at": basis_at, + "transaction_count": len(rows), + "signed_transaction_total": int(signed_total), + } + + +def get_balance_reconciliation_report(account_id: str = "", baseline_balance: int | None = None, baseline_at: str = "", source_note: str = "", memo: str = "", tolerance: int = BALANCE_RECONCILIATION_TOLERANCE_KRW, db_path: str | None = None) -> dict[str, Any]: + """Preview manual/partial-import account balance reconciliation without mutating transactions.""" + try: + baseline_at_norm = normalize_snapshot_at(baseline_at.strip(), default_now=False) if baseline_at and baseline_at.strip() else None + tolerance_int = int(tolerance) + if tolerance_int < 0: + raise ValueError("tolerance must be non-negative") + baseline_balance_int = int(baseline_balance) if baseline_balance is not None else None + except Exception as e: + return fail("validation_error", str(e)) + with db(db_path) as conn: + clauses = ["active=1"] + params: list[Any] = [] + if account_id: + clauses.append("id=?") + params.append(account_id) + else: + placeholders = ",".join("?" for _ in RECONCILIATION_TARGET_STATUSES) + clauses.append(f"statement_status IN ({placeholders})") + params.extend(sorted(RECONCILIATION_TARGET_STATUSES)) + accounts = conn.execute(f"SELECT * FROM accounts WHERE {' AND '.join(clauses)} ORDER BY name", params).fetchall() + items: list[dict[str, Any]] = [] + for account in accounts: + effective_baseline = baseline_balance_int if baseline_balance_int is not None else account["last_balance"] + effective_at = baseline_at_norm or account["last_synced_at"] or now_iso() + calc = _account_balance_row(conn, account["id"], effective_at) + if effective_baseline is None: + difference = None + ratio = None + status = "BASELINE_REQUIRED" + else: + difference = int(effective_baseline) - int(calc["calculated_balance"]) + ratio = abs(difference) / abs(int(effective_baseline)) if int(effective_baseline) else None + status = "OK" if abs(difference) <= tolerance_int else "REVIEW_NEEDED" + items.append({ + "account_id": account["id"], + "account_name": account["name"], + "statement_status": account["statement_status"], + "is_reconciliation_target": account["statement_status"] in RECONCILIATION_TARGET_STATUSES, + "baseline_balance": effective_baseline, + "baseline_at": effective_at, + "calculated_balance": calc["calculated_balance"], + "difference": difference, + "tolerance": tolerance_int, + "reference_ratio_threshold": BALANCE_RECONCILIATION_REFERENCE_RATIO, + "difference_ratio": ratio, + "status": status, + "recommended_decisions": ["๊ณ„์ขŒ์ด๋™", "์‹ค์ง€์ถœ", "์‹ค์ˆ˜์ž…", "ํˆฌ์ž์ด๋™", "๋ถ„์„์ œ์™ธ", "ํ™•์ธํ•„์š”"] if status == "REVIEW_NEEDED" else [], + "source_note": source_note, + "memo": memo, + **calc, + }) + return ok(items=items, count=len(items), tolerance=tolerance_int, baseline_at=baseline_at_norm, routing="preview_only_manual_confirmation_required") + +def update_ledger_transaction(transaction_id: str, db_path: str | None = None, **fields: Any) -> dict[str, Any]: + allowed = {"transaction_date","posted_date","type","amount","currency","category","subcategory","merchant","description","account_id","payment_method","person","project","tags","is_fixed","source","external_id","memo","status","transaction_datetime","counterparty","balance_after","branch","raw_summary","raw_payload"} + updates=[]; params=[] + for k,v in fields.items(): + if k not in allowed or v is None: + continue + if k == "tags": v = normalize_tags(v) + if k in {"transaction_date","posted_date"} and v: v = normalize_date(v) + if k == "amount": v = int(v) + if k == "is_fixed": v = int(bool(v)) + updates.append(f"{k}=?"); params.append(v) + if not updates: + return fail("validation_error", "no fields to update") + updates.append("updated_at=?"); params.append(now_iso()); params.append(transaction_id) + with db(db_path) as conn: + cur = conn.execute(f"UPDATE transactions SET {', '.join(updates)} WHERE id=?", params) + if cur.rowcount == 0: + return fail("not_found", "transaction not found") + row = conn.execute("SELECT * FROM transactions WHERE id=?", (transaction_id,)).fetchone() + return ok(transaction=row_to_dict(row)) + +def delete_ledger_transaction(transaction_id: str, hard_delete: bool = False, db_path: str | None = None) -> dict[str, Any]: + with db(db_path) as conn: + if hard_delete: + cur = conn.execute("DELETE FROM transactions WHERE id=?", (transaction_id,)) + else: + cur = conn.execute("UPDATE transactions SET status='deleted', updated_at=? WHERE id=?", (now_iso(), transaction_id)) + if cur.rowcount == 0: + return fail("not_found", "transaction not found") + return ok(transaction_id=transaction_id, deleted=True, hard_delete=hard_delete) + +def create_ledger_budget(month: str, category: str, amount: int | float, subcategory: str = "", person: str = "", currency: str = "KRW", memo: str = "", db_path: str | None = None) -> dict[str, Any]: + try: + month_norm = normalize_month(month); amount_int = int(amount) + except Exception as e: + return fail("validation_error", str(e)) + if not category: + return fail("validation_error", "category is required") + if amount_int < 0: + return fail("validation_error", "amount must be non-negative") + ts = now_iso(); budget_id = new_id("bud") + with db(db_path) as conn: + conn.execute("""INSERT INTO budgets(id,month,category,subcategory,person,amount,currency,memo,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(month,category,subcategory,person) DO UPDATE SET amount=excluded.amount,currency=excluded.currency,memo=excluded.memo,updated_at=excluded.updated_at""", (budget_id, month_norm, category, subcategory, person, amount_int, currency, memo, ts, ts)) + row = conn.execute("SELECT * FROM budgets WHERE month=? AND category=? AND subcategory=? AND person=?", (month_norm, category, subcategory, person)).fetchone() + return ok(budget=row_to_dict(row)) + +def list_ledger_budgets(month: str = "", db_path: str | None = None) -> dict[str, Any]: + clauses=[]; params=[] + if month: + clauses.append("month=?"); params.append(normalize_month(month)) + where = " WHERE " + " AND ".join(clauses) if clauses else "" + with db(db_path) as conn: + rows = conn.execute(f"SELECT * FROM budgets{where} ORDER BY month DESC, category", params).fetchall() + return ok(budgets=[row_to_dict(r) for r in rows], count=len(rows)) + +def _month_range(month: str) -> tuple[str, str]: + m = normalize_month(month) + y, mo = map(int, m.split("-")) + start = date(y, mo, 1) + end = date(y + (mo == 12), 1 if mo == 12 else mo + 1, 1) + return start.isoformat(), end.isoformat() + +def get_monthly_cashflow_report(month: str = "", db_path: str | None = None) -> dict[str, Any]: + month_norm = normalize_month(month); start, next_month = _month_range(month_norm) + with db(db_path) as conn: + type_rows = conn.execute("""SELECT type, SUM(amount) AS total FROM transactions + WHERE status='active' AND transaction_date>=? AND transaction_date=? AND t.transaction_date dict[str, Any]: + if month: + start_date, next_month = _month_range(month); end_op = "<"; end_value = next_month + else: + start_date = normalize_date(start_date, default_today=False) if start_date else "0001-01-01" + end_value = normalize_date(end_date, default_today=False) if end_date else "9999-12-31"; end_op = "<=" + params=[start_date, end_value]; person_clause="" + if person: + person_clause=" AND t.person=?"; params.append(person) + sql=f"""SELECT t.category, t.subcategory, SUM(t.amount) AS total, COUNT(*) AS count FROM transactions t + LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory + WHERE t.status='active' AND {CASHFLOW_KIND_SQL}='expense' AND t.transaction_date>=? AND t.transaction_date{end_op}?{person_clause} + GROUP BY t.category, t.subcategory ORDER BY total DESC""" + with db(db_path) as conn: + rows=conn.execute(sql, params).fetchall() + total=sum(int(r["total"] or 0) for r in rows) + items=[{"category": r["category"], "subcategory": r["subcategory"], "amount": int(r["total"] or 0), "count": r["count"], "share": round((int(r["total"] or 0)/total),4) if total else 0} for r in rows] + return ok(total_expense=total, items=items, count=len(items)) + +def get_fixed_cost_report(month: str = "", db_path: str | None = None) -> dict[str, Any]: + month_norm=normalize_month(month); start,next_month=_month_range(month_norm) + with db(db_path) as conn: + rows=conn.execute(f"""SELECT t.category, t.merchant, t.amount, t.transaction_date, t.person, t.memo FROM transactions t + LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory + WHERE t.status='active' AND {CASHFLOW_KIND_SQL}='expense' AND t.is_fixed=1 AND t.transaction_date>=? AND t.transaction_date dict[str, Any]: + month_norm=normalize_month(month); start,next_month=_month_range(month_norm) + with db(db_path) as conn: + budgets=conn.execute("SELECT * FROM budgets WHERE month=?", (month_norm,)).fetchall() + actuals=conn.execute(f"""SELECT t.category, t.subcategory, t.person, SUM(t.amount) AS amount FROM transactions t + LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory + WHERE t.status='active' AND {CASHFLOW_KIND_SQL}='expense' AND t.transaction_date>=? AND t.transaction_datebudget else "ok"}) + return ok(month=month_norm, items=items, total_budget=sum(i["budget"] for i in items), total_actual=sum(i["actual"] for i in items)) + +def get_investable_cashflow_report(month: str = "", minimum_cash_buffer: int = 0, planned_extra_expense: int = 0, db_path: str | None = None) -> dict[str, Any]: + cashflow=get_monthly_cashflow_report(month, db_path) + if not cashflow.get("success"): + return cashflow + investable=max(0, int(cashflow["net_cashflow"]) - int(minimum_cash_buffer) - int(planned_extra_expense)) + return ok(month=cashflow["month"], income=cashflow["income"], expenses=cashflow["expenses"], net_cashflow=cashflow["net_cashflow"], minimum_cash_buffer=int(minimum_cash_buffer), planned_extra_expense=int(planned_extra_expense), investable_cashflow=investable) diff --git a/jarca_ledger_mcp/db.py b/jarca_ledger_mcp/db.py index b9b195f..61f4134 100644 --- a/jarca_ledger_mcp/db.py +++ b/jarca_ledger_mcp/db.py @@ -1,333 +1,3 @@ from __future__ import annotations -import os -import sqlite3 -from contextlib import contextmanager -from pathlib import Path -from typing import Iterator - -DEFAULT_DB_PATH = Path.home() / ".local" / "share" / "jarca" / "ledger.sqlite3" -SCHEMA_VERSION = "10" - -SCHEMA_SQL = """ -PRAGMA journal_mode=WAL; -PRAGMA foreign_keys=ON; - -CREATE TABLE IF NOT EXISTS schema_meta ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS accounts ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - type TEXT NOT NULL DEFAULT 'bank', - institution TEXT NOT NULL DEFAULT '', - currency TEXT NOT NULL DEFAULT 'KRW', - active INTEGER NOT NULL DEFAULT 1, - memo TEXT NOT NULL DEFAULT '', - account_number_masked TEXT NOT NULL DEFAULT '', - holder_name TEXT NOT NULL DEFAULT '', - last_balance INTEGER, - last_synced_at TEXT NOT NULL DEFAULT '', - statement_status TEXT NOT NULL DEFAULT 'unknown', - liquidity_bucket TEXT NOT NULL DEFAULT 'other', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS transactions ( - id TEXT PRIMARY KEY, - transaction_date TEXT NOT NULL, - posted_date TEXT NOT NULL DEFAULT '', - type TEXT NOT NULL, - amount INTEGER NOT NULL, - currency TEXT NOT NULL DEFAULT 'KRW', - category TEXT NOT NULL DEFAULT '', - subcategory TEXT NOT NULL DEFAULT '', - merchant TEXT NOT NULL DEFAULT '', - description TEXT NOT NULL DEFAULT '', - account_id TEXT NOT NULL DEFAULT '', - payment_method TEXT NOT NULL DEFAULT '', - person TEXT NOT NULL DEFAULT '', - project TEXT NOT NULL DEFAULT '', - tags TEXT NOT NULL DEFAULT '[]', - is_fixed INTEGER NOT NULL DEFAULT 0, - source TEXT NOT NULL DEFAULT 'manual', - import_batch_id TEXT NOT NULL DEFAULT '', - external_id TEXT NOT NULL DEFAULT '', - dedupe_key TEXT NOT NULL DEFAULT '', - memo TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'active', - transaction_datetime TEXT NOT NULL DEFAULT '', - counterparty TEXT NOT NULL DEFAULT '', - balance_after INTEGER, - branch TEXT NOT NULL DEFAULT '', - raw_summary TEXT NOT NULL DEFAULT '', - raw_payload TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(transaction_date); -CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category, subcategory); -CREATE INDEX IF NOT EXISTS idx_transactions_person ON transactions(person); -CREATE INDEX IF NOT EXISTS idx_transactions_account ON transactions(account_id); -CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_dedupe ON transactions(dedupe_key) WHERE dedupe_key <> ''; - -CREATE TABLE IF NOT EXISTS budgets ( - id TEXT PRIMARY KEY, - month TEXT NOT NULL, - category TEXT NOT NULL, - subcategory TEXT NOT NULL DEFAULT '', - person TEXT NOT NULL DEFAULT '', - amount INTEGER NOT NULL, - currency TEXT NOT NULL DEFAULT 'KRW', - memo TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_budgets_month ON budgets(month); -CREATE UNIQUE INDEX IF NOT EXISTS idx_budgets_unique ON budgets(month, category, subcategory, person); - -CREATE TABLE IF NOT EXISTS import_batches ( - id TEXT PRIMARY KEY, - source TEXT NOT NULL, - filename TEXT NOT NULL DEFAULT '', - imported_at TEXT NOT NULL, - row_count INTEGER NOT NULL DEFAULT 0, - status TEXT NOT NULL DEFAULT 'active', - memo TEXT NOT NULL DEFAULT '' -); - -CREATE TABLE IF NOT EXISTS category_rules ( - id TEXT PRIMARY KEY, - priority INTEGER NOT NULL DEFAULT 100, - match_field TEXT NOT NULL, - match_type TEXT NOT NULL DEFAULT 'contains', - pattern TEXT NOT NULL, - amount_min INTEGER, - amount_max INTEGER, - day_of_month INTEGER, - transaction_type TEXT NOT NULL DEFAULT '', - category TEXT NOT NULL, - subcategory TEXT NOT NULL DEFAULT '', - person TEXT NOT NULL DEFAULT '', - is_fixed INTEGER, - confidence REAL NOT NULL DEFAULT 1.0, - active INTEGER NOT NULL DEFAULT 1, - memo TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_category_rules_active_priority ON category_rules(active, priority); -CREATE UNIQUE INDEX IF NOT EXISTS idx_category_rules_unique ON category_rules(match_field, match_type, pattern, transaction_type, category, subcategory, person); - -CREATE TABLE IF NOT EXISTS ledger_categories ( - id TEXT PRIMARY KEY, - category TEXT NOT NULL, - subcategory TEXT NOT NULL DEFAULT '', - cashflow_kind TEXT NOT NULL DEFAULT 'expense', - active INTEGER NOT NULL DEFAULT 1, - sort_order INTEGER NOT NULL DEFAULT 100, - memo TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_ledger_categories_active_sort ON ledger_categories(active, sort_order, category, subcategory); -CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_categories_unique ON ledger_categories(category, subcategory); - -CREATE TABLE IF NOT EXISTS manual_assets ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - liquidity_bucket TEXT NOT NULL DEFAULT 'other', - valuation_amount INTEGER, - valuation_date TEXT NOT NULL DEFAULT '', - currency TEXT NOT NULL DEFAULT 'KRW', - source_note TEXT NOT NULL DEFAULT '', - memo TEXT NOT NULL DEFAULT '', - active INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_manual_assets_active_bucket ON manual_assets(active, liquidity_bucket, name); -CREATE INDEX IF NOT EXISTS idx_manual_assets_valuation_date ON manual_assets(valuation_date); - -CREATE TABLE IF NOT EXISTS asset_snapshots ( - id TEXT PRIMARY KEY, - basis_date TEXT NOT NULL, - source_type TEXT NOT NULL, - source_id TEXT NOT NULL DEFAULT '', - name TEXT NOT NULL, - amount INTEGER, - currency TEXT NOT NULL DEFAULT 'KRW', - liquidity_bucket TEXT NOT NULL DEFAULT 'other', - valuation_source TEXT NOT NULL DEFAULT '', - source_note TEXT NOT NULL DEFAULT '', - memo TEXT NOT NULL DEFAULT '', - snapshot_run_id TEXT NOT NULL DEFAULT '', - correction_of_run_id TEXT NOT NULL DEFAULT '', - correction_reason TEXT NOT NULL DEFAULT '', - is_correction INTEGER NOT NULL DEFAULT 0, - closed_at TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_asset_snapshots_basis_date ON asset_snapshots(basis_date); -CREATE INDEX IF NOT EXISTS idx_asset_snapshots_source ON asset_snapshots(source_type, source_id); - -INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '10'); -""" - -ACCOUNT_ADDITIVE_COLUMNS: dict[str, str] = { - "account_number_masked": "TEXT NOT NULL DEFAULT ''", - "holder_name": "TEXT NOT NULL DEFAULT ''", - "last_balance": "INTEGER", - "last_synced_at": "TEXT NOT NULL DEFAULT ''", - "statement_status": "TEXT NOT NULL DEFAULT 'unknown'", - "liquidity_bucket": "TEXT NOT NULL DEFAULT 'other'", -} - -TRANSACTION_ADDITIVE_COLUMNS: dict[str, str] = { - "transaction_datetime": "TEXT NOT NULL DEFAULT ''", - "counterparty": "TEXT NOT NULL DEFAULT ''", - "balance_after": "INTEGER", - "branch": "TEXT NOT NULL DEFAULT ''", - "raw_summary": "TEXT NOT NULL DEFAULT ''", - "raw_payload": "TEXT NOT NULL DEFAULT ''", -} - - -def resolve_db_path(db_path: str | None = None) -> Path: - raw = db_path or os.getenv("JARCA_LEDGER_DB_PATH") or str(DEFAULT_DB_PATH) - return Path(raw).expanduser().resolve() - - -def connect(db_path: str | None = None) -> sqlite3.Connection: - path = resolve_db_path(db_path) - path.parent.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(path) - conn.row_factory = sqlite3.Row - conn.execute("PRAGMA foreign_keys=ON") - return conn - - -def _existing_columns(conn: sqlite3.Connection, table: str) -> set[str]: - return {row["name"] for row in conn.execute(f"PRAGMA table_info({table})")} - - -def _add_missing_columns(conn: sqlite3.Connection, table: str, columns: dict[str, str]) -> None: - existing = _existing_columns(conn, table) - for name, definition in columns.items(): - if name not in existing: - conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {definition}") - - -def migrate_db(conn: sqlite3.Connection) -> None: - """Apply additive, idempotent migrations only.""" - _add_missing_columns(conn, "accounts", ACCOUNT_ADDITIVE_COLUMNS) - _add_missing_columns(conn, "transactions", TRANSACTION_ADDITIVE_COLUMNS) - conn.execute("CREATE INDEX IF NOT EXISTS idx_transactions_datetime ON transactions(transaction_datetime)") - conn.execute("""CREATE TABLE IF NOT EXISTS category_rules ( - id TEXT PRIMARY KEY, - priority INTEGER NOT NULL DEFAULT 100, - match_field TEXT NOT NULL, - match_type TEXT NOT NULL DEFAULT 'contains', - pattern TEXT NOT NULL, - amount_min INTEGER, - amount_max INTEGER, - day_of_month INTEGER, - transaction_type TEXT NOT NULL DEFAULT '', - category TEXT NOT NULL, - subcategory TEXT NOT NULL DEFAULT '', - person TEXT NOT NULL DEFAULT '', - is_fixed INTEGER, - confidence REAL NOT NULL DEFAULT 1.0, - active INTEGER NOT NULL DEFAULT 1, - memo TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - )""") - _add_missing_columns(conn, "category_rules", {"amount_min": "INTEGER", "amount_max": "INTEGER", "day_of_month": "INTEGER"}) - conn.execute("CREATE INDEX IF NOT EXISTS idx_category_rules_active_priority ON category_rules(active, priority)") - conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_category_rules_unique ON category_rules(match_field, match_type, pattern, transaction_type, category, subcategory, person)") - conn.execute("""CREATE TABLE IF NOT EXISTS ledger_categories ( - id TEXT PRIMARY KEY, - category TEXT NOT NULL, - subcategory TEXT NOT NULL DEFAULT '', - cashflow_kind TEXT NOT NULL DEFAULT 'expense', - active INTEGER NOT NULL DEFAULT 1, - sort_order INTEGER NOT NULL DEFAULT 100, - memo TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - )""") - _add_missing_columns(conn, "ledger_categories", {"cashflow_kind": "TEXT NOT NULL DEFAULT 'expense'"}) - conn.execute("CREATE INDEX IF NOT EXISTS idx_ledger_categories_active_sort ON ledger_categories(active, sort_order, category, subcategory)") - conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_categories_unique ON ledger_categories(category, subcategory)") - conn.execute("""CREATE TABLE IF NOT EXISTS manual_assets ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - liquidity_bucket TEXT NOT NULL DEFAULT 'other', - valuation_amount INTEGER, - valuation_date TEXT NOT NULL DEFAULT '', - currency TEXT NOT NULL DEFAULT 'KRW', - source_note TEXT NOT NULL DEFAULT '', - memo TEXT NOT NULL DEFAULT '', - active INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - )""") - conn.execute("CREATE INDEX IF NOT EXISTS idx_manual_assets_active_bucket ON manual_assets(active, liquidity_bucket, name)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_manual_assets_valuation_date ON manual_assets(valuation_date)") - conn.execute("""CREATE TABLE IF NOT EXISTS asset_snapshots ( - id TEXT PRIMARY KEY, - basis_date TEXT NOT NULL, - source_type TEXT NOT NULL, - source_id TEXT NOT NULL DEFAULT '', - name TEXT NOT NULL, - amount INTEGER, - currency TEXT NOT NULL DEFAULT 'KRW', - liquidity_bucket TEXT NOT NULL DEFAULT 'other', - valuation_source TEXT NOT NULL DEFAULT '', - source_note TEXT NOT NULL DEFAULT '', - memo TEXT NOT NULL DEFAULT '', - snapshot_run_id TEXT NOT NULL DEFAULT '', - correction_of_run_id TEXT NOT NULL DEFAULT '', - correction_reason TEXT NOT NULL DEFAULT '', - is_correction INTEGER NOT NULL DEFAULT 0, - closed_at TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL - )""") - _add_missing_columns(conn, "asset_snapshots", { - "snapshot_run_id": "TEXT NOT NULL DEFAULT ''", - "correction_of_run_id": "TEXT NOT NULL DEFAULT ''", - "correction_reason": "TEXT NOT NULL DEFAULT ''", - "is_correction": "INTEGER NOT NULL DEFAULT 0", - "closed_at": "TEXT NOT NULL DEFAULT ''", - }) - conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_basis_date ON asset_snapshots(basis_date)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_source ON asset_snapshots(source_type, source_id)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_run ON asset_snapshots(basis_date, snapshot_run_id, created_at)") - conn.execute("INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', ?)", (SCHEMA_VERSION,)) - - -def init_db(db_path: str | None = None) -> str: - path = resolve_db_path(db_path) - with connect(str(path)) as conn: - conn.executescript(SCHEMA_SQL) - migrate_db(conn) - return str(path) - - -@contextmanager -def db(db_path: str | None = None) -> Iterator[sqlite3.Connection]: - init_db(db_path) - conn = connect(db_path) - try: - yield conn - conn.commit() - except Exception: - conn.rollback() - raise - finally: - conn.close() +from .infrastructure.sqlite import * diff --git a/jarca_ledger_mcp/infrastructure/__init__.py b/jarca_ledger_mcp/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jarca_ledger_mcp/infrastructure/sqlite/__init__.py b/jarca_ledger_mcp/infrastructure/sqlite/__init__.py new file mode 100644 index 0000000..c8d6585 --- /dev/null +++ b/jarca_ledger_mcp/infrastructure/sqlite/__init__.py @@ -0,0 +1,3 @@ +from .connection import DEFAULT_DB_PATH, connect, db, resolve_db_path +from .migrations import init_db, migrate_db +from .schema import ACCOUNT_ADDITIVE_COLUMNS, SCHEMA_SQL, SCHEMA_VERSION, TRANSACTION_ADDITIVE_COLUMNS diff --git a/jarca_ledger_mcp/infrastructure/sqlite/connection.py b/jarca_ledger_mcp/infrastructure/sqlite/connection.py new file mode 100644 index 0000000..098d6bf --- /dev/null +++ b/jarca_ledger_mcp/infrastructure/sqlite/connection.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import os +import sqlite3 +from contextlib import contextmanager +from pathlib import Path +from typing import Iterator + +DEFAULT_DB_PATH = Path.home() / ".local" / "share" / "jarca" / "ledger.sqlite3" + + +def resolve_db_path(db_path: str | None = None) -> Path: + raw = db_path or os.getenv("JARCA_LEDGER_DB_PATH") or str(DEFAULT_DB_PATH) + return Path(raw).expanduser().resolve() + + +def connect(db_path: str | None = None) -> sqlite3.Connection: + path = resolve_db_path(db_path) + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys=ON") + return conn + + +@contextmanager +def db(db_path: str | None = None) -> Iterator[sqlite3.Connection]: + from .migrations import init_db + + init_db(db_path) + conn = connect(db_path) + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() diff --git a/jarca_ledger_mcp/infrastructure/sqlite/migrations.py b/jarca_ledger_mcp/infrastructure/sqlite/migrations.py new file mode 100644 index 0000000..f09c984 --- /dev/null +++ b/jarca_ledger_mcp/infrastructure/sqlite/migrations.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +from .connection import connect, resolve_db_path +from .schema import ACCOUNT_ADDITIVE_COLUMNS, SCHEMA_SQL, SCHEMA_VERSION, TRANSACTION_ADDITIVE_COLUMNS + +def _existing_columns(conn: sqlite3.Connection, table: str) -> set[str]: + return {row["name"] for row in conn.execute(f"PRAGMA table_info({table})")} + + +def _add_missing_columns(conn: sqlite3.Connection, table: str, columns: dict[str, str]) -> None: + existing = _existing_columns(conn, table) + for name, definition in columns.items(): + if name not in existing: + conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {definition}") + + +def migrate_db(conn: sqlite3.Connection) -> None: + """Apply additive, idempotent migrations only.""" + _add_missing_columns(conn, "accounts", ACCOUNT_ADDITIVE_COLUMNS) + _add_missing_columns(conn, "transactions", TRANSACTION_ADDITIVE_COLUMNS) + conn.execute("CREATE INDEX IF NOT EXISTS idx_transactions_datetime ON transactions(transaction_datetime)") + conn.execute("""CREATE TABLE IF NOT EXISTS category_rules ( + id TEXT PRIMARY KEY, + priority INTEGER NOT NULL DEFAULT 100, + match_field TEXT NOT NULL, + match_type TEXT NOT NULL DEFAULT 'contains', + pattern TEXT NOT NULL, + amount_min INTEGER, + amount_max INTEGER, + day_of_month INTEGER, + transaction_type TEXT NOT NULL DEFAULT '', + category TEXT NOT NULL, + subcategory TEXT NOT NULL DEFAULT '', + person TEXT NOT NULL DEFAULT '', + is_fixed INTEGER, + confidence REAL NOT NULL DEFAULT 1.0, + active INTEGER NOT NULL DEFAULT 1, + memo TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )""") + _add_missing_columns(conn, "category_rules", {"amount_min": "INTEGER", "amount_max": "INTEGER", "day_of_month": "INTEGER"}) + conn.execute("CREATE INDEX IF NOT EXISTS idx_category_rules_active_priority ON category_rules(active, priority)") + conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_category_rules_unique ON category_rules(match_field, match_type, pattern, transaction_type, category, subcategory, person)") + conn.execute("""CREATE TABLE IF NOT EXISTS ledger_categories ( + id TEXT PRIMARY KEY, + category TEXT NOT NULL, + subcategory TEXT NOT NULL DEFAULT '', + cashflow_kind TEXT NOT NULL DEFAULT 'expense', + active INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 100, + memo TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )""") + _add_missing_columns(conn, "ledger_categories", {"cashflow_kind": "TEXT NOT NULL DEFAULT 'expense'"}) + conn.execute("CREATE INDEX IF NOT EXISTS idx_ledger_categories_active_sort ON ledger_categories(active, sort_order, category, subcategory)") + conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_categories_unique ON ledger_categories(category, subcategory)") + conn.execute("""CREATE TABLE IF NOT EXISTS manual_assets ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + liquidity_bucket TEXT NOT NULL DEFAULT 'other', + valuation_amount INTEGER, + valuation_date TEXT NOT NULL DEFAULT '', + currency TEXT NOT NULL DEFAULT 'KRW', + source_note TEXT NOT NULL DEFAULT '', + memo TEXT NOT NULL DEFAULT '', + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )""") + conn.execute("CREATE INDEX IF NOT EXISTS idx_manual_assets_active_bucket ON manual_assets(active, liquidity_bucket, name)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_manual_assets_valuation_date ON manual_assets(valuation_date)") + conn.execute("""CREATE TABLE IF NOT EXISTS asset_snapshots ( + id TEXT PRIMARY KEY, + basis_date TEXT NOT NULL, + source_type TEXT NOT NULL, + source_id TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL, + amount INTEGER, + currency TEXT NOT NULL DEFAULT 'KRW', + liquidity_bucket TEXT NOT NULL DEFAULT 'other', + valuation_source TEXT NOT NULL DEFAULT '', + source_note TEXT NOT NULL DEFAULT '', + memo TEXT NOT NULL DEFAULT '', + snapshot_run_id TEXT NOT NULL DEFAULT '', + correction_of_run_id TEXT NOT NULL DEFAULT '', + correction_reason TEXT NOT NULL DEFAULT '', + is_correction INTEGER NOT NULL DEFAULT 0, + closed_at TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL + )""") + _add_missing_columns(conn, "asset_snapshots", { + "snapshot_run_id": "TEXT NOT NULL DEFAULT ''", + "correction_of_run_id": "TEXT NOT NULL DEFAULT ''", + "correction_reason": "TEXT NOT NULL DEFAULT ''", + "is_correction": "INTEGER NOT NULL DEFAULT 0", + "closed_at": "TEXT NOT NULL DEFAULT ''", + }) + conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_basis_date ON asset_snapshots(basis_date)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_source ON asset_snapshots(source_type, source_id)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_run ON asset_snapshots(basis_date, snapshot_run_id, created_at)") + conn.execute("INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', ?)", (SCHEMA_VERSION,)) + + +def init_db(db_path: str | None = None) -> str: + path = resolve_db_path(db_path) + with connect(str(path)) as conn: + conn.executescript(SCHEMA_SQL) + migrate_db(conn) + return str(path) diff --git a/jarca_ledger_mcp/infrastructure/sqlite/schema.py b/jarca_ledger_mcp/infrastructure/sqlite/schema.py new file mode 100644 index 0000000..ca46513 --- /dev/null +++ b/jarca_ledger_mcp/infrastructure/sqlite/schema.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +SCHEMA_VERSION = "10" + +SCHEMA_SQL = """ +PRAGMA journal_mode=WAL; +PRAGMA foreign_keys=ON; + +CREATE TABLE IF NOT EXISTS schema_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS accounts ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'bank', + institution TEXT NOT NULL DEFAULT '', + currency TEXT NOT NULL DEFAULT 'KRW', + active INTEGER NOT NULL DEFAULT 1, + memo TEXT NOT NULL DEFAULT '', + account_number_masked TEXT NOT NULL DEFAULT '', + holder_name TEXT NOT NULL DEFAULT '', + last_balance INTEGER, + last_synced_at TEXT NOT NULL DEFAULT '', + statement_status TEXT NOT NULL DEFAULT 'unknown', + liquidity_bucket TEXT NOT NULL DEFAULT 'other', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS transactions ( + id TEXT PRIMARY KEY, + transaction_date TEXT NOT NULL, + posted_date TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL, + amount INTEGER NOT NULL, + currency TEXT NOT NULL DEFAULT 'KRW', + category TEXT NOT NULL DEFAULT '', + subcategory TEXT NOT NULL DEFAULT '', + merchant TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + account_id TEXT NOT NULL DEFAULT '', + payment_method TEXT NOT NULL DEFAULT '', + person TEXT NOT NULL DEFAULT '', + project TEXT NOT NULL DEFAULT '', + tags TEXT NOT NULL DEFAULT '[]', + is_fixed INTEGER NOT NULL DEFAULT 0, + source TEXT NOT NULL DEFAULT 'manual', + import_batch_id TEXT NOT NULL DEFAULT '', + external_id TEXT NOT NULL DEFAULT '', + dedupe_key TEXT NOT NULL DEFAULT '', + memo TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + transaction_datetime TEXT NOT NULL DEFAULT '', + counterparty TEXT NOT NULL DEFAULT '', + balance_after INTEGER, + branch TEXT NOT NULL DEFAULT '', + raw_summary TEXT NOT NULL DEFAULT '', + raw_payload TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(transaction_date); +CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category, subcategory); +CREATE INDEX IF NOT EXISTS idx_transactions_person ON transactions(person); +CREATE INDEX IF NOT EXISTS idx_transactions_account ON transactions(account_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_dedupe ON transactions(dedupe_key) WHERE dedupe_key <> ''; + +CREATE TABLE IF NOT EXISTS budgets ( + id TEXT PRIMARY KEY, + month TEXT NOT NULL, + category TEXT NOT NULL, + subcategory TEXT NOT NULL DEFAULT '', + person TEXT NOT NULL DEFAULT '', + amount INTEGER NOT NULL, + currency TEXT NOT NULL DEFAULT 'KRW', + memo TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_budgets_month ON budgets(month); +CREATE UNIQUE INDEX IF NOT EXISTS idx_budgets_unique ON budgets(month, category, subcategory, person); + +CREATE TABLE IF NOT EXISTS import_batches ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL, + filename TEXT NOT NULL DEFAULT '', + imported_at TEXT NOT NULL, + row_count INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + memo TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS category_rules ( + id TEXT PRIMARY KEY, + priority INTEGER NOT NULL DEFAULT 100, + match_field TEXT NOT NULL, + match_type TEXT NOT NULL DEFAULT 'contains', + pattern TEXT NOT NULL, + amount_min INTEGER, + amount_max INTEGER, + day_of_month INTEGER, + transaction_type TEXT NOT NULL DEFAULT '', + category TEXT NOT NULL, + subcategory TEXT NOT NULL DEFAULT '', + person TEXT NOT NULL DEFAULT '', + is_fixed INTEGER, + confidence REAL NOT NULL DEFAULT 1.0, + active INTEGER NOT NULL DEFAULT 1, + memo TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_category_rules_active_priority ON category_rules(active, priority); +CREATE UNIQUE INDEX IF NOT EXISTS idx_category_rules_unique ON category_rules(match_field, match_type, pattern, transaction_type, category, subcategory, person); + +CREATE TABLE IF NOT EXISTS ledger_categories ( + id TEXT PRIMARY KEY, + category TEXT NOT NULL, + subcategory TEXT NOT NULL DEFAULT '', + cashflow_kind TEXT NOT NULL DEFAULT 'expense', + active INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 100, + memo TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_ledger_categories_active_sort ON ledger_categories(active, sort_order, category, subcategory); +CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_categories_unique ON ledger_categories(category, subcategory); + +CREATE TABLE IF NOT EXISTS manual_assets ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + liquidity_bucket TEXT NOT NULL DEFAULT 'other', + valuation_amount INTEGER, + valuation_date TEXT NOT NULL DEFAULT '', + currency TEXT NOT NULL DEFAULT 'KRW', + source_note TEXT NOT NULL DEFAULT '', + memo TEXT NOT NULL DEFAULT '', + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_manual_assets_active_bucket ON manual_assets(active, liquidity_bucket, name); +CREATE INDEX IF NOT EXISTS idx_manual_assets_valuation_date ON manual_assets(valuation_date); + +CREATE TABLE IF NOT EXISTS asset_snapshots ( + id TEXT PRIMARY KEY, + basis_date TEXT NOT NULL, + source_type TEXT NOT NULL, + source_id TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL, + amount INTEGER, + currency TEXT NOT NULL DEFAULT 'KRW', + liquidity_bucket TEXT NOT NULL DEFAULT 'other', + valuation_source TEXT NOT NULL DEFAULT '', + source_note TEXT NOT NULL DEFAULT '', + memo TEXT NOT NULL DEFAULT '', + snapshot_run_id TEXT NOT NULL DEFAULT '', + correction_of_run_id TEXT NOT NULL DEFAULT '', + correction_reason TEXT NOT NULL DEFAULT '', + is_correction INTEGER NOT NULL DEFAULT 0, + closed_at TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_asset_snapshots_basis_date ON asset_snapshots(basis_date); +CREATE INDEX IF NOT EXISTS idx_asset_snapshots_source ON asset_snapshots(source_type, source_id); + +INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '10'); +""" + +ACCOUNT_ADDITIVE_COLUMNS: dict[str, str] = { + "account_number_masked": "TEXT NOT NULL DEFAULT ''", + "holder_name": "TEXT NOT NULL DEFAULT ''", + "last_balance": "INTEGER", + "last_synced_at": "TEXT NOT NULL DEFAULT ''", + "statement_status": "TEXT NOT NULL DEFAULT 'unknown'", + "liquidity_bucket": "TEXT NOT NULL DEFAULT 'other'", +} + +TRANSACTION_ADDITIVE_COLUMNS: dict[str, str] = { + "transaction_datetime": "TEXT NOT NULL DEFAULT ''", + "counterparty": "TEXT NOT NULL DEFAULT ''", + "balance_after": "INTEGER", + "branch": "TEXT NOT NULL DEFAULT ''", + "raw_summary": "TEXT NOT NULL DEFAULT ''", + "raw_payload": "TEXT NOT NULL DEFAULT ''", +} diff --git a/jarca_ledger_mcp/server.py b/jarca_ledger_mcp/server.py index 67dceae..163d520 100644 --- a/jarca_ledger_mcp/server.py +++ b/jarca_ledger_mcp/server.py @@ -1,296 +1,6 @@ from __future__ import annotations -from pathlib import Path -from dotenv import load_dotenv -from mcp.server.fastmcp import FastMCP - -from . import service - -load_dotenv(Path(__file__).resolve().parent.parent / ".env") -mcp = FastMCP("jarca-ledger-mcp") - -@mcp.tool() -def ledger_health_check() -> dict: - """Verify the Jarca Ledger MCP server and SQLite database are reachable.""" - return service.health_check() - -@mcp.tool() -def get_ledger_db_status(include_hash: bool = True, include_integrity: bool = True) -> dict: - """Return non-sensitive DB status evidence: path, size, mtime, checksum, table row counts, and aggregate totals.""" - return service.get_ledger_db_status(include_hash=include_hash, include_integrity=include_integrity) - -@mcp.tool() -def create_ledger_backup(backup_dir: str = "", retention_days: int = 30, retention_count: int = 20) -> dict: - """Create a timestamped ledger DB backup. Restore is intentionally not exposed as an MCP tool.""" - return service.create_ledger_backup(backup_dir=backup_dir or None, retention_days=retention_days, retention_count=retention_count) - -@mcp.tool() -def list_ledger_backups(backup_dir: str = "", include_hash: bool = False, limit: int = 50) -> dict: - """List ledger DB backups with metadata-only evidence.""" - return service.list_ledger_backups(backup_dir=backup_dir or None, include_hash=include_hash, limit=limit) - -@mcp.tool() -def get_ledger_backup_status(file_path: str, include_hash: bool = True, include_integrity: bool = True) -> dict: - """Return metadata-only status and integrity evidence for one backup file.""" - return service.get_ledger_backup_status(file_path, include_hash=include_hash, include_integrity=include_integrity) - -@mcp.tool() -def create_ledger_account(name: str, type: str = "bank", institution: str = "", currency: str = "KRW", active: bool = True, memo: str = "", account_number_masked: str = "", holder_name: str = "", last_balance: int | None = None, last_synced_at: str = "", statement_status: str = "unknown", liquidity_bucket: str = "other") -> dict: - """Create an account/card/cash/investment/loan ledger account.""" - return service.create_ledger_account(name, type, institution, currency, active, memo, account_number_masked, holder_name, last_balance, last_synced_at, statement_status, liquidity_bucket) - -@mcp.tool() -def list_ledger_accounts(active_only: bool = True) -> dict: - """List ledger accounts and cards.""" - return service.list_ledger_accounts(active_only) - -@mcp.tool() -def update_ledger_account(account_id: str, name: str | None = None, type: str | None = None, institution: str | None = None, currency: str | None = None, active: bool | None = None, memo: str | None = None, account_number_masked: str | None = None, holder_name: str | None = None, last_balance: int | None = None, last_synced_at: str | None = None, statement_status: str | None = None, liquidity_bucket: str | None = None) -> dict: - """Update selected account fields, including statement_status/liquidity_bucket for manual reconciliation.""" - return service.update_ledger_account(account_id, name=name, type=type, institution=institution, currency=currency, active=active, memo=memo, account_number_masked=account_number_masked, holder_name=holder_name, last_balance=last_balance, last_synced_at=last_synced_at, statement_status=statement_status, liquidity_bucket=liquidity_bucket) - -@mcp.tool() -def get_asset_liquidity_report() -> dict: - """Return asset liquidity, debt, and net worth from shared account/manual valuation sources.""" - return service.get_asset_liquidity_report() - -@mcp.tool() -def list_manual_assets(active_only: bool = True) -> dict: - """List manual non-transaction assets/liabilities used for net-worth valuation.""" - return service.list_manual_assets(active_only=active_only) - -@mcp.tool() -def create_manual_asset(name: str, valuation_amount: int, valuation_date: str, liquidity_bucket: str, source_note: str, memo: str = "", currency: str = "KRW", active: bool = True) -> dict: - """Create a manual non-transaction asset/liability valuation row.""" - return service.create_manual_asset(name, valuation_amount, valuation_date, liquidity_bucket, source_note=source_note, memo=memo, currency=currency, active=active) - -@mcp.tool() -def update_manual_asset(asset_id: str, name: str | None = None, valuation_amount: int | None = None, valuation_date: str | None = None, liquidity_bucket: str | None = None, source_note: str | None = None, memo: str | None = None, currency: str | None = None, active: bool | None = None) -> dict: - """Update selected fields on a manual asset/liability valuation row.""" - return service.update_manual_asset(asset_id, name=name, valuation_amount=valuation_amount, valuation_date=valuation_date, liquidity_bucket=liquidity_bucket, source_note=source_note, memo=memo, currency=currency, active=active) - -@mcp.tool() -def deactivate_manual_asset(asset_id: str, hard_delete: bool = False) -> dict: - """Deactivate a manual asset/liability by default; hard delete only for exceptional cleanup.""" - return service.deactivate_manual_asset(asset_id, hard_delete=hard_delete) - -@mcp.tool() -def preview_asset_snapshot_close(basis_date: str = "") -> dict: - """Preview a month-end asset snapshot close without mutating the database.""" - return service.preview_asset_snapshot_close(basis_date=basis_date) - -@mcp.tool() -def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "") -> dict: - """Close an asset snapshot for a basis date; same-date re-close appends correction rows.""" - return service.close_asset_snapshot(basis_date=basis_date, source_note=source_note, memo=memo, correction_reason=correction_reason) - -@mcp.tool() -def get_asset_snapshot_report(basis_date: str = "") -> dict: - """Return basis-date asset snapshot/net-worth report with live-authority fallback.""" - return service.get_asset_snapshot_report(basis_date=basis_date) - -@mcp.tool() -def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: int = 12) -> dict: - """Return net-worth timeseries from stored asset snapshot rows.""" - return service.get_net_worth_timeseries(start_date=start_date, end_date=end_date, months=months) - -@mcp.tool() -def get_balance_reconciliation_report(account_id: str = "", baseline_balance: int | None = None, baseline_at: str = "", source_note: str = "", memo: str = "", tolerance: int = 1000) -> dict: - """Preview balance reconciliation for manual_only/partial_import accounts without mutating the DB.""" - return service.get_balance_reconciliation_report(account_id, baseline_balance, baseline_at, source_note, memo, tolerance) - -@mcp.tool() -def create_ledger_transaction(transaction_date: str = "", type: str = "expense", amount: int = 0, category: str = "", subcategory: str = "", merchant: str = "", description: str = "", account_id: str = "", payment_method: str = "", person: str = "", project: str = "", tags: list[str] | str | None = None, is_fixed: bool = False, currency: str = "KRW", posted_date: str = "", source: str = "manual", external_id: str = "", memo: str = "", dedupe: bool = True) -> dict: - """Create an income, expense, transfer, refund, or adjustment transaction. Amount is positive KRW minor unit; type determines direction.""" - return service.create_ledger_transaction(transaction_date, type, amount, category, subcategory, merchant, description, account_id, payment_method, person, project, tags, is_fixed, currency, posted_date, source, external_id, memo, dedupe) - -@mcp.tool() -def list_ledger_transactions(start_date: str = "", end_date: str = "", type: str = "", category: str = "", person: str = "", account_id: str = "", include_deleted: bool = False, limit: int = 100) -> dict: - """List ledger transactions with date/category/person/account filters.""" - return service.list_ledger_transactions(start_date, end_date, type, category, person, account_id, include_deleted, limit) - -@mcp.tool() -def update_ledger_transaction(transaction_id: str, transaction_date: str | None = None, type: str | None = None, amount: int | None = None, category: str | None = None, subcategory: str | None = None, merchant: str | None = None, description: str | None = None, account_id: str | None = None, payment_method: str | None = None, person: str | None = None, project: str | None = None, tags: list[str] | str | None = None, is_fixed: bool | None = None, memo: str | None = None, status: str | None = None) -> dict: - """Update selected fields on a ledger transaction.""" - return service.update_ledger_transaction(transaction_id, transaction_date=transaction_date, type=type, amount=amount, category=category, subcategory=subcategory, merchant=merchant, description=description, account_id=account_id, payment_method=payment_method, person=person, project=project, tags=tags, is_fixed=is_fixed, memo=memo, status=status) - -@mcp.tool() -def delete_ledger_transaction(transaction_id: str, hard_delete: bool = False) -> dict: - """Soft-delete a ledger transaction by default, or permanently delete with hard_delete=true.""" - return service.delete_ledger_transaction(transaction_id, hard_delete) - -@mcp.tool() -def create_ledger_budget(month: str, category: str, amount: int, subcategory: str = "", person: str = "", currency: str = "KRW", memo: str = "") -> dict: - """Create or update a monthly category/person budget.""" - return service.create_ledger_budget(month, category, amount, subcategory, person, currency, memo) - -@mcp.tool() -def list_ledger_budgets(month: str = "") -> dict: - """List ledger budgets, optionally for a month YYYY-MM.""" - return service.list_ledger_budgets(month) - -@mcp.tool() -def get_monthly_cashflow_report(month: str = "") -> dict: - """Return income, expenses, net cashflow, and savings rate for a month.""" - return service.get_monthly_cashflow_report(month) - -@mcp.tool() -def get_category_spending_report(month: str = "", start_date: str = "", end_date: str = "", person: str = "") -> dict: - """Return category/subcategory spending for a month or date range.""" - return service.get_category_spending_report(month, start_date, end_date, person) - -@mcp.tool() -def get_fixed_cost_report(month: str = "") -> dict: - """Return fixed expense transactions and total fixed cost for a month.""" - return service.get_fixed_cost_report(month) - -@mcp.tool() -def get_budget_vs_actual_report(month: str = "") -> dict: - """Compare monthly budgets against actual spending.""" - return service.get_budget_vs_actual_report(month) - -@mcp.tool() -def get_investable_cashflow_report(month: str = "", minimum_cash_buffer: int = 0, planned_extra_expense: int = 0) -> dict: - """Estimate cashflow available for investing after buffer and planned extra expenses.""" - return service.get_investable_cashflow_report(month, minimum_cash_buffer, planned_extra_expense) - -@mcp.tool() -def preview_ledger_csv_import(file_path: str, source: str = "generic") -> dict: - """Preview a UTF-8/UTF-8-BOM CSV import before saving transactions.""" - return service.preview_ledger_csv_import(file_path, source) - -@mcp.tool() -def preview_kb_bank_pdf_import(file_path: str, password: str, preview_limit: int = 20) -> dict: - """Preview transactions from a password-protected KB Kookmin Bank account-history PDF without saving them.""" - return service.preview_kb_bank_pdf_import(file_path, password, preview_limit) - -@mcp.tool() -def import_kb_bank_pdf(file_path: str, password: str, account_id: str = "", skip_duplicates: bool = True, skip_validation: bool = False) -> dict: - """Import transactions from a KB Kookmin Bank account-history PDF into the ledger database.""" - return service.import_kb_bank_pdf(file_path, password, account_id, skip_duplicates, skip_validation=skip_validation) - -@mcp.tool() -def preview_seoulpay_xlsx_import(file_path: str, password: str = "", preview_limit: int = 20) -> dict: - """Preview transactions from a SeoulPay usage-history XLSX before saving them.""" - return service.preview_seoulpay_xlsx_import(file_path, password, preview_limit) - -@mcp.tool() -def import_seoulpay_xlsx(file_path: str, password: str = "", account_id: str = "", skip_duplicates: bool = True) -> dict: - """Import transactions from a SeoulPay usage-history XLSX into the ledger database.""" - return service.import_seoulpay_xlsx(file_path, password, account_id, skip_duplicates) - -@mcp.tool() -def preview_toss_securities_pdf_import(file_path: str, preview_limit: int = 20) -> dict: - """Preview transactions from a Toss Securities transaction-history PDF without saving them.""" - return service.preview_toss_securities_pdf_import(file_path, preview_limit) - -@mcp.tool() -def import_toss_securities_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True) -> dict: - """Import transactions from a Toss Securities transaction-history PDF into the ledger database.""" - return service.import_toss_securities_pdf(file_path, account_id, skip_duplicates) - -@mcp.tool() -def preview_toss_bank_pdf_import(file_path: str, preview_limit: int = 20) -> dict: - """Preview transactions from a Toss Bank transaction-history PDF without saving them.""" - return service.preview_toss_bank_pdf_import(file_path, preview_limit) - -@mcp.tool() -def import_toss_bank_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True) -> dict: - """Import transactions from a Toss Bank transaction-history PDF into the ledger database.""" - return service.import_toss_bank_pdf(file_path, account_id, skip_duplicates) - -@mcp.tool() -def list_ledger_import_batches(source: str = "", include_rolled_back: bool = True, limit: int = 50) -> dict: - """List CSV/PDF/API import batches.""" - return service.list_ledger_import_batches(source, include_rolled_back, limit) - -@mcp.tool() -def rollback_ledger_import_batch(import_batch_id: str, hard_delete: bool = False) -> dict: - """Roll back all transactions created by one import batch. Soft-deletes by default.""" - return service.rollback_ledger_import_batch(import_batch_id, hard_delete) - -@mcp.tool() -def create_category_rule(match_field: str, pattern: str, category: str, subcategory: str = "", transaction_type: str = "", match_type: str = "contains", priority: int = 100, person: str = "", is_fixed: bool | None = None, confidence: float = 1.0, memo: str = "", amount_min: int | None = None, amount_max: int | None = None, day_of_month: int | None = None) -> dict: - """Create or update a ledger transaction categorization rule.""" - return service.create_category_rule(match_field, pattern, category, subcategory, transaction_type, match_type, priority, person, is_fixed, confidence, memo, amount_min, amount_max, day_of_month) - -@mcp.tool() -def seed_default_category_rules(overwrite: bool = False) -> dict: - """Seed default Jarca category rules for common Korean household ledger patterns.""" - return service.seed_default_category_rules(overwrite) - -@mcp.tool() -def list_category_rules(active_only: bool = True) -> dict: - """List transaction categorization rules.""" - return service.list_category_rules(active_only) - -@mcp.tool() -def update_category_rule(rule_id: str, match_field: str, pattern: str, category: str, subcategory: str = "", transaction_type: str = "", match_type: str = "contains", priority: int = 100, person: str = "", is_fixed: bool | None = None, confidence: float = 1.0, memo: str = "", amount_min: int | None = None, amount_max: int | None = None, day_of_month: int | None = None, active: bool = True) -> dict: - """Update an existing ledger transaction categorization rule.""" - return service.update_category_rule(rule_id, match_field, pattern, category, subcategory, transaction_type, match_type, priority, person, is_fixed, confidence, memo, amount_min, amount_max, day_of_month, active) - -@mcp.tool() -def delete_category_rule(rule_id: str) -> dict: - """Delete a transaction categorization rule.""" - return service.delete_category_rule(rule_id) - -@mcp.tool() -def preview_categorization(limit: int = 1000, only_uncategorized: bool = True) -> dict: - """Preview category-rule matches before writing categories to transactions.""" - return service.preview_categorization(limit, only_uncategorized) - -@mcp.tool() -def apply_categorization_rules(limit: int = 1000, only_uncategorized: bool = True) -> dict: - """Apply active category rules to matching ledger transactions.""" - return service.apply_categorization_rules(limit, only_uncategorized) - -@mcp.tool() -def list_uncategorized_transactions(limit: int = 100, min_amount: int = 0) -> dict: - """List active transactions that still have no category.""" - return service.list_uncategorized_transactions(limit, min_amount) - -@mcp.tool() -def get_uncategorized_merchant_summary(limit: int = 50) -> dict: - """Summarize uncategorized transactions by merchant/raw_summary/type for user review.""" - return service.get_uncategorized_merchant_summary(limit) - -@mcp.tool() -def create_ledger_category(category: str, subcategory: str = "", sort_order: int = 100, memo: str = "", cashflow_kind: str = "") -> dict: - """Create or reactivate a ledger category/subcategory used by the dashboard and transactions.""" - return service.create_ledger_category(category, subcategory, sort_order, memo, cashflow_kind) - -@mcp.tool() -def seed_default_ledger_categories(overwrite: bool = False) -> dict: - """Seed default ledger categories and subcategories.""" - return service.seed_default_ledger_categories(overwrite) - -@mcp.tool() -def list_ledger_categories(active_only: bool = True, include_usage: bool = True) -> dict: - """List ledger categories and subcategories, optionally with usage counts.""" - return service.list_ledger_categories(active_only, include_usage) - -@mcp.tool() -def list_cross_transfer_review_queue(limit: int = 300) -> dict: - """List KBโ†”ํ† ์Šค ๊ณ„์ขŒ ์ด์ฒด ํ™•์ธํ•„์š” ํ ํ•ญ๋ชฉ.""" - return service.list_cross_transfer_review_queue(limit) - -@mcp.tool() -def preview_cross_account_transfers(limit: int = 500) -> dict: - """Preview KBโ†”ํ† ์Šค์€ํ–‰ ์ด์ฒด ๋งค์นญ ํ›„๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.""" - return service.preview_cross_account_transfers(limit) - -@mcp.tool() -def run_cross_account_transfer_match(limit: int = 500) -> dict: - """Run KBโ†”ํ† ์Šค์€ํ–‰ ์ž๋™ ์ด์ฒด ๋งค์นญ ๋ฐ ํƒœ๊ทธ/๋ถ„๋ฅ˜ ๋ฐ˜์˜์„ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค.""" - return service.run_cross_account_transfer_match(limit) - -@mcp.tool() -def delete_ledger_category(category: str, subcategory: str = "", hard_delete: bool = False, force: bool = False) -> dict: - """Deactivate or delete a ledger category/subcategory.""" - return service.delete_ledger_category(category, subcategory, hard_delete, force) - -def main() -> None: - mcp.run() +from .adapters.mcp.server import main, mcp if __name__ == "__main__": main() diff --git a/jarca_ledger_mcp/service.py b/jarca_ledger_mcp/service.py index b8bd47f..8c7766c 100644 --- a/jarca_ledger_mcp/service.py +++ b/jarca_ledger_mcp/service.py @@ -1,3378 +1,40 @@ from __future__ import annotations -import csv -import hashlib -import json -import re -import sqlite3 -from datetime import date, datetime, timedelta -from pathlib import Path -from typing import Any -from uuid import uuid4 -from zoneinfo import ZoneInfo - -from .db import db, init_db, resolve_db_path -from .kb_bank_pdf import parse_kb_bank_pdf, transaction_preview -from .toss_bank_pdf import parse_toss_bank_pdf, transaction_preview as toss_bank_transaction_preview -from .toss_securities_pdf import parse_toss_securities_pdf, transaction_preview as toss_transaction_preview -from .seoulpay_xlsx import parse_seoulpay_xlsx, transaction_preview as seoulpay_transaction_preview - -VALID_TRANSACTION_TYPES = {"income", "expense", "transfer", "refund", "adjustment"} -VALID_ACCOUNT_TYPES = {"bank", "card", "cash", "investment", "loan", "other"} -VALID_STATEMENT_STATUSES = {"unknown", "import_supported", "manual_only", "partial_import"} -VALID_LIQUIDITY_BUCKETS = {"cash", "investment", "deposit", "real_estate", "loan", "other"} -ASSET_BUCKETS = ["cash", "investment", "deposit", "real_estate", "other"] -LIABILITY_BUCKETS = ["loan"] -ASSET_BUCKET_ORDER = [*ASSET_BUCKETS, *LIABILITY_BUCKETS] -RECONCILIATION_TARGET_STATUSES = {"manual_only", "partial_import"} -BALANCE_RECONCILIATION_TOLERANCE_KRW = 1_000 -BALANCE_RECONCILIATION_REFERENCE_RATIO = 0.001 -VALID_CASHFLOW_KINDS = {"income", "expense", "transfer", "investment", "debt_payment", "exclude", "unknown"} -CASHFLOW_KIND_SQL = """COALESCE(lc.cashflow_kind, CASE - WHEN t.category='์ˆ˜์ž…' THEN 'income' - WHEN t.category='์ด์ฒด' AND t.subcategory='ํˆฌ์ž์ด๋™' THEN 'investment' - WHEN t.category='์ด์ฒด' THEN 'transfer' - WHEN t.category='๊ธˆ์œต' AND t.subcategory='๋Œ€์ถœ์ƒํ™˜' THEN 'debt_payment' - WHEN t.category='๊ธˆ์œต' THEN 'exclude' - WHEN t.category='๋ฏธ๋ถ„๋ฅ˜' THEN 'unknown' - WHEN COALESCE(t.category, '')='' THEN 'unknown' - ELSE 'expense' -END)""" - -CROSS_TRANSFER_MATCH_TAG = "cross_transfer_match:" -CROSS_TRANSFER_REVIEW_TAG = "cross_transfer_review:" -DEFAULT_LEDGER_BACKUP_DIR = Path.home() / ".local" / "share" / "jarca" / "backups" / "ledger" -KST = ZoneInfo("Asia/Seoul") -BACKUP_RESTORE_POLICY = { - "mcp_restore_exposed": False, - "restore_requires_explicit_user_approval": True, - "restore_entrypoint": "scripts/ledger_restore_smoke.py ๋˜๋Š” docs/runbooks/ledger-backup-restore.md", -} - - -def _normalize_counterparty_for_match(value: str) -> str: - if not value: - return "" - # normalize party names for fuzzy matching across KB/ํ† ์Šค๋ฑ…ํฌ records - normalized = re.sub(r"\\([^)]*\\)", "", value) - normalized = normalized.lower().strip() - normalized = normalized.replace("ํ† ์Šค๋ฑ…ํฌ", "").replace("ํ† ์Šค", "") - normalized = normalized.replace("kb", "").replace("๊ตญ๋ฏผ์€ํ–‰", "") - normalized = re.sub(r"\\s+", "", normalized) - normalized = re.sub(r"[^0-9๊ฐ€-ํžฃa-zA-Z]", "", normalized) - return normalized - - -def _party_match_score(left: str, right: str) -> int: - left_norm = _normalize_counterparty_for_match(left) - right_norm = _normalize_counterparty_for_match(right) - if not left_norm or not right_norm: - return 0 - if left_norm == right_norm: - return 2 - if left_norm in right_norm or right_norm in left_norm: - return 1 - return 0 - - -def _append_memo_tag(raw_memo: str, tag: str) -> str: - memo = (raw_memo or "").strip() - if not memo: - return tag - if tag in memo: - return memo - return f"{memo}; {tag}" - - -def _memo_has_tag(raw_memo: str, tag_prefix: str) -> bool: - memo = (raw_memo or "").strip() - return tag_prefix in memo - - -def _current_kst_datetime() -> datetime: - return datetime.now(KST).replace(microsecond=0) - - -def current_kst_date() -> date: - return _current_kst_datetime().date() - - -def current_kst_month() -> str: - return current_kst_date().strftime("%Y-%m") - - -def now_iso() -> str: - return _current_kst_datetime().isoformat() - -def new_id(prefix: str) -> str: - return f"{prefix}_{uuid4().hex[:12]}" - -def ok(**data: Any) -> dict[str, Any]: - return {"success": True, **data} - -def fail(error_code: str, message: str, **data: Any) -> dict[str, Any]: - return {"success": False, "error_code": error_code, "error": message, **data} - -def row_to_dict(row: Any) -> dict[str, Any]: - d = dict(row) - if "tags" in d: - try: - d["tags"] = json.loads(d["tags"] or "[]") - except json.JSONDecodeError: - d["tags"] = [] - for k in ("active", "is_fixed", "is_correction"): - if k in d: - d[k] = bool(d[k]) - return d - -def normalize_date(value: str | None, default_today: bool = False) -> str: - if not value: - if default_today: - return current_kst_date().isoformat() - raise ValueError("date is required") - return date.fromisoformat(value[:10]).isoformat() - -def normalize_month(value: str | None) -> str: - if not value: - return current_kst_month() - if re.fullmatch(r"\d{4}-\d{2}", value): - return value - return date.fromisoformat(value[:10]).strftime("%Y-%m") - - -def normalize_statement_status(value: str | None) -> str: - status = (value or "unknown").strip() or "unknown" - if status not in VALID_STATEMENT_STATUSES: - raise ValueError(f"statement_status must be one of {sorted(VALID_STATEMENT_STATUSES)}") - return status - - -def normalize_liquidity_bucket(value: str | None) -> str: - bucket = (value or "other").strip() or "other" - if bucket not in VALID_LIQUIDITY_BUCKETS: - raise ValueError(f"liquidity_bucket must be one of {sorted(VALID_LIQUIDITY_BUCKETS)}") - return bucket - - -def normalize_snapshot_at(value: str | None, default_now: bool = True) -> str: - if not value: - if default_now: - return now_iso() - raise ValueError("baseline_at is required") - raw = value.strip() - if re.fullmatch(r"\d{4}-\d{2}", raw): - month_start = date.fromisoformat(raw + "-01") - if month_start.month == 12: - next_month = date(month_start.year + 1, 1, 1) - else: - next_month = date(month_start.year, month_start.month + 1, 1) - last_day = next_month - timedelta(days=1) - return f"{last_day.isoformat()}T23:59:00+09:00" - if "T" not in raw and len(raw) == 10: - return f"{normalize_date(raw)}T23:59:00+09:00" - datetime.fromisoformat(raw.replace("Z", "+00:00")) - return raw - -def normalize_tags(tags: Any) -> str: - if tags is None or tags == "": - return "[]" - if isinstance(tags, str): - parts = [p.strip() for p in re.split(r"[,#]", tags) if p.strip()] - return json.dumps(parts, ensure_ascii=False) - if isinstance(tags, list): - return json.dumps([str(x).strip() for x in tags if str(x).strip()], ensure_ascii=False) - raise ValueError("tags must be a list or comma-separated string") - -def make_dedupe_key(transaction_date: str, amount: int, merchant: str = "", account_id: str = "", external_id: str = "") -> str: - if external_id: - raw = f"external:{external_id}" - else: - raw = f"{transaction_date}|{amount}|{merchant.strip().lower()}|{account_id.strip().lower()}" - return hashlib.sha256(raw.encode("utf-8")).hexdigest() - -def health_check(db_path: str | None = None) -> dict[str, Any]: - path = init_db(db_path) - with db(path) as conn: - schema_version = conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] - tx_count = conn.execute("SELECT COUNT(*) FROM transactions WHERE status='active'").fetchone()[0] - return ok(server="jarca-ledger-mcp", db_path=path, schema_version=schema_version, active_transactions=tx_count) - - -def _sha256_file(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as f: - for chunk in iter(lambda: f.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def _file_metadata(path: Path, include_hash: bool = True) -> dict[str, Any]: - stat = path.stat() - metadata: dict[str, Any] = { - "path": str(path.resolve()), - "size_bytes": stat.st_size, - "mtime": datetime.fromtimestamp(stat.st_mtime).replace(microsecond=0).isoformat(), - } - if include_hash: - metadata["sha256"] = _sha256_file(path) - return metadata - - -def _readonly_sqlite_connection(path: Path) -> sqlite3.Connection: - conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True) - conn.row_factory = sqlite3.Row - return conn - - -def _safe_table_counts(conn: sqlite3.Connection) -> dict[str, int]: - counts: dict[str, int] = {} - for table in ("accounts", "transactions", "budgets", "import_batches", "category_rules", "ledger_categories", "manual_assets", "asset_snapshots"): - exists = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone() - if exists: - counts[table] = int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]) - return counts - - -def _safe_monthly_aggregates(conn: sqlite3.Connection) -> dict[str, Any]: - current_month = current_kst_month() - start, next_month = _month_range(current_month) - rows = conn.execute( - f"""SELECT {CASHFLOW_KIND_SQL} AS cashflow_kind, SUM(t.amount) AS total, COUNT(*) AS count - FROM transactions t - LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory - WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date dict[str, Any]: - status: dict[str, Any] = {"db_path": str(path.resolve()), "exists": path.exists()} - if not path.exists(): - return status - status.update(_file_metadata(path, include_hash=include_hash)) - try: - with _readonly_sqlite_connection(path) as conn: - if include_integrity: - status["integrity_check"] = str(conn.execute("PRAGMA integrity_check").fetchone()[0]) - status["table_counts"] = _safe_table_counts(conn) - status["aggregate_totals"] = _safe_monthly_aggregates(conn) - except Exception as e: - status["integrity_check"] = "error" - status["error"] = str(e) - return status - - -def get_ledger_db_status(db_path: str | None = None, include_hash: bool = True, include_integrity: bool = True) -> dict[str, Any]: - """Return non-sensitive metadata evidence for the selected ledger DB.""" - path = resolve_db_path(db_path) - if not path.exists(): - return fail("not_found", f"ledger DB not found: {path}", db_path=str(path), exists=False) - return ok(**_ledger_db_status_for_path(path, include_hash=include_hash, include_integrity=include_integrity)) - - -def _resolve_backup_dir(backup_dir: str | None = None) -> Path: - return Path(backup_dir).expanduser().resolve() if backup_dir else DEFAULT_LEDGER_BACKUP_DIR.resolve() - - -def _backup_file_metadata(path: Path, include_hash: bool = False) -> dict[str, Any]: - metadata = _file_metadata(path, include_hash=include_hash) - metadata["name"] = path.name - metadata["restore_policy"] = BACKUP_RESTORE_POLICY - return metadata - - -def _apply_backup_retention(backup_dir: Path, retention_days: int = 30, retention_count: int = 20) -> list[str]: - backups = sorted(backup_dir.glob("*.sqlite3"), key=lambda p: p.stat().st_mtime, reverse=True) - keep_count = max(1, int(retention_count)) - # Retention comparison needs sub-second precision for retention_days=0: - # the timestamp used for backup names is second-truncated, but filesystem mtimes - # can identify files created just before retention runs as older than "now". - cutoff = datetime.now(KST) - timedelta(days=max(0, int(retention_days))) - pruned: list[str] = [] - for idx, path in enumerate(backups): - mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=KST) - should_keep = idx < keep_count or mtime >= cutoff - if should_keep: - continue - path.unlink() - pruned.append(str(path.resolve())) - return pruned - - -def create_ledger_backup(db_path: str | None = None, backup_dir: str | None = None, retention_days: int = 30, retention_count: int = 20) -> dict[str, Any]: - """Create a timestamped SQLite backup and return metadata-only evidence.""" - source_path = resolve_db_path(db_path) - if not source_path.exists(): - return fail("not_found", f"ledger DB not found: {source_path}", db_path=str(source_path), exists=False) - backup_root = _resolve_backup_dir(backup_dir) - backup_root.mkdir(parents=True, exist_ok=True) - timestamp = _current_kst_datetime().strftime("%Y%m%d%H%M%S%f") - backup_path = backup_root / f"ledger_{timestamp}.sqlite3" - try: - # Use SQLite's online backup API so WAL-mode databases are copied consistently. - with sqlite3.connect(f"file:{source_path}?mode=ro", uri=True) as source_conn: - with sqlite3.connect(backup_path) as backup_conn: - source_conn.backup(backup_conn) - except Exception: - if backup_path.exists(): - backup_path.unlink() - raise - source_status = _ledger_db_status_for_path(source_path, include_hash=True, include_integrity=True) - backup_status = _ledger_db_status_for_path(backup_path, include_hash=True, include_integrity=True) - pruned_paths = _apply_backup_retention(backup_root, retention_days=retention_days, retention_count=retention_count) - return ok( - source_db=source_status, - backup=backup_status, - backup_dir=str(backup_root), - retention={"retention_days": int(retention_days), "retention_count": int(retention_count), "pruned_paths": pruned_paths}, - restore_policy=BACKUP_RESTORE_POLICY, - ) - - -def list_ledger_backups(backup_dir: str | None = None, include_hash: bool = False, limit: int = 50) -> dict[str, Any]: - backup_root = _resolve_backup_dir(backup_dir) - if not backup_root.exists(): - return ok(backup_dir=str(backup_root), backups=[], count=0, restore_policy=BACKUP_RESTORE_POLICY) - max_items = max(1, min(int(limit), 500)) - files = sorted(backup_root.glob("*.sqlite3"), key=lambda p: p.stat().st_mtime, reverse=True)[:max_items] - backups = [_backup_file_metadata(path, include_hash=include_hash) for path in files] - return ok(backup_dir=str(backup_root), backups=backups, count=len(backups), restore_policy=BACKUP_RESTORE_POLICY) - - -def get_ledger_backup_status(file_path: str, include_hash: bool = True, include_integrity: bool = True) -> dict[str, Any]: - path = Path(file_path).expanduser().resolve() - if not path.exists(): - return fail("not_found", f"backup file not found: {path}", path=str(path), exists=False) - return ok(backup=_ledger_db_status_for_path(path, include_hash=include_hash, include_integrity=include_integrity), restore_policy=BACKUP_RESTORE_POLICY) - -def create_ledger_account(name: str, type: str = "bank", institution: str = "", currency: str = "KRW", active: bool = True, memo: str = "", account_number_masked: str = "", holder_name: str = "", last_balance: int | None = None, last_synced_at: str = "", statement_status: str = "unknown", liquidity_bucket: str = "other", db_path: str | None = None) -> dict[str, Any]: - if not name.strip(): - return fail("validation_error", "account name is required") - if type not in VALID_ACCOUNT_TYPES: - return fail("validation_error", f"type must be one of {sorted(VALID_ACCOUNT_TYPES)}") - try: - statement_status_norm = normalize_statement_status(statement_status) - liquidity_bucket_norm = normalize_liquidity_bucket(liquidity_bucket) - except Exception as e: - return fail("validation_error", str(e)) - ts = now_iso(); account_id = new_id("acct") - with db(db_path) as conn: - conn.execute("""INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,statement_status,liquidity_bucket,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (account_id, name.strip(), type, institution, currency, int(active), memo, account_number_masked, holder_name, last_balance, last_synced_at, statement_status_norm, liquidity_bucket_norm, ts, ts)) - row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone() - return ok(account=row_to_dict(row)) - -def list_ledger_accounts(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]: - sql = "SELECT * FROM accounts" + (" WHERE active=1" if active_only else "") + " ORDER BY name" - with db(db_path) as conn: - rows = conn.execute(sql).fetchall() - return ok(accounts=[row_to_dict(r) for r in rows], count=len(rows)) - - -def _valuation_policy() -> dict[str, Any]: - return { - "authority_sources": ["accounts.last_balance", "manual_assets"], - "snapshot_role": "historical_record_layer", - "formula": "asset_buckets - loan", - "asset_buckets": ASSET_BUCKETS, - "liability_buckets": LIABILITY_BUCKETS, - "missing_valuation_policy": "exclude_without_transaction_inference", - } - - -def _account_valuation_items(conn: sqlite3.Connection) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]: - rows = conn.execute("SELECT * FROM accounts WHERE active=1 ORDER BY name").fetchall() - included: list[dict[str, Any]] = [] - excluded: list[dict[str, Any]] = [] - invalid: list[dict[str, Any]] = [] - for row in rows: - account = row_to_dict(row) - raw_bucket = str(account.get("liquidity_bucket") or "other") - try: - bucket = normalize_liquidity_bucket(raw_bucket) - except ValueError: - bucket = "other" - invalid.append({"id": account.get("id", ""), "name": account.get("name", ""), "liquidity_bucket": raw_bucket}) - base = { - "id": account.get("id", ""), - "source_id": account.get("id", ""), - "source_type": "account", - "authority_source": "accounts.last_balance", - "name": account.get("name", ""), - "type": account.get("type", ""), - "liquidity_bucket": bucket, - "valuation_date": account.get("last_synced_at", ""), - "last_synced_at": account.get("last_synced_at", ""), - "source_note": account.get("memo", ""), - "memo": account.get("memo", ""), - } - balance = account.get("last_balance") - if balance is None: - excluded.append({**base, "reason": "missing_last_balance"}) - continue - amount = int(balance) - included.append({**base, "amount": amount, "last_balance": amount}) - return included, excluded, invalid - - -def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool = True) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: - where = "WHERE active=1" if active_only else "" - rows = conn.execute(f"SELECT * FROM manual_assets {where} ORDER BY name").fetchall() - included: list[dict[str, Any]] = [] - excluded: list[dict[str, Any]] = [] - for row in rows: - asset = row_to_dict(row) - try: - bucket = normalize_liquidity_bucket(asset.get("liquidity_bucket")) - except ValueError: - bucket = "other" - base = { - "id": asset.get("id", ""), - "source_id": asset.get("id", ""), - "source_type": "manual_asset", - "authority_source": "manual_assets", - "name": asset.get("name", ""), - "liquidity_bucket": bucket, - "valuation_date": asset.get("valuation_date", ""), - "source_note": asset.get("source_note", ""), - "memo": asset.get("memo", ""), - "active": asset.get("active", True), - } - value = asset.get("valuation_amount") - if value is None: - excluded.append({**base, "reason": "missing_valuation_amount"}) - continue - included.append({**base, "amount": int(value), "valuation_amount": int(value)}) - return included, excluded - - -def _snapshot_rows_for_latest_run(conn: sqlite3.Connection, basis_date: str) -> tuple[list[sqlite3.Row], dict[str, Any]]: - rows = conn.execute( - "SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC", - (basis_date,), - ).fetchall() - if not rows: - return [], {} - run_rows = [row for row in rows if "snapshot_run_id" in row.keys() and row["snapshot_run_id"]] - if not run_rows: - return rows, {"snapshot_run_id": "", "is_correction": False, "correction_of_run_id": "", "correction_reason": "", "total_snapshot_rows_for_basis_date": len(rows)} - latest = sorted(run_rows, key=lambda row: (int(row["_rowid"]), row["closed_at"] or row["created_at"] or "", row["snapshot_run_id"] or ""))[-1] - latest_run_id = latest["snapshot_run_id"] - selected = [row for row in run_rows if row["snapshot_run_id"] == latest_run_id] - metadata = { - "snapshot_run_id": latest_run_id, - "is_correction": bool(latest["is_correction"]), - "correction_of_run_id": latest["correction_of_run_id"] or "", - "correction_reason": latest["correction_reason"] or "", - "closed_at": latest["closed_at"] or latest["created_at"] or "", - "total_snapshot_rows_for_basis_date": len(rows), - } - return selected, metadata - - -def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str, Any]]: - rows, _metadata = _snapshot_rows_for_latest_run(conn, basis_date) - items: list[dict[str, Any]] = [] - for row in rows: - snap = row_to_dict(row) - try: - bucket = normalize_liquidity_bucket(snap.get("liquidity_bucket")) - except ValueError: - bucket = "other" - items.append( - { - "id": snap.get("id", ""), - "source_id": snap.get("source_id", ""), - "source_type": snap.get("source_type", ""), - "authority_source": "asset_snapshots", - "valuation_source": snap.get("valuation_source", ""), - "name": snap.get("name", ""), - "liquidity_bucket": bucket, - "amount": int(snap.get("amount") or 0), - "valuation_date": snap.get("basis_date", ""), - "source_note": snap.get("source_note", ""), - "memo": snap.get("memo", ""), - "snapshot_run_id": snap.get("snapshot_run_id", ""), - "is_correction": bool(snap.get("is_correction", False)), - "correction_of_run_id": snap.get("correction_of_run_id", ""), - "correction_reason": snap.get("correction_reason", ""), - } - ) - return items - - -def _summarize_valuation_items(included_items: list[dict[str, Any]], excluded_items: list[dict[str, Any]] | None = None) -> dict[str, Any]: - bucket_totals = {bucket: 0 for bucket in ASSET_BUCKET_ORDER} - bucket_counts = {bucket: 0 for bucket in ASSET_BUCKET_ORDER} - for item in included_items: - bucket = normalize_liquidity_bucket(item.get("liquidity_bucket")) - amount = int(item.get("amount") or 0) - bucket_counts[bucket] += 1 - bucket_totals[bucket] += amount - total_assets = sum(bucket_totals[bucket] for bucket in ASSET_BUCKETS) - total_liabilities = sum(abs(bucket_totals[bucket]) for bucket in LIABILITY_BUCKETS) - net_worth = total_assets - total_liabilities - buckets = [] - for bucket in ASSET_BUCKET_ORDER: - amount = bucket_totals[bucket] - denominator = total_liabilities if bucket in LIABILITY_BUCKETS else total_assets - buckets.append( - { - "liquidity_bucket": bucket, - "amount": amount, - "account_count": sum(1 for item in included_items if item.get("source_type") == "account" and item.get("liquidity_bucket") == bucket), - "manual_asset_count": sum(1 for item in included_items if item.get("source_type") == "manual_asset" and item.get("liquidity_bucket") == bucket), - "item_count": bucket_counts[bucket], - "asset_share": round(amount / total_assets, 4) if bucket not in LIABILITY_BUCKETS and total_assets else None, - "liability_share": round(abs(amount) / total_liabilities, 4) if bucket in LIABILITY_BUCKETS and total_liabilities else None, - "share": round(abs(amount) / denominator, 4) if denominator else None, - } - ) - return { - "buckets": buckets, - "bucket_totals": bucket_totals, - "total_assets": total_assets, - "total_liabilities": total_liabilities, - "net_worth": net_worth, - "included_items": included_items, - "excluded_items": excluded_items or [], - "valuation_policy": _valuation_policy(), - } - - -def create_manual_asset(name: str, valuation_amount: int | None, valuation_date: str, liquidity_bucket: str, source_note: str = "", memo: str = "", currency: str = "KRW", active: bool = True, db_path: str | None = None) -> dict[str, Any]: - if not name.strip(): - return fail("validation_error", "manual asset name is required") - try: - bucket = normalize_liquidity_bucket(liquidity_bucket) - amount = int(valuation_amount) if valuation_amount is not None else None - vdate = normalize_date(valuation_date, default_today=True) - except Exception as e: - return fail("validation_error", str(e)) - ts = now_iso() - asset_id = new_id("asset") - with db(db_path) as conn: - conn.execute( - """INSERT INTO manual_assets(id,name,liquidity_bucket,valuation_amount,valuation_date,currency,source_note,memo,active,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?)""", - (asset_id, name.strip(), bucket, amount, vdate, currency, source_note, memo, int(active), ts, ts), - ) - row = conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone() - return ok(manual_asset={**row_to_dict(row), "authority_source": "manual_assets"}) - - -def list_manual_assets(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]: - where = "WHERE active=1" if active_only else "" - with db(db_path) as conn: - rows = conn.execute(f"SELECT * FROM manual_assets {where} ORDER BY name").fetchall() - assets = [{**row_to_dict(row), "authority_source": "manual_assets"} for row in rows] - return ok(manual_assets=assets, count=len(assets), valuation_policy=_valuation_policy()) - - -def update_manual_asset(asset_id: str, name: str | None = None, valuation_amount: int | None = None, valuation_date: str | None = None, liquidity_bucket: str | None = None, source_note: str | None = None, memo: str | None = None, currency: str | None = None, active: bool | None = None, db_path: str | None = None) -> dict[str, Any]: - allowed_updates: list[str] = [] - params: list[Any] = [] - try: - if name is not None: - if not name.strip(): - return fail("validation_error", "manual asset name is required") - allowed_updates.append("name=?") - params.append(name.strip()) - if valuation_amount is not None: - allowed_updates.append("valuation_amount=?") - params.append(int(valuation_amount)) - if valuation_date is not None: - allowed_updates.append("valuation_date=?") - params.append(normalize_date(valuation_date, default_today=True)) - if liquidity_bucket is not None: - allowed_updates.append("liquidity_bucket=?") - params.append(normalize_liquidity_bucket(liquidity_bucket)) - if source_note is not None: - allowed_updates.append("source_note=?") - params.append(source_note) - if memo is not None: - allowed_updates.append("memo=?") - params.append(memo) - if currency is not None: - allowed_updates.append("currency=?") - params.append(currency or "KRW") - if active is not None: - allowed_updates.append("active=?") - params.append(int(bool(active))) - except Exception as e: - return fail("validation_error", str(e)) - if not allowed_updates: - return fail("validation_error", "no fields to update") - allowed_updates.append("updated_at=?") - params.extend([now_iso(), asset_id]) - with db(db_path) as conn: - cur = conn.execute(f"UPDATE manual_assets SET {', '.join(allowed_updates)} WHERE id=?", params) - if cur.rowcount == 0: - return fail("not_found", "manual asset not found") - row = conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone() - return ok(manual_asset={**row_to_dict(row), "authority_source": "manual_assets"}) - - -def deactivate_manual_asset(asset_id: str, hard_delete: bool = False, db_path: str | None = None) -> dict[str, Any]: - with db(db_path) as conn: - if hard_delete: - cur = conn.execute("DELETE FROM manual_assets WHERE id=?", (asset_id,)) - else: - cur = conn.execute("UPDATE manual_assets SET active=0, updated_at=? WHERE id=?", (now_iso(), asset_id)) - if cur.rowcount == 0: - return fail("not_found", "manual asset not found") - row = None if hard_delete else conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone() - payload = {"manual_asset_id": asset_id, "deactivated": True, "hard_delete": hard_delete} - if row is not None: - payload["manual_asset"] = {**row_to_dict(row), "authority_source": "manual_assets"} - return ok(**payload) - - -def _snapshot_preview_from_conn(conn: sqlite3.Connection, basis: str) -> dict[str, Any]: - account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn) - manual_items, manual_excluded = _manual_asset_valuation_items(conn) - included = account_items + manual_items - excluded = account_excluded + manual_excluded - summary = _summarize_valuation_items(included, excluded) - existing_rows = conn.execute("SELECT COUNT(*) FROM asset_snapshots WHERE basis_date=?", (basis,)).fetchone()[0] - warnings: list[dict[str, Any]] = [] - for item in excluded: - warnings.append({"code": item.get("reason", "excluded_item"), "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "name": item.get("name", "")}) - for item in included: - item_date = str(item.get("valuation_date") or "")[:10] - if item_date and item_date < basis: - warnings.append({"code": "stale_valuation", "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "name": item.get("name", ""), "valuation_date": item_date, "basis_date": basis}) - for item in invalid_bucket_accounts: - warnings.append({"code": "invalid_liquidity_bucket", **item}) - if existing_rows: - warnings.append({"code": "existing_snapshot_rows", "basis_date": basis, "snapshot_row_count": int(existing_rows), "policy": "append_only_correction_required"}) - return { - "basis_date": basis, - "source": "live_authority", - "is_closed_snapshot": False, - "snapshot_count": 0, - "existing_snapshot_row_count": int(existing_rows), - "would_be_correction": bool(existing_rows), - "warnings": warnings, - **summary, - } - - -def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = None) -> dict[str, Any]: - try: - basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat() - except Exception as e: - return fail("validation_error", str(e)) - with db(db_path) as conn: - preview = _snapshot_preview_from_conn(conn, basis) - return ok(**preview) - - -def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "", db_path: str | None = None) -> dict[str, Any]: - try: - basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat() - except Exception as e: - return fail("validation_error", str(e)) - ts = now_iso() - run_id = new_id("snaprun") - with db(db_path) as conn: - preview = _snapshot_preview_from_conn(conn, basis) - included = preview["included_items"] - latest_rows, latest_meta = _snapshot_rows_for_latest_run(conn, basis) - is_correction = bool(preview["existing_snapshot_row_count"]) - if is_correction and not correction_reason.strip(): - return fail("validation_error", "correction_reason is required when closing a basis_date that already has snapshot rows", basis_date=basis, existing_snapshot_row_count=preview["existing_snapshot_row_count"]) - correction_of_run_id = latest_meta.get("snapshot_run_id", "") if latest_rows else "" - snapshot_ids: list[str] = [] - for item in included: - snapshot_id = new_id("snap") - row_note = source_note or item.get("source_note", "") or "month-end close" - row_memo = memo or item.get("memo", "") - conn.execute( - """INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,snapshot_run_id,correction_of_run_id,correction_reason,is_correction,closed_at,created_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", - ( - snapshot_id, - basis, - item.get("source_type", ""), - item.get("source_id", item.get("id", "")), - item.get("name", ""), - int(item.get("amount") or 0), - item.get("currency", "KRW"), - normalize_liquidity_bucket(item.get("liquidity_bucket")), - item.get("authority_source", item.get("source_type", "")), - row_note, - row_memo, - run_id, - correction_of_run_id, - correction_reason.strip(), - int(is_correction), - ts, - ts, - ), - ) - snapshot_ids.append(snapshot_id) - report_items = _snapshot_items(conn, basis) - summary = _summarize_valuation_items(report_items, []) - return ok( - basis_date=basis, - source="snapshot", - is_closed_snapshot=True, - snapshot_run_id=run_id, - correction_of_run_id=correction_of_run_id, - correction_reason=correction_reason.strip(), - is_correction=is_correction, - closed_at=ts, - inserted_count=len(snapshot_ids), - inserted_snapshot_ids=snapshot_ids, - warnings=preview["warnings"], - **summary, - ) - - -def create_asset_snapshot(basis_date: str, source_type: str, source_id: str, name: str, amount: int | None, liquidity_bucket: str, valuation_source: str = "", source_note: str = "", memo: str = "", currency: str = "KRW", db_path: str | None = None) -> dict[str, Any]: - if source_type not in {"account", "manual_asset"}: - return fail("validation_error", "source_type must be one of ['account', 'manual_asset']") - if not name.strip(): - return fail("validation_error", "snapshot item name is required") - try: - basis = normalize_date(basis_date, default_today=True) - bucket = normalize_liquidity_bucket(liquidity_bucket) - amount_int = int(amount) if amount is not None else None - except Exception as e: - return fail("validation_error", str(e)) - ts = now_iso() - snapshot_id = new_id("snap") - with db(db_path) as conn: - conn.execute( - """INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,created_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""", - (snapshot_id, basis, source_type, source_id, name.strip(), amount_int, currency, bucket, valuation_source or source_type, source_note, memo, ts), - ) - row = conn.execute("SELECT * FROM asset_snapshots WHERE id=?", (snapshot_id,)).fetchone() - return ok(asset_snapshot=row_to_dict(row)) - - -def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None) -> dict[str, Any]: - basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat() - with db(db_path) as conn: - rows, close_metadata = _snapshot_rows_for_latest_run(conn, basis) - snapshots = _snapshot_items(conn, basis) if rows else [] - if snapshots: - summary = _summarize_valuation_items(snapshots, []) - return ok( - basis_date=basis, - source="asset_snapshots", - is_closed_snapshot=True, - snapshot_count=len(snapshots), - close_metadata=close_metadata, - **summary, - ) - account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn) - manual_items, manual_excluded = _manual_asset_valuation_items(conn) - included = account_items + manual_items - excluded = account_excluded + manual_excluded - summary = _summarize_valuation_items(included, excluded) - return ok( - basis_date=basis, - source="live_authority", - is_closed_snapshot=False, - snapshot_count=0, - invalid_bucket_accounts=invalid_bucket_accounts, - **summary, - ) - - -def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: int = 12, db_path: str | None = None) -> dict[str, Any]: - try: - months_int = max(1, min(int(months), 120)) - end_norm = normalize_date(end_date, default_today=True) if end_date else current_kst_date().isoformat() - if start_date: - start_norm = normalize_date(start_date) - else: - end_dt = date.fromisoformat(end_norm) - start_month = end_dt.month - months_int + 1 - year = end_dt.year + (start_month - 1) // 12 - month = (start_month - 1) % 12 + 1 - start_norm = date(year, month, 1).isoformat() - except Exception as e: - return fail("validation_error", str(e)) - with db(db_path) as conn: - basis_rows = conn.execute( - """SELECT DISTINCT basis_date FROM asset_snapshots - WHERE basis_date>=? AND basis_date<=? - ORDER BY basis_date ASC""", - (start_norm, end_norm), - ).fetchall() - points = [] - for row in basis_rows: - basis = row["basis_date"] - items = _snapshot_items(conn, basis) - summary = _summarize_valuation_items(items, []) - points.append( - { - "basis_date": basis, - "total_assets": summary["total_assets"], - "total_liabilities": summary["total_liabilities"], - "net_worth": summary["net_worth"], - "source": "asset_snapshots", - "item_count": len(items), - } - ) - return ok(start_date=start_norm, end_date=end_norm, months=months_int, points=points, count=len(points), valuation_policy=_valuation_policy()) - - -def get_asset_liquidity_report(db_path: str | None = None) -> dict[str, Any]: - """Summarize assets/liabilities from shared valuation authority sources. - - Existing REQ-005 keys are preserved. When manual_assets is empty, totals and - source remain equivalent to the original accounts.last_balance behavior. - """ - with db(db_path) as conn: - account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn) - manual_items, manual_excluded = _manual_asset_valuation_items(conn) - included = account_items + manual_items - excluded = account_excluded + manual_excluded - summary = _summarize_valuation_items(included, excluded) - source_accounts = [item for item in included if item.get("source_type") == "account"] - source = "accounts.last_balance" if not manual_items and not manual_excluded else "accounts.last_balance+manual_assets" - return ok( - buckets=summary["buckets"], - accounts=source_accounts, - manual_assets=manual_items, - total_assets=summary["total_assets"], - total_liabilities=summary["total_liabilities"], - net_worth=summary["net_worth"], - missing_balance_accounts=[ - {"id": item.get("id", ""), "name": item.get("name", ""), "liquidity_bucket": item.get("liquidity_bucket", "")} - for item in account_excluded - ], - missing_valuation_items=excluded, - invalid_bucket_accounts=invalid_bucket_accounts, - manual_asset_count=len(manual_items), - included_items=included, - excluded_items=excluded, - valuation_policy=summary["valuation_policy"], - source=source, - ) - - -def update_ledger_account(account_id: str, db_path: str | None = None, **fields: Any) -> dict[str, Any]: - allowed = {"name", "type", "institution", "currency", "active", "memo", "account_number_masked", "holder_name", "last_balance", "last_synced_at", "statement_status", "liquidity_bucket"} - updates: list[str] = [] - params: list[Any] = [] - for k, v in fields.items(): - if k not in allowed or v is None: - continue - try: - if k == "type" and v not in VALID_ACCOUNT_TYPES: - return fail("validation_error", f"type must be one of {sorted(VALID_ACCOUNT_TYPES)}") - if k == "statement_status": - v = normalize_statement_status(str(v)) - if k == "liquidity_bucket": - v = normalize_liquidity_bucket(str(v)) - if k == "active": - v = int(bool(v)) - if k == "last_balance" and v != "": - v = int(v) - except Exception as e: - return fail("validation_error", str(e)) - updates.append(f"{k}=?") - params.append(v) - if not updates: - return fail("validation_error", "no fields to update") - updates.append("updated_at=?") - params.extend([now_iso(), account_id]) - with db(db_path) as conn: - cur = conn.execute(f"UPDATE accounts SET {', '.join(updates)} WHERE id=?", params) - if cur.rowcount == 0: - return fail("not_found", "account not found") - row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone() - return ok(account=row_to_dict(row)) - -def create_ledger_transaction(transaction_date: str = "", type: str = "expense", amount: int | float = 0, category: str = "", subcategory: str = "", merchant: str = "", description: str = "", account_id: str = "", payment_method: str = "", person: str = "", project: str = "", tags: Any = None, is_fixed: bool = False, currency: str = "KRW", posted_date: str = "", source: str = "manual", external_id: str = "", memo: str = "", dedupe: bool = True, transaction_datetime: str = "", counterparty: str = "", balance_after: int | None = None, branch: str = "", raw_summary: str = "", raw_payload: str = "", db_path: str | None = None) -> dict[str, Any]: - try: - tx_date = normalize_date(transaction_date, default_today=True) - posted = normalize_date(posted_date, default_today=False) if posted_date else "" - amount_int = int(amount) - tags_json = normalize_tags(tags) - except Exception as e: - return fail("validation_error", str(e)) - if type not in VALID_TRANSACTION_TYPES: - return fail("validation_error", f"type must be one of {sorted(VALID_TRANSACTION_TYPES)}") - if amount_int <= 0: - return fail("validation_error", "amount must be a positive integer in minor units; type determines direction") - dedupe_key = make_dedupe_key(tx_date, amount_int, merchant, account_id, external_id) if dedupe else "" - ts = now_iso(); tx_id = new_id("tx") - try: - with db(db_path) as conn: - conn.execute("""INSERT INTO transactions(id,transaction_date,posted_date,type,amount,currency,category,subcategory,merchant,description,account_id,payment_method,person,project,tags,is_fixed,source,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (tx_id, tx_date, posted, type, amount_int, currency, category, subcategory, merchant, description, account_id, payment_method, person, project, tags_json, int(is_fixed), source, external_id, dedupe_key, memo, transaction_datetime, counterparty, balance_after, branch, raw_summary, raw_payload, ts, ts)) - row = conn.execute("SELECT * FROM transactions WHERE id=?", (tx_id,)).fetchone() - except Exception as e: - if "UNIQUE constraint failed" in str(e): - return fail("duplicate_transaction", "transaction appears to be duplicated", dedupe_key=dedupe_key) - return fail("db_error", str(e)) - return ok(transaction=row_to_dict(row)) - -def list_ledger_transactions(start_date: str = "", end_date: str = "", type: str = "", category: str = "", person: str = "", account_id: str = "", include_deleted: bool = False, limit: int = 100, db_path: str | None = None) -> dict[str, Any]: - clauses=[]; params=[] - if not include_deleted: - clauses.append("status='active'") - for field, value in [("type", type), ("category", category), ("person", person), ("account_id", account_id)]: - if value: - clauses.append(f"{field}=?"); params.append(value) - if start_date: - clauses.append("transaction_date>=?"); params.append(normalize_date(start_date)) - if end_date: - clauses.append("transaction_date<=?"); params.append(normalize_date(end_date)) - where = " WHERE " + " AND ".join(clauses) if clauses else "" - sql = f"SELECT * FROM transactions{where} ORDER BY transaction_date DESC, created_at DESC LIMIT ?" - params.append(max(1, min(int(limit), 1000))) - with db(db_path) as conn: - rows = conn.execute(sql, params).fetchall() - return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows)) - - -def _transaction_signed_amount(tx_type: str, amount: int) -> int: - if tx_type in {"income", "refund", "adjustment"}: - return int(amount) - if tx_type in {"expense", "transfer"}: - return -int(amount) - return 0 - - -def _account_balance_row(conn: sqlite3.Connection, account_id: str, baseline_at: str) -> dict[str, Any]: - date_cutoff = normalize_date(baseline_at[:10]) - rows = conn.execute( - """SELECT type, amount, balance_after, transaction_date, transaction_datetime, created_at - FROM transactions - WHERE status='active' AND account_id=? AND transaction_date<=? - ORDER BY transaction_date ASC, transaction_datetime ASC, created_at ASC""", - (account_id, date_cutoff), - ).fetchall() - signed_total = sum(_transaction_signed_amount(row["type"], int(row["amount"] or 0)) for row in rows) - latest_balance_row = next((row for row in reversed(rows) if row["balance_after"] is not None), None) - if latest_balance_row is not None: - calculated_balance = int(latest_balance_row["balance_after"]) - basis = "latest_transaction_balance_after" - basis_at = latest_balance_row["transaction_datetime"] or latest_balance_row["transaction_date"] - else: - calculated_balance = int(signed_total) - basis = "net_transaction_delta_without_opening_balance" - basis_at = baseline_at - return { - "calculated_balance": calculated_balance, - "calculation_basis": basis, - "calculation_basis_at": basis_at, - "transaction_count": len(rows), - "signed_transaction_total": int(signed_total), - } - - -def get_balance_reconciliation_report(account_id: str = "", baseline_balance: int | None = None, baseline_at: str = "", source_note: str = "", memo: str = "", tolerance: int = BALANCE_RECONCILIATION_TOLERANCE_KRW, db_path: str | None = None) -> dict[str, Any]: - """Preview manual/partial-import account balance reconciliation without mutating transactions.""" - try: - baseline_at_norm = normalize_snapshot_at(baseline_at.strip(), default_now=False) if baseline_at and baseline_at.strip() else None - tolerance_int = int(tolerance) - if tolerance_int < 0: - raise ValueError("tolerance must be non-negative") - baseline_balance_int = int(baseline_balance) if baseline_balance is not None else None - except Exception as e: - return fail("validation_error", str(e)) - with db(db_path) as conn: - clauses = ["active=1"] - params: list[Any] = [] - if account_id: - clauses.append("id=?") - params.append(account_id) - else: - placeholders = ",".join("?" for _ in RECONCILIATION_TARGET_STATUSES) - clauses.append(f"statement_status IN ({placeholders})") - params.extend(sorted(RECONCILIATION_TARGET_STATUSES)) - accounts = conn.execute(f"SELECT * FROM accounts WHERE {' AND '.join(clauses)} ORDER BY name", params).fetchall() - items: list[dict[str, Any]] = [] - for account in accounts: - effective_baseline = baseline_balance_int if baseline_balance_int is not None else account["last_balance"] - effective_at = baseline_at_norm or account["last_synced_at"] or now_iso() - calc = _account_balance_row(conn, account["id"], effective_at) - if effective_baseline is None: - difference = None - ratio = None - status = "BASELINE_REQUIRED" - else: - difference = int(effective_baseline) - int(calc["calculated_balance"]) - ratio = abs(difference) / abs(int(effective_baseline)) if int(effective_baseline) else None - status = "OK" if abs(difference) <= tolerance_int else "REVIEW_NEEDED" - items.append({ - "account_id": account["id"], - "account_name": account["name"], - "statement_status": account["statement_status"], - "is_reconciliation_target": account["statement_status"] in RECONCILIATION_TARGET_STATUSES, - "baseline_balance": effective_baseline, - "baseline_at": effective_at, - "calculated_balance": calc["calculated_balance"], - "difference": difference, - "tolerance": tolerance_int, - "reference_ratio_threshold": BALANCE_RECONCILIATION_REFERENCE_RATIO, - "difference_ratio": ratio, - "status": status, - "recommended_decisions": ["๊ณ„์ขŒ์ด๋™", "์‹ค์ง€์ถœ", "์‹ค์ˆ˜์ž…", "ํˆฌ์ž์ด๋™", "๋ถ„์„์ œ์™ธ", "ํ™•์ธํ•„์š”"] if status == "REVIEW_NEEDED" else [], - "source_note": source_note, - "memo": memo, - **calc, - }) - return ok(items=items, count=len(items), tolerance=tolerance_int, baseline_at=baseline_at_norm, routing="preview_only_manual_confirmation_required") - -def update_ledger_transaction(transaction_id: str, db_path: str | None = None, **fields: Any) -> dict[str, Any]: - allowed = {"transaction_date","posted_date","type","amount","currency","category","subcategory","merchant","description","account_id","payment_method","person","project","tags","is_fixed","source","external_id","memo","status","transaction_datetime","counterparty","balance_after","branch","raw_summary","raw_payload"} - updates=[]; params=[] - for k,v in fields.items(): - if k not in allowed or v is None: - continue - if k == "tags": v = normalize_tags(v) - if k in {"transaction_date","posted_date"} and v: v = normalize_date(v) - if k == "amount": v = int(v) - if k == "is_fixed": v = int(bool(v)) - updates.append(f"{k}=?"); params.append(v) - if not updates: - return fail("validation_error", "no fields to update") - updates.append("updated_at=?"); params.append(now_iso()); params.append(transaction_id) - with db(db_path) as conn: - cur = conn.execute(f"UPDATE transactions SET {', '.join(updates)} WHERE id=?", params) - if cur.rowcount == 0: - return fail("not_found", "transaction not found") - row = conn.execute("SELECT * FROM transactions WHERE id=?", (transaction_id,)).fetchone() - return ok(transaction=row_to_dict(row)) - -def delete_ledger_transaction(transaction_id: str, hard_delete: bool = False, db_path: str | None = None) -> dict[str, Any]: - with db(db_path) as conn: - if hard_delete: - cur = conn.execute("DELETE FROM transactions WHERE id=?", (transaction_id,)) - else: - cur = conn.execute("UPDATE transactions SET status='deleted', updated_at=? WHERE id=?", (now_iso(), transaction_id)) - if cur.rowcount == 0: - return fail("not_found", "transaction not found") - return ok(transaction_id=transaction_id, deleted=True, hard_delete=hard_delete) - -def create_ledger_budget(month: str, category: str, amount: int | float, subcategory: str = "", person: str = "", currency: str = "KRW", memo: str = "", db_path: str | None = None) -> dict[str, Any]: - try: - month_norm = normalize_month(month); amount_int = int(amount) - except Exception as e: - return fail("validation_error", str(e)) - if not category: - return fail("validation_error", "category is required") - if amount_int < 0: - return fail("validation_error", "amount must be non-negative") - ts = now_iso(); budget_id = new_id("bud") - with db(db_path) as conn: - conn.execute("""INSERT INTO budgets(id,month,category,subcategory,person,amount,currency,memo,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?) - ON CONFLICT(month,category,subcategory,person) DO UPDATE SET amount=excluded.amount,currency=excluded.currency,memo=excluded.memo,updated_at=excluded.updated_at""", (budget_id, month_norm, category, subcategory, person, amount_int, currency, memo, ts, ts)) - row = conn.execute("SELECT * FROM budgets WHERE month=? AND category=? AND subcategory=? AND person=?", (month_norm, category, subcategory, person)).fetchone() - return ok(budget=row_to_dict(row)) - -def list_ledger_budgets(month: str = "", db_path: str | None = None) -> dict[str, Any]: - clauses=[]; params=[] - if month: - clauses.append("month=?"); params.append(normalize_month(month)) - where = " WHERE " + " AND ".join(clauses) if clauses else "" - with db(db_path) as conn: - rows = conn.execute(f"SELECT * FROM budgets{where} ORDER BY month DESC, category", params).fetchall() - return ok(budgets=[row_to_dict(r) for r in rows], count=len(rows)) - -def _month_range(month: str) -> tuple[str, str]: - m = normalize_month(month) - y, mo = map(int, m.split("-")) - start = date(y, mo, 1) - end = date(y + (mo == 12), 1 if mo == 12 else mo + 1, 1) - return start.isoformat(), end.isoformat() - -def get_monthly_cashflow_report(month: str = "", db_path: str | None = None) -> dict[str, Any]: - month_norm = normalize_month(month); start, next_month = _month_range(month_norm) - with db(db_path) as conn: - type_rows = conn.execute("""SELECT type, SUM(amount) AS total FROM transactions - WHERE status='active' AND transaction_date>=? AND transaction_date=? AND t.transaction_date dict[str, Any]: - if month: - start_date, next_month = _month_range(month); end_op = "<"; end_value = next_month - else: - start_date = normalize_date(start_date, default_today=False) if start_date else "0001-01-01" - end_value = normalize_date(end_date, default_today=False) if end_date else "9999-12-31"; end_op = "<=" - params=[start_date, end_value]; person_clause="" - if person: - person_clause=" AND t.person=?"; params.append(person) - sql=f"""SELECT t.category, t.subcategory, SUM(t.amount) AS total, COUNT(*) AS count FROM transactions t - LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory - WHERE t.status='active' AND {CASHFLOW_KIND_SQL}='expense' AND t.transaction_date>=? AND t.transaction_date{end_op}?{person_clause} - GROUP BY t.category, t.subcategory ORDER BY total DESC""" - with db(db_path) as conn: - rows=conn.execute(sql, params).fetchall() - total=sum(int(r["total"] or 0) for r in rows) - items=[{"category": r["category"], "subcategory": r["subcategory"], "amount": int(r["total"] or 0), "count": r["count"], "share": round((int(r["total"] or 0)/total),4) if total else 0} for r in rows] - return ok(total_expense=total, items=items, count=len(items)) - -def get_fixed_cost_report(month: str = "", db_path: str | None = None) -> dict[str, Any]: - month_norm=normalize_month(month); start,next_month=_month_range(month_norm) - with db(db_path) as conn: - rows=conn.execute(f"""SELECT t.category, t.merchant, t.amount, t.transaction_date, t.person, t.memo FROM transactions t - LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory - WHERE t.status='active' AND {CASHFLOW_KIND_SQL}='expense' AND t.is_fixed=1 AND t.transaction_date>=? AND t.transaction_date dict[str, Any]: - month_norm=normalize_month(month); start,next_month=_month_range(month_norm) - with db(db_path) as conn: - budgets=conn.execute("SELECT * FROM budgets WHERE month=?", (month_norm,)).fetchall() - actuals=conn.execute(f"""SELECT t.category, t.subcategory, t.person, SUM(t.amount) AS amount FROM transactions t - LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory - WHERE t.status='active' AND {CASHFLOW_KIND_SQL}='expense' AND t.transaction_date>=? AND t.transaction_datebudget else "ok"}) - return ok(month=month_norm, items=items, total_budget=sum(i["budget"] for i in items), total_actual=sum(i["actual"] for i in items)) - -def get_investable_cashflow_report(month: str = "", minimum_cash_buffer: int = 0, planned_extra_expense: int = 0, db_path: str | None = None) -> dict[str, Any]: - cashflow=get_monthly_cashflow_report(month, db_path) - if not cashflow.get("success"): - return cashflow - investable=max(0, int(cashflow["net_cashflow"]) - int(minimum_cash_buffer) - int(planned_extra_expense)) - return ok(month=cashflow["month"], income=cashflow["income"], expenses=cashflow["expenses"], net_cashflow=cashflow["net_cashflow"], minimum_cash_buffer=int(minimum_cash_buffer), planned_extra_expense=int(planned_extra_expense), investable_cashflow=investable) - -def preview_ledger_csv_import(file_path: str, source: str = "generic", db_path: str | None = None) -> dict[str, Any]: - path=Path(file_path).expanduser() - if not path.exists(): - return fail("not_found", f"CSV file not found: {path}") - with path.open("r", encoding="utf-8-sig", newline="") as f: - rows=list(csv.DictReader(f)) - preview=[] - for r in rows[:20]: - preview.append(r) - return ok(source=source, filename=str(path), row_count=len(rows), preview=preview, required_columns=["transaction_date/date", "amount", "type optional", "category optional", "merchant/description optional"]) - - -def preview_kb_bank_pdf_import(file_path: str, password: str, preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]: - try: - parsed = parse_kb_bank_pdf(file_path, password) - except FileNotFoundError as e: - return fail("not_found", str(e)) - except ValueError as e: - return fail("pdf_password_error", str(e)) - except Exception as e: - return fail("pdf_parse_error", str(e)) - transactions = parsed["transactions"] - validation = parsed["validation"] - source_hash = parsed["source_hash"] - duplicate_count = 0 - with db(db_path) as conn: - for tx in transactions: - dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, tx.counterparty, "", tx.external_id(source_hash)) - found = conn.execute("SELECT 1 FROM transactions WHERE dedupe_key=?", (dedupe_key,)).fetchone() - duplicate_count += 1 if found else 0 - preview = [transaction_preview(tx, source_hash) for tx in transactions[: max(1, min(int(preview_limit), 100))]] - return ok( - source="kb_bank_pdf", - file_path=parsed["file_path"], - source_hash=source_hash, - page_count=parsed["page_count"], - metadata=parsed["metadata"], - row_count=len(transactions), - duplicate_count=duplicate_count, - validation=validation, - preview=preview, - ) - - -def _find_or_create_kb_account(conn: Any, metadata: dict[str, Any], account_id: str = "") -> str: - if account_id: - row = conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone() - if row: - return account_id - raise ValueError(f"account_id not found: {account_id}") - account_number_masked = metadata.get("account_number_masked") or "" - row = None - if account_number_masked: - row = conn.execute( - "SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1", - ("KB๊ตญ๋ฏผ์€ํ–‰", account_number_masked), - ).fetchone() - if row: - return row["id"] - ts = now_iso() - new_account_id = new_id("acct") - account_name = metadata.get("account_name") or "KB๊ตญ๋ฏผ์€ํ–‰ ๊ณ„์ขŒ" - display_name = f"KB๊ตญ๋ฏผ์€ํ–‰ {account_name} {account_number_masked}".strip() - conn.execute( - """INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""", - ( - new_account_id, - display_name, - "bank", - "KB๊ตญ๋ฏผ์€ํ–‰", - "KRW", - 1, - "๊ตญ๋ฏผ์€ํ–‰ ๊ฑฐ๋ž˜๋‚ด์—ญ PDF import๋กœ ์ž๋™ ์ƒ์„ฑ", - account_number_masked, - metadata.get("holder_name") or "", - metadata.get("final_balance"), - ts, - ts, - ts, - ), - ) - return new_account_id - - -def import_kb_bank_pdf(file_path: str, password: str, account_id: str = "", skip_duplicates: bool = True, db_path: str | None = None, skip_validation: bool = False) -> dict[str, Any]: - try: - parsed = parse_kb_bank_pdf(file_path, password) - except FileNotFoundError as e: - return fail("not_found", str(e)) - except ValueError as e: - return fail("pdf_password_error", str(e)) - except Exception as e: - return fail("pdf_parse_error", str(e)) - transactions = parsed["transactions"] - validation = parsed["validation"] - if not validation.get("ok") and not skip_validation: - return fail("balance_validation_failed", "KB Bank PDF balance flow validation failed", validation=validation) - source_hash = parsed["source_hash"] - metadata = parsed["metadata"] - ts = now_iso() - batch_id = new_id("imp") - inserted = 0 - skipped_duplicates = 0 - inserted_ids: list[str] = [] - try: - with db(db_path) as conn: - linked_account_id = _find_or_create_kb_account(conn, metadata, account_id) - conn.execute( - """INSERT INTO import_batches(id,source,filename,imported_at,row_count,status,memo) - VALUES(?,?,?,?,?,?,?)""", - ( - batch_id, - "kb_bank_pdf", - parsed["file_path"], - ts, - 0, - "active", - json.dumps( - { - "source_hash": source_hash, - "page_count": parsed["page_count"], - "metadata": metadata, - "validation": validation, - "parsed_row_count": len(transactions), - }, - ensure_ascii=False, - ), - ), - ) - for tx in transactions: - tx_id = new_id("tx") - external_id = tx.external_id(source_hash) - dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, tx.counterparty, linked_account_id, external_id) - payload = tx.raw_payload() - description = f"๊ตญ๋ฏผ์€ํ–‰ PDF import; ์ ์š”={tx.raw_summary}; ๊ฑฐ๋ž˜์ ={tx.branch}; ์ž”์•ก={tx.balance_after}" - try: - 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,import_batch_id,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", - ( - tx_id, - tx.transaction_date, - "", - tx.type, - tx.amount, - "KRW", - "", - "", - tx.counterparty, - description, - linked_account_id, - tx.raw_summary, - "", - "", - normalize_tags(["kb_bank_pdf", tx.raw_summary] if tx.raw_summary else ["kb_bank_pdf"]), - 0, - "kb_bank_pdf", - batch_id, - external_id, - dedupe_key, - tx.remittance_memo, - tx.transaction_datetime, - tx.counterparty, - tx.balance_after, - tx.branch, - tx.raw_summary, - payload, - ts, - ts, - ), - ) - inserted += 1 - inserted_ids.append(tx_id) - except Exception as e: - if "UNIQUE constraint failed" in str(e) and skip_duplicates: - skipped_duplicates += 1 - continue - raise - conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id)) - if transactions: - conn.execute( - "UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?", - (transactions[-1].balance_after, ts, ts, linked_account_id), - ) - except ValueError as e: - return fail("validation_error", str(e)) - except Exception as e: - return fail("db_error", str(e)) - return ok( - import_batch_id=batch_id, - account_id=linked_account_id, - source="kb_bank_pdf", - parsed_row_count=len(transactions), - inserted_count=inserted, - skipped_duplicate_count=skipped_duplicates, - inserted_transaction_ids=inserted_ids[:20], - validation=validation, - metadata=metadata, - ) - - -def _find_or_create_seoulpay_account(conn: Any, metadata: dict[str, Any], account_id: str = "") -> str: - if account_id: - row = conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone() - if row: - return account_id - raise ValueError(f"account_id not found: {account_id}") - holder_name = metadata.get("holder_name") or "" - row = conn.execute( - "SELECT id FROM accounts WHERE institution=? AND name=? AND active=1 ORDER BY created_at LIMIT 1", - ("์„œ์šธํŽ˜์ด", f"์„œ์šธํŽ˜์ด ์ด์šฉ๋‚ด์—ญ {holder_name}".strip()), - ).fetchone() - if row: - return row["id"] - ts = now_iso() - new_account_id = new_id("acct") - display_name = f"์„œ์šธํŽ˜์ด ์ด์šฉ๋‚ด์—ญ {holder_name}".strip() - conn.execute( - """INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""", - ( - new_account_id, - display_name, - "cash", - "์„œ์šธํŽ˜์ด", - "KRW", - 1, - "์„œ์šธํŽ˜์ด ์ด์šฉ๋‚ด์—ญ XLSX import๋กœ ์ž๋™ ์ƒ์„ฑ", - "", - holder_name, - None, - ts, - ts, - ts, - ), - ) - return new_account_id - - -def _classify_seoulpay_transaction(tx: Any) -> tuple[str, str, str, bool]: - merchant = (tx.merchant or "").strip() - text = f"{merchant} {tx.transaction_kind} {tx.payment_kind}" - if tx.type == "refund": - return "์ˆ˜์ž…", "ํ™˜๊ธ‰", merchant, False - if "๋•ก๊ฒจ์š”" in text or "๋ฐฐ๋‹ฌ" in text: - return "์‹๋น„", "๋ฐฐ๋‹ฌ", merchant, False - if any(keyword in text for keyword in ("๊ต์Šต์†Œ", "ํ•™์›", "๋ฐ”์ด์˜ฌ๋ฆฐ", "ํ”ผ์•„๋…ธ")): - return "๊ต์œก", "ํ•™์›", merchant, False - if any(keyword in text for keyword in ("๊น€์˜๋ชจ", "๋ฒ ์ด์ปค๋ฆฌ", "์ œ๊ณผ", "์นดํŽ˜", "์ปคํ”ผ")): - return "์‹๋น„", "์นดํŽ˜", merchant, False - if "์˜จ๋ผ์ธ" in text: - return "์‡ผํ•‘", "์˜จ๋ผ์ธ", merchant, False - return "์‡ผํ•‘", "์ƒํ™œ์šฉํ’ˆ", merchant, False - - -def _find_or_create_toss_bank_account(conn: Any, metadata: dict[str, Any], account_id: str = "") -> str: - if account_id: - row = conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone() - if row: - return account_id - raise ValueError(f"account_id not found: {account_id}") - account_number_masked = metadata.get("account_number_masked") or "" - row = None - if account_number_masked: - row = conn.execute( - "SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1", - ("ํ† ์Šค๋ฑ…ํฌ", account_number_masked), - ).fetchone() - if row: - return row["id"] - ts = now_iso() - new_account_id = new_id("acct") - account_name = metadata.get("account_name") or "ํ† ์Šค๋ฑ…ํฌ ๊ณ„์ขŒ" - display_name = f"ํ† ์Šค๋ฑ…ํฌ {account_name} {account_number_masked}".strip() - conn.execute( - """INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""", - ( - new_account_id, - display_name, - "bank", - "ํ† ์Šค๋ฑ…ํฌ", - "KRW", - 1, - "ํ† ์Šค๋ฑ…ํฌ ๊ฑฐ๋ž˜๋‚ด์—ญ PDF import๋กœ ์ž๋™ ์ƒ์„ฑ", - account_number_masked, - metadata.get("holder_name") or "", - metadata.get("final_balance"), - ts, - ts, - ts, - ), - ) - return new_account_id - - -def _classify_toss_bank_transaction(tx: Any, owner_name: str = "") -> tuple[str, str, str, bool]: - action = (tx.action or "").strip() - counterparty = (tx.counterparty or "").strip() - owner_name = (owner_name or "").strip() - if "์ด์ž" in action: - return "์ˆ˜์ž…", "์ด์ž", counterparty, False - if "์บ์‹œ๋ฐฑ" in action: - return "์ˆ˜์ž…", "์บ์‹œ๋ฐฑ", counterparty, False - if "์ฒดํฌ์นด๋“œ๊ฒฐ์ œ" in action: - return "์‹๋น„", "์นด๋“œ๊ฒฐ์ œ", counterparty, False - transfer_like = { - "์ด์ฒด", - "๋‚ด๊ณ„์ขŒ๊ฐ„์ž๋™์ด์ฒด", - "์˜คํ”ˆ๋ฑ…ํ‚น", - "ํŽŒ๋ฑ…ํ‚น์ถœ๊ธˆ", - "ํŽŒ๋ฑ…ํ‚น์ž…๊ธˆ", - "์ ๊ธˆ ๊ฐœ์„ค", - "์ถœ๊ธˆ", - "์ž…๊ธˆ", - "ATM์ถœ๊ธˆ", - } - if any(keyword in action for keyword in transfer_like): - if counterparty and owner_name and counterparty != owner_name: - return "์ด์ฒด", "ํ™•์ธํ•„์š”", counterparty, False - return "์ด์ฒด", "๋ณธ์ธ๊ณ„์ขŒ์ด๋™", counterparty, False - if "์บ์‹œ๋ฐฑ" in action: - return "์ˆ˜์ž…", "์บ์‹œ๋ฐฑ", counterparty, False - return "์ด์ฒด", "ํ™•์ธํ•„์š”", counterparty, False - - -def _build_cross_transfer_index(transactions: list[dict[str, Any]]) -> dict[tuple[str, int, str], list[dict[str, Any]]]: - index: dict[tuple[str, int, str], list[dict[str, Any]]] = {} - for tx in transactions: - if tx.get("status") != "active": - continue - key = (tx["transaction_date"], int(tx["amount"]), tx["type"]) - index.setdefault(key, []).append(tx) - return index - - -def _fetch_transfer_candidates(db_conn: Any, source: str) -> list[dict[str, Any]]: - rows = db_conn.execute( - """ - SELECT t.id, t.transaction_datetime, t.transaction_date, t.type, t.amount, t.category, t.subcategory, - t.counterparty, t.memo, t.person, t.account_id, t.payment_method, t.status, - a.holder_name, t.merchant - FROM transactions t - LEFT JOIN accounts a ON a.id = t.account_id - WHERE t.source=? - AND t.type IN ('income','expense') - AND ( - t.category='์ด์ฒด' - OR t.subcategory IN ('ํ™•์ธํ•„์š”', '๋ณธ์ธ๊ณ„์ขŒ์ด๋™', 'ํˆฌ์ž์ด๋™') - OR t.payment_method IN ('๋‚ด๊ณ„์ขŒ๊ฐ„์ž๋™์ด์ „', '๋‚ด๊ณ„์ขŒ๊ฐ„์ž๋™์ด์ฒด', '์˜คํ”ˆ๋ฑ…ํ‚น', 'ํŽŒ๋ฑ…ํ‚น์ถœ๊ธˆ', 'ํŽŒ๋ฑ…ํ‚น์ž…๊ธˆ', '์ž…๊ธˆ', '์ถœ๊ธˆ', 'ATM์ถœ๊ธˆ', '์ ๊ธˆ๊ฐœ์„ค') - OR t.merchant LIKE '%์ด์ฒด%' - ) - """, - (source,), - ).fetchall() - return [dict(r) for r in rows] - - -def _parse_tx_datetime(value: str | None) -> datetime | None: - if not value: - return None - text = value.strip() - for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"): - try: - return datetime.strptime(text, fmt) - except ValueError: - continue - try: - return datetime.fromisoformat(text) - except ValueError: - return None - - -def _self_transfer_direction_score(source_type: str, candidate_type: str) -> int: - if source_type == "expense": - if candidate_type == "income": - return 3 - if candidate_type == "transfer": - return 2 - elif source_type == "income": - if candidate_type == "expense": - return 3 - if candidate_type == "transfer": - return 2 - elif source_type == "transfer": - if candidate_type in {"income", "expense"}: - return 2 - if candidate_type == "transfer": - return 1 - return 0 - - -def _normalize_cancel_lookup_text(value: str) -> str: - if not value: - return "" - text = value.lower().strip() - replacements = [ - "kb์ฒดํฌ์ทจ์†Œ", "์Šน์ธ์ทจ์†Œ", "์ทจ์†Œ", "ํ™˜๋ถˆ", "ํ™˜๊ธ‰", "refund", "reversal", - "์นด๋“œ์ž…๊ธˆ", "์นด๋“œํ™˜๊ธ‰", "๋งค์ถœ์ทจ์†Œ", "์ž…๊ธˆ", "์ถœ๊ธˆ", "์ฒดํฌ์นด๋“œ", "์‹ ์šฉ์นด๋“œ", - ] - for token in replacements: - text = text.replace(token, " ") - text = re.sub(r"\s+", " ", text) - return _normalize_counterparty_for_match(text) - - -def _cancel_channel(value: str) -> str: - text = (value or "").lower() - if any(token in text for token in ("์นด๋“œ", "check", "credit")): - return "card" - if any(token in text for token in ("์ด์ฒด", "์˜คํ”ˆ๋ฑ…ํ‚น", "์ถœ๊ธˆ", "์ž…๊ธˆ", "bank")): - return "bank" - return "other" - - -def _is_cancel_like_transaction(tx: dict[str, Any]) -> bool: - if str(tx.get("type") or "") == "refund": - return True - combined = " ".join( - str(tx.get(key) or "") for key in ("merchant", "raw_summary", "payment_method", "memo", "description") - ).lower() - keywords = ("์ทจ์†Œ", "ํ™˜๋ถˆ", "ํ™˜๊ธ‰", "refund", "reversal", "์นด๋“œ์ž…๊ธˆ", "๋งค์ถœ์ทจ์†Œ") - return str(tx.get("type") or "") == "income" and any(token in combined for token in keywords) - - -def find_cancel_original_candidates(transaction_id: str, limit: int = 20, lookback_days: int = 365, db_path: str | None = None) -> dict[str, Any]: - limit = max(1, min(int(limit), 50)) - lookback_days = max(1, min(int(lookback_days), 730)) - with db(db_path) as conn: - source_row = conn.execute( - """ - SELECT t.*, a.name AS account_name, a.institution, a.holder_name - FROM transactions t - LEFT JOIN accounts a ON a.id = t.account_id - WHERE t.id=? - """, - (transaction_id,), - ).fetchone() - if not source_row: - return fail("not_found", f"transaction not found: {transaction_id}") - source = row_to_dict(source_row) - explicit_cancel_like = _is_cancel_like_transaction(source) - source_type = str(source.get("type") or "") - if source_type not in {"income", "refund"} and not explicit_cancel_like: - return fail("not_cancel_like", "cancel/original lookup is intended for ์ทจ์†Œยทํ™˜๊ธ‰์„ฑ ๊ฑฐ๋ž˜") - - source_date = normalize_date(source.get("transaction_date"), default_today=False) - source_dt = _parse_tx_datetime(source.get("transaction_datetime")) - source_day = date.fromisoformat(source_date) - start_date = date.fromordinal(source_day.toordinal() - lookback_days).isoformat() - source_text = " ".join(str(source.get(key) or "") for key in ("merchant", "counterparty", "raw_summary", "payment_method")) - source_norm_text = _normalize_cancel_lookup_text(source_text) - source_channel = _cancel_channel(str(source.get("payment_method") or "") + " " + str(source.get("raw_summary") or "") + " " + str(source.get("merchant") or "")) - candidate_rows = conn.execute( - """ - SELECT t.*, a.name AS account_name, a.institution, a.holder_name - FROM transactions t - LEFT JOIN accounts a ON a.id = t.account_id - WHERE t.status='active' - AND t.id<>? - AND t.amount=? - AND t.transaction_date BETWEEN ? AND ? - AND t.type IN ('expense', 'adjustment') - ORDER BY t.transaction_date DESC, t.transaction_datetime DESC, t.created_at DESC - """, - (transaction_id, int(source.get("amount") or 0), start_date, source_date), - ).fetchall() - - candidates: list[dict[str, Any]] = [] - for row in candidate_rows: - candidate = row_to_dict(row) - candidate_date = normalize_date(candidate.get("transaction_date"), default_today=False) - candidate_day = date.fromisoformat(candidate_date) - day_delta = (source_day - candidate_day).days - if day_delta < 0: - continue - candidate_dt = _parse_tx_datetime(candidate.get("transaction_datetime")) - time_delta_seconds = abs(int((source_dt - candidate_dt).total_seconds())) if source_dt and candidate_dt else None - same_account = bool(source.get("account_id") and source.get("account_id") == candidate.get("account_id")) - source_same = bool(source.get("source") and source.get("source") == candidate.get("source")) - candidate_text = " ".join(str(candidate.get(key) or "") for key in ("merchant", "counterparty", "raw_summary", "payment_method")) - candidate_norm_text = _normalize_cancel_lookup_text(candidate_text) - merchant_score = 0 - if source_norm_text and candidate_norm_text: - if source_norm_text == candidate_norm_text: - merchant_score = 4 - elif source_norm_text in candidate_norm_text or candidate_norm_text in source_norm_text: - merchant_score = 2 - channel_score = 0 - candidate_channel = _cancel_channel(str(candidate.get("payment_method") or "") + " " + str(candidate.get("raw_summary") or "") + " " + str(candidate.get("merchant") or "")) - if source_channel != "other" and source_channel == candidate_channel: - channel_score = 2 - if not explicit_cancel_like and not (same_account and merchant_score >= 2 and channel_score >= 1): - continue - recency_score = 0 - if day_delta == 0: - recency_score = 5 - elif day_delta <= 7: - recency_score = 4 - elif day_delta <= 30: - recency_score = 3 - elif day_delta <= 90: - recency_score = 2 - else: - recency_score = 1 - time_score = 0 - if time_delta_seconds is not None: - if time_delta_seconds <= 3600: - time_score = 2 - elif time_delta_seconds <= 86400: - time_score = 1 - score = merchant_score + channel_score + recency_score + time_score + (3 if same_account else 0) + (1 if source_same else 0) - reason_bits = [ - f"same_account={int(same_account)}", - f"merchant={merchant_score}", - f"channel={channel_score}", - f"days={day_delta}", - ] - if time_delta_seconds is not None: - reason_bits.append(f"time_delta={time_delta_seconds}s") - candidates.append({ - "id": candidate.get("id"), - "transaction_date": candidate.get("transaction_date"), - "transaction_datetime": candidate.get("transaction_datetime"), - "source": candidate.get("source"), - "type": candidate.get("type"), - "amount": candidate.get("amount"), - "category": candidate.get("category"), - "subcategory": candidate.get("subcategory"), - "merchant": candidate.get("merchant"), - "raw_summary": candidate.get("raw_summary"), - "counterparty": candidate.get("counterparty"), - "account_id": candidate.get("account_id"), - "account_name": candidate.get("account_name"), - "institution": candidate.get("institution"), - "payment_method": candidate.get("payment_method"), - "memo": candidate.get("memo"), - "score": score, - "day_delta": day_delta, - "time_delta_seconds": time_delta_seconds, - "reason": ", ".join(reason_bits), - }) - - candidates.sort(key=lambda item: (-int(item.get("score", 0)), int(item.get("day_delta", 9999)), int(item.get("time_delta_seconds") or 99999999), str(item.get("transaction_datetime") or ""))) - return ok(source_tx=source, count=len(candidates[:limit]), candidates=candidates[:limit], source="cancel_original_candidate_search") - - -def find_self_transfer_candidates(transaction_id: str, limit: int = 20, db_path: str | None = None) -> dict[str, Any]: - limit = max(1, min(int(limit), 50)) - with db(db_path) as conn: - source_row = conn.execute( - """ - SELECT t.*, a.name AS account_name, a.institution, a.holder_name - FROM transactions t - LEFT JOIN accounts a ON a.id = t.account_id - WHERE t.id=? - """, - (transaction_id,), - ).fetchone() - if not source_row: - return fail("not_found", f"transaction not found: {transaction_id}") - - source = row_to_dict(source_row) - source_date = normalize_date(source.get("transaction_date"), default_today=False) - source_dt = _parse_tx_datetime(source.get("transaction_datetime")) - source_holder = _normalize_counterparty_for_match(source.get("holder_name", "")) - anchor_date = date.fromisoformat(source_date) if source_date else current_kst_date() - window_start = anchor_date.toordinal() - 1 - window_end = anchor_date.toordinal() + 1 - candidate_rows = conn.execute( - """ - SELECT t.*, a.name AS account_name, a.institution, a.holder_name - FROM transactions t - LEFT JOIN accounts a ON a.id = t.account_id - WHERE t.status='active' - AND t.id<>? - AND COALESCE(t.account_id, '')<>COALESCE(?, '') - AND t.amount=? - AND t.transaction_date BETWEEN ? AND ? - ORDER BY t.transaction_date, t.transaction_datetime, t.created_at - """, - ( - transaction_id, - source.get("account_id", ""), - int(source.get("amount") or 0), - date.fromordinal(window_start).isoformat(), - date.fromordinal(window_end).isoformat(), - ), - ).fetchall() - - candidates: list[dict[str, Any]] = [] - for row in candidate_rows: - candidate = row_to_dict(row) - direction_score = _self_transfer_direction_score(str(source.get("type", "")), str(candidate.get("type", ""))) - if direction_score == 0: - continue - - candidate_dt = _parse_tx_datetime(candidate.get("transaction_datetime")) - day_delta = abs((date.fromisoformat(candidate.get("transaction_date", source_date)) - date.fromisoformat(source_date)).days) - time_delta_seconds = abs(int((candidate_dt - source_dt).total_seconds())) if candidate_dt and source_dt else None - same_owner = bool(source_holder and source_holder == _normalize_counterparty_for_match(candidate.get("holder_name", ""))) - party_score = max( - _party_match_score(str(source.get("counterparty", "")), str(candidate.get("counterparty", ""))), - _party_match_score(str(source.get("counterparty", "")), str(candidate.get("holder_name", ""))), - _party_match_score(str(source.get("merchant", "")), str(candidate.get("counterparty", ""))), - _party_match_score(str(source.get("merchant", "")), str(candidate.get("holder_name", ""))), - ) - source_gap_score = 1 if source.get("source") != candidate.get("source") else 0 - day_score = 3 if day_delta == 0 else 1 - time_score = 0 - if time_delta_seconds is not None: - if time_delta_seconds <= 120: - time_score = 3 - elif time_delta_seconds <= 3600: - time_score = 2 - elif time_delta_seconds <= 86400: - time_score = 1 - score = direction_score + party_score + source_gap_score + day_score + time_score + (3 if same_owner else 0) - reason_bits = [ - f"direction={direction_score}", - f"party={party_score}", - f"same_owner={int(same_owner)}", - f"same_source={int(source.get('source') == candidate.get('source'))}", - f"day_delta={day_delta}", - ] - if time_delta_seconds is not None: - reason_bits.append(f"time_delta={time_delta_seconds}s") - candidates.append( - { - "id": candidate.get("id"), - "transaction_date": candidate.get("transaction_date"), - "transaction_datetime": candidate.get("transaction_datetime"), - "source": candidate.get("source"), - "type": candidate.get("type"), - "amount": candidate.get("amount"), - "category": candidate.get("category"), - "subcategory": candidate.get("subcategory"), - "merchant": candidate.get("merchant"), - "counterparty": candidate.get("counterparty"), - "account_id": candidate.get("account_id"), - "account_name": candidate.get("account_name"), - "institution": candidate.get("institution"), - "holder_name": candidate.get("holder_name"), - "memo": candidate.get("memo"), - "score": score, - "same_owner": same_owner, - "day_delta": day_delta, - "time_delta_seconds": time_delta_seconds, - "reason": ", ".join(reason_bits), - } - ) - - candidates.sort(key=lambda item: (-int(item.get("score", 0)), int(item.get("day_delta", 99)), int(item.get("time_delta_seconds") or 999999), str(item.get("transaction_datetime") or ""))) - return ok(source_tx=source, count=len(candidates[:limit]), candidates=candidates[:limit], source="self_transfer_candidate_search") - - -def link_self_transfer_pair(source_transaction_id: str, candidate_transaction_id: str, db_path: str | None = None) -> dict[str, Any]: - if not source_transaction_id or not candidate_transaction_id: - return fail("validation_error", "both transaction ids are required") - if source_transaction_id == candidate_transaction_id: - return fail("validation_error", "source and candidate transaction must differ") - - preview = find_self_transfer_candidates(source_transaction_id, limit=50, db_path=db_path) - if not preview.get("success"): - return preview - candidates = preview.get("candidates") or [] - candidate = next((item for item in candidates if item.get("id") == candidate_transaction_id), None) - if candidate is None: - return fail("candidate_not_found", f"candidate not found for source transaction: {candidate_transaction_id}") - - link_tag = f"manual_self_transfer_link:{source_transaction_id}|{candidate_transaction_id}" - ts = now_iso() - with db(db_path) as conn: - source_row = conn.execute("SELECT * FROM transactions WHERE id=?", (source_transaction_id,)).fetchone() - candidate_row = conn.execute("SELECT * FROM transactions WHERE id=?", (candidate_transaction_id,)).fetchone() - if not source_row or not candidate_row: - return fail("not_found", "source or candidate transaction not found") - source_memo = _append_memo_tag(source_row["memo"], link_tag) - candidate_memo = _append_memo_tag(candidate_row["memo"], link_tag) - conn.execute( - "UPDATE transactions SET category='์ด์ฒด', subcategory='๋ณธ์ธ๊ณ„์ขŒ์ด๋™', memo=?, updated_at=? WHERE id=?", - (source_memo, ts, source_transaction_id), - ) - conn.execute( - "UPDATE transactions SET category='์ด์ฒด', subcategory='๋ณธ์ธ๊ณ„์ขŒ์ด๋™', memo=?, updated_at=? WHERE id=?", - (candidate_memo, ts, candidate_transaction_id), - ) - updated_source = conn.execute("SELECT * FROM transactions WHERE id=?", (source_transaction_id,)).fetchone() - updated_candidate = conn.execute("SELECT * FROM transactions WHERE id=?", (candidate_transaction_id,)).fetchone() - - return ok( - source_transaction=row_to_dict(updated_source), - candidate_transaction=row_to_dict(updated_candidate), - link_tag=link_tag, - score=candidate.get("score"), - reason=candidate.get("reason"), - source="manual_self_transfer_link", - ) - - -def link_cancel_to_original(cancel_transaction_id: str, original_transaction_id: str, db_path: str | None = None) -> dict[str, Any]: - if not cancel_transaction_id or not original_transaction_id: - return fail("validation_error", "both transaction ids are required") - if cancel_transaction_id == original_transaction_id: - return fail("validation_error", "cancel and original transaction must differ") - - preview = find_cancel_original_candidates(cancel_transaction_id, limit=50, db_path=db_path) - if not preview.get("success"): - return preview - candidates = preview.get("candidates") or [] - original = next((item for item in candidates if item.get("id") == original_transaction_id), None) - if original is None: - return fail("candidate_not_found", f"original transaction candidate not found: {original_transaction_id}") - - link_tag = f"manual_cancel_link:cancel={cancel_transaction_id}|original={original_transaction_id}" - ts = now_iso() - with db(db_path) as conn: - cancel_row = conn.execute("SELECT * FROM transactions WHERE id=?", (cancel_transaction_id,)).fetchone() - original_row = conn.execute("SELECT * FROM transactions WHERE id=?", (original_transaction_id,)).fetchone() - if not cancel_row or not original_row: - return fail("not_found", "cancel or original transaction not found") - - cancel_memo = _append_memo_tag(cancel_row["memo"], link_tag) - original_memo = _append_memo_tag(original_row["memo"], link_tag) - original_category = original_row["category"] or "์ˆ˜์ž…" - original_subcategory = original_row["subcategory"] or "ํ™˜๊ธ‰" - original_person = original_row["person"] or "" - conn.execute( - "UPDATE transactions SET type='refund', category=?, subcategory=?, person=?, memo=?, updated_at=? WHERE id=?", - (original_category, original_subcategory, original_person, cancel_memo, ts, cancel_transaction_id), - ) - conn.execute( - "UPDATE transactions SET memo=?, updated_at=? WHERE id=?", - (original_memo, ts, original_transaction_id), - ) - updated_cancel = conn.execute("SELECT * FROM transactions WHERE id=?", (cancel_transaction_id,)).fetchone() - updated_original = conn.execute("SELECT * FROM transactions WHERE id=?", (original_transaction_id,)).fetchone() - - return ok( - cancel_transaction=row_to_dict(updated_cancel), - original_transaction=row_to_dict(updated_original), - link_tag=link_tag, - score=original.get("score"), - reason=original.get("reason"), - source="manual_cancel_link", - ) - - -def _tag_memos(conn: Any, tx_id: str, tag: str) -> None: - existing = conn.execute("SELECT memo FROM transactions WHERE id=?", (tx_id,)).fetchone() - if not existing: - return - memo = _append_memo_tag(existing["memo"], tag) - conn.execute("UPDATE transactions SET memo=?, updated_at=? WHERE id=?", (memo, now_iso(), tx_id)) - - -def _run_cross_account_transfer_match(limit: int = 500, dry_run: bool = True, db_path: str | None = None) -> dict[str, Any]: - limit = max(1, min(int(limit), 1000)) - matched_pairs: list[dict[str, Any]] = [] - review_items: list[dict[str, Any]] = [] - matched_tx_ids: set[str] = set() - - with db(db_path) as conn: - kb_rows = _fetch_transfer_candidates(conn, "kb_bank_pdf") - toss_rows = _fetch_transfer_candidates(conn, "toss_bank_pdf") - toss_index = _build_cross_transfer_index(toss_rows) - - for kb in kb_rows: - if kb["status"] != "active": - continue - if _memo_has_tag(kb.get("memo", ""), CROSS_TRANSFER_MATCH_TAG): - continue - required_type = "income" if kb["type"] == "expense" else "expense" - candidates = [ - t - for t in toss_index.get((kb["transaction_date"], int(kb["amount"]), required_type), []) - if t.get("status") == "active" and not _memo_has_tag(t.get("memo", ""), CROSS_TRANSFER_MATCH_TAG) - ] - - if not candidates: - review_items.append( - { - "source_tx_id": kb["id"], - "source": "kb_bank_pdf", - "target_tx_id": "", - "reason": "no_candidate_found", - "type": kb["type"], - "amount": kb["amount"], - "transaction_date": kb["transaction_date"], - "counterparty": kb["counterparty"], - } - ) - continue - - scored = [] - for candidate in candidates: - score = _party_match_score(kb.get("counterparty", ""), candidate.get("counterparty", "")) - scored.append((score, candidate["transaction_datetime"], candidate)) - scored.sort(key=lambda item: (item[0], item[1]), reverse=True) - best_score = scored[0][0] - top = [c for c in scored if c[0] == best_score] - - if best_score == 0 or len(top) != 1: - review_items.append( - { - "source_tx_id": kb["id"], - "source": "kb_bank_pdf", - "target_tx_id": "", - "reason": "ambiguous_or_name_mismatch", - "candidate_count": len(candidates), - "best_name_score": best_score, - "type": kb["type"], - "amount": kb["amount"], - "transaction_date": kb["transaction_date"], - "counterparty": kb["counterparty"], - } - ) - continue - - toss = top[0][2] - if toss["id"] in matched_tx_ids: - review_items.append( - { - "source_tx_id": kb["id"], - "source": "kb_bank_pdf", - "target_tx_id": toss["id"], - "reason": "target_already_matched", - "type": kb["type"], - "amount": kb["amount"], - "transaction_date": kb["transaction_date"], - "counterparty": kb["counterparty"], - } - ) - continue - - kb_party = _normalize_counterparty_for_match(kb.get("counterparty", "")) - toss_party = _normalize_counterparty_for_match(toss.get("counterparty", "")) - owner_candidates = { - _normalize_counterparty_for_match(kb.get("holder_name", "")), - _normalize_counterparty_for_match(toss.get("holder_name", "")), - } - if (kb_party and kb_party in owner_candidates) or (toss_party and toss_party in owner_candidates): - subcategory = "ํˆฌ์ž์ด๋™" - else: - subcategory = "ํ™•์ธํ•„์š”" - - matched_pairs.append( - { - "kb_tx_id": kb["id"], - "toss_tx_id": toss["id"], - "transaction_date": kb["transaction_date"], - "amount": kb["amount"], - "counterparty_kb": kb["counterparty"], - "counterparty_toss": toss["counterparty"], - "subcategory": subcategory, - "name_score": best_score, - "reason": "paired_by_date_amount_party", - } - ) - if not dry_run: - review_tag = f"{CROSS_TRANSFER_MATCH_TAG}kb={kb['id']}|toss={toss['id']}" - conn.execute( - "UPDATE transactions SET category='์ด์ฒด', subcategory=?, memo=?, updated_at=? WHERE id=?", - (subcategory, _append_memo_tag(kb.get("memo", ""), review_tag), now_iso(), kb["id"]), - ) - conn.execute( - "UPDATE transactions SET category='์ด์ฒด', subcategory=?, memo=?, updated_at=? WHERE id=?", - (subcategory, _append_memo_tag(toss.get("memo", ""), review_tag), now_iso(), toss["id"]), - ) - - if not dry_run: - _tag_memos(conn, kb["id"], f"{CROSS_TRANSFER_REVIEW_TAG}paired") - _tag_memos(conn, toss["id"], f"{CROSS_TRANSFER_REVIEW_TAG}paired") - matched_tx_ids.add(kb["id"]) - matched_tx_ids.add(toss["id"]) - - if len(matched_pairs) >= limit: - break - - for review in review_items: - if review["source_tx_id"] in matched_tx_ids: - continue - if not dry_run: - _tag_memos(conn, review["source_tx_id"], f"{CROSS_TRANSFER_REVIEW_TAG}{review['reason']}") - - - return ok( - dry_run=dry_run, - matched_count=len(matched_pairs), - review_count=len(review_items), - matched_pairs=matched_pairs[:limit], - review_items=review_items[:limit], - source="kb_toss_cross_transfer_match", - ) - - -def preview_cross_account_transfers(limit: int = 500, db_path: str | None = None) -> dict[str, Any]: - return _run_cross_account_transfer_match(limit=limit, dry_run=True, db_path=db_path) - - -def run_cross_account_transfer_match(limit: int = 500, db_path: str | None = None) -> dict[str, Any]: - return _run_cross_account_transfer_match(limit=limit, dry_run=False, db_path=db_path) - - -def list_cross_transfer_review_queue(limit: int = 300, db_path: str | None = None) -> dict[str, Any]: - clauses = ["t.status='active'", "t.source IN ('kb_bank_pdf','toss_bank_pdf')"] - clauses.append("t.memo LIKE '%cross_transfer_review:%'") - where = " WHERE " + " AND ".join(clauses) - with db(db_path) as conn: - rows = conn.execute( - f"SELECT * FROM transactions t{where} ORDER BY t.transaction_date DESC, t.created_at DESC LIMIT ?", - (max(1, min(int(limit), 1000)),), - ).fetchall() - return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows), source="cross_transfer_review_queue") - - -def preview_seoulpay_xlsx_import(file_path: str, password: str = "", preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]: - try: - parsed = parse_seoulpay_xlsx(file_path, password=password) - except FileNotFoundError as e: - return fail("not_found", str(e)) - except ValueError as e: - return fail("xlsx_parse_error", str(e)) - except Exception as e: - return fail("xlsx_parse_error", str(e)) - - transactions = parsed["transactions"] - source_hash = parsed["source_hash"] - duplicate_count = 0 - by_category: dict[str, dict[str, int]] = {} - with db(db_path) as conn: - linked_account_id = "" - metadata = parsed["metadata"] - holder_name = metadata.get("holder_name") or "" - if holder_name: - row = conn.execute( - "SELECT id FROM accounts WHERE institution=? AND name=? AND active=1 ORDER BY created_at LIMIT 1", - ("์„œ์šธํŽ˜์ด", f"์„œ์šธํŽ˜์ด ์ด์šฉ๋‚ด์—ญ {holder_name}".strip()), - ).fetchone() - linked_account_id = row["id"] if row else "" - for tx in transactions: - category, subcategory, _, _ = _classify_seoulpay_transaction(tx) - key = f"{category}/{subcategory}" - item = by_category.setdefault(key, {"count": 0, "amount": 0}) - item["count"] += 1 - item["amount"] += int(tx.amount) - merchant = f"์„œ์šธํŽ˜์ด {tx.merchant}".strip() - dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, tx.external_id(source_hash)) - found = conn.execute("SELECT 1 FROM transactions WHERE dedupe_key=?", (dedupe_key,)).fetchone() - duplicate_count += 1 if found else 0 - - preview = [seoulpay_transaction_preview(tx, source_hash) for tx in transactions[: max(1, min(int(preview_limit), 100))]] - return ok( - source="seoulpay_xlsx", - file_path=parsed["file_path"], - source_hash=source_hash, - sheet_name=parsed["sheet_name"], - metadata=parsed["metadata"], - row_count=len(transactions), - duplicate_count=duplicate_count, - validation=parsed["validation"], - by_category=by_category, - preview=preview, - ) - - -def import_seoulpay_xlsx(file_path: str, password: str = "", account_id: str = "", skip_duplicates: bool = True, db_path: str | None = None) -> dict[str, Any]: - try: - parsed = parse_seoulpay_xlsx(file_path, password=password) - except FileNotFoundError as e: - return fail("not_found", str(e)) - except ValueError as e: - return fail("xlsx_parse_error", str(e)) - except Exception as e: - return fail("xlsx_parse_error", str(e)) - - transactions = parsed["transactions"] - validation = parsed["validation"] - if not validation.get("ok"): - return fail("xlsx_parse_error", "SeoulPay XLSX parsing produced invalid transactions", validation=validation) - - source_hash = parsed["source_hash"] - metadata = parsed["metadata"] - ts = now_iso() - batch_id = new_id("imp") - inserted = 0 - skipped_duplicates = 0 - inserted_ids: list[str] = [] - - try: - with db(db_path) as conn: - linked_account_id = _find_or_create_seoulpay_account(conn, metadata, account_id) - conn.execute( - """INSERT INTO import_batches(id,source,filename,imported_at,row_count,status,memo) - VALUES(?,?,?,?,?,?,?)""", - ( - batch_id, - "seoulpay_xlsx", - parsed["file_path"], - ts, - 0, - "active", - json.dumps( - { - "source_hash": source_hash, - "sheet_name": parsed["sheet_name"], - "metadata": metadata, - "validation": validation, - "parsed_row_count": len(transactions), - }, - ensure_ascii=False, - ), - ), - ) - for tx in transactions: - tx_id = new_id("tx") - external_id = tx.external_id(source_hash) - merchant = f"์„œ์šธํŽ˜์ด {tx.merchant}".strip() - category, subcategory, person, is_fixed = _classify_seoulpay_transaction(tx) - dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, external_id) - description = f"์„œ์šธํŽ˜์ด XLSX import; ๊ฑฐ๋ž˜์œ ํ˜•={tx.transaction_kind}; ๊ฒฐ์ œ๊ตฌ๋ถ„={tx.payment_kind}; ๊ฑฐ๋ž˜๋ฒˆํ˜ธ={tx.transaction_no}; ๊ฐ€๋งน์ ID={tx.merchant_id}" - try: - 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,import_batch_id,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", - ( - tx_id, - tx.transaction_date, - "", - tx.type, - tx.amount, - "KRW", - category, - subcategory, - merchant, - description, - linked_account_id, - tx.payment_kind, - person, - "", - normalize_tags(["seoulpay_xlsx", tx.channel, tx.payment_kind, tx.transaction_kind]), - int(is_fixed), - "seoulpay_xlsx", - batch_id, - external_id, - dedupe_key, - tx.payment_kind, - tx.transaction_datetime, - tx.merchant, - None, - tx.channel, - tx.transaction_kind, - tx.raw_payload(), - ts, - ts, - ), - ) - inserted += 1 - inserted_ids.append(tx_id) - except Exception as e: - if "UNIQUE constraint failed" in str(e) and skip_duplicates: - skipped_duplicates += 1 - continue - raise - conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id)) - except ValueError as e: - return fail("validation_error", str(e)) - except Exception as e: - return fail("db_error", str(e)) - - return ok( - import_batch_id=batch_id, - account_id=linked_account_id, - source="seoulpay_xlsx", - parsed_row_count=len(transactions), - inserted_count=inserted, - skipped_duplicate_count=skipped_duplicates, - inserted_transaction_ids=inserted_ids[:20], - validation=validation, - metadata=metadata, - ) - - -def preview_toss_bank_pdf_import(file_path: str, preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]: - try: - parsed = parse_toss_bank_pdf(file_path) - except FileNotFoundError as e: - return fail("not_found", str(e)) - except ValueError as e: - return fail("pdf_parse_error", str(e)) - except Exception as e: - return fail("pdf_parse_error", str(e)) - - transactions = parsed["transactions"] - source_hash = parsed["source_hash"] - duplicate_count = 0 - with db(db_path) as conn: - metadata = parsed["metadata"] - account_number_masked = metadata.get("account_number_masked") or "" - linked_account_id = "" - if account_number_masked: - row = conn.execute( - "SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1", - ("ํ† ์Šค๋ฑ…ํฌ", account_number_masked), - ).fetchone() - linked_account_id = row["id"] if row else "" - for tx in transactions: - merchant = f"ํ† ์Šค๋ฑ…ํฌ {tx.action}" + (f" {tx.counterparty}" if tx.counterparty else "") - dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, tx.external_id(source_hash)) - found = conn.execute("SELECT 1 FROM transactions WHERE dedupe_key=?", (dedupe_key,)).fetchone() - duplicate_count += 1 if found else 0 - - preview = [toss_bank_transaction_preview(tx, source_hash) for tx in transactions[: max(1, min(int(preview_limit), 100))]] - by_action: dict[str, dict[str, int]] = {} - for tx in transactions: - item = by_action.setdefault(tx.action, {"count": 0, "amount": 0}) - item["count"] += 1 - item["amount"] += int(tx.amount) - return ok( - source="toss_bank_pdf", - file_path=parsed["file_path"], - source_hash=source_hash, - page_count=parsed["page_count"], - metadata=parsed["metadata"], - row_count=len(transactions), - duplicate_count=duplicate_count, - validation=parsed["validation"], - by_action=by_action, - preview=preview, - ) - - -def import_toss_bank_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True, db_path: str | None = None) -> dict[str, Any]: - try: - parsed = parse_toss_bank_pdf(file_path) - except FileNotFoundError as e: - return fail("not_found", str(e)) - except ValueError as e: - return fail("pdf_parse_error", str(e)) - except Exception as e: - return fail("pdf_parse_error", str(e)) - - transactions = parsed["transactions"] - validation = parsed["validation"] - if not validation.get("ok"): - return fail("pdf_parse_error", "Toss Bank PDF parsing produced no valid transactions", validation=validation) - - source_hash = parsed["source_hash"] - metadata = parsed["metadata"] - ts = now_iso() - batch_id = new_id("imp") - inserted = 0 - skipped_duplicates = 0 - inserted_ids: list[str] = [] - - try: - with db(db_path) as conn: - linked_account_id = _find_or_create_toss_bank_account(conn, metadata, account_id) - conn.execute( - """INSERT INTO import_batches(id,source,filename,imported_at,row_count,status,memo) - VALUES(?,?,?,?,?,?,?)""", - ( - batch_id, - "toss_bank_pdf", - parsed["file_path"], - ts, - 0, - "active", - json.dumps( - { - "source_hash": source_hash, - "page_count": parsed["page_count"], - "metadata": metadata, - "validation": validation, - "parsed_row_count": len(transactions), - }, - ensure_ascii=False, - ), - ), - ) - for tx in transactions: - tx_id = new_id("tx") - external_id = tx.external_id(source_hash) - merchant = f"ํ† ์Šค๋ฑ…ํฌ {tx.action}" + (f" {tx.counterparty}" if tx.counterparty else "") - category, subcategory, person, is_fixed = _classify_toss_bank_transaction(tx, metadata.get("holder_name") or "") - if category == "์ด์ฒด": - tx_type = "transfer" - else: - tx_type = tx.type - dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, external_id) - description = f"ํ† ์Šค๋ฑ…ํฌ PDF import; ๊ตฌ๋ถ„={tx.action}; ์ƒ๋Œ€={tx.counterparty}; ์ž”์•ก={tx.balance_after}" - payload = tx.raw_payload() - try: - 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,import_batch_id,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", - ( - tx_id, - tx.transaction_date, - "", - tx_type, - tx.amount, - "KRW", - category, - subcategory, - merchant, - description, - linked_account_id, - tx.action, - person, - "", - normalize_tags(["toss_bank_pdf", tx.action] + ([tx.counterparty] if tx.counterparty else [])), - int(is_fixed), - "toss_bank_pdf", - batch_id, - external_id, - dedupe_key, - tx.action, - tx.transaction_datetime, - tx.counterparty, - tx.balance_after, - "ํ† ์Šค๋ฑ…ํฌ", - tx.raw_summary, - payload, - ts, - ts, - ), - ) - inserted += 1 - inserted_ids.append(tx_id) - except Exception as e: - if "UNIQUE constraint failed" in str(e) and skip_duplicates: - skipped_duplicates += 1 - continue - raise - conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id)) - if transactions: - conn.execute( - "UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?", - (transactions[-1].balance_after, ts, ts, linked_account_id), - ) - except ValueError as e: - return fail("validation_error", str(e)) - except Exception as e: - return fail("db_error", str(e)) - - return ok( - import_batch_id=batch_id, - account_id=linked_account_id, - source="toss_bank_pdf", - parsed_row_count=len(transactions), - inserted_count=inserted, - skipped_duplicate_count=skipped_duplicates, - inserted_transaction_ids=inserted_ids[:20], - validation=validation, - metadata=metadata, - ) - - -def _find_or_create_toss_securities_account(conn: Any, metadata: dict[str, Any], account_id: str = "") -> str: - if account_id: - row = conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone() - if row: - return account_id - raise ValueError(f"account_id not found: {account_id}") - account_number_masked = metadata.get("account_number_masked") or "" - row = None - if account_number_masked: - row = conn.execute( - "SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1", - ("ํ† ์Šค์ฆ๊ถŒ", account_number_masked), - ).fetchone() - if row: - return row["id"] - ts = now_iso() - new_account_id = new_id("acct") - display_name = f"ํ† ์Šค์ฆ๊ถŒ {metadata.get('account_name') or '๊ณ„์ขŒ'} {account_number_masked}".strip() - conn.execute( - """INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""", - ( - new_account_id, - display_name, - "investment", - "ํ† ์Šค์ฆ๊ถŒ", - "KRW", - 1, - "ํ† ์Šค์ฆ๊ถŒ ๊ฑฐ๋ž˜๋‚ด์—ญ PDF import๋กœ ์ž๋™ ์ƒ์„ฑ", - account_number_masked, - metadata.get("holder_name") or "", - None, - ts, - ts, - ts, - ), - ) - return new_account_id - - -def _classify_toss_transaction(tx: Any, owner_name: str = "") -> tuple[str, str, str, bool]: - action = tx.action - counterparty = (tx.name or "").strip() - owner_name = (owner_name or "").strip() - if "๋ฐฐ๋‹น" in action: - return "์ˆ˜์ž…", "๋ฐฐ๋‹น", counterparty, False - if "์ด์ž" in action: - return "์ˆ˜์ž…", "์ด์ž", counterparty, False - if "ํ™˜๊ธ‰" in action: - return "์ˆ˜์ž…", "ํ™˜๊ธ‰", counterparty, False - if "์ด์ฒด" in action: - # ํ† ์Šค์ฆ๊ถŒ ์ž…์ถœ๊ธˆ์—์„œ ์ƒ๋Œ€๊ฐ€ ๊ณ„์ขŒ ์†Œ์œ ์ž์™€ ๋‹ค๋ฅด๋ฉด ๋ณธ์ธ ํˆฌ์ž ์ด๋™์œผ๋กœ ํ™•์ •ํ•˜์ง€ ์•Š๋Š”๋‹ค. - # ์˜ˆ: ์ด์ฒด์ถœ๊ธˆ(๊ตญ๋ฏผ์€ํ–‰) ์กฐ์ƒ๋ฌธ -> ์™ธ๋ถ€/ํƒ€์ธ ์†ก๊ธˆ ๊ฐ€๋Šฅ์„ฑ์ด ์žˆ์–ด ํ™•์ธํ•„์š”. - if counterparty and owner_name and counterparty != owner_name: - return "์ด์ฒด", "ํ™•์ธํ•„์š”", counterparty, False - return "์ด์ฒด", "ํˆฌ์ž์ด๋™", counterparty, False - return "์ด์ฒด", "ํˆฌ์ž์ด๋™", counterparty, False - - -def preview_toss_securities_pdf_import(file_path: str, preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]: - try: - parsed = parse_toss_securities_pdf(file_path) - except FileNotFoundError as e: - return fail("not_found", str(e)) - except ValueError as e: - return fail("pdf_parse_error", str(e)) - except Exception as e: - return fail("pdf_parse_error", str(e)) - transactions = parsed["transactions"] - source_hash = parsed["source_hash"] - duplicate_count = 0 - with db(db_path) as conn: - metadata = parsed["metadata"] - account_number_masked = metadata.get("account_number_masked") or "" - linked_account_id = "" - if account_number_masked: - row = conn.execute( - "SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1", - ("ํ† ์Šค์ฆ๊ถŒ", account_number_masked), - ).fetchone() - linked_account_id = row["id"] if row else "" - for tx in transactions: - merchant = f"ํ† ์Šค์ฆ๊ถŒ {tx.action}" + (f" {tx.name}" if tx.name else "") - dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, tx.external_id(source_hash)) - found = conn.execute("SELECT 1 FROM transactions WHERE dedupe_key=?", (dedupe_key,)).fetchone() - duplicate_count += 1 if found else 0 - preview = [toss_transaction_preview(tx, source_hash) for tx in transactions[: max(1, min(int(preview_limit), 100))]] - by_action: dict[str, dict[str, int]] = {} - for tx in transactions: - item = by_action.setdefault(tx.action, {"count": 0, "amount": 0}) - item["count"] += 1 - item["amount"] += int(tx.amount) - return ok( - source="toss_securities_pdf", - file_path=parsed["file_path"], - source_hash=source_hash, - page_count=parsed["page_count"], - metadata=parsed["metadata"], - row_count=len(transactions), - duplicate_count=duplicate_count, - validation=parsed["validation"], - by_action=by_action, - preview=preview, - ) - - -def import_toss_securities_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True, db_path: str | None = None) -> dict[str, Any]: - try: - parsed = parse_toss_securities_pdf(file_path) - except FileNotFoundError as e: - return fail("not_found", str(e)) - except ValueError as e: - return fail("pdf_parse_error", str(e)) - except Exception as e: - return fail("pdf_parse_error", str(e)) - transactions = parsed["transactions"] - validation = parsed["validation"] - if not validation.get("ok"): - return fail("pdf_parse_error", "Toss Securities PDF parsing produced no valid transactions", validation=validation) - source_hash = parsed["source_hash"] - metadata = parsed["metadata"] - ts = now_iso() - batch_id = new_id("imp") - inserted = 0 - skipped_duplicates = 0 - inserted_ids: list[str] = [] - try: - with db(db_path) as conn: - linked_account_id = _find_or_create_toss_securities_account(conn, metadata, account_id) - conn.execute( - """INSERT INTO import_batches(id,source,filename,imported_at,row_count,status,memo) - VALUES(?,?,?,?,?,?,?)""", - ( - batch_id, - "toss_securities_pdf", - parsed["file_path"], - ts, - 0, - "active", - json.dumps( - { - "source_hash": source_hash, - "page_count": parsed["page_count"], - "metadata": metadata, - "validation": validation, - "parsed_row_count": len(transactions), - }, - ensure_ascii=False, - ), - ), - ) - for tx in transactions: - tx_id = new_id("tx") - external_id = tx.external_id(source_hash) - merchant = f"ํ† ์Šค์ฆ๊ถŒ {tx.action}" + (f" {tx.name}" if tx.name else "") - category, subcategory, person, is_fixed = _classify_toss_transaction(tx, metadata.get("holder_name") or "") - dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, external_id) - description = f"ํ† ์Šค์ฆ๊ถŒ PDF import; ๊ตฌ๋ถ„={tx.action}; ์ข…๋ชฉ/์ƒ๋Œ€={tx.name}; ์ˆ˜๋Ÿ‰={tx.quantity}; ๋‹จ๊ฐ€={tx.unit_price}; ์ˆ˜์ˆ˜๋ฃŒ={tx.fee}; ์„ธ๊ธˆ={tx.tax}; ์ž”์•ก={tx.balance_after}" - try: - 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,import_batch_id,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", - ( - tx_id, - tx.transaction_date, - "", - tx.type, - tx.amount, - "KRW", - category, - subcategory, - merchant, - description, - linked_account_id, - tx.action, - person, - "", - normalize_tags(["toss_securities_pdf", tx.section, tx.action, tx.code] if tx.code else ["toss_securities_pdf", tx.section, tx.action]), - int(is_fixed), - "toss_securities_pdf", - batch_id, - external_id, - dedupe_key, - tx.code, - tx.transaction_date, - tx.name, - tx.balance_after, - "ํ† ์Šค์ฆ๊ถŒ", - tx.action, - tx.raw_payload(), - ts, - ts, - ), - ) - inserted += 1 - inserted_ids.append(tx_id) - except Exception as e: - if "UNIQUE constraint failed" in str(e) and skip_duplicates: - skipped_duplicates += 1 - continue - raise - conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id)) - if transactions: - conn.execute( - "UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?", - (transactions[-1].balance_after, ts, ts, linked_account_id), - ) - except ValueError as e: - return fail("validation_error", str(e)) - except Exception as e: - return fail("db_error", str(e)) - return ok( - import_batch_id=batch_id, - account_id=linked_account_id, - source="toss_securities_pdf", - parsed_row_count=len(transactions), - inserted_count=inserted, - skipped_duplicate_count=skipped_duplicates, - inserted_transaction_ids=inserted_ids[:20], - validation=validation, - metadata=metadata, - ) - - -def list_ledger_import_batches(source: str = "", include_rolled_back: bool = True, limit: int = 50, db_path: str | None = None) -> dict[str, Any]: - clauses=[]; params=[] - if source: - clauses.append("source=?"); params.append(source) - if not include_rolled_back: - clauses.append("status='active'") - where = " WHERE " + " AND ".join(clauses) if clauses else "" - params.append(max(1, min(int(limit), 500))) - with db(db_path) as conn: - rows=conn.execute(f"SELECT * FROM import_batches{where} ORDER BY imported_at DESC LIMIT ?", params).fetchall() - return ok(import_batches=[row_to_dict(r) for r in rows], count=len(rows)) - - -def rollback_ledger_import_batch(import_batch_id: str, hard_delete: bool = False, db_path: str | None = None) -> dict[str, Any]: - with db(db_path) as conn: - batch = conn.execute("SELECT * FROM import_batches WHERE id=?", (import_batch_id,)).fetchone() - if not batch: - return fail("not_found", "import batch not found") - if hard_delete: - cur = conn.execute("DELETE FROM transactions WHERE import_batch_id=?", (import_batch_id,)) - else: - cur = conn.execute("UPDATE transactions SET status='deleted', updated_at=? WHERE import_batch_id=? AND status='active'", (now_iso(), import_batch_id)) - conn.execute("UPDATE import_batches SET status='rolled_back' WHERE id=?", (import_batch_id,)) - return ok(import_batch_id=import_batch_id, affected_transactions=cur.rowcount, hard_delete=hard_delete) - - -RULE_FIELDS = {"merchant", "counterparty", "description", "payment_method", "raw_summary", "branch", "memo"} -RULE_MATCH_TYPES = {"contains", "equals", "regex", "starts_with", "ends_with"} - -DEFAULT_CATEGORY_RULES: list[dict[str, Any]] = [ - {"priority": 10, "match_field": "merchant", "pattern": "๊ธ‰์—ฌ", "transaction_type": "income", "category": "์ˆ˜์ž…", "subcategory": "๊ธ‰์—ฌ"}, - {"priority": 10, "match_field": "merchant", "pattern": "์ด์ž", "transaction_type": "income", "category": "์ˆ˜์ž…", "subcategory": "์ด์ž"}, - {"priority": 10, "match_field": "merchant", "pattern": "ํ™˜๊ธ‰", "transaction_type": "income", "category": "์ˆ˜์ž…", "subcategory": "ํ™˜๊ธ‰"}, - {"priority": 20, "match_field": "raw_summary", "match_type": "equals", "pattern": "๊ตญ๋ฏผ์นด๋“œ", "transaction_type": "expense", "category": "๊ธˆ์œต", "subcategory": "์นด๋“œ๋Œ€๊ธˆ", "is_fixed": 1}, - {"priority": 20, "match_field": "merchant", "pattern": "KB์นด๋“œ์ถœ๊ธˆ", "transaction_type": "expense", "category": "๊ธˆ์œต", "subcategory": "์นด๋“œ๋Œ€๊ธˆ", "is_fixed": 1}, - {"priority": 30, "match_field": "merchant", "pattern": "SKํ…”๋ ˆ์ฝค", "transaction_type": "expense", "category": "ํ†ต์‹ ", "subcategory": "ํœด๋Œ€ํฐ", "is_fixed": 1}, - {"priority": 30, "match_field": "merchant", "pattern": "KTํ†ต์‹ ", "transaction_type": "expense", "category": "ํ†ต์‹ ", "subcategory": "ํ†ต์‹ ์š”๊ธˆ", "is_fixed": 1}, - {"priority": 40, "match_field": "merchant", "pattern": "ํ•™์›", "transaction_type": "expense", "category": "๊ต์œก", "subcategory": "ํ•™์›"}, - {"priority": 40, "match_field": "merchant", "pattern": "๊ต์Šต์†Œ", "transaction_type": "expense", "category": "๊ต์œก", "subcategory": "์˜ˆ์ฒด๋Šฅ"}, - {"priority": 40, "match_field": "merchant", "pattern": "์—ฐ์„ธ๋Œ€ํ•™๊ต", "transaction_type": "expense", "category": "์˜๋ฃŒ", "subcategory": "๋ณ‘์›"}, - {"priority": 40, "match_field": "merchant", "pattern": "๋ณ‘์›", "transaction_type": "expense", "category": "์˜๋ฃŒ", "subcategory": "๋ณ‘์›"}, - {"priority": 40, "match_field": "merchant", "pattern": "์˜์›", "transaction_type": "expense", "category": "์˜๋ฃŒ", "subcategory": "๋ณ‘์›"}, - {"priority": 40, "match_field": "merchant", "pattern": "์น˜๊ณผ", "transaction_type": "expense", "category": "์˜๋ฃŒ", "subcategory": "์น˜๊ณผ"}, - {"priority": 40, "match_field": "merchant", "pattern": "์•ฝ๊ตญ", "transaction_type": "expense", "category": "์˜๋ฃŒ", "subcategory": "์•ฝ๊ตญ"}, - {"priority": 50, "match_field": "merchant", "pattern": "์ด๋งˆํŠธ", "transaction_type": "expense", "category": "์‹๋น„", "subcategory": "๋งˆํŠธ"}, - {"priority": 50, "match_field": "merchant", "pattern": "์˜ค์•„์‹œ์Šค๋งˆ์ผ“", "transaction_type": "expense", "category": "์‹๋น„", "subcategory": "๋งˆํŠธ"}, - {"priority": 50, "match_field": "merchant", "pattern": "์ƒํ˜‘", "transaction_type": "expense", "category": "์‹๋น„", "subcategory": "๋งˆํŠธ"}, - {"priority": 50, "match_field": "merchant", "pattern": "์Šคํƒ€๋ฒ…์Šค", "transaction_type": "expense", "category": "์‹๋น„", "subcategory": "์นดํŽ˜"}, - {"priority": 50, "match_field": "merchant", "pattern": "๋ฉ”๊ฐ€MGC", "transaction_type": "expense", "category": "์‹๋น„", "subcategory": "์นดํŽ˜"}, - {"priority": 50, "match_field": "merchant", "pattern": "๋งค๋จธ๋“œ", "transaction_type": "expense", "category": "์‹๋น„", "subcategory": "์นดํŽ˜"}, - {"priority": 50, "match_field": "merchant", "pattern": "์ปคํ”ผ", "transaction_type": "expense", "category": "์‹๋น„", "subcategory": "์นดํŽ˜"}, - {"priority": 60, "match_field": "merchant", "pattern": "KFC", "transaction_type": "expense", "category": "์‹๋น„", "subcategory": "์™ธ์‹"}, - {"priority": 60, "match_field": "merchant", "pattern": "์Šค์‹œ", "transaction_type": "expense", "category": "์‹๋น„", "subcategory": "์™ธ์‹"}, - {"priority": 60, "match_field": "merchant", "pattern": "์Œ€๊ตญ์ˆ˜", "transaction_type": "expense", "category": "์‹๋น„", "subcategory": "์™ธ์‹"}, - {"priority": 60, "match_field": "merchant", "pattern": "์งœ์žฅ", "transaction_type": "expense", "category": "์‹๋น„", "subcategory": "์™ธ์‹"}, - {"priority": 80, "match_field": "raw_summary", "match_type": "equals", "pattern": "์Šค๋งˆํŠธ์ถœ๊ธˆ", "category": "์ด์ฒด", "subcategory": "ํ˜„๊ธˆ/๊ณ„์ขŒ์ด๋™"}, - {"priority": 80, "match_field": "raw_summary", "match_type": "equals", "pattern": "ATM์ž…๊ธˆ", "category": "์ด์ฒด", "subcategory": "ํ˜„๊ธˆ/๊ณ„์ขŒ์ด๋™"}, -] - - -def _rule_matches(rule: dict[str, Any], tx: dict[str, Any]) -> bool: - if rule.get("transaction_type") and rule.get("transaction_type") != tx.get("type", ""): - return False - amount = int(tx.get("amount") or 0) - if rule.get("amount_min") is not None and amount < int(rule["amount_min"]): - return False - if rule.get("amount_max") is not None and amount > int(rule["amount_max"]): - return False - if rule.get("day_of_month") is not None: - tx_date = tx.get("transaction_datetime") or tx.get("transaction_date") or "" - try: - if datetime.fromisoformat(str(tx_date)[:10]).day != int(rule["day_of_month"]): - return False - except ValueError: - return False - value = str(tx.get(rule["match_field"], "") or "") - pattern = str(rule.get("pattern", "")) - match_type = rule.get("match_type") or "contains" - if match_type == "contains": - return pattern.lower() in value.lower() - if match_type == "equals": - return value == pattern - if match_type == "starts_with": - return value.startswith(pattern) - if match_type == "ends_with": - return value.endswith(pattern) - if match_type == "regex": - return re.search(pattern, value) is not None - return False - - -def create_category_rule(match_field: str, pattern: str, category: str, subcategory: str = "", transaction_type: str = "", match_type: str = "contains", priority: int = 100, person: str = "", is_fixed: bool | None = None, confidence: float = 1.0, memo: str = "", amount_min: int | None = None, amount_max: int | None = None, day_of_month: int | None = None, db_path: str | None = None) -> dict[str, Any]: - if match_field not in RULE_FIELDS: - return fail("validation_error", f"match_field must be one of {sorted(RULE_FIELDS)}") - if match_type not in RULE_MATCH_TYPES: - return fail("validation_error", f"match_type must be one of {sorted(RULE_MATCH_TYPES)}") - if transaction_type and transaction_type not in VALID_TRANSACTION_TYPES: - return fail("validation_error", f"transaction_type must be empty or one of {sorted(VALID_TRANSACTION_TYPES)}") - if day_of_month is not None and not 1 <= int(day_of_month) <= 31: - return fail("validation_error", "day_of_month must be between 1 and 31") - if amount_min is not None and amount_max is not None and int(amount_min) > int(amount_max): - return fail("validation_error", "amount_min must be <= amount_max") - if not pattern or not category: - return fail("validation_error", "pattern and category are required") - ts=now_iso(); rule_id=new_id("rule") - with db(db_path) as conn: - conn.execute("""INSERT INTO category_rules(id,priority,match_field,match_type,pattern,amount_min,amount_max,day_of_month,transaction_type,category,subcategory,person,is_fixed,confidence,active,memo,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - ON CONFLICT(match_field,match_type,pattern,transaction_type,category,subcategory,person) DO UPDATE SET priority=excluded.priority,amount_min=excluded.amount_min,amount_max=excluded.amount_max,day_of_month=excluded.day_of_month,is_fixed=excluded.is_fixed,confidence=excluded.confidence,active=1,memo=excluded.memo,updated_at=excluded.updated_at""", - (rule_id, int(priority), match_field, match_type, pattern, None if amount_min is None else int(amount_min), None if amount_max is None else int(amount_max), None if day_of_month is None else int(day_of_month), transaction_type, category, subcategory, person, None if is_fixed is None else int(is_fixed), float(confidence), 1, memo, ts, ts)) - row=conn.execute("SELECT * FROM category_rules WHERE match_field=? AND match_type=? AND pattern=? AND transaction_type=? AND category=? AND subcategory=? AND person=?", (match_field, match_type, pattern, transaction_type, category, subcategory, person)).fetchone() - return ok(rule=row_to_dict(row)) - - -def seed_default_category_rules(overwrite: bool = False, db_path: str | None = None) -> dict[str, Any]: - created=0; skipped=0 - with db(db_path) as conn: - if overwrite: - conn.execute("UPDATE category_rules SET active=0 WHERE memo LIKE 'default:%'") - for rule in DEFAULT_CATEGORY_RULES: - memo = "default: initial Jarca ledger categorization" - res=create_category_rule(memo=memo, db_path=db_path, **rule) - if res.get("success"): - created += 1 - else: - skipped += 1 - return ok(created_or_updated=created, skipped=skipped) - - -def list_category_rules(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]: - sql="SELECT * FROM category_rules" + (" WHERE active=1" if active_only else "") + " ORDER BY priority, id" - with db(db_path) as conn: - rows=conn.execute(sql).fetchall() - return ok(rules=[row_to_dict(r) for r in rows], count=len(rows)) - - -def update_category_rule(rule_id: str, match_field: str, pattern: str, category: str, subcategory: str = "", transaction_type: str = "", match_type: str = "contains", priority: int = 100, person: str = "", is_fixed: bool | None = None, confidence: float = 1.0, memo: str = "", amount_min: int | None = None, amount_max: int | None = None, day_of_month: int | None = None, active: bool = True, db_path: str | None = None) -> dict[str, Any]: - if match_field not in RULE_FIELDS: - return fail("validation_error", f"match_field must be one of {sorted(RULE_FIELDS)}") - if match_type not in RULE_MATCH_TYPES: - return fail("validation_error", f"match_type must be one of {sorted(RULE_MATCH_TYPES)}") - if transaction_type and transaction_type not in VALID_TRANSACTION_TYPES: - return fail("validation_error", f"transaction_type must be empty or one of {sorted(VALID_TRANSACTION_TYPES)}") - if day_of_month is not None and not 1 <= int(day_of_month) <= 31: - return fail("validation_error", "day_of_month must be between 1 and 31") - if amount_min is not None and amount_max is not None and int(amount_min) > int(amount_max): - return fail("validation_error", "amount_min must be <= amount_max") - if not pattern or not category: - return fail("validation_error", "pattern and category are required") - ts = now_iso() - with db(db_path) as conn: - existing = conn.execute("SELECT * FROM category_rules WHERE id=?", (rule_id,)).fetchone() - if not existing: - return fail("not_found", f"category rule not found: {rule_id}") - duplicate = conn.execute( - "SELECT id FROM category_rules WHERE match_field=? AND match_type=? AND pattern=? AND transaction_type=? AND category=? AND subcategory=? AND person=? AND id<>?", - (match_field, match_type, pattern, transaction_type, category, subcategory, person, rule_id), - ).fetchone() - if duplicate: - return fail("conflict", f"another rule already exists with the same unique fields: {duplicate['id']}") - conn.execute( - """UPDATE category_rules - SET priority=?, match_field=?, match_type=?, pattern=?, amount_min=?, amount_max=?, day_of_month=?, - transaction_type=?, category=?, subcategory=?, person=?, is_fixed=?, confidence=?, active=?, memo=?, updated_at=? - WHERE id=?""", - ( - int(priority), match_field, match_type, pattern, - None if amount_min is None else int(amount_min), - None if amount_max is None else int(amount_max), - None if day_of_month is None else int(day_of_month), - transaction_type, category, subcategory, person, - None if is_fixed is None else int(is_fixed), - float(confidence), int(bool(active)), memo, ts, rule_id, - ), - ) - row = conn.execute("SELECT * FROM category_rules WHERE id=?", (rule_id,)).fetchone() - return ok(rule=row_to_dict(row)) - - -def delete_category_rule(rule_id: str, db_path: str | None = None) -> dict[str, Any]: - if not rule_id: - return fail("validation_error", "rule_id is required") - with db(db_path) as conn: - existing = conn.execute("SELECT * FROM category_rules WHERE id=?", (rule_id,)).fetchone() - if not existing: - return fail("not_found", f"category rule not found: {rule_id}") - conn.execute("DELETE FROM category_rules WHERE id=?", (rule_id,)) - return ok(deleted_rule_id=rule_id) - - -def _load_active_rules(conn: Any) -> list[dict[str, Any]]: - return [row_to_dict(r) for r in conn.execute("SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id").fetchall()] - - -def _categorization_preview_data(limit: int = 1000, only_uncategorized: bool = True, db_path: str | None = None) -> dict[str, Any]: - with db(db_path) as conn: - rules = _load_active_rules(conn) - where = "WHERE status='active'" + (" AND category=''" if only_uncategorized else "") - rows = [row_to_dict(r) for r in conn.execute( - f"SELECT * FROM transactions {where} ORDER BY transaction_datetime, transaction_date LIMIT ?", - (max(1, min(int(limit), 5000)),), - ).fetchall()] - matches = [] - unmatched = [] - for tx in rows: - match = next((r for r in rules if _rule_matches(r, tx)), None) - if match: - matches.append({ - "transaction_id": tx["id"], - "merchant": tx["merchant"], - "type": tx["type"], - "amount": tx["amount"], - "transaction_datetime": tx.get("transaction_datetime", ""), - "category": match["category"], - "subcategory": match["subcategory"], - "person": match["person"], - "is_fixed": match["is_fixed"], - "rule_id": match["id"], - "rule_pattern": match["pattern"], - }) - else: - unmatched.append({ - "transaction_id": tx["id"], - "merchant": tx["merchant"], - "raw_summary": tx.get("raw_summary", ""), - "type": tx["type"], - "amount": tx["amount"], - "transaction_datetime": tx.get("transaction_datetime", ""), - }) - by_category = {} - for m in matches: - key = f"{m['category']}/{m['subcategory']}" if m['subcategory'] else m['category'] - by_category.setdefault(key, {"count": 0, "amount": 0}) - by_category[key]["count"] += 1 - by_category[key]["amount"] += int(m["amount"]) - return { - "rows": rows, - "matches": matches, - "unmatched": unmatched, - "by_category": by_category, - } - - -def preview_categorization(limit: int = 1000, only_uncategorized: bool = True, db_path: str | None = None) -> dict[str, Any]: - data = _categorization_preview_data(limit, only_uncategorized, db_path) - return ok( - scanned=len(data["rows"]), - matched_count=len(data["matches"]), - unmatched_count=len(data["unmatched"]), - by_category=data["by_category"], - matches=data["matches"][:100], - unmatched=data["unmatched"][:100], - ) - - - -def _category_rule_match_rows(rule_id: str, limit: int = 10000, db_path: str | None = None) -> dict[str, Any]: - if not rule_id: - return fail("validation_error", "rule_id is required") - row_limit = max(1, min(int(limit), 50000)) - with db(db_path) as conn: - rule_row = conn.execute("SELECT * FROM category_rules WHERE id=?", (rule_id,)).fetchone() - if not rule_row: - return fail("not_found", f"category rule not found: {rule_id}") - rule = row_to_dict(rule_row) - rows = [row_to_dict(r) for r in conn.execute( - """SELECT * FROM transactions - WHERE status='active' - ORDER BY transaction_datetime DESC, transaction_date DESC, created_at DESC - LIMIT ?""", - (row_limit,), - ).fetchall()] - - matched = [] - uncategorized = [] - for tx in rows: - if not _rule_matches(rule, tx): - continue - item = { - "transaction_id": tx["id"], - "transaction_date": tx.get("transaction_date", ""), - "transaction_datetime": tx.get("transaction_datetime", ""), - "type": tx.get("type", ""), - "amount": tx.get("amount"), - "merchant": tx.get("merchant", ""), - "raw_summary": tx.get("raw_summary", ""), - "counterparty": tx.get("counterparty", ""), - "current_category": tx.get("category", "") or "", - "current_subcategory": tx.get("subcategory", "") or "", - "new_category": rule.get("category", ""), - "new_subcategory": rule.get("subcategory", ""), - "rule_id": rule_id, - "rule_pattern": rule.get("pattern", ""), - } - matched.append(item) - if not (tx.get("category") or ""): - uncategorized.append(item) - return ok(rule=rule, scanned=len(rows), matched=matched, uncategorized=uncategorized) - - -def preview_category_rule_matches(rule_id: str, limit: int = 10000, sample_limit: int = 3, db_path: str | None = None) -> dict[str, Any]: - data = _category_rule_match_rows(rule_id, limit, db_path) - if not data.get("success"): - return data - sample_n = max(1, min(int(sample_limit), 20)) - return ok( - rule=data["rule"], - scanned=data["scanned"], - matched_count=len(data["matched"]), - uncategorized_matched_count=len(data["uncategorized"]), - sample=data["matched"][:sample_n], - ) - - -def apply_category_rule(rule_id: str, limit: int = 10000, db_path: str | None = None) -> dict[str, Any]: - data = _category_rule_match_rows(rule_id, limit, db_path) - if not data.get("success"): - return data - rule = data["rule"] - ts = now_iso() - applied = 0 - with db(db_path) as conn: - for m in data["uncategorized"]: - conn.execute( - """UPDATE transactions - SET category=?, subcategory=?, - person=CASE WHEN ?<>'' THEN ? ELSE person END, - is_fixed=CASE WHEN ? IS NULL THEN is_fixed ELSE ? END, - updated_at=? - WHERE id=? AND status='active' AND COALESCE(category,'')=''""", - ( - rule.get("category", ""), rule.get("subcategory", ""), - rule.get("person", ""), rule.get("person", ""), - rule.get("is_fixed"), rule.get("is_fixed"), - ts, m["transaction_id"], - ), - ) - applied += 1 - return ok( - applied_count=applied, - matched_count=len(data["matched"]), - uncategorized_matched_count=len(data["uncategorized"]), - rule=rule, - ) - -def apply_categorization_rules(limit: int = 1000, only_uncategorized: bool = True, db_path: str | None = None) -> dict[str, Any]: - data = _categorization_preview_data(limit, only_uncategorized, db_path) - preview = ok( - scanned=len(data["rows"]), - matched_count=len(data["matches"]), - unmatched_count=len(data["unmatched"]), - by_category=data["by_category"], - matches=data["matches"][:100], - unmatched=data["unmatched"][:100], - ) - applied = 0 - ts = now_iso() - with db(db_path) as conn: - for m in data["matches"]: - conn.execute("UPDATE transactions SET category=?, subcategory=?, person=CASE WHEN ?<>'' THEN ? ELSE person END, is_fixed=CASE WHEN ? IS NULL THEN is_fixed ELSE ? END, updated_at=? WHERE id=?", (m["category"], m["subcategory"], m["person"], m["person"], m["is_fixed"], m["is_fixed"], ts, m["transaction_id"])) - applied += 1 - return ok(applied_count=applied, preview=preview) - - -def list_uncategorized_transactions(limit: int = 100, min_amount: int = 0, db_path: str | None = None) -> dict[str, Any]: - with db(db_path) as conn: - rows=conn.execute("""SELECT * FROM transactions WHERE status='active' AND COALESCE(category,'')='' AND amount>=? ORDER BY amount DESC, transaction_datetime DESC LIMIT ?""", (int(min_amount), max(1,min(int(limit),1000)))).fetchall() - return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows)) - - -def get_uncategorized_merchant_summary(limit: int = 50, db_path: str | None = None) -> dict[str, Any]: - with db(db_path) as conn: - rows=conn.execute("""SELECT merchant, raw_summary, type, COUNT(*) count, SUM(amount) total_amount, MAX(amount) max_amount - FROM transactions WHERE status='active' AND COALESCE(category,'')='' - GROUP BY merchant, raw_summary, type ORDER BY total_amount DESC LIMIT ?""", (max(1,min(int(limit),500)),)).fetchall() - return ok(items=[row_to_dict(r) for r in rows], count=len(rows)) - - -def _recommend_category_for_transaction(tx: dict[str, Any], rules: list[dict[str, Any]], history: dict[tuple[str, str], list[dict[str, Any]]]) -> dict[str, Any]: - """Recommend a category for one transaction using deterministic, explainable signals.""" - for rule in rules: - try: - if _rule_matches(rule, tx): - return { - "category": rule.get("category", ""), - "subcategory": rule.get("subcategory", ""), - "confidence": round(float(rule.get("confidence") or 1.0), 2), - "source": "rule", - "reason": f"ํ™œ์„ฑ ๊ทœ์น™ `{rule.get('pattern', '')}` ๋งค์นญ", - "rule_id": rule.get("id", ""), - } - except re.error: - continue - - key = (tx.get("merchant", "") or "", tx.get("type", "") or "") - candidates = history.get(key, []) - if candidates: - best = candidates[0] - count = int(best.get("count") or 0) - confidence = min(0.9, 0.55 + min(count, 5) * 0.07) - return { - "category": best.get("category", ""), - "subcategory": best.get("subcategory", ""), - "confidence": round(confidence, 2), - "source": "history", - "reason": f"๊ฐ™์€ ์‚ฌ์šฉ์ฒ˜์˜ ๊ธฐ์กด ๋ถ„๋ฅ˜ {count}๊ฑด", - "rule_id": "", - } - - return {"category": "", "subcategory": "", "confidence": 0.0, "source": "none", "reason": "์ถ”์ฒœ ๊ทผ๊ฑฐ ์—†์Œ", "rule_id": ""} - - -def get_uncategorized_workbench( - limit: int = 100, - min_amount: int = 0, - sort_by: str = "amount_desc", - db_path: str | None = None, -) -> dict[str, Any]: - row_limit = max(1, min(int(limit), 1000)) - minimum_amount = int(min_amount) - order_by_map = { - "amount_desc": "amount DESC, transaction_datetime DESC, transaction_date DESC, created_at DESC", - "date_desc": "transaction_datetime DESC, transaction_date DESC, created_at DESC, amount DESC", - } - order_by = order_by_map.get(sort_by, order_by_map["amount_desc"]) - with db(db_path) as conn: - tx_rows = [row_to_dict(r) for r in conn.execute( - f"""SELECT id, transaction_date, transaction_datetime, type, amount, currency, merchant, raw_summary, - counterparty, account_id, payment_method, person, is_fixed, source, memo, created_at - FROM transactions - WHERE status='active' AND COALESCE(category,'')='' AND amount>=? - ORDER BY {order_by} - LIMIT ?""", - (minimum_amount, row_limit), - ).fetchall()] - total_row = conn.execute( - """SELECT COUNT(*) AS count, COALESCE(SUM(amount),0) AS total_amount, - COUNT(DISTINCT COALESCE(NULLIF(merchant,''), raw_summary, counterparty, '')) AS merchant_group_count - FROM transactions - WHERE status='active' AND COALESCE(category,'')='' AND amount>=?""", - (minimum_amount,), - ).fetchone() - history_rows = [row_to_dict(r) for r in conn.execute( - """SELECT merchant, type, category, subcategory, COUNT(*) AS count, - SUM(amount) AS total_amount, MAX(transaction_date) AS latest_date - FROM transactions - WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>'' - GROUP BY merchant, type, category, subcategory - ORDER BY merchant, type, count DESC, total_amount DESC""" - ).fetchall()] - rules = [row_to_dict(r) for r in conn.execute( - "SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id" - ).fetchall()] - - history: dict[tuple[str, str], list[dict[str, Any]]] = {} - for row in history_rows: - history.setdefault((row.get("merchant", "") or "", row.get("type", "") or ""), []).append(row) - - txs: list[dict[str, Any]] = [] - groups: dict[tuple[str, str, str], dict[str, Any]] = {} - for tx in tx_rows: - rec = _recommend_category_for_transaction(tx, rules, history) - public_tx = { - **tx, - "recommendation": rec, - "recommended_category": rec.get("category", ""), - "recommended_subcategory": rec.get("subcategory", ""), - "recommendation_confidence": rec.get("confidence", 0.0), - "recommendation_reason": rec.get("reason", ""), - "recommendation_source": rec.get("source", "none"), - } - txs.append(public_tx) - gkey = (tx.get("merchant", "") or "", tx.get("raw_summary", "") or "", tx.get("type", "") or "") - group = groups.setdefault(gkey, { - "merchant": gkey[0], - "raw_summary": gkey[1], - "type": gkey[2], - "count": 0, - "total_amount": 0, - "max_amount": 0, - "latest_date": "", - "sample_transaction_id": tx.get("id", ""), - "recommendation": rec, - }) - group["count"] += 1 - group["total_amount"] += int(tx.get("amount") or 0) - group["max_amount"] = max(int(group["max_amount"] or 0), int(tx.get("amount") or 0)) - date_value = tx.get("transaction_datetime") or tx.get("transaction_date") or "" - if date_value > (group.get("latest_date") or ""): - group["latest_date"] = date_value - if not group["recommendation"].get("category") and rec.get("category"): - group["recommendation"] = rec - - group_items = sorted(groups.values(), key=lambda g: (int(g["count"]), int(g["total_amount"])), reverse=True) - recommendable_count = sum(1 for tx in txs if tx.get("recommended_category")) - repeated_group_count = sum(1 for g in group_items if int(g.get("count") or 0) >= 2) - total = row_to_dict(total_row) if total_row else {"count": 0, "total_amount": 0, "merchant_group_count": 0} - return ok( - summary={ - "uncategorized_count": int(total.get("count") or 0), - "uncategorized_total_amount": int(total.get("total_amount") or 0), - "merchant_group_count": int(total.get("merchant_group_count") or 0), - "recommendable_count": recommendable_count, - "repeated_group_count": repeated_group_count, - }, - groups=group_items, - transactions=txs, - count=len(txs), - ) - - -def apply_uncategorized_recommendation(transaction_id: str, db_path: str | None = None) -> dict[str, Any]: - if not transaction_id: - return fail("validation_error", "transaction_id is required") - with db(db_path) as conn: - row = conn.execute("SELECT * FROM transactions WHERE id=? AND status='active'", (transaction_id,)).fetchone() - if not row: - return fail("not_found", "transaction not found") - tx = row_to_dict(row) - if tx.get("category"): - return fail("validation_error", "transaction is already categorized") - rules = [row_to_dict(r) for r in conn.execute("SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id").fetchall()] - history_rows = [row_to_dict(r) for r in conn.execute( - """SELECT merchant, type, category, subcategory, COUNT(*) AS count, SUM(amount) AS total_amount - FROM transactions - WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>'' - GROUP BY merchant, type, category, subcategory - ORDER BY count DESC, total_amount DESC""" - ).fetchall()] - history: dict[tuple[str, str], list[dict[str, Any]]] = {} - for h in history_rows: - history.setdefault((h.get("merchant", "") or "", h.get("type", "") or ""), []).append(h) - rec = _recommend_category_for_transaction(tx, rules, history) - if not rec.get("category"): - return fail("no_recommendation", "์ถ”์ฒœ ๋ถ„๋ฅ˜๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค") - cur = conn.execute( - """UPDATE transactions - SET category=?, subcategory=?, updated_at=? - WHERE id=? AND status='active' AND COALESCE(category,'')=''""", - (rec.get("category", ""), rec.get("subcategory", ""), now_iso(), transaction_id), - ) - return ok(applied_count=cur.rowcount, transaction_id=transaction_id, recommendation=rec) - - -def apply_uncategorized_group_recommendation(merchant: str, raw_summary: str = "", transaction_type: str = "", db_path: str | None = None) -> dict[str, Any]: - with db(db_path) as conn: - sample = conn.execute( - """SELECT * FROM transactions - WHERE status='active' AND COALESCE(category,'')='' AND merchant=? AND raw_summary=? AND type=? - ORDER BY amount DESC, transaction_datetime DESC, transaction_date DESC LIMIT 1""", - (merchant, raw_summary, transaction_type), - ).fetchone() - if not sample: - return fail("not_found", "uncategorized group not found") - tx = row_to_dict(sample) - rules = [row_to_dict(r) for r in conn.execute("SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id").fetchall()] - history_rows = [row_to_dict(r) for r in conn.execute( - """SELECT merchant, type, category, subcategory, COUNT(*) AS count, SUM(amount) AS total_amount - FROM transactions - WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>'' - GROUP BY merchant, type, category, subcategory - ORDER BY count DESC, total_amount DESC""" - ).fetchall()] - history: dict[tuple[str, str], list[dict[str, Any]]] = {} - for h in history_rows: - history.setdefault((h.get("merchant", "") or "", h.get("type", "") or ""), []).append(h) - rec = _recommend_category_for_transaction(tx, rules, history) - if not rec.get("category"): - return fail("no_recommendation", "์ถ”์ฒœ ๋ถ„๋ฅ˜๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค") - cur = conn.execute( - """UPDATE transactions - SET category=?, subcategory=?, updated_at=? - WHERE status='active' AND COALESCE(category,'')='' AND merchant=? AND raw_summary=? AND type=?""", - (rec.get("category", ""), rec.get("subcategory", ""), now_iso(), merchant, raw_summary, transaction_type), - ) - return ok(applied_count=cur.rowcount, merchant=merchant, raw_summary=raw_summary, type=transaction_type, recommendation=rec) - - -DEFAULT_LEDGER_CATEGORIES: list[dict[str, Any]] = [ - {"category": "์ˆ˜์ž…", "subcategory": "๊ธ‰์—ฌ", "sort_order": 10}, - {"category": "์ˆ˜์ž…", "subcategory": "์ด์ž", "sort_order": 11}, - {"category": "์ˆ˜์ž…", "subcategory": "ํ™˜๊ธ‰", "sort_order": 12}, - {"category": "์ˆ˜์ž…", "subcategory": "๋ฐฐ๋‹น", "sort_order": 13}, - {"category": "์ˆ˜์ž…", "subcategory": "๊ธฐํƒ€์ˆ˜์ž…", "sort_order": 19}, - {"category": "์ด์ฒด", "subcategory": "๋ณธ์ธ๊ณ„์ขŒ์ด๋™", "sort_order": 20}, - {"category": "์ด์ฒด", "subcategory": "ํ˜„๊ธˆ์ž…์ถœ๊ธˆ", "sort_order": 21}, - {"category": "์ด์ฒด", "subcategory": "๊ฐ€์กฑ์†ก๊ธˆ", "sort_order": 22}, - {"category": "์ด์ฒด", "subcategory": "ํˆฌ์ž์ด๋™", "sort_order": 23}, - {"category": "์ด์ฒด", "subcategory": "ํ™•์ธํ•„์š”", "sort_order": 29}, - {"category": "์‹๋น„", "subcategory": "๋งˆํŠธ", "sort_order": 30}, - {"category": "์‹๋น„", "subcategory": "์™ธ์‹", "sort_order": 31}, - {"category": "์‹๋น„", "subcategory": "์นดํŽ˜", "sort_order": 32}, - {"category": "์‹๋น„", "subcategory": "๋ฐฐ๋‹ฌ", "sort_order": 33}, - {"category": "์ฃผ๊ฑฐ", "subcategory": "๊ด€๋ฆฌ๋น„", "sort_order": 40}, - {"category": "์ฃผ๊ฑฐ", "subcategory": "์ „๊ธฐ", "sort_order": 41}, - {"category": "์ฃผ๊ฑฐ", "subcategory": "๊ฐ€์Šค", "sort_order": 42}, - {"category": "์ฃผ๊ฑฐ", "subcategory": "์ˆ˜๋„", "sort_order": 43}, - {"category": "์ฃผ๊ฑฐ", "subcategory": "์ธํ„ฐ๋„ท", "sort_order": 44}, - {"category": "ํ†ต์‹ ", "subcategory": "ํœด๋Œ€ํฐ", "sort_order": 50}, - {"category": "ํ†ต์‹ ", "subcategory": "์ธํ„ฐ๋„ท", "sort_order": 51}, - {"category": "ํ†ต์‹ ", "subcategory": "ํ†ต์‹ ์š”๊ธˆ", "sort_order": 52}, - {"category": "๊ตํ†ต", "subcategory": "๋Œ€์ค‘๊ตํ†ต", "sort_order": 60}, - {"category": "๊ตํ†ต", "subcategory": "ํƒ์‹œ", "sort_order": 61}, - {"category": "๊ตํ†ต", "subcategory": "์ฃผ์œ ", "sort_order": 62}, - {"category": "๊ตํ†ต", "subcategory": "์ฃผ์ฐจ", "sort_order": 63}, - {"category": "๊ตํ†ต", "subcategory": "์ฐจ๋Ÿ‰์ •๋น„", "sort_order": 64}, - {"category": "๊ต์œก", "subcategory": "ํ•™์›", "sort_order": 70}, - {"category": "๊ต์œก", "subcategory": "๊ต์žฌ", "sort_order": 71}, - {"category": "๊ต์œก", "subcategory": "ํ•™๊ต", "sort_order": 72}, - {"category": "๊ต์œก", "subcategory": "์˜ˆ์ฒด๋Šฅ", "sort_order": 73}, - {"category": "์˜๋ฃŒ", "subcategory": "๋ณ‘์›", "sort_order": 80}, - {"category": "์˜๋ฃŒ", "subcategory": "์•ฝ๊ตญ", "sort_order": 81}, - {"category": "์˜๋ฃŒ", "subcategory": "์น˜๊ณผ", "sort_order": 82}, - {"category": "์˜๋ฃŒ", "subcategory": "๋ณดํ—˜", "sort_order": 83}, - {"category": "์ƒํ™œ", "subcategory": "์ƒํ•„ํ’ˆ", "sort_order": 90}, - {"category": "์ƒํ™œ", "subcategory": "์˜๋ฅ˜", "sort_order": 91}, - {"category": "์ƒํ™œ", "subcategory": "๋ฏธ์šฉ", "sort_order": 92}, - {"category": "์ƒํ™œ", "subcategory": "์„ธํƒ", "sort_order": 93}, - {"category": "๋ฌธํ™”/์—ฌ๊ฐ€", "subcategory": "์—ฌํ–‰", "sort_order": 100}, - {"category": "๋ฌธํ™”/์—ฌ๊ฐ€", "subcategory": "์ˆ™๋ฐ•", "sort_order": 101}, - {"category": "๋ฌธํ™”/์—ฌ๊ฐ€", "subcategory": "๊ณต์—ฐ", "sort_order": 102}, - {"category": "๋ฌธํ™”/์—ฌ๊ฐ€", "subcategory": "์ทจ๋ฏธ", "sort_order": 103}, - {"category": "๋ฌธํ™”/์—ฌ๊ฐ€", "subcategory": "์Šคํฌ์ธ ", "sort_order": 104}, - {"category": "๊ธˆ์œต", "subcategory": "์นด๋“œ๋Œ€๊ธˆ", "sort_order": 110}, - {"category": "๊ธˆ์œต", "subcategory": "๋Œ€์ถœ์ƒํ™˜", "sort_order": 111}, - {"category": "๊ธˆ์œต", "subcategory": "์ˆ˜์ˆ˜๋ฃŒ", "sort_order": 112}, - {"category": "๊ธˆ์œต", "subcategory": "๋ณดํ—˜๋ฃŒ", "sort_order": 113}, - {"category": "๊ฐ€์กฑ/์šฉ๋ˆ", "subcategory": "๋ถ€๋ชจ๋‹˜", "sort_order": 120}, - {"category": "๊ฐ€์กฑ/์šฉ๋ˆ", "subcategory": "์ž๋…€", "sort_order": 121}, - {"category": "๊ฐ€์กฑ/์šฉ๋ˆ", "subcategory": "๊ฐ€์กฑ์†ก๊ธˆ", "sort_order": 122}, - {"category": "๋ฏธ๋ถ„๋ฅ˜", "subcategory": "ํ™•์ธํ•„์š”", "sort_order": 999}, -] - - -def infer_cashflow_kind(category: str, subcategory: str = "") -> str: - if category == "์ˆ˜์ž…": - return "income" - if category == "์ด์ฒด": - if subcategory == "ํˆฌ์ž์ด๋™": - return "investment" - return "transfer" - if category == "๊ธˆ์œต": - if subcategory == "๋Œ€์ถœ์ƒํ™˜": - return "debt_payment" - if subcategory in {"์นด๋“œ๋Œ€๊ธˆ", "์ˆ˜์ˆ˜๋ฃŒ"}: - return "exclude" - return "exclude" - if category == "๋ฏธ๋ถ„๋ฅ˜": - return "unknown" - return "expense" - - -def create_ledger_category(category: str, subcategory: str = "", sort_order: int = 100, memo: str = "", cashflow_kind: str = "", db_path: str | None = None) -> dict[str, Any]: - category = (category or "").strip() - subcategory = (subcategory or "").strip() - cashflow_kind = (cashflow_kind or infer_cashflow_kind(category, subcategory)).strip() - if not category: - return fail("validation_error", "category is required") - if cashflow_kind not in VALID_CASHFLOW_KINDS: - return fail("validation_error", f"cashflow_kind must be one of {sorted(VALID_CASHFLOW_KINDS)}") - ts = now_iso(); category_id = new_id("cat") - with db(db_path) as conn: - conn.execute("""INSERT INTO ledger_categories(id,category,subcategory,cashflow_kind,active,sort_order,memo,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?) - ON CONFLICT(category, subcategory) DO UPDATE SET active=1, cashflow_kind=excluded.cashflow_kind, sort_order=excluded.sort_order, memo=excluded.memo, updated_at=excluded.updated_at""", - (category_id, category, subcategory, cashflow_kind, 1, int(sort_order), memo, ts, ts)) - row = conn.execute("SELECT * FROM ledger_categories WHERE category=? AND subcategory=?", (category, subcategory)).fetchone() - return ok(category=row_to_dict(row)) - - -def seed_default_ledger_categories(overwrite: bool = False, db_path: str | None = None) -> dict[str, Any]: - if overwrite: - with db(db_path) as conn: - conn.execute("UPDATE ledger_categories SET active=0 WHERE memo LIKE 'default:%'") - created = 0 - for item in DEFAULT_LEDGER_CATEGORIES: - res = create_ledger_category(memo="default: Jarca initial category", db_path=db_path, **item) - if res.get("success"): - created += 1 - return ok(created_or_updated=created) - - -def list_ledger_categories(active_only: bool = True, include_usage: bool = True, db_path: str | None = None) -> dict[str, Any]: - clauses = [] - if active_only: - clauses.append("active=1") - where = " WHERE " + " AND ".join(clauses) if clauses else "" - with db(db_path) as conn: - rows = conn.execute(f"SELECT * FROM ledger_categories{where} ORDER BY sort_order, category, subcategory").fetchall() - items = [row_to_dict(r) for r in rows] - if include_usage: - usage_rows = conn.execute("""SELECT category, subcategory, COUNT(*) AS transaction_count, SUM(amount) AS total_amount - FROM transactions WHERE status='active' - GROUP BY category, subcategory""").fetchall() - usage = {(r["category"], r["subcategory"]): {"transaction_count": int(r["transaction_count"] or 0), "total_amount": int(r["total_amount"] or 0)} for r in usage_rows} - for item in items: - item.update(usage.get((item["category"], item["subcategory"]), {"transaction_count": 0, "total_amount": 0})) - grouped: dict[str, list[str]] = {} - for item in items: - grouped.setdefault(item["category"], []) - if item["subcategory"] and item["subcategory"] not in grouped[item["category"]]: - grouped[item["category"]].append(item["subcategory"]) - return ok(categories=items, grouped=grouped, count=len(items)) - - -def delete_ledger_category(category: str, subcategory: str = "", hard_delete: bool = False, force: bool = False, db_path: str | None = None) -> dict[str, Any]: - category = (category or "").strip() - subcategory = (subcategory or "").strip() - if not category: - return fail("validation_error", "category is required") - with db(db_path) as conn: - row = conn.execute("SELECT * FROM ledger_categories WHERE category=? AND subcategory=?", (category, subcategory)).fetchone() - if not row: - return fail("not_found", "category not found") - tx_count = conn.execute("SELECT COUNT(*) FROM transactions WHERE status='active' AND category=? AND subcategory=?", (category, subcategory)).fetchone()[0] - budget_count = conn.execute("SELECT COUNT(*) FROM budgets WHERE category=? AND subcategory=?", (category, subcategory)).fetchone()[0] - rule_count = conn.execute("SELECT COUNT(*) FROM category_rules WHERE active=1 AND category=? AND subcategory=?", (category, subcategory)).fetchone()[0] - in_use = int(tx_count or 0) + int(budget_count or 0) + int(rule_count or 0) - if in_use and hard_delete and not force: - return fail("category_in_use", "category is referenced; use soft delete or force=true", transaction_count=tx_count, budget_count=budget_count, rule_count=rule_count) - if hard_delete: - conn.execute("DELETE FROM ledger_categories WHERE id=?", (row["id"],)) - else: - conn.execute("UPDATE ledger_categories SET active=0, updated_at=? WHERE id=?", (now_iso(), row["id"])) - return ok(category=category, subcategory=subcategory, deleted=True, hard_delete=hard_delete, transaction_count=tx_count, budget_count=budget_count, rule_count=rule_count) +import sys +from types import ModuleType + +from .application import asset_account_services as _asset_account_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 transaction_report_services as _transaction_report_services +from .application.asset_account_services import * +from .application.categorization_services import * +from .application.import_transfer_services import * +from .application.shared import * +from .application.system_services import * +from .application.transaction_report_services import * + +# Keep compatibility for tests/monkeypatches that reach into service internals. +_current_kst_datetime = _shared._current_kst_datetime + +_PROXY_MODULES = ( + _shared, + _system_services, + _asset_account_services, + _transaction_report_services, + _import_transfer_services, + _categorization_services, +) + + +class _ServiceModule(ModuleType): + def __setattr__(self, name: str, value) -> None: + super().__setattr__(name, value) + for module in _PROXY_MODULES: + if hasattr(module, name): + setattr(module, name, value) + + +sys.modules[__name__].__class__ = _ServiceModule