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,
)