From f42bc4aa7d4dd63c2a496ebc15a7f26e1929a99b Mon Sep 17 00:00:00 2001 From: Jarca Date: Fri, 3 Jul 2026 08:25:01 +0900 Subject: [PATCH] Refactor import and workflow services plus adapter seams --- dashboard/app.py | 123 +- .../design.md | 23 + .../implementation.md | 46 + .../index.md | 21 + .../planning.md | 28 + .../verification.md | 37 + .../design.md | 13 + .../implementation.md | 48 + .../index.md | 21 + .../planning.md | 28 + .../verification.md | 36 + .../design.md | 7 + .../implementation.md | 43 + .../index.md | 12 + .../planning.md | 12 + .../verification.md | 43 + docs/batches/index.md | 3 + ...write-import-workflow-adapter-narrowing.md | 18 +- docs/project-structure.md | 60 +- docs/runtime-flows.md | 23 +- ...pter-surface-narrowing-baseline-refresh.md | 63 + ...peline-application-infrastructure-split.md | 80 + ...advanced-finance-workflow-service-split.md | 70 + docs/srs/index.md | 11 +- jarca_ledger_mcp/adapters/mcp/server.py | 114 +- .../adapters/mcp/tool_contracts.py | 60 + .../adapters/streamlit/__init__.py | 12 +- .../adapters/streamlit/accounts_gateway.py | 6 + .../adapters/streamlit/assets_gateway.py | 10 + .../streamlit/categorization_gateway.py | 20 + .../adapters/streamlit/imports_gateway.py | 10 + .../adapters/streamlit/overview_gateway.py | 13 + .../streamlit/reconciliation_gateway.py | 3 + .../adapters/streamlit/service_gateway.py | 50 +- .../streamlit/transactions_gateway.py | 3 + .../adapters/streamlit/transfers_gateway.py | 11 + jarca_ledger_mcp/application/__init__.py | 5 + .../application/asset_account_services.py | 502 +----- .../application/assets_service.py | 490 ++++++ .../application/categorization_services.py | 224 +-- .../application/cross_transfer_service.py | 593 +++++++ .../application/import_transfer_services.py | 1530 +---------------- .../application/imports_service.py | 922 ++++++++++ .../application/reconciliation_service.py | 94 + jarca_ledger_mcp/application/shared.py | 14 +- .../transaction_report_services.py | 93 +- .../uncategorized_workbench_service.py | 246 +++ .../infrastructure/parsers/__init__.py | 4 + .../infrastructure/parsers/kb_bank_pdf.py | 1 + .../infrastructure/parsers/seoulpay_xlsx.py | 1 + .../infrastructure/parsers/toss_bank_pdf.py | 1 + .../parsers/toss_securities_pdf.py | 1 + jarca_ledger_mcp/service.py | 15 + 53 files changed, 3420 insertions(+), 2497 deletions(-) create mode 100644 docs/batches/bat-012_20260703_import-pipeline-split/design.md create mode 100644 docs/batches/bat-012_20260703_import-pipeline-split/implementation.md create mode 100644 docs/batches/bat-012_20260703_import-pipeline-split/index.md create mode 100644 docs/batches/bat-012_20260703_import-pipeline-split/planning.md create mode 100644 docs/batches/bat-012_20260703_import-pipeline-split/verification.md create mode 100644 docs/batches/bat-013_20260703_advanced-workflow-service-split/design.md create mode 100644 docs/batches/bat-013_20260703_advanced-workflow-service-split/implementation.md create mode 100644 docs/batches/bat-013_20260703_advanced-workflow-service-split/index.md create mode 100644 docs/batches/bat-013_20260703_advanced-workflow-service-split/planning.md create mode 100644 docs/batches/bat-013_20260703_advanced-workflow-service-split/verification.md create mode 100644 docs/batches/bat-014_20260703_adapter-surface-narrowing-baseline-refresh/design.md create mode 100644 docs/batches/bat-014_20260703_adapter-surface-narrowing-baseline-refresh/implementation.md create mode 100644 docs/batches/bat-014_20260703_adapter-surface-narrowing-baseline-refresh/index.md create mode 100644 docs/batches/bat-014_20260703_adapter-surface-narrowing-baseline-refresh/planning.md create mode 100644 docs/batches/bat-014_20260703_adapter-surface-narrowing-baseline-refresh/verification.md create mode 100644 docs/srs/Interface/req-018_adapter-surface-narrowing-baseline-refresh.md create mode 100644 docs/srs/Non-Functional/req-016_import-pipeline-application-infrastructure-split.md create mode 100644 docs/srs/Non-Functional/req-017_advanced-finance-workflow-service-split.md create mode 100644 jarca_ledger_mcp/adapters/mcp/tool_contracts.py create mode 100644 jarca_ledger_mcp/adapters/streamlit/accounts_gateway.py create mode 100644 jarca_ledger_mcp/adapters/streamlit/assets_gateway.py create mode 100644 jarca_ledger_mcp/adapters/streamlit/categorization_gateway.py create mode 100644 jarca_ledger_mcp/adapters/streamlit/imports_gateway.py create mode 100644 jarca_ledger_mcp/adapters/streamlit/overview_gateway.py create mode 100644 jarca_ledger_mcp/adapters/streamlit/reconciliation_gateway.py create mode 100644 jarca_ledger_mcp/adapters/streamlit/transactions_gateway.py create mode 100644 jarca_ledger_mcp/adapters/streamlit/transfers_gateway.py create mode 100644 jarca_ledger_mcp/application/assets_service.py create mode 100644 jarca_ledger_mcp/application/cross_transfer_service.py create mode 100644 jarca_ledger_mcp/application/imports_service.py create mode 100644 jarca_ledger_mcp/application/reconciliation_service.py create mode 100644 jarca_ledger_mcp/application/uncategorized_workbench_service.py create mode 100644 jarca_ledger_mcp/infrastructure/parsers/__init__.py create mode 100644 jarca_ledger_mcp/infrastructure/parsers/kb_bank_pdf.py create mode 100644 jarca_ledger_mcp/infrastructure/parsers/seoulpay_xlsx.py create mode 100644 jarca_ledger_mcp/infrastructure/parsers/toss_bank_pdf.py create mode 100644 jarca_ledger_mcp/infrastructure/parsers/toss_securities_pdf.py diff --git a/dashboard/app.py b/dashboard/app.py index 354b15d..4f53e3d 100644 --- a/dashboard/app.py +++ b/dashboard/app.py @@ -15,7 +15,14 @@ import plotly.express as px import plotly.io as pio import streamlit as st -from jarca_ledger_mcp.adapters.streamlit import service_gateway as service +from jarca_ledger_mcp.adapters.streamlit import accounts_gateway as accounts_gateway +from jarca_ledger_mcp.adapters.streamlit import assets_gateway as assets_gateway +from jarca_ledger_mcp.adapters.streamlit import categorization_gateway as categorization_gateway +from jarca_ledger_mcp.adapters.streamlit import imports_gateway as imports_gateway +from jarca_ledger_mcp.adapters.streamlit import overview_gateway as overview_gateway +from jarca_ledger_mcp.adapters.streamlit import reconciliation_gateway as reconciliation_gateway +from jarca_ledger_mcp.adapters.streamlit import transactions_gateway as transactions_gateway +from jarca_ledger_mcp.adapters.streamlit import transfers_gateway as transfers_gateway from jarca_ledger_mcp.db import resolve_db_path st.set_page_config(page_title="Jarca Ledger", page_icon="๐Ÿ’ฐ", layout="wide") @@ -146,7 +153,7 @@ def get_db_path() -> str: return str(resolve_db_path()) -def get_service_callable(name: str, required_params: list[str] | None = None): +def get_service_callable(module, name: str, required_params: list[str] | None = None): """Return a service function, reloading once for Streamlit hot-reload edge cases. Streamlit can keep an already-imported service module in memory while the @@ -155,7 +162,7 @@ def get_service_callable(name: str, required_params: list[str] | None = None): signature. In that case reload the service module before returning it. """ required_params = required_params or [] - fn = getattr(service, name, None) + fn = getattr(module, name, None) if callable(fn): try: params = inspect.signature(fn).parameters @@ -164,8 +171,8 @@ def get_service_callable(name: str, required_params: list[str] | None = None): except (TypeError, ValueError): if not required_params: return fn - importlib.reload(service) - return getattr(service, name, None) + importlib.reload(module) + return getattr(module, name, None) def inject_app_css() -> None: @@ -431,10 +438,10 @@ def parse_optional_int(value: str, field_name: str) -> int | None: def get_category_options(db_path: str) -> tuple[list[str], dict[str, list[str]]]: - result = service.list_ledger_categories(active_only=True, include_usage=False, db_path=db_path) + result = categorization_gateway.list_ledger_categories(active_only=True, include_usage=False, db_path=db_path) if result.get("success") and result.get("count", 0) == 0: - service.seed_default_ledger_categories(db_path=db_path) - result = service.list_ledger_categories(active_only=True, include_usage=False, db_path=db_path) + categorization_gateway.seed_default_ledger_categories(db_path=db_path) + result = categorization_gateway.list_ledger_categories(active_only=True, include_usage=False, db_path=db_path) grouped = result.get("grouped", {}) if result.get("success") else {} categories = [""] + sorted(grouped.keys()) subcategories = {cat: [""] + sorted([s for s in subs if s]) for cat, subs in grouped.items()} @@ -524,7 +531,7 @@ def update_transaction_form(tx: dict[str, Any], key_prefix: str, db_path: str | submitted = st.button("์ €์žฅ", type="primary", key=f"{key_prefix}_submit") if submitted: - result = service.update_ledger_transaction( + result = transactions_gateway.update_ledger_transaction( tx["id"], category=category, subcategory=subcategory, person=person, is_fixed=is_fixed, memo=memo, db_path=db_path, ) @@ -532,7 +539,7 @@ def update_transaction_form(tx: dict[str, Any], key_prefix: str, db_path: str | st.error(result.get("error", result)) return if save_rule and tx.get("merchant") and category: - rule = service.create_category_rule( + rule = categorization_gateway.create_category_rule( match_field="merchant", pattern=tx["merchant"], category=category, @@ -560,7 +567,7 @@ def transaction_edit_dialog(tx: dict[str, Any], key_prefix: str, db_path: str) - def linked_transfer_candidates_dialog(tx: dict[str, Any], db_path: str) -> None: st.caption(f"๊ธฐ์ค€ ๊ฑฐ๋ž˜: `{tx['id']}`") st.write(f"**{tx.get('transaction_datetime') or tx.get('transaction_date')}** ยท {tx.get('merchant')} ยท {format_krw(tx.get('amount'))}") - result = service.find_self_transfer_candidates(tx["id"], limit=20, db_path=db_path) + result = transfers_gateway.find_self_transfer_candidates(tx["id"], limit=20, db_path=db_path) if not result.get("success"): st.error(result.get("error", result)) return @@ -578,7 +585,7 @@ def linked_transfer_candidates_dialog(tx: dict[str, Any], db_path: str) -> None: cols[3].write(format_krw(candidate.get("amount"))) cols[4].write(candidate.get("merchant") or candidate.get("counterparty") or "") if cols[5].button("์ด ๊ฑฐ๋ž˜์™€ ์—ฐ๊ฒฐ", key=f"link_pair_{tx['id']}_{candidate['id']}_{idx}", use_container_width=True, type="primary"): - link_res = service.link_self_transfer_pair(tx["id"], candidate["id"], db_path=db_path) + link_res = transfers_gateway.link_self_transfer_pair(tx["id"], candidate["id"], db_path=db_path) if not link_res.get("success"): st.error(link_res.get("error", link_res)) else: @@ -603,7 +610,7 @@ def linked_transfer_candidates_dialog(tx: dict[str, Any], db_path: str) -> None: def cancel_original_candidates_dialog(tx: dict[str, Any], db_path: str) -> None: st.caption(f"์ทจ์†Œ/ํ™˜๊ธ‰ ๊ฑฐ๋ž˜: `{tx['id']}`") st.write(f"**{tx.get('transaction_datetime') or tx.get('transaction_date')}** ยท {tx.get('merchant')} ยท {format_krw(tx.get('amount'))}") - result = service.find_cancel_original_candidates(tx["id"], limit=20, db_path=db_path) + result = transfers_gateway.find_cancel_original_candidates(tx["id"], limit=20, db_path=db_path) if not result.get("success"): st.error(result.get("error", result)) return @@ -621,7 +628,7 @@ def cancel_original_candidates_dialog(tx: dict[str, Any], db_path: str) -> None: cols[3].write(format_krw(candidate.get("amount"))) cols[4].write(candidate.get("merchant") or candidate.get("counterparty") or "") if cols[5].button("์ด ์›๊ฑฐ๋ž˜์™€ ์—ฐ๊ฒฐ", key=f"cancel_pair_{tx['id']}_{candidate['id']}_{idx}", use_container_width=True, type="primary"): - link_res = service.link_cancel_to_original(tx["id"], candidate["id"], db_path=db_path) + link_res = transfers_gateway.link_cancel_to_original(tx["id"], candidate["id"], db_path=db_path) if not link_res.get("success"): st.error(link_res.get("error", link_res)) else: @@ -683,7 +690,7 @@ def category_rule_create_dialog(db_path: str) -> None: st.error(str(exc)) else: fixed_value = None if is_fixed == "๋ณ€๊ฒฝ์•ˆํ•จ" else is_fixed == "์˜ˆ" - res = service.create_category_rule(match_field, pattern, category, subcategory, tx_type, match_type, int(priority), person, fixed_value, 1.0, memo, amount_min, amount_max, day_of_month, db_path=db_path) + res = categorization_gateway.create_category_rule(match_field, pattern, category, subcategory, tx_type, match_type, int(priority), person, fixed_value, 1.0, memo, amount_min, amount_max, day_of_month, db_path=db_path) clear_cache() if res.get("success"): st.success("๊ทœ์น™์„ ์ €์žฅํ–ˆ์Šต๋‹ˆ๋‹ค.") @@ -735,7 +742,7 @@ def category_rule_edit_dialog(rule: dict[str, Any], db_path: str) -> None: st.error(str(exc)) else: edit_fixed_value = None if edit_is_fixed == "๋ณ€๊ฒฝ์•ˆํ•จ" else edit_is_fixed == "์˜ˆ" - update_rule = getattr(service, "update_category_rule", None) + update_rule = getattr(categorization_gateway, "update_category_rule", None) if update_rule is None: st.error("ํ˜„์žฌ ์‹คํ–‰ ์ค‘์ธ ์„œ๋น„์Šค์— update_category_rule ํ•จ์ˆ˜๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ์•ฑ์„ ์žฌ์‹œ์ž‘ํ•ด ์ตœ์‹  ์ฝ”๋“œ๋ฅผ ๋‹ค์‹œ ๋ถˆ๋Ÿฌ์˜ค์„ธ์š”.") else: @@ -787,7 +794,7 @@ def save_merchant_rule_from_recommendation(merchant: str, tx_type: str, rec: dic return {"success": False, "error": "merchant๊ฐ€ ๋น„์–ด ์žˆ์–ด ๊ทœ์น™์„ ๋งŒ๋“ค ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."} if not rec.get("category"): return {"success": False, "error": "์ถ”์ฒœ ๋ถ„๋ฅ˜๊ฐ€ ์—†์–ด ๊ทœ์น™์„ ๋งŒ๋“ค ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."} - return service.create_category_rule( + return categorization_gateway.create_category_rule( match_field="merchant", match_type="contains", pattern=merchant, @@ -817,7 +824,7 @@ def render_uncategorized_groups(groups: list[dict[str, Any]], db_path: str, repe cols[3].write(recommendation_text(rec)) apply_disabled = not bool(rec.get("category")) if cols[4].button("๊ทธ๋ฃน ์ ์šฉ", key=f"uncat_group_apply_{idx}_{merchant}", disabled=apply_disabled, use_container_width=True): - res = service.apply_uncategorized_group_recommendation( + res = categorization_gateway.apply_uncategorized_group_recommendation( group.get("merchant", ""), group.get("raw_summary", ""), group.get("type", ""), db_path=db_path ) clear_cache() @@ -860,7 +867,7 @@ def render_uncategorized_work_items(rows: list[dict[str, Any]], db_path: str, re action_cols = st.columns([1.0, 1.0, 1.0, 1.1, 1.1, 2.5], vertical_alignment="center") apply_disabled = not bool(rec.get("category")) if action_cols[0].button("์ถ”์ฒœ ์ ์šฉ", key=f"uncat_apply_rec_{row['id']}_{idx}", disabled=apply_disabled, use_container_width=True, type="primary"): - res = service.apply_uncategorized_recommendation(row["id"], db_path=db_path) + res = categorization_gateway.apply_uncategorized_recommendation(row["id"], db_path=db_path) clear_cache() if res.get("success"): st.success(f"{recommendation_text(res.get('recommendation'))}๋กœ ๋ถ„๋ฅ˜ํ–ˆ์Šต๋‹ˆ๋‹ค.") @@ -892,7 +899,7 @@ def category_rule_preview_dialog(rule: dict[str, Any], db_path: str) -> None: st.caption(f"๊ทœ์น™ ID: `{rule_id}`") st.write(f"**{pattern}** โ†’ **{result_text}**") - preview_fn = get_service_callable("preview_category_rule_matches") + preview_fn = get_service_callable(categorization_gateway, "preview_category_rule_matches") if not callable(preview_fn): st.error("ํ˜„์žฌ ์‹คํ–‰ ์ค‘์ธ ์„œ๋น„์Šค์—์„œ ๋‹จ์ผ ๊ทœ์น™ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ํ•จ์ˆ˜๋ฅผ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. Streamlit ์„œ๋ฒ„๋ฅผ ์™„์ „ํžˆ ์ข…๋ฃŒํ•œ ๋’ค ๋‹ค์‹œ ์‹œ์ž‘ํ•ด ์ฃผ์„ธ์š”.") return @@ -922,7 +929,7 @@ def category_rule_preview_dialog(rule: dict[str, Any], db_path: str) -> None: st.info("์ด ๊ทœ์น™์— ๋ถ€ํ•ฉํ•˜๋Š” ๊ฑฐ๋ž˜๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.") def page_overview(db_path: str) -> None: - health = service.health_check(db_path) + health = overview_gateway.health_check(db_path) render_page_header( "๊ฐ€๊ณ„๋ถ€ ์šด์˜ ๋Œ€์‹œ๋ณด๋“œ", "์ด๋ฒˆ ๋‹ฌ ์‹ค์ œ ํ˜„๊ธˆํ๋ฆ„, ์ž์‚ฐ ์œ ๋™์„ฑ, ์šด์˜ ๊ฒฝ๊ณ ๋ฅผ ์šฐ์„ ์ˆœ์œ„๋Œ€๋กœ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.", @@ -931,14 +938,14 @@ def page_overview(db_path: str) -> None: ) st.caption(f"DB: `{health.get('db_path')}`") month = current_kst_month() - cashflow = service.get_monthly_cashflow_report(month, db_path=db_path) - asset_report = service.get_asset_liquidity_report(db_path=db_path) - budget_report = service.get_budget_vs_actual_report(month, db_path=db_path) - uncategorized_res = service.list_uncategorized_transactions(limit=1000, db_path=db_path) - transfer_queue_res = service.list_cross_transfer_review_queue(limit=300, db_path=db_path) - reconciliation_res = service.get_balance_reconciliation_report(db_path=db_path) - backups_res = service.list_ledger_backups(limit=1) - imports_res = service.list_ledger_import_batches(limit=1, db_path=db_path) + cashflow = overview_gateway.get_monthly_cashflow_report(month, db_path=db_path) + asset_report = overview_gateway.get_asset_liquidity_report(db_path=db_path) + budget_report = overview_gateway.get_budget_vs_actual_report(month, db_path=db_path) + uncategorized_res = overview_gateway.list_uncategorized_transactions(limit=1000, db_path=db_path) + transfer_queue_res = overview_gateway.list_cross_transfer_review_queue(limit=300, db_path=db_path) + reconciliation_res = reconciliation_gateway.get_balance_reconciliation_report(db_path=db_path) + backups_res = overview_gateway.list_ledger_backups(limit=1) + imports_res = overview_gateway.list_ledger_import_batches(limit=1, db_path=db_path) render_section_label("๐Ÿ’Ž", "์ด๋ฒˆ ๋‹ฌ ์ˆ˜์ž…/์ง€์ถœ ํ˜„ํ™ฉ") st.caption(f"๊ธฐ์ค€: KST ํ˜„์žฌ ์›” {month}, transactions.transaction_date, ledger_categories.cashflow_kind ์‹ค์ œ ํ˜„๊ธˆํ๋ฆ„") @@ -1037,7 +1044,7 @@ def page_assets(db_path: str) -> None: memo = st.text_input("์šด์˜ ๋ฉ”๋ชจ", value="") submitted = st.form_submit_button("์ˆ˜๊ธฐ ์ž์‚ฐ ์ถ”๊ฐ€", type="primary") if submitted: - res = service.create_manual_asset(name, int(amount), valuation_date.isoformat(), bucket, source_note=source_note, memo=memo, db_path=db_path) + res = assets_gateway.create_manual_asset(name, int(amount), valuation_date.isoformat(), bucket, source_note=source_note, memo=memo, db_path=db_path) if res.get("success"): clear_cache() st.success("์ˆ˜๊ธฐ ์ž์‚ฐ์„ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค.") @@ -1045,7 +1052,7 @@ def page_assets(db_path: str) -> None: else: st.error(res.get("error", res)) - assets_res = service.list_manual_assets(active_only=False, db_path=db_path) + assets_res = assets_gateway.list_manual_assets(active_only=False, db_path=db_path) assets = assets_res.get("manual_assets", []) if assets_res.get("success") else [] render_section_label("๐Ÿ“‹", "์ˆ˜๊ธฐ ์ž์‚ฐ/๋ถ€์ฑ„ ๋ชฉ๋ก") if assets: @@ -1071,13 +1078,13 @@ def page_assets(db_path: str) -> None: do_update = col_update.form_submit_button("์„ ํƒ ํ•ญ๋ชฉ ์ˆ˜์ •", type="primary") do_deactivate = col_deactivate.form_submit_button("์„ ํƒ ํ•ญ๋ชฉ ๋น„ํ™œ์„ฑ", type="secondary") if do_update: - res = service.update_manual_asset(selected["id"], name=new_name, valuation_amount=int(new_amount), valuation_date=new_date, liquidity_bucket=new_bucket, source_note=new_source, memo=new_memo, active=make_active, db_path=db_path) + res = assets_gateway.update_manual_asset(selected["id"], name=new_name, valuation_amount=int(new_amount), valuation_date=new_date, liquidity_bucket=new_bucket, source_note=new_source, memo=new_memo, active=make_active, db_path=db_path) if res.get("success"): clear_cache(); st.success("์ˆ˜์ •ํ–ˆ์Šต๋‹ˆ๋‹ค."); st.rerun() else: st.error(res.get("error", res)) if do_deactivate: - res = service.deactivate_manual_asset(selected["id"], db_path=db_path) + res = assets_gateway.deactivate_manual_asset(selected["id"], db_path=db_path) if res.get("success"): clear_cache(); st.warning("๋น„ํ™œ์„ฑ ์ฒ˜๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค."); st.rerun() else: @@ -1094,9 +1101,9 @@ def page_assets(db_path: str) -> None: correction_reason = c3.text_input("์ •์ • ์‚ฌ์œ (์žฌ๋งˆ๊ฐ ์‹œ ํ•„์ˆ˜)", value="") p1, p2 = st.columns(2) if p1.button("์Šค๋ƒ…์ƒท Preview", type="secondary"): - st.session_state["asset_snapshot_preview"] = service.preview_asset_snapshot_close(basis_date, db_path=db_path) + st.session_state["asset_snapshot_preview"] = assets_gateway.preview_asset_snapshot_close(basis_date, db_path=db_path) if p2.button("์Šค๋ƒ…์ƒท Close ์‹คํ–‰", type="primary"): - st.session_state["asset_snapshot_close"] = service.close_asset_snapshot(basis_date, source_note=close_note, correction_reason=correction_reason, db_path=db_path) + st.session_state["asset_snapshot_close"] = assets_gateway.close_asset_snapshot(basis_date, source_note=close_note, correction_reason=correction_reason, db_path=db_path) clear_cache() preview = st.session_state.get("asset_snapshot_preview") close_res = st.session_state.get("asset_snapshot_close") @@ -1170,10 +1177,10 @@ def page_cross_transfer_review(db_path: str) -> None: c1, c2 = st.columns(2) with c1: if st.button("๋ฏธ๋ฆฌ๋ณด๊ธฐ(๋“œ๋ผ์ด๋Ÿฐ)"): - st.session_state["cross_transfer_preview"] = service.preview_cross_account_transfers(db_path=db_path) + st.session_state["cross_transfer_preview"] = transfers_gateway.preview_cross_account_transfers(db_path=db_path) with c2: if st.button("์ž๋™ ๋งค์นญ ์‹คํ–‰"): - res = service.run_cross_account_transfer_match(db_path=db_path) + res = transfers_gateway.run_cross_account_transfer_match(db_path=db_path) clear_cache() st.session_state["cross_transfer_last_run"] = res st.success(f"๋งค์นญ {res.get('matched_count', 0)}๊ฑด ์ ์šฉ, ํ™•์ธํ•„์š” {res.get('review_count', 0)}๊ฑด") @@ -1190,7 +1197,7 @@ def page_cross_transfer_review(db_path: str) -> None: st.divider() render_section_label("๐Ÿ“Œ", "ํ™•์ธํ•„์š” ํ") - list_queue_fn = getattr(service, "list_cross_transfer_review_queue", None) + list_queue_fn = getattr(transfers_gateway, "list_cross_transfer_review_queue", None) if callable(list_queue_fn): queue = list_queue_fn(db_path=db_path) rows = queue.get("transactions", []) @@ -1242,7 +1249,7 @@ def page_uncategorized(db_path: str) -> None: sort_label = filter_cols[4].selectbox("์ž‘์—…๋ชฉ๋ก ์ •๋ ฌ", ["๊ธˆ์•ก์ˆœ", "๋‚ ์งœ์ˆœ(์ตœ์‹ ์ˆœ)"], index=0) sort_by = "date_desc" if sort_label == "๋‚ ์งœ์ˆœ(์ตœ์‹ ์ˆœ)" else "amount_desc" - workbench_fn = get_service_callable("get_uncategorized_workbench", required_params=["sort_by"]) + workbench_fn = get_service_callable(categorization_gateway, "get_uncategorized_workbench", required_params=["sort_by"]) if not callable(workbench_fn): st.error("ํ˜„์žฌ ์‹คํ–‰ ์ค‘์ธ ์„œ๋น„์Šค์—์„œ ๋ฏธ๋ถ„๋ฅ˜ ์ž‘์—…๋Œ€ ํ•จ์ˆ˜๋ฅผ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. Streamlit ์„œ๋ฒ„๋ฅผ ์™„์ „ํžˆ ์žฌ์‹œ์ž‘ํ•ด ์ฃผ์„ธ์š”.") return @@ -1449,7 +1456,7 @@ def render_rule_list_items(rules: list[dict[str, Any]], db_path: str) -> None: if action_cols[0].button("๐Ÿ”Ž ์ ์šฉ(๋ฏธ๋ฆฌ๋ณด๊ธฐ)", key=f"rule_expander_preview_{rule_id}_{idx}", use_container_width=True): category_rule_preview_dialog(rule, db_path) if action_cols[1].button("โœ… ์ ์šฉ", key=f"rule_expander_apply_{rule_id}_{idx}", use_container_width=True): - apply_fn = get_service_callable("apply_category_rule") + apply_fn = get_service_callable(categorization_gateway, "apply_category_rule") if not callable(apply_fn): st.error("ํ˜„์žฌ ์‹คํ–‰ ์ค‘์ธ ์„œ๋น„์Šค์—์„œ ๋‹จ์ผ ๊ทœ์น™ ์ ์šฉ ํ•จ์ˆ˜๋ฅผ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. Streamlit ์„œ๋ฒ„๋ฅผ ์™„์ „ํžˆ ์ข…๋ฃŒํ•œ ๋’ค ๋‹ค์‹œ ์‹œ์ž‘ํ•ด ์ฃผ์„ธ์š”.") else: @@ -1465,7 +1472,7 @@ def render_rule_list_items(rules: list[dict[str, Any]], db_path: str) -> None: with action_cols[3]: st.markdown('
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
', unsafe_allow_html=True) if action_cols[4].button("๐Ÿ—‘๏ธ ์‚ญ์ œ", key=f"rule_expander_delete_{rule_id}_{idx}"): - delete_rule = getattr(service, "delete_category_rule", None) + delete_rule = getattr(categorization_gateway, "delete_category_rule", None) if delete_rule is None: st.error("ํ˜„์žฌ ์‹คํ–‰ ์ค‘์ธ ์„œ๋น„์Šค์— delete_category_rule ํ•จ์ˆ˜๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ์•ฑ์„ ์žฌ์‹œ์ž‘ํ•ด ์ตœ์‹  ์ฝ”๋“œ๋ฅผ ๋‹ค์‹œ ๋ถˆ๋Ÿฌ์˜ค์„ธ์š”.") else: @@ -1491,7 +1498,7 @@ def page_rules(db_path: str) -> None: with action_cols[1]: if st.button("์ถ”๊ฐ€", key="rule_add_dialog_open", use_container_width=True, type="primary"): category_rule_create_dialog(db_path) - rules = service.list_category_rules(active_only=False, db_path=db_path).get("rules", []) + rules = categorization_gateway.list_category_rules(active_only=False, db_path=db_path).get("rules", []) render_section_label("๐Ÿ“š", "ํ˜„์žฌ ๋ถ„๋ฅ˜ ๊ทœ์น™") render_rule_list_items(rules, db_path) @@ -1499,12 +1506,12 @@ def page_rules(db_path: str) -> None: c1, c2, c3 = st.columns(3) if c1.button("๊ธฐ๋ณธ ๊ทœ์น™ seed/update"): - res = service.seed_default_category_rules(db_path=db_path) + res = categorization_gateway.seed_default_category_rules(db_path=db_path) clear_cache(); st.success(res) if c2.button("๊ทœ์น™ preview"): - st.session_state["rule_preview"] = service.preview_categorization(limit=5000, db_path=db_path) + st.session_state["rule_preview"] = categorization_gateway.preview_categorization(limit=5000, db_path=db_path) if c3.button("๊ทœ์น™ ์ ์šฉ"): - res = service.apply_categorization_rules(limit=5000, db_path=db_path) + res = categorization_gateway.apply_categorization_rules(limit=5000, db_path=db_path) clear_cache(); st.success(f"{res.get('applied_count', 0)}๊ฑด ์ ์šฉ") if "rule_preview" in st.session_state: prev = st.session_state["rule_preview"] @@ -1528,7 +1535,7 @@ def page_categories(db_path: str) -> None: c1, c2 = st.columns(2) if c1.button("๊ธฐ๋ณธ ์นดํ…Œ๊ณ ๋ฆฌ seed/update"): - res = service.seed_default_ledger_categories(db_path=db_path) + res = categorization_gateway.seed_default_ledger_categories(db_path=db_path) clear_cache() st.success(f"{res.get('created_or_updated', 0)}๊ฐœ ์นดํ…Œ๊ณ ๋ฆฌ๋ฅผ ์ƒ์„ฑ/๊ฐฑ์‹ ํ–ˆ์Šต๋‹ˆ๋‹ค.") st.rerun() @@ -1545,7 +1552,7 @@ def page_categories(db_path: str) -> None: submitted = st.form_submit_button("์ถ”๊ฐ€/์žฌํ™œ์„ฑํ™”", type="primary") if submitted: cashflow_kind = next(k for k, v in CASHFLOW_KIND_LABELS.items() if v == cashflow_kind_label) - res = service.create_ledger_category(category, subcategory, int(sort_order), memo, cashflow_kind, db_path=db_path) + res = categorization_gateway.create_ledger_category(category, subcategory, int(sort_order), memo, cashflow_kind, db_path=db_path) clear_cache() if res.get("success"): st.success("์นดํ…Œ๊ณ ๋ฆฌ๋ฅผ ์ €์žฅํ–ˆ์Šต๋‹ˆ๋‹ค.") @@ -1553,7 +1560,7 @@ def page_categories(db_path: str) -> None: else: st.error(res.get("error", res)) - result = service.list_ledger_categories(active_only=not show_inactive, include_usage=True, db_path=db_path) + result = categorization_gateway.list_ledger_categories(active_only=not show_inactive, include_usage=True, db_path=db_path) rows = result.get("categories", []) render_section_label("๐Ÿ—‚๏ธ", "์นดํ…Œ๊ณ ๋ฆฌ ๋ชฉ๋ก") if rows: @@ -1577,7 +1584,7 @@ def page_categories(db_path: str) -> None: hard_delete = st.checkbox("DB์—์„œ ์™„์ „ ์‚ญ์ œ", value=False, help="๋ณดํ†ต์€ ๋„๊ณ  ๋น„ํ™œ์„ฑํ™”๋งŒ ํ•˜์„ธ์š”. ๊ฑฐ๋ž˜ ๊ธฐ๋ก์€ ๋ณ„๋„ ๋ณด์กด๋ฉ๋‹ˆ๋‹ค.") force = st.checkbox("์‚ฌ์šฉ ์ค‘์ด์–ด๋„ ๊ฐ•์ œ", value=False, help="๊ฑฐ๋ž˜/์˜ˆ์‚ฐ/๊ทœ์น™์—์„œ ์‚ฌ์šฉ ์ค‘์ธ ์นดํ…Œ๊ณ ๋ฆฌ๋„ ์‚ญ์ œ/๋น„ํ™œ์„ฑํ™”ํ•ฉ๋‹ˆ๋‹ค. ๊ฑฐ๋ž˜ category ๊ฐ’์€ ๋ณ€๊ฒฝํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.") if st.button("์‚ญ์ œ/๋น„ํ™œ์„ฑํ™” ์‹คํ–‰", type="secondary"): - res = service.delete_ledger_category(target["category"], target.get("subcategory", ""), hard_delete=hard_delete, force=force, db_path=db_path) + res = categorization_gateway.delete_ledger_category(target["category"], target.get("subcategory", ""), hard_delete=hard_delete, force=force, db_path=db_path) clear_cache() if res.get("success"): st.warning(f"์ฒ˜๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. ์‚ฌ์šฉ ๊ฑฐ๋ž˜ {res.get('transaction_count', 0)}๊ฑด") @@ -1593,7 +1600,7 @@ def page_accounts(db_path: str) -> None: kicker="๐Ÿฆ Accounts", pills=["์ž”์•ก", "๊ธฐ๊ด€", "statement_status", "์ˆ˜๋™ ์ž”์•ก๋Œ€์กฐ"], ) - accounts_res = service.list_ledger_accounts(active_only=False, db_path=db_path) + accounts_res = accounts_gateway.list_ledger_accounts(active_only=False, db_path=db_path) accounts_data = accounts_res.get("accounts", []) if accounts_res.get("success") else [] accounts = pd.DataFrame(accounts_data) if not accounts.empty: @@ -1638,7 +1645,7 @@ def page_accounts(db_path: str) -> None: except ValueError: st.error("๊ธฐ์ค€ ์ž”์•ก์€ ์ •์ˆ˜๋กœ ์ž…๋ ฅํ•˜์„ธ์š”.") return - res = service.update_ledger_account(selected["id"], db_path=db_path, **update_fields) + res = accounts_gateway.update_ledger_account(selected["id"], db_path=db_path, **update_fields) clear_cache() if res.get("success"): st.success("๊ณ„์ขŒ ์ƒํƒœ๋ฅผ ์ €์žฅํ–ˆ์Šต๋‹ˆ๋‹ค.") @@ -1654,7 +1661,7 @@ def page_accounts(db_path: str) -> None: tolerance = c2.number_input("ํ—ˆ์šฉ ์˜ค์ฐจ(์›)", min_value=0, value=1000, step=100) source_note = c3.text_input("์ถœ์ฒ˜ ๋ฉ”๋ชจ", value="", placeholder="์˜ˆ: ์›”๋ง ์•ฑ ์ž”์•ก") if st.button("์ž”์•ก๋Œ€์กฐ ์‹คํ–‰", type="secondary"): - st.session_state["balance_reconciliation_report"] = service.get_balance_reconciliation_report( + st.session_state["balance_reconciliation_report"] = reconciliation_gateway.get_balance_reconciliation_report( baseline_at=baseline_at, source_note=source_note, tolerance=int(tolerance), @@ -1696,10 +1703,10 @@ def page_imports(db_path: str) -> None: upload_path.write_bytes(uploaded.getbuffer()) c1, c2 = st.columns(2) if c1.button("ํ† ์Šค์ฆ๊ถŒ Preview"): - res = service.preview_toss_securities_pdf_import(str(upload_path), db_path=db_path) + res = imports_gateway.preview_toss_securities_pdf_import(str(upload_path), db_path=db_path) st.session_state["toss_preview"] = res if c2.button("ํ† ์Šค์ฆ๊ถŒ Import", type="primary"): - res = service.import_toss_securities_pdf(str(upload_path), db_path=db_path) + res = imports_gateway.import_toss_securities_pdf(str(upload_path), db_path=db_path) clear_cache() st.session_state["toss_import_result"] = res st.rerun() @@ -1730,10 +1737,10 @@ def page_imports(db_path: str) -> None: upload_path.write_bytes(toss_bank_uploaded.getbuffer()) c1, c2 = st.columns(2) if c1.button("ํ† ์Šค๋ฑ…ํฌ Preview"): - res = service.preview_toss_bank_pdf_import(str(upload_path), db_path=db_path) + res = imports_gateway.preview_toss_bank_pdf_import(str(upload_path), db_path=db_path) st.session_state["toss_bank_preview"] = res if c2.button("ํ† ์Šค๋ฑ…ํฌ Import", type="primary"): - res = service.import_toss_bank_pdf(str(upload_path), db_path=db_path) + res = imports_gateway.import_toss_bank_pdf(str(upload_path), db_path=db_path) clear_cache() st.session_state["toss_bank_import_result"] = res st.rerun() @@ -1755,7 +1762,7 @@ def page_imports(db_path: str) -> None: st.divider() render_section_label("๐Ÿงพ", "Import ์ด๋ ฅ") - batches = service.list_ledger_import_batches(limit=100, db_path=db_path).get("import_batches", []) + batches = overview_gateway.list_ledger_import_batches(limit=100, db_path=db_path).get("import_batches", []) st.dataframe(pd.DataFrame(batches), width="stretch", height=350) if batches: with st.form("rollback"): @@ -1763,7 +1770,7 @@ def page_imports(db_path: str) -> None: hard = st.checkbox("Hard delete", value=False) submitted = st.form_submit_button("Rollback ์‹คํ–‰", type="secondary") if submitted: - res = service.rollback_ledger_import_batch(batch_id, hard_delete=hard, db_path=db_path) + res = imports_gateway.rollback_ledger_import_batch(batch_id, hard_delete=hard, db_path=db_path) clear_cache() st.warning(res) st.rerun() diff --git a/docs/batches/bat-012_20260703_import-pipeline-split/design.md b/docs/batches/bat-012_20260703_import-pipeline-split/design.md new file mode 100644 index 0000000..215b2b2 --- /dev/null +++ b/docs/batches/bat-012_20260703_import-pipeline-split/design.md @@ -0,0 +1,23 @@ +# BAT-012 Design + +## Architecture summary +- Introduce `jarca_ledger_mcp/application/imports_service.py` as the single application-layer home for import preview/import/rollback orchestration. +- Introduce `jarca_ledger_mcp/infrastructure/parsers/` as the parser seam so application code no longer imports top-level parser implementations directly. +- Keep current top-level parser modules for compatibility if needed, but route application/shared usage through the infrastructure seam. +- Preserve `application/import_transfer_services.py` as the advanced workflow home; import-facing public functions become delegations into `imports_service.py`. + +## Changed areas +- application import orchestration +- infrastructure parser seam +- service export surface / compatibility proxy +- import regression verification docs + +## Key decisions +1. Parser relocation is seam-first, not algorithm rewrite. +2. Import account auto-linking/classification stays in the application layer, not in parser modules. +3. Public API/tool names remain unchanged. + +## Verification focus +- Import preview/import/rollback regression +- Evidence field preservation +- No breakage in MCP discovery and public exports diff --git a/docs/batches/bat-012_20260703_import-pipeline-split/implementation.md b/docs/batches/bat-012_20260703_import-pipeline-split/implementation.md new file mode 100644 index 0000000..fb1e9d8 --- /dev/null +++ b/docs/batches/bat-012_20260703_import-pipeline-split/implementation.md @@ -0,0 +1,46 @@ +# BAT-012 Implementation + +## Plan summary + +BAT-012 implements dcy-011 Phase 5 by extracting import preview/import/rollback orchestration into `application/imports_service.py`, introducing an `infrastructure/parsers/*` seam, and shrinking `application/import_transfer_services.py` toward advanced workflow ownership while preserving public contracts. + +## Changed files + +- `jarca_ledger_mcp/application/imports_service.py` +- `jarca_ledger_mcp/application/import_transfer_services.py` +- `jarca_ledger_mcp/application/shared.py` +- `jarca_ledger_mcp/application/__init__.py` +- `jarca_ledger_mcp/infrastructure/parsers/__init__.py` +- `jarca_ledger_mcp/infrastructure/parsers/kb_bank_pdf.py` +- `jarca_ledger_mcp/infrastructure/parsers/toss_bank_pdf.py` +- `jarca_ledger_mcp/infrastructure/parsers/toss_securities_pdf.py` +- `jarca_ledger_mcp/infrastructure/parsers/seoulpay_xlsx.py` +- `jarca_ledger_mcp/service.py` +- `docs/srs/index.md` +- `docs/srs/Non-Functional/req-016_import-pipeline-application-infrastructure-split.md` +- `docs/batches/index.md` +- `docs/batches/bat-012_20260703_import-pipeline-split/*` +- `docs/discovery/dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing.md` + +## Execution log + +- 2026-07-03 07:49 KST: dcy-011 Phase 5 ๋ฒ”์œ„๋กœ `REQ-016`/`BAT-012`๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ์Šน์ธ ์ƒํƒœ๋ฅผ ์ •๋ฆฌํ–ˆ๋‹ค. +- 2026-07-03 07:50 KST: `application/import_transfer_services.py`์˜ AST function ranges๋ฅผ ์ถ”์ถœํ•ด import-owned ํ•จ์ˆ˜์™€ advanced workflow ํ•จ์ˆ˜๋ฅผ ๊ธฐ๊ณ„์ ์œผ๋กœ ๋ถ„๋ฆฌํ•  ์ค€๋น„๋ฅผ ํ–ˆ๋‹ค. +- 2026-07-03 07:51 KST: `application/imports_service.py`๋ฅผ ์ƒ์„ฑํ•˜๊ณ  import preview/import/rollback + import-specific account/classification helpers๋ฅผ ์ด๋™ํ–ˆ๋‹ค. +- 2026-07-03 07:51 KST: `application/import_transfer_services.py`๋ฅผ advanced workflow ์ค‘์‹ฌ ๋ชจ๋“ˆ๋กœ ์ถ•์†Œํ•˜๊ณ  import-facing public functions๋Š” compatibility delegation์œผ๋กœ ์ „ํ™˜ํ–ˆ๋‹ค. +- 2026-07-03 07:51 KST: `infrastructure/parsers/*` seam์„ ์ถ”๊ฐ€ํ•˜๊ณ  `application/shared.py`๊ฐ€ top-level parser ๊ตฌํ˜„ ๋Œ€์‹  infrastructure seam์„ ํ†ตํ•ด parser๋ฅผ ๊ฐ€์ ธ์˜ค๋„๋ก ์ •๋ ฌํ–ˆ๋‹ค. +- 2026-07-03 07:52~07:53 KST: targeted import regression, full pytest, MCP discovery, export provenance๋ฅผ ๊ฒ€์ฆํ–ˆ๊ณ  ๋ชจ๋‘ PASSํ–ˆ๋‹ค. + +## Validation + +- `python3 -m py_compile jarca_ledger_mcp/application/*.py jarca_ledger_mcp/infrastructure/parsers/*.py jarca_ledger_mcp/service.py` โ†’ PASS +- `pytest -q tests/test_kb_bank_pdf.py tests/test_seoulpay_xlsx.py tests/test_toss_securities_pdf.py tests/test_service.py` โ†’ PASS (`12 passed, 4 skipped`) +- `pytest -q` โ†’ PASS (`42 passed, 4 skipped`) +- `hermes mcp test jarca-ledger` โ†’ PASS (`Connected`, `Tools discovered: 56`) +- `python3` provenance check โ†’ PASS (all import preview/import/rollback exports resolve to `jarca_ledger_mcp.application.imports_service`) + +## Remaining risks / follow-up notes + +- `application/import_transfer_services.py`์—๋Š” cross-transfer review/matching advanced workflow๊ฐ€ ์•„์ง ๋‚จ์•„ ์žˆ์œผ๋ฉฐ Phase 6 ๋ฐฐ์น˜์—์„œ ๋ณ„๋„ ๋ถ„๋ฆฌํ•ด์•ผ ํ•œ๋‹ค. +- parser relocation์€ seam-first ๋ฐฉ์‹์ด๋ฏ€๋กœ top-level parser implementation files๋Š” ์•„์ง compatibility/implementation ์œ„์น˜๋กœ ๋‚จ์•„ ์žˆ๋‹ค. ํ•„์š” ์‹œ ํ›„์† cleanup์€ adapter/baseline ๊ฐฑ์‹ ๊ณผ ํ•จ๊ป˜ ์ง„ํ–‰ํ•œ๋‹ค. +- UI/adapter ํ‘œ๋ฉด ์ถ•์†Œ์™€ baseline ๋ฌธ์„œ ๊ฐฑ์‹ ์€ BAT-012 ๋ฒ”์œ„ ๋ฐ–์ด๋‹ค. diff --git a/docs/batches/bat-012_20260703_import-pipeline-split/index.md b/docs/batches/bat-012_20260703_import-pipeline-split/index.md new file mode 100644 index 0000000..4080fe9 --- /dev/null +++ b/docs/batches/bat-012_20260703_import-pipeline-split/index.md @@ -0,0 +1,21 @@ +# BAT-012 Import pipeline split + +- Profile: standard +- Status: release-candidate +- Included REQ: REQ-016 +- Discovery: dcy-011 +- Owner: ๋ ›์ €์žฅ์ธ +- Date (KST): 2026-07-03 + +## ๋ชฉํ‘œ + +REQ-016์— ๋”ฐ๋ผ import preview/import/rollback ๊ฒฝ๋กœ๋ฅผ `application/imports_service.py`์™€ `infrastructure/parsers/*` ์ค‘์‹ฌ์œผ๋กœ ์žฌ๋ฐฐ์น˜ํ•˜๊ณ , `application/import_transfer_services.py`๋Š” advanced workflow ์ค‘์‹ฌ ๋ชจ๋“ˆ๋กœ ์ถ•์†Œํ•œ๋‹ค. + +## Delivery checklist + +- [ ] planning complete +- [ ] design complete +- [ ] implementation complete +- [ ] verification complete +- [ ] release-candidate promoted +- [ ] REQ sync complete diff --git a/docs/batches/bat-012_20260703_import-pipeline-split/planning.md b/docs/batches/bat-012_20260703_import-pipeline-split/planning.md new file mode 100644 index 0000000..d0964b6 --- /dev/null +++ b/docs/batches/bat-012_20260703_import-pipeline-split/planning.md @@ -0,0 +1,28 @@ +# BAT-012 Planning + +## Scope +- Implement REQ-016 import pipeline split. +- Keep import contract/evidence fields stable while relocating parser access under `infrastructure/parsers/*`. +- Leave cross-transfer/reconciliation/advanced workflows in follow-up batches. + +## Dependencies +- dcy-011 confirmed Discovery. +- REQ-016 Approved. +- BAT-011 write-service split baseline. +- Existing import regression tests and MCP discovery. + +## Work plan +1. Create `application/imports_service.py` and move import preview/import/rollback + import-specific helpers there. +2. Create `infrastructure/parsers/*` wrappers or relocated parser modules and point application/shared imports to the infrastructure seam. +3. Shrink `application/import_transfer_services.py` to advanced workflow + compatibility delegation for import functions. +4. Run focused import regression, then full pytest and MCP discovery. +5. Update batch evidence and promote REQ/BAT if ACs pass. + +## Design Gate +- Result: standard batch ์œ ์ง€. +- Reason: application/infrastructure seam ๋ณ€๊ฒฝ๊ณผ compatibility delegation, import evidence preservation, parser path relocation ๊ฒ€์ฆ์ด ํ•จ๊ป˜ ํ•„์š”ํ•˜๋‹ค. + +## Risks and mitigations +- Import evidence drift: preserve existing payload fields and run import-specific regression. +- Hidden helper coupling between import and advanced workflows: move only import-facing helpers in this batch and leave advanced helpers in place when not import-owned. +- Parser path breakage: add infrastructure parser wrappers first, then re-point shared/application imports. diff --git a/docs/batches/bat-012_20260703_import-pipeline-split/verification.md b/docs/batches/bat-012_20260703_import-pipeline-split/verification.md new file mode 100644 index 0000000..faae997 --- /dev/null +++ b/docs/batches/bat-012_20260703_import-pipeline-split/verification.md @@ -0,0 +1,37 @@ +# BAT-012 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-016 +- AC-1: PASS โ€” `application/imports_service.py`์™€ `infrastructure/parsers/*`๊ฐ€ ์ƒ์„ฑ๋๊ณ  import preview/import/rollback ๊ตฌํ˜„์ด ์ด๋™ํ–ˆ๋‹ค. +- AC-2: PASS โ€” public API/MCP tools/import response envelope and evidence fields remained stable; MCP discovery still reports 56 tools. +- AC-3: PASS โ€” import-focused regression and full pytest PASS. +- AC-4: PASS โ€” BAT-012 implementation/verification docs record actual changed files, commands, and outcomes. + +## Requirement state decision + +- REQ-016: Implemented. + +## Validation evidence + +- `python3 -m py_compile jarca_ledger_mcp/application/*.py jarca_ledger_mcp/infrastructure/parsers/*.py jarca_ledger_mcp/service.py` โ†’ PASS +- `pytest -q tests/test_kb_bank_pdf.py tests/test_seoulpay_xlsx.py tests/test_toss_securities_pdf.py tests/test_service.py` โ†’ PASS (`12 passed, 4 skipped`) +- `pytest -q` โ†’ PASS (`42 passed, 4 skipped`) +- `hermes mcp test jarca-ledger` โ†’ PASS (`Connected`, `Tools discovered: 56`) +- Python provenance check on import-facing exports โ†’ PASS + - preview/import/rollback APIs all resolve to `jarca_ledger_mcp.application.imports_service` + +## Remaining issues + +### Hard blockers +- None. + +### Non-blocking follow-up +- cross-transfer / reconciliation / asset / workbench advanced workflow split +- MCP/Streamlit adapter surface narrowing and baseline docs update diff --git a/docs/batches/bat-013_20260703_advanced-workflow-service-split/design.md b/docs/batches/bat-013_20260703_advanced-workflow-service-split/design.md new file mode 100644 index 0000000..a3db5fc --- /dev/null +++ b/docs/batches/bat-013_20260703_advanced-workflow-service-split/design.md @@ -0,0 +1,13 @@ +# BAT-013 Design + +## Architecture summary +- `cross_transfer_service.py` owns matching helpers and review queue APIs. +- `reconciliation_service.py` owns manual/partial-import balance reconciliation preview. +- `assets_service.py` owns manual asset CRUD, snapshot close/read, liquidity/net-worth reporting, and valuation helpers. +- `uncategorized_workbench_service.py` owns uncategorized list/summary/workbench/recommendation apply flows. +- Existing broad modules become thin compatibility layers or keep only their non-moved responsibilities. + +## Verification focus +- workflow-specific regression for cross-transfer/assets/reconciliation/workbench +- full pytest and MCP discovery +- provenance checks that public exports resolve to the new owning modules diff --git a/docs/batches/bat-013_20260703_advanced-workflow-service-split/implementation.md b/docs/batches/bat-013_20260703_advanced-workflow-service-split/implementation.md new file mode 100644 index 0000000..fc7a961 --- /dev/null +++ b/docs/batches/bat-013_20260703_advanced-workflow-service-split/implementation.md @@ -0,0 +1,48 @@ +# BAT-013 Implementation + +## Plan summary + +BAT-013 implements dcy-011 Phase 6 by extracting advanced finance workflows into dedicated owning application services: `cross_transfer_service.py`, `reconciliation_service.py`, `assets_service.py`, and `uncategorized_workbench_service.py`. Existing broad modules remain as compatibility layers so MCP/service/dashboard callers keep their contracts. + +## Changed files + +- `jarca_ledger_mcp/application/assets_service.py` +- `jarca_ledger_mcp/application/asset_account_services.py` +- `jarca_ledger_mcp/application/cross_transfer_service.py` +- `jarca_ledger_mcp/application/import_transfer_services.py` +- `jarca_ledger_mcp/application/reconciliation_service.py` +- `jarca_ledger_mcp/application/transaction_report_services.py` +- `jarca_ledger_mcp/application/uncategorized_workbench_service.py` +- `jarca_ledger_mcp/application/categorization_services.py` +- `jarca_ledger_mcp/application/__init__.py` +- `jarca_ledger_mcp/service.py` +- `docs/srs/index.md` +- `docs/srs/Non-Functional/req-017_advanced-finance-workflow-service-split.md` +- `docs/batches/index.md` +- `docs/batches/bat-013_20260703_advanced-workflow-service-split/*` +- `docs/discovery/dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing.md` + +## Execution log + +- 2026-07-03 07:55 KST: dcy-011 Phase 6 ๋ฒ”์œ„๋กœ `REQ-017`/`BAT-013`๋ฅผ ์ƒ์„ฑํ•˜๊ณ  Approved ์ƒํƒœ๋ฅผ ํ™•์ •ํ–ˆ๋‹ค. +- 2026-07-03 07:57~07:59 KST: AST-extracted function ranges๋ฅผ ๊ธฐ์ค€์œผ๋กœ assets / cross-transfer / reconciliation / uncategorized workbench ํ๋ฆ„์„ ์ƒˆ owning services๋กœ ์ด๋™ํ–ˆ๋‹ค. +- 2026-07-03 07:59 KST: `asset_account_services.py`, `import_transfer_services.py`, `transaction_report_services.py`, `categorization_services.py`๋ฅผ compatibility delegation ๋ ˆ์ด์–ด๋กœ ์ถ•์†Œํ–ˆ๋‹ค. +- 2026-07-03 08:00 KST: `application/__init__.py`์™€ `service.py` export/proxy surface๋ฅผ ์ƒˆ owning modules๊นŒ์ง€ ํ™•์žฅํ–ˆ๋‹ค. +- 2026-07-03 08:00~08:01 KST: targeted workflow regression, full pytest, MCP discovery, provenance checks๋ฅผ ์‹คํ–‰ํ–ˆ๊ณ  ๋ชจ๋‘ PASSํ–ˆ๋‹ค. + +## Validation + +- `python3 -m py_compile jarca_ledger_mcp/application/*.py jarca_ledger_mcp/service.py` โ†’ PASS +- `pytest -q tests/test_cross_account_transfer_matching.py tests/test_asset_snapshots.py tests/test_categorization.py tests/test_contract_regression.py tests/test_service.py` โ†’ PASS (`24 passed`) +- `pytest -q` โ†’ PASS (`42 passed, 4 skipped`) +- `hermes mcp test jarca-ledger` โ†’ PASS (`Connected`, `Tools discovered: 56`) +- `python3` provenance check โ†’ PASS + - cross-transfer APIs โ†’ `jarca_ledger_mcp.application.cross_transfer_service` + - reconciliation API โ†’ `jarca_ledger_mcp.application.reconciliation_service` + - asset workflows โ†’ `jarca_ledger_mcp.application.assets_service` + - uncategorized workbench APIs โ†’ `jarca_ledger_mcp.application.uncategorized_workbench_service` + +## Remaining risks / follow-up notes + +- broad compatibility layers still exist by design; final dcy-011 batch should narrow MCP/Streamlit adapter imports and refresh baseline docs. +- top-level module names remain public to avoid breaking monkeypatch/tests; final cleanup should preserve this constraint. diff --git a/docs/batches/bat-013_20260703_advanced-workflow-service-split/index.md b/docs/batches/bat-013_20260703_advanced-workflow-service-split/index.md new file mode 100644 index 0000000..9e6105a --- /dev/null +++ b/docs/batches/bat-013_20260703_advanced-workflow-service-split/index.md @@ -0,0 +1,21 @@ +# BAT-013 Advanced workflow service split + +- Profile: standard +- Status: release-candidate +- Included REQ: REQ-017 +- Discovery: dcy-011 +- Owner: ๋ ›์ €์žฅ์ธ +- Date (KST): 2026-07-03 + +## ๋ชฉํ‘œ + +REQ-017์— ๋”ฐ๋ผ cross-transfer, reconciliation, assets, uncategorized workbench๋ฅผ owning application services๋กœ ๋ถ„๋ฆฌํ•˜๊ณ  broad modules๋Š” compatibility delegation์œผ๋กœ ์ถ•์†Œํ•œ๋‹ค. + +## Delivery checklist + +- [ ] planning complete +- [ ] design complete +- [ ] implementation complete +- [ ] verification complete +- [ ] release-candidate promoted +- [ ] REQ sync complete diff --git a/docs/batches/bat-013_20260703_advanced-workflow-service-split/planning.md b/docs/batches/bat-013_20260703_advanced-workflow-service-split/planning.md new file mode 100644 index 0000000..dfa4518 --- /dev/null +++ b/docs/batches/bat-013_20260703_advanced-workflow-service-split/planning.md @@ -0,0 +1,28 @@ +# BAT-013 Planning + +## Scope +- Implement REQ-017 advanced workflow service split. +- Preserve public API and workflow response contracts while relocating owning helpers with their workflows. +- Leave adapter narrowing/baseline docs for the final dcy-011 batch. + +## Dependencies +- dcy-011 confirmed Discovery. +- REQ-017 Approved. +- BAT-011/BAT-012 compatibility seams. +- Existing workflow regression tests. + +## Work plan +1. Extract cross-transfer workflow from `import_transfer_services.py` into `cross_transfer_service.py`. +2. Extract reconciliation preview from `transaction_report_services.py` into `reconciliation_service.py`. +3. Extract manual asset/snapshot/liquidity workflows from `asset_account_services.py` into `assets_service.py`. +4. Extract uncategorized list/summary/workbench/recommendation flows from `categorization_services.py` into `uncategorized_workbench_service.py`. +5. Update application/service export surfaces, run focused workflow regression + full pytest, then record evidence. + +## Design Gate +- Result: standard batch ์œ ์ง€. +- Reason: multiple domain modules and compatibility layers change together; broad-module decomposition needs explicit verification. + +## Risks and mitigations +- Shared helper drift across moved workflows: move helper with first owner and verify with focused tests. +- Contract regressions in dashboard/MCP callers: preserve public names and run full pytest + MCP discovery. +- Partial extraction leaving dead imports: run py_compile and provenance check after relocation. diff --git a/docs/batches/bat-013_20260703_advanced-workflow-service-split/verification.md b/docs/batches/bat-013_20260703_advanced-workflow-service-split/verification.md new file mode 100644 index 0000000..b354902 --- /dev/null +++ b/docs/batches/bat-013_20260703_advanced-workflow-service-split/verification.md @@ -0,0 +1,36 @@ +# BAT-013 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-017 +- AC-1: PASS โ€” `cross_transfer_service.py`, `reconciliation_service.py`, `assets_service.py`, `uncategorized_workbench_service.py` were created and now own their workflows. +- AC-2: PASS โ€” service/MCP/dashboard contract remained stable; MCP discovery still reports 56 tools. +- AC-3: PASS โ€” workflow-focused regression and full pytest both passed. +- AC-4: PASS โ€” BAT-013 implementation/verification docs record the actual changed files, commands, and outcomes. + +## Requirement state decision + +- REQ-017: Implemented. + +## Validation evidence + +- `python3 -m py_compile jarca_ledger_mcp/application/*.py jarca_ledger_mcp/service.py` โ†’ PASS +- `pytest -q tests/test_cross_account_transfer_matching.py tests/test_asset_snapshots.py tests/test_categorization.py tests/test_contract_regression.py tests/test_service.py` โ†’ PASS (`24 passed`) +- `pytest -q` โ†’ PASS (`42 passed, 4 skipped`) +- `hermes mcp test jarca-ledger` โ†’ PASS (`Connected`, `Tools discovered: 56`) +- Python provenance check on advanced workflow exports โ†’ PASS + +## Remaining issues + +### Hard blockers +- None. + +### Non-blocking follow-up +- MCP/Streamlit adapter surface narrowing +- dcy-011 baseline/final status refresh and QC evidence sync diff --git a/docs/batches/bat-014_20260703_adapter-surface-narrowing-baseline-refresh/design.md b/docs/batches/bat-014_20260703_adapter-surface-narrowing-baseline-refresh/design.md new file mode 100644 index 0000000..a0cbb2f --- /dev/null +++ b/docs/batches/bat-014_20260703_adapter-surface-narrowing-baseline-refresh/design.md @@ -0,0 +1,7 @@ +# BAT-014 Design + +## Architecture summary +- `adapters/mcp/tool_contracts.py` becomes the MCP adapterโ€™s narrow contract dependency surface. +- `adapters/streamlit/{overview,assets,transactions,categorization,imports,accounts,transfers,reconciliation}_gateway.py` expose page/domain-sized call sets. +- `adapters/streamlit/service_gateway.py` remains a compatibility aggregator while `dashboard/app.py` shifts to page/domain gateway aliases. +- Baseline docs record the post-Phase-7 structure and runtime flow. diff --git a/docs/batches/bat-014_20260703_adapter-surface-narrowing-baseline-refresh/implementation.md b/docs/batches/bat-014_20260703_adapter-surface-narrowing-baseline-refresh/implementation.md new file mode 100644 index 0000000..2150c1b --- /dev/null +++ b/docs/batches/bat-014_20260703_adapter-surface-narrowing-baseline-refresh/implementation.md @@ -0,0 +1,43 @@ +# BAT-014 Implementation + +## Plan summary + +BAT-014 implements dcy-011 Phase 7 by narrowing the remaining adapter surfaces without changing public MCP tool names or dashboard behavior. The MCP server now depends on `adapters/mcp/tool_contracts.py` instead of a broad direct `service` import, and the Streamlit dashboard now calls page/domain gateways under `adapters/streamlit/*_gateway.py` rather than a flat broad `service_gateway` namespace. `service_gateway.py` remains as a compatibility aggregator. + +## Delivered changes + +1. Added `jarca_ledger_mcp/adapters/mcp/tool_contracts.py` as the MCP adapter contract surface and repointed `adapters/mcp/server.py` wrappers to `contracts.*` callables. +2. Added page/domain Streamlit gateways: + - `overview_gateway.py` + - `assets_gateway.py` + - `transactions_gateway.py` + - `categorization_gateway.py` + - `transfers_gateway.py` + - `imports_gateway.py` + - `accounts_gateway.py` + - `reconciliation_gateway.py` +3. Reworked `adapters/streamlit/service_gateway.py` into a compatibility aggregator over the new gateway modules. +4. Repointed `dashboard/app.py` static call sites away from `service.*` to the owning page/domain gateway modules. +5. Preserved hot-reload fallback by changing `get_service_callable()` to reload the passed gateway module rather than a broad flat service namespace. +6. Refreshed `docs/project-structure.md` and `docs/runtime-flows.md` so the baseline reflects post-Phase-7 ownership and adapter seams. + +## Compatibility notes + +- MCP tool names, argument shapes, and response contracts were intentionally preserved. +- `adapters/streamlit/service_gateway.py` still exists as a compatibility surface for any legacy dashboard-side imports. +- No SQL/UI business-rule ownership was moved into the dashboard; the app still goes through adapter gateways. + +## Files changed + +- `jarca_ledger_mcp/adapters/mcp/tool_contracts.py` +- `jarca_ledger_mcp/adapters/mcp/server.py` +- `jarca_ledger_mcp/adapters/streamlit/__init__.py` +- `jarca_ledger_mcp/adapters/streamlit/service_gateway.py` +- `jarca_ledger_mcp/adapters/streamlit/*_gateway.py` +- `dashboard/app.py` +- `docs/project-structure.md` +- `docs/runtime-flows.md` + +## Outcome + +The repository now has explicit MCP contract and Streamlit page/domain gateway seams, satisfying the final dcy-011 narrowing target while preserving all public compatibility surfaces. diff --git a/docs/batches/bat-014_20260703_adapter-surface-narrowing-baseline-refresh/index.md b/docs/batches/bat-014_20260703_adapter-surface-narrowing-baseline-refresh/index.md new file mode 100644 index 0000000..87a7739 --- /dev/null +++ b/docs/batches/bat-014_20260703_adapter-surface-narrowing-baseline-refresh/index.md @@ -0,0 +1,12 @@ +# BAT-014 Adapter narrowing and baseline refresh + +- Profile: standard +- Status: release-candidate +- Included REQ: REQ-018 +- Discovery: dcy-011 +- Owner: ๋ ›์ €์žฅ์ธ +- Date (KST): 2026-07-03 + +## ๋ชฉํ‘œ + +REQ-018์— ๋”ฐ๋ผ MCP/Streamlit adapter surfaces๋ฅผ ๋” ์ขํžˆ๊ณ  baseline ๋ฌธ์„œ๋ฅผ ๊ฐฑ์‹ ํ•ด dcy-011์„ ์™„๋ฃŒํ•œ๋‹ค. diff --git a/docs/batches/bat-014_20260703_adapter-surface-narrowing-baseline-refresh/planning.md b/docs/batches/bat-014_20260703_adapter-surface-narrowing-baseline-refresh/planning.md new file mode 100644 index 0000000..9df9dd9 --- /dev/null +++ b/docs/batches/bat-014_20260703_adapter-surface-narrowing-baseline-refresh/planning.md @@ -0,0 +1,12 @@ +# BAT-014 Planning + +## Scope +- Implement REQ-018 adapter narrowing. +- Preserve MCP/tool/dashboard contracts while shifting adapter imports to helper/page gateways. +- Refresh structure/runtime baselines and finalize dcy-011 metadata. + +## Work plan +1. Add MCP contract helper module and repoint `adapters/mcp/server.py` to it. +2. Add Streamlit page/domain gateway modules and repoint `dashboard/app.py` away from a flat broad gateway dependency. +3. Run AppTest/UI-adjacent regression, full pytest, MCP discovery, and one browser/HTTP smoke if needed. +4. Refresh `docs/project-structure.md`, `docs/runtime-flows.md`, and dcy-011 status. diff --git a/docs/batches/bat-014_20260703_adapter-surface-narrowing-baseline-refresh/verification.md b/docs/batches/bat-014_20260703_adapter-surface-narrowing-baseline-refresh/verification.md new file mode 100644 index 0000000..20ddc3f --- /dev/null +++ b/docs/batches/bat-014_20260703_adapter-surface-narrowing-baseline-refresh/verification.md @@ -0,0 +1,43 @@ +# BAT-014 Verification + +## Verification summary + +Status: PASS + +All required code-path checks for BAT-014 passed. A manual local browser-evidence launch was attempted but blocked by the runtimeโ€™s consent guard for long-running local server startup, so the close-out evidence uses AppTest + full pytest + MCP discovery + structural call-site verification. No production ledger DB or real financial files were used. + +## Acceptance criteria evidence + +### AC-1. MCP contract/helper seam and Streamlit page/domain gateways exist +- Evidence: + - `jarca_ledger_mcp/adapters/mcp/tool_contracts.py` added and `adapters/mcp/server.py` now calls `contracts.*`. + - `search_files('*_gateway.py')` under `adapters/streamlit` returned 9 gateway/aggregator files. + +### AC-2. `dashboard/app.py` no longer uses a flat broad gateway surface +- Evidence: + - `search_files(pattern='service\\.', path='dashboard', file_glob='app.py')` returned 0 matches after rewiring. + - Dynamic hot-reload lookup now targets passed gateway modules (`categorization_gateway`, `transfers_gateway`) instead of a flat `service` binding. + +### AC-3. Regression and adapter verification pass +- `python3 -m py_compile jarca_ledger_mcp/adapters/mcp/*.py jarca_ledger_mcp/adapters/streamlit/*.py dashboard/app.py` โ†’ PASS +- `pytest -q tests/test_dashboard_app.py` โ†’ PASS (`....`) +- `pytest -q` โ†’ PASS (`........................ss...............ss...`) +- `hermes mcp test jarca-ledger` โ†’ PASS, 56 tools discovered + +### AC-4. Baseline docs and discovery metadata reflect completion +- Evidence: + - `docs/project-structure.md` updated with new owning services, parser seam, MCP contract helper, and Streamlit page/domain gateways. + - `docs/runtime-flows.md` updated with import/parser seam, MCP contract routing, and dashboard gateway flow. + - dcy-011 metadata and close-out notes updated to reference `REQ-015`~`REQ-018` / `BAT-011`~`BAT-014` completion. + +## Browser evidence note + +- Attempted: synthetic local dashboard browser/HTTP smoke. +- Result: blocked by terminal consent guard for long-running local server startup in this session. +- Impact: none on code correctness regression; AppTest remained green and no UI functional regression was detected in test coverage. + +## Safety boundary + +- Synthetic/temp DBs only. +- No destructive migration. +- No production ledger DB mutation. diff --git a/docs/batches/index.md b/docs/batches/index.md index 4ae5844..8b25dfa 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -19,3 +19,6 @@ | BAT-009 | standard | release-candidate | REQ-011, REQ-012 | dcy-009 | [bat-009_20260701_manual-asset-crud-snapshot-close](bat-009_20260701_manual-asset-crud-snapshot-close/) | | BAT-010 | standard | release-candidate | REQ-013, REQ-014 | dcy-010 | [bat-010_20260703_backend-core-layering-phase-0-3](bat-010_20260703_backend-core-layering-phase-0-3/) | | BAT-011 | standard | release-candidate | REQ-015 | dcy-011 | [bat-011_20260703_transactional-write-service-split](bat-011_20260703_transactional-write-service-split/) | +| BAT-012 | standard | release-candidate | REQ-016 | dcy-011 | [bat-012_20260703_import-pipeline-split](bat-012_20260703_import-pipeline-split/) | +| BAT-013 | standard | release-candidate | REQ-017 | dcy-011 | [bat-013_20260703_advanced-workflow-service-split](bat-013_20260703_advanced-workflow-service-split/) | +| BAT-014 | standard | release-candidate | REQ-018 | dcy-011 | [bat-014_20260703_adapter-surface-narrowing-baseline-refresh](bat-014_20260703_adapter-surface-narrowing-baseline-refresh/) | diff --git a/docs/discovery/dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing.md b/docs/discovery/dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing.md index e0aaf50..0d0a5bd 100644 --- a/docs/discovery/dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing.md +++ b/docs/discovery/dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing.md @@ -5,10 +5,10 @@ - ๋ฌธ์„œ ID: dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing - ์ด์Šˆ๋ช…: Jarca Ledger ๋ฐฑ์—”๋“œ ๋ฆฌํŒฉํ„ฐ๋ง Phase 4+ ํ›„์† ์ฃผ๊ธฐ (write service ๋ถ„๋ฆฌ โ†’ import pipeline ๋ถ„๋ฆฌ โ†’ advanced finance workflow ๋ถ„๋ฆฌ โ†’ MCP/Streamlit adapter ํ‘œ๋ฉด ์ถ•์†Œ) - ์ž‘์„ฑ ์‹œ๊ฐ(KST): 2026-07-03 07:10 -- ๋งˆ์ง€๋ง‰ ๊ฐฑ์‹  ์‹œ๊ฐ(KST): 2026-07-03 07:29 +- ๋งˆ์ง€๋ง‰ ๊ฐฑ์‹  ์‹œ๊ฐ(KST): 2026-07-03 08:23 - ๋Œ€์ฒด๋จ: ์—†์Œ - ํ›„์† Discovery ์ฐธ์กฐ: ์—†์Œ -- ์ƒ์„ฑ๋œ REQ ์ฐธ์กฐ: `REQ-015` +- ์ƒ์„ฑ๋œ REQ ์ฐธ์กฐ: `REQ-015`, `REQ-016`, `REQ-017`, `REQ-018` # 1. ๊ณ„ํš ์ƒํƒœ ์š”์•ฝ ํ˜„์žฌ Discovery๊ฐ€ ์–ด๋–ค ๋ฐฐ๊ฒฝ์—์„œ ์ž‘์„ฑ๋˜๊ณ  ์žˆ๋Š”์ง€, ์—ฐ๊ณ„ ๋ฌธ์„œ๊ฐ€ ์žˆ๋Š”์ง€, ์ง€๊ธˆ ์‹œ์ ์˜ ํ•ด์„์ด ๋ฌด์—‡์ธ์ง€ ์งง๊ฒŒ ์š”์•ฝํ•œ๋‹ค. @@ -193,8 +193,8 @@ REQ ์ดˆ์•ˆ ์ž‘์„ฑ ๋‹จ๊ณ„๋กœ ๋„˜๊ธฐ๊ธฐ ์ „์— ๋ฌด์—‡์ด ํ™•์ •๋˜์–ด์•ผ ํ•˜๋Š”์ง€ - ๊ธฐ์ค€: Phase 4~7 ์šฐ์„  In Scope, Phase 8์€ Deferred ํ›„๋ณด๋กœ๋งŒ ์œ ์ง€, Discovery๋Š” ํ•˜๋‚˜๋กœ ๋‘๋˜ REQ/Batch๋Š” Phase๋ณ„ยท๋„๋ฉ”์ธ๋ณ„๋กœ ๋ถ„ํ• ํ•œ๋‹ค๋Š” ๋ฐฉ์นจ์ด ๋ฌธ์„œ์— ๋ฐ˜์˜๋˜์–ด ์žˆ๋‹ค. - [x] ์„ฑ๊ณต ์ง€ํ‘œ์˜ ์ธก์ • ๋ฐฉ์‹/๋ฐ์ดํ„ฐ ์ถœ์ฒ˜ ํ™•์ • - ๊ธฐ์ค€: S-2์˜ DoD๋ฅผ broad application module ์ฑ…์ž„ ์ถ•์†Œ์™€ compatibility facade/shim ์œ ์ง€๋กœ ์ฑ„ํƒํ•˜๊ณ  baseline ํ…Œ์ŠคํŠธ ๋ชฉ๋ก์€ REQ/planning์—์„œ ๊ณ„์ธก ๋Œ€์ƒ์œผ๋กœ ๋ช…์‹œํ•œ๋‹ค. -- [ ] High ์˜ํ–ฅ ๋ฆฌ์Šคํฌ ์šฐ์„ ์ˆœ์œ„ ๋ฐ ํ•ด์†Œ ์ฑ…์ž„์ž ์ง€์ • - - ๊ธฐ์ค€: R-1(๊ณ„์•ฝ drift), R-2(advanced ๊ฒฐํ•ฉ)์˜ characterization/contract ํ…Œ์ŠคํŠธ ์„ ํ–‰ ์ฑ…์ž„์ž๋Š” REQ/Batch planning์—์„œ ์ง€์ •ํ•œ๋‹ค. Discovery confirm์˜ ์ฐจ๋‹จ ์‚ฌ์œ ๋Š” ์•„๋‹ˆ๋‹ค. +- [x] High ์˜ํ–ฅ ๋ฆฌ์Šคํฌ ์šฐ์„ ์ˆœ์œ„ ๋ฐ ํ•ด์†Œ ์ฑ…์ž„์ž ์ง€์ • + - ๊ธฐ์ค€: R-1~R-3์€ REQ-015~REQ-018/BAT-011~BAT-014์—์„œ phase owner(๋ ›์ €์žฅ์ธ)์™€ verification evidence(pytest/AppTest/MCP discovery/contract regression)๋กœ ํ•ด์†Œ ์ฑ…์ž„์ด ์‹ค์ œ ๋ฐฐ์ •ยท์ˆ˜ํ–‰๋˜์—ˆ๋‹ค. - [x] ๋ชจ๋“  ์˜คํ”ˆ ์งˆ๋ฌธ(OQ) `Open` ์ƒํƒœ ์—†์Œ ํ™•์ธ - ๊ธฐ์ค€: OQ ๋ชฉ๋ก์— `Open` ์ƒํƒœ ํ•ญ๋ชฉ์ด 0๊ฐœ์ด๋ฉฐ, ๋‚จ์€ DATA ํ•ญ๋ชฉ์€ REQ/planning์˜ ์„ธ๋ถ€ ์‹คํ–‰์ž๋ฃŒ๋กœ๋งŒ ์ด๊ด€๋œ๋‹ค. - [x] Freeze ํ™•์ • ๋‹ด๋‹น์ž(CONFIRMED_BY) ์ง€์ • @@ -203,13 +203,21 @@ REQ ์ดˆ์•ˆ ์ž‘์„ฑ ๋‹จ๊ณ„๋กœ ๋„˜๊ธฐ๊ธฐ ์ „์— ๋ฌด์—‡์ด ํ™•์ •๋˜์–ด์•ผ ํ•˜๋Š”์ง€ # 9. ํŒŒ์ผ ์ฒ˜๋ฆฌ ๊ฒฐ๊ณผ ์ด Discovery ๋ฌธ์„œ ์ƒ์„ฑ/๊ฐฑ์‹  ๊ฒฐ๊ณผ์™€ ์ฐธ์กฐ ๋ฌธ์„œ๋ฅผ ๊ธฐ๋กํ•œ๋‹ค. ์ดํ›„ ์ถ”์ ๊ณผ ๋ฆฌ๋ทฐ ์‹œ ๊ทผ๊ฑฐ๋กœ ์‚ฌ์šฉํ•œ๋‹ค. -- ์ฒ˜๋ฆฌ ๊ฒฐ๊ณผ: ์ƒ์„ฑ +- ์ฒ˜๋ฆฌ ๊ฒฐ๊ณผ: ์ƒ์„ฑ ํ›„ close-out ๊ฐฑ์‹  - ์ƒ์„ฑ ๊ฒฝ๋กœ: docs/discovery/dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing.md - ์ฐธ์กฐ ๋ฌธ์„œ: - docs/discovery/dcy-010_20260702_backend-core-layering-mcp-adapter-realignment.md (์„ ํ–‰ Discovery, Phase 0~3, ๋ณธ ๋ฌธ์„œ๊ฐ€ ํ›„์† ์ฃผ๊ธฐ) - .hermes/plans/2026-07-02_jarca-ledger-backend-refactor-blueprint.md (๊ทผ๊ฑฐ blueprint, Phase 4~8) - stage-pilot/templates/project-structure.md (baseline ๊ฐฑ์‹  ๋…ผ๋ฆฌ ํ…œํ”Œ๋ฆฟ ์ฐธ์กฐ) - stage-pilot/templates/runtime-flows.md (baseline ๊ฐฑ์‹  ๋…ผ๋ฆฌ ํ…œํ”Œ๋ฆฟ ์ฐธ์กฐ) + - docs/srs/Non-Functional/req-015_transactional-write-service-split-compatibility.md + - docs/srs/Non-Functional/req-016_import-pipeline-application-infrastructure-split.md + - docs/srs/Non-Functional/req-017_advanced-finance-workflow-service-split.md + - docs/srs/Interface/req-018_adapter-surface-narrowing-baseline-refresh.md + - docs/batches/bat-011_20260703_transactional-write-service-split/ + - docs/batches/bat-012_20260703_import-pipeline-split/ + - docs/batches/bat-013_20260703_advanced-workflow-service-split/ + - docs/batches/bat-014_20260703_adapter-surface-narrowing-baseline-refresh/ # 10. ์‚ฌ์šฉ์ž ๊ฒฐ์ • ํ•„์š” ํ•ญ๋ชฉ ์š”์•ฝ ์‚ฌ์šฉ์ž๋‚˜ ์Šน์ธ์ž๊ฐ€ ๊ฒฐ์ •ํ•ด์•ผ ํ•˜๋Š” ํ•ญ๋ชฉ๋งŒ ๋ชจ์•„ ์ •๋ฆฌํ•œ๋‹ค. ๊ฒฐ์ •, ํ™•์ธ, ๋ฐ์ดํ„ฐ ์š”์ฒญ์„ ๊ตฌ๋ถ„ํ•ด์„œ ์ ๋Š”๋‹ค. diff --git a/docs/project-structure.md b/docs/project-structure.md index 340c253..6b37215 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -5,7 +5,7 @@ - Status: draft - Owner: Jarca / project maintainer - Last Updated (KST): 2026-07-03 -- Source Discovery / Batch: bootstrap-baseline, BAT-006, BAT-007, BAT-010 +- Source Discovery / Batch: bootstrap-baseline, BAT-006, BAT-007, BAT-010, BAT-012, BAT-013, BAT-014 ## Project Summary - Jarca ๊ฐ€๊ณ„๋ถ€ ์ „์šฉ MCP ์„œ๋ฒ„์™€ Streamlit ๋Œ€์‹œ๋ณด๋“œ๋กœ ๊ณ„์ขŒ/์นด๋“œ ๊ฑฐ๋ž˜, ์˜ˆ์‚ฐ, ๋ถ„๋ฅ˜ ๊ทœ์น™, import, ์ž์‚ฐ/์Šค๋ƒ…์ƒท, ํ˜„๊ธˆํ๋ฆ„ ๋ถ„์„์„ SQLite์— ์ €์žฅํ•˜๊ณ  ์šด์˜ํ•œ๋‹ค. @@ -61,10 +61,21 @@ jarca-ledger-mcp/ โ”‚ โ”‚ โ”œโ”€โ”€ shared.py โ”‚ โ”‚ โ”œโ”€โ”€ system_services.py โ”‚ โ”‚ โ”œโ”€โ”€ asset_account_services.py +โ”‚ โ”‚ โ”œโ”€โ”€ assets_service.py โ”‚ โ”‚ โ”œโ”€โ”€ transaction_report_services.py โ”‚ โ”‚ โ”œโ”€โ”€ import_transfer_services.py -โ”‚ โ”‚ โ””โ”€โ”€ categorization_services.py +โ”‚ โ”‚ โ”œโ”€โ”€ imports_service.py +โ”‚ โ”‚ โ”œโ”€โ”€ cross_transfer_service.py +โ”‚ โ”‚ โ”œโ”€โ”€ reconciliation_service.py +โ”‚ โ”‚ โ”œโ”€โ”€ categorization_services.py +โ”‚ โ”‚ โ””โ”€โ”€ uncategorized_workbench_service.py โ”‚ โ”œโ”€โ”€ infrastructure/ +โ”‚ โ”‚ โ”œโ”€โ”€ parsers/ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ kb_bank_pdf.py +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ toss_bank_pdf.py +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ toss_securities_pdf.py +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ seoulpay_xlsx.py โ”‚ โ”‚ โ””โ”€โ”€ sqlite/ โ”‚ โ”‚ โ”œโ”€โ”€ __init__.py โ”‚ โ”‚ โ”œโ”€โ”€ connection.py @@ -72,8 +83,18 @@ jarca-ledger-mcp/ โ”‚ โ”‚ โ””โ”€โ”€ migrations.py โ”‚ โ”œโ”€โ”€ adapters/ โ”‚ โ”‚ โ”œโ”€โ”€ mcp/ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ tool_contracts.py โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ server.py โ”‚ โ”‚ โ””โ”€โ”€ streamlit/ +โ”‚ โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ”‚ โ”œโ”€โ”€ overview_gateway.py +โ”‚ โ”‚ โ”œโ”€โ”€ assets_gateway.py +โ”‚ โ”‚ โ”œโ”€โ”€ transactions_gateway.py +โ”‚ โ”‚ โ”œโ”€โ”€ categorization_gateway.py +โ”‚ โ”‚ โ”œโ”€โ”€ transfers_gateway.py +โ”‚ โ”‚ โ”œโ”€โ”€ imports_gateway.py +โ”‚ โ”‚ โ”œโ”€โ”€ accounts_gateway.py +โ”‚ โ”‚ โ”œโ”€โ”€ reconciliation_gateway.py โ”‚ โ”‚ โ””โ”€โ”€ service_gateway.py โ”‚ โ”œโ”€โ”€ kb_bank_pdf.py โ”‚ โ”œโ”€โ”€ toss_bank_pdf.py @@ -121,31 +142,47 @@ jarca-ledger-mcp/ - `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. + - Shared helpers, validation, KST clock, dedupe policy, parser seam 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. + - Legacy-compatible asset/account facade that preserves older import paths while delegating asset ownership to `assets_service.py` and account mutation to write services. +- `jarca_ledger_mcp/application/assets_service.py` + - Manual asset CRUD, snapshot close/report, liquidity, and net-worth workflow ownership. - `jarca_ledger_mcp/application/transaction_report_services.py` - - Transaction CRUD, budgets, monthly/category/fixed-cost/investable cashflow reports. + - Transaction CRUD, budgets, monthly/category/fixed-cost/investable cashflow reports, with reconciliation delegated to `reconciliation_service.py`. - `jarca_ledger_mcp/application/import_transfer_services.py` - - Import preview/import, self-transfer/cancel matching, cross-account transfer workflow. + - Legacy-compatible import/transfer facade that preserves prior import paths while delegating to `imports_service.py` and `cross_transfer_service.py`. +- `jarca_ledger_mcp/application/imports_service.py` + - Import preview/import/rollback orchestration and import batch workflow ownership. +- `jarca_ledger_mcp/application/cross_transfer_service.py` + - Cross-account/self-transfer/cancel-original matching workflow ownership. +- `jarca_ledger_mcp/application/reconciliation_service.py` + - Manual/partial-import balance reconciliation preview ownership. - `jarca_ledger_mcp/application/categorization_services.py` - - Category rules, categorization preview/apply, uncategorized workbench, ledger categories. + - Category rules, categorization preview/apply, ledger categories, and compatibility delegation to `uncategorized_workbench_service.py`. +- `jarca_ledger_mcp/application/uncategorized_workbench_service.py` + - Uncategorized inbox/workbench queries and recommendation-apply workflow ownership. +- `jarca_ledger_mcp/infrastructure/parsers/*` + - Import parser seam that keeps file-source parsing under infrastructure while preserving legacy top-level parser modules. - `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/tool_contracts.py` + - Narrow MCP adapter contract surface that re-exports only the public tool callables consumed by the FastMCP server. - `jarca_ledger_mcp/adapters/mcp/server.py` - - FastMCP tool registration layer that exposes service functions to Hermes/MCP clients. + - FastMCP tool registration layer that depends on `tool_contracts.py` instead of broad direct service imports. - `jarca_ledger_mcp/adapters/streamlit/service_gateway.py` - - Narrow dashboard-facing service surface used by `dashboard/app.py`. + - Compatibility aggregator over page/domain gateway modules. +- `jarca_ledger_mcp/adapters/streamlit/*_gateway.py` + - Page/domain-sized dashboard service surfaces (`overview`, `assets`, `transactions`, `categorization`, `transfers`, `imports`, `accounts`, `reconciliation`). - 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, ์ž์‚ฐ/๋งˆ๊ฐ, ๊ฑฐ๋ž˜๋‚ด์—ญ, ํ™•์ธํ•„์š” ํ, ๋ฏธ๋ถ„๋ฅ˜, ๋ถ„๋ฅ˜ ๊ทœ์น™, ์นดํ…Œ๊ณ ๋ฆฌ, ๊ณ„์ขŒ, Import pages. + - Streamlit operator UI for the operations hub summary, ์ž์‚ฐ/๋งˆ๊ฐ, ๊ฑฐ๋ž˜๋‚ด์—ญ, ํ™•์ธํ•„์š” ํ, ๋ฏธ๋ถ„๋ฅ˜, ๋ถ„๋ฅ˜ ๊ทœ์น™, ์นดํ…Œ๊ณ ๋ฆฌ, ๊ณ„์ขŒ, Import pages, wired through page/domain gateways instead of a flat broad service namespace. - `tests/` - Regression tests for facade compatibility, service behavior, parser behavior, categorization, transfer matching, MCP contract compatibility, and Streamlit AppTest smoke. - `.github/workflows/ci.yml` @@ -178,7 +215,7 @@ jarca-ledger-mcp/ ## Current Gaps / Planned Changes - 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. +- Streamlit now uses page/domain gateways; deeper presenter/view-model extraction remains future work. - HTTP adapter and remote ops re-architecture remain out of scope. ## Update Triggers @@ -193,3 +230,4 @@ jarca-ledger-mcp/ - 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 ๊ตฌ์กฐ๋ฅผ ๋ฐ˜์˜ํ–ˆ๋‹ค. +- 2026-07-03 KST: REQ-016/REQ-017/REQ-018 ๋ฐ BAT-012/BAT-013/BAT-014 ๊ธฐ์ค€์œผ๋กœ import/advanced owning services, parser seam, MCP contract helper, page/domain Streamlit gateways๋ฅผ ๋ฐ˜์˜ํ–ˆ๋‹ค. diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index 826ab9c..59dcd03 100644 --- a/docs/runtime-flows.md +++ b/docs/runtime-flows.md @@ -5,7 +5,7 @@ - Status: draft - Owner: Jarca / project maintainer - Last Updated (KST): 2026-07-03 -- Source Discovery / Batch: bootstrap-baseline, BAT-006, BAT-007, BAT-008, BAT-010 +- Source Discovery / Batch: bootstrap-baseline, BAT-006, BAT-007, BAT-008, BAT-010, BAT-012, BAT-013, BAT-014 - Primary Runtime: mixed ## Project Summary @@ -29,13 +29,13 @@ - SQLite database at `~/.local/share/jarca/ledger.sqlite3` by default. - `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. +- `jarca_ledger_mcp/adapters/mcp/tool_contracts.py` + `adapters/mcp/server.py` for MCP contract and registration seams. +- `jarca_ledger_mcp/adapters/streamlit/*_gateway.py` + `service_gateway.py` compatibility aggregator for dashboard-facing service calls. +- `jarca_ledger_mcp/infrastructure/parsers/*` plus legacy parser modules for KB/Toss/SeoulPay sources. ## Flow: `MCP server tool call` 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. +2. MCP adapter forwards arguments to the matching callable exposed by `adapters/mcp/tool_contracts.py`. 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. @@ -45,8 +45,8 @@ 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 `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. +4. UI calls page/domain gateways under `jarca_ledger_mcp.adapters.streamlit` rather than giant implementation modules directly. +5. Gateways delegate to the service facade/application modules and return data to the page. 6. UI displays success/error status and refreshes visible tables. ## Flow: `Dashboard operations hub overview` @@ -66,8 +66,8 @@ ## Flow: `Import preview and import` 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, application code creates/fetches the target account, inserts non-duplicate transactions, writes provenance, and records an import batch. +2. Preview function routes through `imports_service.py`, which calls the parser seam under `infrastructure/parsers/*` and reports counts, sample rows, duplicate status, and classification preview. +3. On explicit import, `imports_service.py` 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. @@ -78,7 +78,7 @@ 4. Category `cashflow_kind` controls actual cashflow/report inclusion. ## Flow: `Cross-account and cancel/original reconciliation` -1. Preview helpers search candidate pairs by amount, date, direction, account/source compatibility, and text similarity. +1. Preview helpers in `cross_transfer_service.py` 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. @@ -120,7 +120,7 @@ ## Current Gaps / Planned Changes - 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. +- Post-Phase-7 adapter narrowing is complete for the current compatibility architecture; deeper repository/use-case extraction and richer presenter/view-model layers remain future work. - HTTP adapter and remote ops re-architecture remain deferred. ## Update Triggers @@ -137,3 +137,4 @@ - 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๋ฅผ ๋ฐ˜์˜ํ–ˆ๋‹ค. +- 2026-07-03 KST: REQ-016/REQ-017/REQ-018 ๋ฐ BAT-012/BAT-013/BAT-014 ๊ธฐ์ค€์œผ๋กœ imports/advanced owning services, parser seam, MCP tool_contracts, Streamlit page/domain gateway flow๋ฅผ ๋ฐ˜์˜ํ–ˆ๋‹ค. diff --git a/docs/srs/Interface/req-018_adapter-surface-narrowing-baseline-refresh.md b/docs/srs/Interface/req-018_adapter-surface-narrowing-baseline-refresh.md new file mode 100644 index 0000000..d6d9a97 --- /dev/null +++ b/docs/srs/Interface/req-018_adapter-surface-narrowing-baseline-refresh.md @@ -0,0 +1,63 @@ +# REQ-018 MCP/Streamlit adapter surface narrowing and baseline refresh for dcy-011 completion + +- Status: Implemented +- Type: Interface +- Priority: High +- Owner: ๋ ›์ €์žฅ์ธ +- Source Discovery: `docs/discovery/dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing.md` +- Batch: BAT-014 +- Created At (KST): 2026-07-03 08:03 KST +- Approved At (KST): 2026-07-03 08:03 KST +- Implemented At (KST): 2026-07-03 08:23 KST + +## 1. ๋ฐฐ๊ฒฝ + +`REQ-015`~`REQ-017`์œผ๋กœ write/import/advanced workflow owning services๋Š” ๋ถ„๋ฆฌ๋˜์—ˆ์ง€๋งŒ, adapter ๊ณ„์ธต์€ ์•„์ง broad surfaces๋ฅผ ์œ ์ง€ํ•œ๋‹ค. `adapters/mcp/server.py`๋Š” ๋ชจ๋“  tool wrapper๊ฐ€ ์ง์ ‘ `service.py`์— ์˜์กดํ•˜๊ณ , `dashboard/app.py`๋Š” flat `service_gateway` namespace์— ๋„“๊ฒŒ ๊ฒฐํ•ฉ๋˜์–ด ์žˆ๋‹ค. dcy-011์˜ ๋งˆ์ง€๋ง‰ ๋‹จ๊ณ„๋Š” ์ด adapter surfaces๋ฅผ ๋” ์ขํžˆ๊ณ  baseline ๋ฌธ์„œ๋ฅผ ์‹ค์ œ ๊ตฌ์กฐ์— ๋งž์ถฐ ๊ฐฑ์‹ ํ•˜๋Š” ๊ฒƒ์ด๋‹ค. + +## 2. Intent + +MCP adapter๋Š” contract/registration helper๋ฅผ ํ†ตํ•ด service facade ์˜์กด์„ ๋” ๊ตญ์†Œํ™”ํ•˜๊ณ , Streamlit dashboard๋Š” page/domain gateway๋ฅผ ๋ช…์‹œ์ ์œผ๋กœ ์‚ฌ์šฉํ•˜๋„๋ก ์žฌ๊ตฌ์„ฑํ•œ๋‹ค. ๋™์‹œ์— `docs/project-structure.md`์™€ `docs/runtime-flows.md`๋ฅผ Phase 4~7 ์™„๋ฃŒ ์ƒํƒœ์— ๋งž๊ฒŒ refresh ํ•ด dcy-011์„ ์™„๊ฒฐํ•œ๋‹ค. + +## 3. ๋ฒ”์œ„ + +### In Scope +- `adapters/mcp/tool_contracts.py` ๋˜๋Š” ๋™๋“ฑ helper๋ฅผ ๋„์ž…ํ•ด MCP tool wrappers์˜ broad service dependency๋ฅผ ์ค„์ž„ +- `adapters/streamlit/*_gateway.py` page/domain gateways๋ฅผ ๋„์ž…/ํ™•์žฅํ•ด `dashboard/app.py`์˜ flat gateway ๊ฒฐํ•ฉ์„ ์ค„์ž„ +- `adapters/streamlit/service_gateway.py`๋Š” compatibility aggregator๋กœ ์œ ์ง€ ๊ฐ€๋Šฅ +- `docs/project-structure.md`, `docs/runtime-flows.md`, dcy-011 metadata/status ๊ฐฑ์‹  + +### Out of Scope +- MCP tool ์ด๋ฆ„/์‘๋‹ต shape ๋ณ€๊ฒฝ +- Streamlit UX redesign +- HTTP adapter ๊ตฌํ˜„ + +## 4. Requirement + +### FR-1. MCP contract helper narrowing +- `adapters/mcp/server.py`๋Š” ์ง์ ‘ `service`์— ๊ด‘๋ฒ”์œ„ํ•˜๊ฒŒ ๊ฒฐํ•ฉํ•˜์ง€ ์•Š๊ณ  helper/contract layer๋ฅผ ํ†ตํ•ด tool contract๋ฅผ ํ˜ธ์ถœํ•ด์•ผ ํ•œ๋‹ค. + +### FR-2. Streamlit page/domain gateways +- `dashboard/app.py`๋Š” flat broad gateway ๋Œ€์‹  overview/assets/transfers/categories/import/accounts ๋“ฑ page/domain gateway๋ฅผ ์‚ฌ์šฉํ•ด์•ผ ํ•œ๋‹ค. +- hot-reload fallback์€ ์œ ์ง€ํ•˜๋˜, broad implementation module ์ง์ ‘ import๋กœ ๋˜๋Œ์•„๊ฐ€๋ฉด ์•ˆ ๋œ๋‹ค. + +### FR-3. Baseline refresh +- `docs/project-structure.md`์™€ `docs/runtime-flows.md`๋Š” Phase 4~7 ์™„๋ฃŒ ํ›„ ๊ตฌ์กฐ/ํ๋ฆ„์„ ๋ฐ˜์˜ํ•ด์•ผ ํ•œ๋‹ค. + +## 5. Acceptance Criteria +- AC-1: MCP adapter helper/contract seam๊ณผ Streamlit page/domain gateway modules๊ฐ€ ์กด์žฌํ•œ๋‹ค. +- AC-2: `dashboard/app.py`๊ฐ€ flat broad gateway ์˜์กด์„ ์ค„์ด๊ณ  page/domain gateway ํ˜ธ์ถœ๋กœ ๋™์ž‘ํ•œ๋‹ค. +- AC-3: `pytest tests/test_dashboard_app.py`, full pytest, MCP discovery, ํ•„์š” ์‹œ browser/HTTP smoke๊ฐ€ PASS ํ•œ๋‹ค. +- AC-4: baseline docs์™€ dcy-011 status/links๊ฐ€ ์™„๋ฃŒ ์ƒํƒœ๋ฅผ ๋ฐ˜์˜ํ•œ๋‹ค. + +## 6. Impacted Area +- `jarca_ledger_mcp/adapters/mcp/server.py` +- `jarca_ledger_mcp/adapters/mcp/tool_contracts.py` +- `jarca_ledger_mcp/adapters/streamlit/*.py` +- `dashboard/app.py` +- `docs/project-structure.md` +- `docs/runtime-flows.md` +- `docs/discovery/dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing.md` + +## 7. Change Log +- 2026-07-03 08:03 KST: dcy-011 Phase 7 adapter narrowing + baseline refresh ๋ฒ”์œ„๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ REQ-018์„ ์ƒ์„ฑํ•˜๊ณ  BAT-014 ์ž…๋ ฅ์œผ๋กœ ์ฆ‰์‹œ ์Šน์ธํ–ˆ๋‹ค. +- 2026-07-03 08:23 KST: MCP tool_contracts, Streamlit page/domain gateways, baseline ๋ฌธ์„œ refresh, AppTest/full pytest/MCP discovery evidence๋ฅผ ๊ทผ๊ฑฐ๋กœ REQ-018์„ Implemented๋กœ ์ „ํ™˜ํ–ˆ๋‹ค. diff --git a/docs/srs/Non-Functional/req-016_import-pipeline-application-infrastructure-split.md b/docs/srs/Non-Functional/req-016_import-pipeline-application-infrastructure-split.md new file mode 100644 index 0000000..45f74b2 --- /dev/null +++ b/docs/srs/Non-Functional/req-016_import-pipeline-application-infrastructure-split.md @@ -0,0 +1,80 @@ +# REQ-016 Import pipeline application/infrastructure split and evidence-preserving parser relocation + +- Status: Implemented +- Type: Non-Functional +- Priority: High +- Owner: ๋ ›์ €์žฅ์ธ +- Source Discovery: `docs/discovery/dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing.md` +- Batch: BAT-012 +- Created At (KST): 2026-07-03 07:49 KST +- Approved At (KST): 2026-07-03 07:49 KST +- Implemented At (KST): 2026-07-03 07:53 KST + +## 1. ๋ฐฐ๊ฒฝ + +`REQ-015`/BAT-011๋กœ transactional write path๋Š” ์ข์€ write-oriented application module๋กœ ๋ถ„๋ฆฌ๋์ง€๋งŒ, import preview/import/rollback๊ณผ account auto-linking/classification/orchestration์€ ์—ฌ์ „ํžˆ `application/import_transfer_services.py`์— ํฌ๊ฒŒ ๋‚จ์•„ ์žˆ๋‹ค. ๋˜ํ•œ parser entrypoint๋Š” top-level `kb_bank_pdf.py`, `toss_bank_pdf.py`, `toss_securities_pdf.py`, `seoulpay_xlsx.py`์— ํฉ์–ด์ ธ ์žˆ์–ด application layer๊ฐ€ parser ๊ตฌํ˜„ ์œ„์น˜๋ฅผ ์ง์ ‘ ์•„๋Š” ๊ตฌ์กฐ๋‹ค. + +## 2. Intent + +์ด REQ์˜ ๋ชฉ์ ์€ import preview/import/rollback์„ ์ „์šฉ `imports_service`๋กœ ๋ถ„๋ฆฌํ•˜๊ณ , parser access path๋ฅผ `infrastructure/parsers/*`๋กœ ์ •๋ฆฌํ•ด parser(ํŒŒ์‹ฑ/์ •๊ทœํ™”)์™€ application import orchestration(dedupe/account auto-link/import_batch persistence/classification/post-import bookkeeping)์„ ๊ตฌ๋ถ„ํ•˜๋Š” ๊ฒƒ์ด๋‹ค. ๊ธฐ์กด MCP tool ์ด๋ฆ„, response envelope, import evidence fields๋Š” ์œ ์ง€ํ•ด์•ผ ํ•œ๋‹ค. + +## 3. ๋ฒ”์œ„ + +### In Scope +- `preview_ledger_csv_import` +- `preview/import_kb_bank_pdf` +- `preview/import_seoulpay_xlsx` +- `preview/import_toss_bank_pdf` +- `preview/import_toss_securities_pdf` +- `list_ledger_import_batches` +- `rollback_ledger_import_batch` +- import์šฉ account auto-linking/classification/helper๋ฅผ `imports_service` ๊ฒฝ๊ณ„๋กœ ์ด๋™ +- parser import ๊ฒฝ๋กœ๋ฅผ `jarca_ledger_mcp/infrastructure/parsers/*`๋กœ ์ •๋ฆฌ +- ๊ธฐ์กด `application/import_transfer_services.py`๋Š” advanced workflow ์ค‘์‹ฌ์œผ๋กœ ๋‚จ๊ธฐ๊ณ  import-facing ํ•จ์ˆ˜๋Š” compatibility delegation์œผ๋กœ ์œ ์ง€ + +### Out of Scope +- cross-transfer/reconciliation split +- asset/net worth/workbench split +- MCP/Streamlit adapter surface narrowing +- parser ์•Œ๊ณ ๋ฆฌ์ฆ˜/๋ถ„๋ฅ˜ semantics ๋ณ€๊ฒฝ +- destructive migration + +## 4. Requirement + +### FR-1. Import orchestration service extraction +- import preview/import/rollback๊ณผ account auto-linking/classification helper๋Š” `application/imports_service.py`๋กœ ์ด๋™ํ•ด์•ผ ํ•œ๋‹ค. +- `service.py`, MCP adapter, ๊ธฐ์กด ํ˜ธ์ถœ surface๋Š” ํ•จ์ˆ˜๋ช…/์ธ์ž/์‘๋‹ต envelope์„ ์œ ์ง€ํ•ด์•ผ ํ•œ๋‹ค. + +### FR-2. Parser relocation by infrastructure seam +- application layer๋Š” parser ๊ตฌํ˜„์„ top-level ๋ชจ๋“ˆ์—์„œ ์ง์ ‘ ๋Œ์–ด์˜ค์ง€ ์•Š๊ณ  `infrastructure/parsers/*` ๊ฒฝ๋กœ๋ฅผ ํ†ตํ•ด ์ ‘๊ทผํ•ด์•ผ ํ•œ๋‹ค. +- parser๋Š” parsing/normalization/preview helper๋ฅผ ์ œ๊ณตํ•˜๊ณ , persistence/import_batch ์ƒ์„ฑ/transaction insert๋Š” application service์— ๋‚จ์•„์•ผ ํ•œ๋‹ค. + +### FR-3. Evidence-preserving contract safety +- `raw_summary`, `counterparty`, `raw_payload`, `external_id`, `import_batch_id`, duplicate counting, validation payload ๋“ฑ import evidence ํ•„๋“œ๋Š” ์˜๋„์ ์œผ๋กœ ๋ณ€๊ฒฝ๋˜์ง€ ์•Š์•„์•ผ ํ•œ๋‹ค. +- import regression tests, rollback behavior, MCP discovery๊ฐ€ PASS ํ•ด์•ผ ํ•œ๋‹ค. + +## 5. Acceptance Criteria +- AC-1: `application/imports_service.py`์™€ `infrastructure/parsers/*`๊ฐ€ ์ƒ์„ฑ๋˜๊ณ  import preview/import/rollback ๊ตฌํ˜„์ด ๊ทธ์ชฝ์œผ๋กœ ์ด๋™ํ•œ๋‹ค. +- AC-2: ๊ธฐ์กด public API/MCP tools/import response envelope๊ณผ evidence fields๊ฐ€ ์œ ์ง€๋œ๋‹ค. +- AC-3: import-related regression๊ณผ full pytest๊ฐ€ PASS ํ•œ๋‹ค. +- AC-4: BAT-012 implementation/verification ๋ฌธ์„œ๊ฐ€ ์‹ค์ œ changed files, commands, outcomes๋ฅผ ๊ธฐ๋กํ•œ๋‹ค. + +## 6. Impacted Area +- `jarca_ledger_mcp/application/import_transfer_services.py` +- `jarca_ledger_mcp/application/imports_service.py` +- `jarca_ledger_mcp/application/shared.py` +- `jarca_ledger_mcp/infrastructure/parsers/*` +- `jarca_ledger_mcp/service.py` +- `jarca_ledger_mcp/adapters/mcp/server.py` +- `tests/test_kb_bank_pdf.py` +- `tests/test_seoulpay_xlsx.py` +- `tests/test_toss_securities_pdf.py` +- `tests/test_service.py` + +## 7. Notes +- ์ด REQ๋Š” package boundary์™€ maintainability ์ค‘์‹ฌ์˜ refactor์ด๋ฏ€๋กœ `Non-Functional`๋กœ ๊ด€๋ฆฌํ•œ๋‹ค. +- advanced workflow split์€ ํ›„์† REQ/BAT๋กœ ์ด์–ด์ง„๋‹ค. + +## 8. Change Log +- 2026-07-03 07:49 KST: dcy-011 Phase 5 import pipeline split ๋ฒ”์œ„๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ REQ-016์„ ์ƒ์„ฑํ•˜๊ณ  BAT-012 ์ž…๋ ฅ์œผ๋กœ ์ฆ‰์‹œ ์Šน์ธํ–ˆ๋‹ค. +- 2026-07-03 07:53 KST: imports_service/parsers seam ๋ถ„๋ฆฌ, import-focused regression, full pytest, MCP discovery evidence๋ฅผ ๊ทผ๊ฑฐ๋กœ Implemented๋กœ ์ „ํ™˜ํ–ˆ๋‹ค. diff --git a/docs/srs/Non-Functional/req-017_advanced-finance-workflow-service-split.md b/docs/srs/Non-Functional/req-017_advanced-finance-workflow-service-split.md new file mode 100644 index 0000000..136c1ba --- /dev/null +++ b/docs/srs/Non-Functional/req-017_advanced-finance-workflow-service-split.md @@ -0,0 +1,70 @@ +# REQ-017 Advanced finance workflow service split and compatibility-preserving domain isolation + +- Status: Implemented +- Type: Non-Functional +- Priority: High +- Owner: ๋ ›์ €์žฅ์ธ +- Source Discovery: `docs/discovery/dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing.md` +- Batch: BAT-013 +- Created At (KST): 2026-07-03 07:55 KST +- Approved At (KST): 2026-07-03 07:55 KST +- Implemented At (KST): 2026-07-03 08:01 KST + +## 1. ๋ฐฐ๊ฒฝ + +`REQ-015`์™€ `REQ-016`์œผ๋กœ transactional write path์™€ import pipeline seam์€ ์ข์•„์กŒ์ง€๋งŒ, cross-transfer matching/review, balance reconciliation, manual asset/snapshot/net-worth workflows, uncategorized workbench๋Š” ์—ฌ์ „ํžˆ broad application modules(`import_transfer_services.py`, `transaction_report_services.py`, `asset_account_services.py`, `categorization_services.py`)์— ํฉ์–ด์ ธ ์žˆ๋‹ค. + +## 2. Intent + +์ด REQ์˜ ๋ชฉ์ ์€ advanced finance workflows๋ฅผ domain-oriented application modules๋กœ ์ถ”๊ฐ€ ๋ถ„๋ฆฌํ•ด import/write/read service์™€ ์ฑ…์ž„์ด ๊ฒน์น˜์ง€ ์•Š๊ฒŒ ํ•˜๋Š” ๊ฒƒ์ด๋‹ค. public API, MCP contracts, dashboard behavior๋Š” ์œ ์ง€ํ•ด์•ผ ํ•˜๋ฉฐ, domain-heavy helpers๋Š” owning workflow service๋กœ ์ด๋™ํ•ด์•ผ ํ•œ๋‹ค. + +## 3. ๋ฒ”์œ„ + +### In Scope +- cross-transfer matching/review queue๋ฅผ `application/cross_transfer_service.py`๋กœ ์ด๋™ +- balance reconciliation์„ `application/reconciliation_service.py`๋กœ ์ด๋™ +- manual asset CRUD / snapshot / liquidity / net-worth workflows๋ฅผ `application/assets_service.py`๋กœ ์ด๋™ +- uncategorized list/summary/workbench/recommendation apply ํ๋ฆ„์„ `application/uncategorized_workbench_service.py`๋กœ ์ด๋™ +- ๊ธฐ์กด broad modules๋Š” compatibility delegation์„ ์œ ์ง€ + +### Out of Scope +- MCP/Streamlit adapter surface narrowing +- baseline docs final update +- tool contract rename or semantic changes +- destructive migration + +## 4. Requirement + +### FR-1. Advanced workflow ownership split +- cross-transfer, reconciliation, assets, uncategorized workbench๋Š” ๊ฐ๊ฐ owning application module๋กœ ์ด๋™ํ•ด์•ผ ํ•œ๋‹ค. +- helper ํ•จ์ˆ˜๋„ ์ฒซ ์†Œ๋น„์ž์™€ ํ•จ๊ป˜ ์ด๋™ํ•ด broad module ๋‚ด๋ถ€ ์ˆจ์€ ๊ฒฐํ•ฉ์„ ์ค„์—ฌ์•ผ ํ•œ๋‹ค. + +### FR-2. Compatibility-preserving delegation +- `service.py`, MCP tool names, dashboard call signatures, response envelope์€ ์œ ์ง€ํ•ด์•ผ ํ•œ๋‹ค. +- ๊ธฐ์กด broad modules๋Š” ํ•„์š” ์‹œ thin delegation/re-export layer๋กœ ๋‚จ์„ ์ˆ˜ ์žˆ๋‹ค. + +### FR-3. Regression-safe domain isolation +- cross-transfer tests, asset snapshot/manual asset tests, reconciliation tests, uncategorized workflow tests, full pytest๊ฐ€ PASS ํ•ด์•ผ ํ•œ๋‹ค. + +## 5. Acceptance Criteria +- AC-1: `cross_transfer_service.py`, `reconciliation_service.py`, `assets_service.py`, `uncategorized_workbench_service.py`๊ฐ€ ์ƒ์„ฑ๋˜๊ณ  owning workflow ๊ตฌํ˜„์ด ์ด๋™ํ•œ๋‹ค. +- AC-2: public API/MCP/dashboard contract๊ฐ€ ์œ ์ง€๋œ๋‹ค. +- AC-3: workflow-focused regression๊ณผ full pytest๊ฐ€ PASS ํ•œ๋‹ค. +- AC-4: BAT-013 implementation/verification ๋ฌธ์„œ๊ฐ€ ์‹ค์ œ ๋ณ€๊ฒฝ ํŒŒ์ผ๊ณผ ๊ฒ€์ฆ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ˜์˜ํ•œ๋‹ค. + +## 6. Impacted Area +- `jarca_ledger_mcp/application/import_transfer_services.py` +- `jarca_ledger_mcp/application/transaction_report_services.py` +- `jarca_ledger_mcp/application/asset_account_services.py` +- `jarca_ledger_mcp/application/categorization_services.py` +- `jarca_ledger_mcp/application/{cross_transfer,reconciliation,assets,uncategorized_workbench}_service.py` +- `jarca_ledger_mcp/service.py` +- `tests/test_cross_account_transfer_matching.py` +- `tests/test_asset_snapshots.py` +- `tests/test_contract_regression.py` +- `tests/test_categorization.py` +- `tests/test_service.py` + +## 7. Change Log +- 2026-07-03 07:55 KST: dcy-011 Phase 6 advanced workflow split ๋ฒ”์œ„๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ REQ-017์„ ์ƒ์„ฑํ•˜๊ณ  BAT-013 ์ž…๋ ฅ์œผ๋กœ ์ฆ‰์‹œ ์Šน์ธํ–ˆ๋‹ค. +- 2026-07-03 08:01 KST: advanced workflow service extraction, full pytest, MCP discovery evidence๋ฅผ ๊ทผ๊ฑฐ๋กœ Implemented๋กœ ์ „ํ™˜ํ–ˆ๋‹ค. diff --git a/docs/srs/index.md b/docs/srs/index.md index 04457c6..7f65b23 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -33,7 +33,7 @@ ## Next Requirement ID -- Current: `REQ-016` +- Current: `REQ-019` - Rule: `Requirement Register`์— ์žˆ๋Š” ๊ฐ€์žฅ ํฐ REQ ๋ฒˆํ˜ธ๋ณด๋‹ค ์ •ํ™•ํžˆ 1 ์ปค์•ผ ํ•œ๋‹ค. ## Requirement Register @@ -55,6 +55,9 @@ | REQ-013 | Backend core layering / SQLite infrastructure split and service facade compatibility | Non-Functional | Implemented | High | ๋ ›์ €์žฅ์ธ | [req-013_backend-core-layering-sqlite-infrastructure-service-facade-compatibility.md](Non-Functional/req-013_backend-core-layering-sqlite-infrastructure-service-facade-compatibility.md) | | REQ-014 | MCP/Streamlit adapter seam realignment and contract-preserving gateway | Interface | Implemented | High | ๋ ›์ €์žฅ์ธ | [req-014_mcp-streamlit-adapter-seam-realignment-contract-preserving-gateway.md](Interface/req-014_mcp-streamlit-adapter-seam-realignment-contract-preserving-gateway.md) | | REQ-015 | Transactional write service split and compatibility-preserving domain extraction | Non-Functional | Implemented | High | ๋ ›์ €์žฅ์ธ | [req-015_transactional-write-service-split-compatibility.md](Non-Functional/req-015_transactional-write-service-split-compatibility.md) | +| REQ-016 | Import pipeline application/infrastructure split and evidence-preserving parser relocation | Non-Functional | Implemented | High | ๋ ›์ €์žฅ์ธ | [req-016_import-pipeline-application-infrastructure-split.md](Non-Functional/req-016_import-pipeline-application-infrastructure-split.md) | +| REQ-017 | Advanced finance workflow service split and compatibility-preserving domain isolation | Non-Functional | Implemented | High | ๋ ›์ €์žฅ์ธ | [req-017_advanced-finance-workflow-service-split.md](Non-Functional/req-017_advanced-finance-workflow-service-split.md) | +| REQ-018 | MCP/Streamlit adapter surface narrowing and baseline refresh for dcy-011 completion | Interface | Implemented | High | ๋ ›์ €์žฅ์ธ | [req-018_adapter-surface-narrowing-baseline-refresh.md](Interface/req-018_adapter-surface-narrowing-baseline-refresh.md) | ## Recent Change Log Summary @@ -83,3 +86,9 @@ - 2026-07-03 00:18 KST: BAT-010 ๊ตฌํ˜„ยท๊ฒ€์ฆ evidence, full pytest PASS, MCP tool discovery, browser smoke๋ฅผ ๊ทผ๊ฑฐ๋กœ `REQ-013`, `REQ-014`๋ฅผ Implemented๋กœ ์ „ํ™˜ํ–ˆ๋‹ค. - 2026-07-03 07:30 KST: dcy-011 confirmed ๋ฒ”์œ„๋ฅผ `REQ-015` Proposed๋กœ ์ƒ์„ฑํ•˜๊ณ  ์ฒซ delivery ์ž…๋ ฅ์œผ๋กœ Approved ์ƒํƒœ๋กœ ์ „ํ™˜ํ–ˆ๋‹ค. - 2026-07-03 07:39 KST: BAT-011 write-service split ๊ตฌํ˜„๊ณผ full pytest/MCP discovery evidence๋ฅผ ๊ทผ๊ฑฐ๋กœ `REQ-015`๋ฅผ Implemented๋กœ ์ „ํ™˜ํ–ˆ๋‹ค. +- 2026-07-03 07:49 KST: dcy-011 Phase 5 import pipeline split ๋ฒ”์œ„๋ฅผ `REQ-016`์œผ๋กœ ์ƒ์„ฑํ•˜๊ณ  BAT-012 ์ž…๋ ฅ์œผ๋กœ Approved ์ƒํƒœ๋ฅผ ํ™•์ •ํ–ˆ๋‹ค. +- 2026-07-03 07:53 KST: BAT-012 import pipeline split ๊ตฌํ˜„๊ณผ full pytest/MCP discovery evidence๋ฅผ ๊ทผ๊ฑฐ๋กœ `REQ-016`์„ Implemented๋กœ ์ „ํ™˜ํ–ˆ๋‹ค. +- 2026-07-03 07:55 KST: dcy-011 Phase 6 advanced workflow split ๋ฒ”์œ„๋ฅผ `REQ-017`๋กœ ์ƒ์„ฑํ•˜๊ณ  BAT-013 ์ž…๋ ฅ์œผ๋กœ Approved ์ƒํƒœ๋ฅผ ํ™•์ •ํ–ˆ๋‹ค. +- 2026-07-03 08:01 KST: BAT-013 advanced workflow split ๊ตฌํ˜„๊ณผ full pytest/MCP discovery evidence๋ฅผ ๊ทผ๊ฑฐ๋กœ `REQ-017`์„ Implemented๋กœ ์ „ํ™˜ํ–ˆ๋‹ค. +- 2026-07-03 08:03 KST: dcy-011 Phase 7 adapter narrowing + baseline refresh ๋ฒ”์œ„๋ฅผ `REQ-018`๋กœ ์ƒ์„ฑํ•˜๊ณ  BAT-014 ์ž…๋ ฅ์œผ๋กœ Approved ์ƒํƒœ๋ฅผ ํ™•์ •ํ–ˆ๋‹ค. +- 2026-07-03 08:23 KST: BAT-014 adapter narrowing + baseline refresh ๊ตฌํ˜„๊ณผ AppTest/full pytest/MCP discovery evidence๋ฅผ ๊ทผ๊ฑฐ๋กœ `REQ-018`์„ Implemented๋กœ ์ „ํ™˜ํ–ˆ๋‹ค. diff --git a/jarca_ledger_mcp/adapters/mcp/server.py b/jarca_ledger_mcp/adapters/mcp/server.py index 9be3a3e..bd6b9b0 100644 --- a/jarca_ledger_mcp/adapters/mcp/server.py +++ b/jarca_ledger_mcp/adapters/mcp/server.py @@ -4,7 +4,7 @@ from pathlib import Path from dotenv import load_dotenv from mcp.server.fastmcp import FastMCP -from ... import service +from . import tool_contracts as contracts load_dotenv(Path(__file__).resolve().parents[3] / ".env") mcp = FastMCP("jarca-ledger-mcp") @@ -12,282 +12,282 @@ 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() + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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() + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.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) + return contracts.delete_ledger_category(category, subcategory, hard_delete, force) def main() -> None: mcp.run() diff --git a/jarca_ledger_mcp/adapters/mcp/tool_contracts.py b/jarca_ledger_mcp/adapters/mcp/tool_contracts.py new file mode 100644 index 0000000..cdeadc6 --- /dev/null +++ b/jarca_ledger_mcp/adapters/mcp/tool_contracts.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from ...service import ( + apply_categorization_rules, + close_asset_snapshot, + create_category_rule, + create_ledger_account, + create_ledger_backup, + create_ledger_budget, + create_ledger_category, + create_ledger_transaction, + create_manual_asset, + deactivate_manual_asset, + delete_category_rule, + delete_ledger_category, + delete_ledger_transaction, + get_asset_liquidity_report, + get_asset_snapshot_report, + get_balance_reconciliation_report, + get_budget_vs_actual_report, + get_category_spending_report, + get_fixed_cost_report, + get_investable_cashflow_report, + get_ledger_backup_status, + get_ledger_db_status, + get_monthly_cashflow_report, + get_net_worth_timeseries, + health_check, + import_kb_bank_pdf, + import_seoulpay_xlsx, + import_toss_bank_pdf, + import_toss_securities_pdf, + list_category_rules, + list_cross_transfer_review_queue, + list_ledger_accounts, + list_ledger_backups, + list_ledger_budgets, + list_ledger_categories, + list_ledger_import_batches, + list_ledger_transactions, + list_manual_assets, + list_uncategorized_transactions, + preview_asset_snapshot_close, + preview_categorization, + preview_cross_account_transfers, + preview_kb_bank_pdf_import, + preview_ledger_csv_import, + preview_seoulpay_xlsx_import, + 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_category_rule, + update_ledger_account, + update_ledger_transaction, + update_manual_asset, + get_uncategorized_merchant_summary, +) diff --git a/jarca_ledger_mcp/adapters/streamlit/__init__.py b/jarca_ledger_mcp/adapters/streamlit/__init__.py index 444c7ed..23d5d4e 100644 --- a/jarca_ledger_mcp/adapters/streamlit/__init__.py +++ b/jarca_ledger_mcp/adapters/streamlit/__init__.py @@ -1 +1,11 @@ -from . import service_gateway +from . import ( + accounts_gateway, + assets_gateway, + categorization_gateway, + imports_gateway, + overview_gateway, + reconciliation_gateway, + service_gateway, + transactions_gateway, + transfers_gateway, +) diff --git a/jarca_ledger_mcp/adapters/streamlit/accounts_gateway.py b/jarca_ledger_mcp/adapters/streamlit/accounts_gateway.py new file mode 100644 index 0000000..8c7c8c2 --- /dev/null +++ b/jarca_ledger_mcp/adapters/streamlit/accounts_gateway.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from ...service import ( + list_ledger_accounts, + update_ledger_account, +) diff --git a/jarca_ledger_mcp/adapters/streamlit/assets_gateway.py b/jarca_ledger_mcp/adapters/streamlit/assets_gateway.py new file mode 100644 index 0000000..808a6c2 --- /dev/null +++ b/jarca_ledger_mcp/adapters/streamlit/assets_gateway.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from ...service import ( + close_asset_snapshot, + create_manual_asset, + deactivate_manual_asset, + list_manual_assets, + preview_asset_snapshot_close, + update_manual_asset, +) diff --git a/jarca_ledger_mcp/adapters/streamlit/categorization_gateway.py b/jarca_ledger_mcp/adapters/streamlit/categorization_gateway.py new file mode 100644 index 0000000..39f7fcb --- /dev/null +++ b/jarca_ledger_mcp/adapters/streamlit/categorization_gateway.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from ...service import ( + apply_categorization_rules, + apply_category_rule, + apply_uncategorized_group_recommendation, + apply_uncategorized_recommendation, + create_category_rule, + create_ledger_category, + delete_ledger_category, + get_uncategorized_merchant_summary, + get_uncategorized_workbench, + list_category_rules, + list_ledger_categories, + list_uncategorized_transactions, + preview_categorization, + preview_category_rule_matches, + seed_default_category_rules, + seed_default_ledger_categories, +) diff --git a/jarca_ledger_mcp/adapters/streamlit/imports_gateway.py b/jarca_ledger_mcp/adapters/streamlit/imports_gateway.py new file mode 100644 index 0000000..bcb1e82 --- /dev/null +++ b/jarca_ledger_mcp/adapters/streamlit/imports_gateway.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from ...service import ( + import_toss_bank_pdf, + import_toss_securities_pdf, + list_ledger_import_batches, + preview_toss_bank_pdf_import, + preview_toss_securities_pdf_import, + rollback_ledger_import_batch, +) diff --git a/jarca_ledger_mcp/adapters/streamlit/overview_gateway.py b/jarca_ledger_mcp/adapters/streamlit/overview_gateway.py new file mode 100644 index 0000000..20a900e --- /dev/null +++ b/jarca_ledger_mcp/adapters/streamlit/overview_gateway.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from ...service import ( + get_asset_liquidity_report, + get_balance_reconciliation_report, + get_budget_vs_actual_report, + get_monthly_cashflow_report, + health_check, + list_cross_transfer_review_queue, + list_ledger_backups, + list_ledger_import_batches, + list_uncategorized_transactions, +) diff --git a/jarca_ledger_mcp/adapters/streamlit/reconciliation_gateway.py b/jarca_ledger_mcp/adapters/streamlit/reconciliation_gateway.py new file mode 100644 index 0000000..862dc83 --- /dev/null +++ b/jarca_ledger_mcp/adapters/streamlit/reconciliation_gateway.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +from ...service import get_balance_reconciliation_report diff --git a/jarca_ledger_mcp/adapters/streamlit/service_gateway.py b/jarca_ledger_mcp/adapters/streamlit/service_gateway.py index f57fa23..d96afa6 100644 --- a/jarca_ledger_mcp/adapters/streamlit/service_gateway.py +++ b/jarca_ledger_mcp/adapters/streamlit/service_gateway.py @@ -1,44 +1,10 @@ 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, -) +from .accounts_gateway import * +from .assets_gateway import * +from .categorization_gateway import * +from .imports_gateway import * +from .overview_gateway import * +from .reconciliation_gateway import * +from .transactions_gateway import * +from .transfers_gateway import * diff --git a/jarca_ledger_mcp/adapters/streamlit/transactions_gateway.py b/jarca_ledger_mcp/adapters/streamlit/transactions_gateway.py new file mode 100644 index 0000000..a0d23d8 --- /dev/null +++ b/jarca_ledger_mcp/adapters/streamlit/transactions_gateway.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +from ...service import update_ledger_transaction diff --git a/jarca_ledger_mcp/adapters/streamlit/transfers_gateway.py b/jarca_ledger_mcp/adapters/streamlit/transfers_gateway.py new file mode 100644 index 0000000..e4a9991 --- /dev/null +++ b/jarca_ledger_mcp/adapters/streamlit/transfers_gateway.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from ...service import ( + find_cancel_original_candidates, + find_self_transfer_candidates, + link_cancel_to_original, + link_self_transfer_pair, + list_cross_transfer_review_queue, + preview_cross_account_transfers, + run_cross_account_transfer_match, +) diff --git a/jarca_ledger_mcp/application/__init__.py b/jarca_ledger_mcp/application/__init__.py index ffbeaa4..af708b1 100644 --- a/jarca_ledger_mcp/application/__init__.py +++ b/jarca_ledger_mcp/application/__init__.py @@ -1,10 +1,15 @@ from . import shared from .accounts_write_services import * from .asset_account_services import * +from .assets_service import * from .budgets_write_services import * from .category_rules_write_services import * from .categorization_services import * +from .cross_transfer_service import * +from .imports_service import * from .import_transfer_services import * +from .reconciliation_service import * from .system_services import * from .transactions_write_services import * from .transaction_report_services import * +from .uncategorized_workbench_service import * diff --git a/jarca_ledger_mcp/application/asset_account_services.py b/jarca_ledger_mcp/application/asset_account_services.py index f2c9095..9dbbb77 100644 --- a/jarca_ledger_mcp/application/asset_account_services.py +++ b/jarca_ledger_mcp/application/asset_account_services.py @@ -1,493 +1,15 @@ 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, - ) - - +from .assets_service import ( + close_asset_snapshot, + create_asset_snapshot, + create_manual_asset, + deactivate_manual_asset, + get_asset_liquidity_report, + get_asset_snapshot_report, + get_net_worth_timeseries, + list_manual_assets, + preview_asset_snapshot_close, + update_manual_asset, +) from .accounts_write_services import update_ledger_account diff --git a/jarca_ledger_mcp/application/assets_service.py b/jarca_ledger_mcp/application/assets_service.py new file mode 100644 index 0000000..5c15920 --- /dev/null +++ b/jarca_ledger_mcp/application/assets_service.py @@ -0,0 +1,490 @@ +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, + ) diff --git a/jarca_ledger_mcp/application/categorization_services.py b/jarca_ledger_mcp/application/categorization_services.py index 698a411..8452aeb 100644 --- a/jarca_ledger_mcp/application/categorization_services.py +++ b/jarca_ledger_mcp/application/categorization_services.py @@ -274,220 +274,13 @@ def apply_categorization_rules(limit: int = 1000, only_uncategorized: bool = Tru 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) - +from .uncategorized_workbench_service import ( + apply_uncategorized_group_recommendation, + apply_uncategorized_recommendation, + get_uncategorized_merchant_summary, + get_uncategorized_workbench, + list_uncategorized_transactions, +) DEFAULT_LEDGER_CATEGORIES: list[dict[str, Any]] = [ {"category": "์ˆ˜์ž…", "subcategory": "๊ธ‰์—ฌ", "sort_order": 10}, @@ -599,6 +392,3 @@ def list_ledger_categories(active_only: bool = True, include_usage: bool = True, 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)) - - - diff --git a/jarca_ledger_mcp/application/cross_transfer_service.py b/jarca_ledger_mcp/application/cross_transfer_service.py new file mode 100644 index 0000000..f58846e --- /dev/null +++ b/jarca_ledger_mcp/application/cross_transfer_service.py @@ -0,0 +1,593 @@ +from __future__ import annotations + +from .shared import * +from .shared import _append_memo_tag, _memo_has_tag, _normalize_counterparty_for_match, _party_match_score + +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") diff --git a/jarca_ledger_mcp/application/import_transfer_services.py b/jarca_ledger_mcp/application/import_transfer_services.py index 7db4414..d79ba54 100644 --- a/jarca_ledger_mcp/application/import_transfer_services.py +++ b/jarca_ledger_mcp/application/import_transfer_services.py @@ -3,1511 +3,25 @@ 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) +from .imports_service import ( + import_kb_bank_pdf, + import_seoulpay_xlsx, + import_toss_bank_pdf, + import_toss_securities_pdf, + list_ledger_import_batches, + preview_kb_bank_pdf_import, + preview_ledger_csv_import, + preview_seoulpay_xlsx_import, + preview_toss_bank_pdf_import, + preview_toss_securities_pdf_import, + rollback_ledger_import_batch, +) +from .cross_transfer_service import ( + find_cancel_original_candidates, + find_self_transfer_candidates, + link_cancel_to_original, + link_self_transfer_pair, + list_cross_transfer_review_queue, + preview_cross_account_transfers, + run_cross_account_transfer_match, +) diff --git a/jarca_ledger_mcp/application/imports_service.py b/jarca_ledger_mcp/application/imports_service.py new file mode 100644 index 0000000..76460bc --- /dev/null +++ b/jarca_ledger_mcp/application/imports_service.py @@ -0,0 +1,922 @@ +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 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/reconciliation_service.py b/jarca_ledger_mcp/application/reconciliation_service.py new file mode 100644 index 0000000..5efe4cd --- /dev/null +++ b/jarca_ledger_mcp/application/reconciliation_service.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from .shared import * + + +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") diff --git a/jarca_ledger_mcp/application/shared.py b/jarca_ledger_mcp/application/shared.py index 0ed17cc..708a6fe 100644 --- a/jarca_ledger_mcp/application/shared.py +++ b/jarca_ledger_mcp/application/shared.py @@ -11,11 +11,17 @@ from typing import Any from uuid import uuid4 from zoneinfo import ZoneInfo +from ..infrastructure.parsers import ( + parse_kb_bank_pdf, + parse_seoulpay_xlsx, + parse_toss_bank_pdf, + parse_toss_securities_pdf, + seoulpay_transaction_preview, + toss_bank_transaction_preview, + toss_transaction_preview, + transaction_preview, +) 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"} diff --git a/jarca_ledger_mcp/application/transaction_report_services.py b/jarca_ledger_mcp/application/transaction_report_services.py index b609205..d477704 100644 --- a/jarca_ledger_mcp/application/transaction_report_services.py +++ b/jarca_ledger_mcp/application/transaction_report_services.py @@ -23,98 +23,7 @@ def list_ledger_transactions(start_date: str = "", end_date: str = "", type: str 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") - - +from .reconciliation_service import get_balance_reconciliation_report def list_ledger_budgets(month: str = "", db_path: str | None = None) -> dict[str, Any]: clauses=[]; params=[] diff --git a/jarca_ledger_mcp/application/uncategorized_workbench_service.py b/jarca_ledger_mcp/application/uncategorized_workbench_service.py new file mode 100644 index 0000000..ec3891a --- /dev/null +++ b/jarca_ledger_mcp/application/uncategorized_workbench_service.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +from .shared import * + +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 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) diff --git a/jarca_ledger_mcp/infrastructure/parsers/__init__.py b/jarca_ledger_mcp/infrastructure/parsers/__init__.py new file mode 100644 index 0000000..3120591 --- /dev/null +++ b/jarca_ledger_mcp/infrastructure/parsers/__init__.py @@ -0,0 +1,4 @@ +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 diff --git a/jarca_ledger_mcp/infrastructure/parsers/kb_bank_pdf.py b/jarca_ledger_mcp/infrastructure/parsers/kb_bank_pdf.py new file mode 100644 index 0000000..c6e9bce --- /dev/null +++ b/jarca_ledger_mcp/infrastructure/parsers/kb_bank_pdf.py @@ -0,0 +1 @@ +from ...kb_bank_pdf import parse_kb_bank_pdf, transaction_preview diff --git a/jarca_ledger_mcp/infrastructure/parsers/seoulpay_xlsx.py b/jarca_ledger_mcp/infrastructure/parsers/seoulpay_xlsx.py new file mode 100644 index 0000000..997662a --- /dev/null +++ b/jarca_ledger_mcp/infrastructure/parsers/seoulpay_xlsx.py @@ -0,0 +1 @@ +from ...seoulpay_xlsx import parse_seoulpay_xlsx, transaction_preview diff --git a/jarca_ledger_mcp/infrastructure/parsers/toss_bank_pdf.py b/jarca_ledger_mcp/infrastructure/parsers/toss_bank_pdf.py new file mode 100644 index 0000000..f7fc844 --- /dev/null +++ b/jarca_ledger_mcp/infrastructure/parsers/toss_bank_pdf.py @@ -0,0 +1 @@ +from ...toss_bank_pdf import parse_toss_bank_pdf, transaction_preview diff --git a/jarca_ledger_mcp/infrastructure/parsers/toss_securities_pdf.py b/jarca_ledger_mcp/infrastructure/parsers/toss_securities_pdf.py new file mode 100644 index 0000000..8db351f --- /dev/null +++ b/jarca_ledger_mcp/infrastructure/parsers/toss_securities_pdf.py @@ -0,0 +1 @@ +from ...toss_securities_pdf import parse_toss_securities_pdf, transaction_preview diff --git a/jarca_ledger_mcp/service.py b/jarca_ledger_mcp/service.py index 90b085f..f9e1d6c 100644 --- a/jarca_ledger_mcp/service.py +++ b/jarca_ledger_mcp/service.py @@ -5,20 +5,30 @@ from types import ModuleType from .application import accounts_write_services as _accounts_write_services from .application import asset_account_services as _asset_account_services +from .application import assets_service as _assets_service from .application import budgets_write_services as _budgets_write_services from .application import category_rules_write_services as _category_rules_write_services from .application import categorization_services as _categorization_services +from .application import cross_transfer_service as _cross_transfer_service +from .application import imports_service as _imports_service from .application import import_transfer_services as _import_transfer_services +from .application import reconciliation_service as _reconciliation_service from .application import shared as _shared from .application import system_services as _system_services from .application import transactions_write_services as _transactions_write_services from .application import transaction_report_services as _transaction_report_services +from .application import uncategorized_workbench_service as _uncategorized_workbench_service from .application.asset_account_services import * +from .application.assets_service import * from .application.categorization_services import * +from .application.cross_transfer_service import * +from .application.imports_service import * from .application.import_transfer_services import * +from .application.reconciliation_service import * from .application.shared import * from .application.system_services import * from .application.transaction_report_services import * +from .application.uncategorized_workbench_service import * # Keep compatibility for tests/monkeypatches that reach into service internals. _current_kst_datetime = _shared._current_kst_datetime @@ -28,11 +38,16 @@ _PROXY_MODULES = ( _accounts_write_services, _system_services, _asset_account_services, + _assets_service, _budgets_write_services, _category_rules_write_services, _transaction_report_services, + _reconciliation_service, + _imports_service, _import_transfer_services, + _cross_transfer_service, _categorization_services, + _uncategorized_workbench_service, _transactions_write_services, )