Refactor import and workflow services plus adapter seams

This commit is contained in:
Jarca
2026-07-03 08:25:01 +09:00
parent ac78e94571
commit f42bc4aa7d
53 changed files with 3420 additions and 2497 deletions
+65 -58
View File
@@ -15,7 +15,14 @@ import plotly.express as px
import plotly.io as pio import plotly.io as pio
import streamlit as st 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 from jarca_ledger_mcp.db import resolve_db_path
st.set_page_config(page_title="Jarca Ledger", page_icon="💰", layout="wide") 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()) 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. """Return a service function, reloading once for Streamlit hot-reload edge cases.
Streamlit can keep an already-imported service module in memory while the 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. signature. In that case reload the service module before returning it.
""" """
required_params = required_params or [] required_params = required_params or []
fn = getattr(service, name, None) fn = getattr(module, name, None)
if callable(fn): if callable(fn):
try: try:
params = inspect.signature(fn).parameters params = inspect.signature(fn).parameters
@@ -164,8 +171,8 @@ def get_service_callable(name: str, required_params: list[str] | None = None):
except (TypeError, ValueError): except (TypeError, ValueError):
if not required_params: if not required_params:
return fn return fn
importlib.reload(service) importlib.reload(module)
return getattr(service, name, None) return getattr(module, name, None)
def inject_app_css() -> 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]]]: 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: if result.get("success") and result.get("count", 0) == 0:
service.seed_default_ledger_categories(db_path=db_path) categorization_gateway.seed_default_ledger_categories(db_path=db_path)
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)
grouped = result.get("grouped", {}) if result.get("success") else {} grouped = result.get("grouped", {}) if result.get("success") else {}
categories = [""] + sorted(grouped.keys()) categories = [""] + sorted(grouped.keys())
subcategories = {cat: [""] + sorted([s for s in subs if s]) for cat, subs in grouped.items()} 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") submitted = st.button("저장", type="primary", key=f"{key_prefix}_submit")
if submitted: if submitted:
result = service.update_ledger_transaction( result = transactions_gateway.update_ledger_transaction(
tx["id"], category=category, subcategory=subcategory, person=person, tx["id"], category=category, subcategory=subcategory, person=person,
is_fixed=is_fixed, memo=memo, db_path=db_path, 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)) st.error(result.get("error", result))
return return
if save_rule and tx.get("merchant") and category: if save_rule and tx.get("merchant") and category:
rule = service.create_category_rule( rule = categorization_gateway.create_category_rule(
match_field="merchant", match_field="merchant",
pattern=tx["merchant"], pattern=tx["merchant"],
category=category, 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: def linked_transfer_candidates_dialog(tx: dict[str, Any], db_path: str) -> None:
st.caption(f"기준 거래: `{tx['id']}`") 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'))}") 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"): if not result.get("success"):
st.error(result.get("error", result)) st.error(result.get("error", result))
return 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[3].write(format_krw(candidate.get("amount")))
cols[4].write(candidate.get("merchant") or candidate.get("counterparty") or "") 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"): 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"): if not link_res.get("success"):
st.error(link_res.get("error", link_res)) st.error(link_res.get("error", link_res))
else: 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: def cancel_original_candidates_dialog(tx: dict[str, Any], db_path: str) -> None:
st.caption(f"취소/환급 거래: `{tx['id']}`") 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'))}") 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"): if not result.get("success"):
st.error(result.get("error", result)) st.error(result.get("error", result))
return 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[3].write(format_krw(candidate.get("amount")))
cols[4].write(candidate.get("merchant") or candidate.get("counterparty") or "") 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"): 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"): if not link_res.get("success"):
st.error(link_res.get("error", link_res)) st.error(link_res.get("error", link_res))
else: else:
@@ -683,7 +690,7 @@ def category_rule_create_dialog(db_path: str) -> None:
st.error(str(exc)) st.error(str(exc))
else: else:
fixed_value = None if is_fixed == "변경안함" else is_fixed == "예" 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() clear_cache()
if res.get("success"): if res.get("success"):
st.success("규칙을 저장했습니다.") st.success("규칙을 저장했습니다.")
@@ -735,7 +742,7 @@ def category_rule_edit_dialog(rule: dict[str, Any], db_path: str) -> None:
st.error(str(exc)) st.error(str(exc))
else: else:
edit_fixed_value = None if edit_is_fixed == "변경안함" else edit_is_fixed == "예" 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: if update_rule is None:
st.error("현재 실행 중인 서비스에 update_category_rule 함수가 없습니다. 앱을 재시작해 최신 코드를 다시 불러오세요.") st.error("현재 실행 중인 서비스에 update_category_rule 함수가 없습니다. 앱을 재시작해 최신 코드를 다시 불러오세요.")
else: else:
@@ -787,7 +794,7 @@ def save_merchant_rule_from_recommendation(merchant: str, tx_type: str, rec: dic
return {"success": False, "error": "merchant가 비어 있어 규칙을 만들 수 없습니다."} return {"success": False, "error": "merchant가 비어 있어 규칙을 만들 수 없습니다."}
if not rec.get("category"): if not rec.get("category"):
return {"success": False, "error": "추천 분류가 없어 규칙을 만들 수 없습니다."} return {"success": False, "error": "추천 분류가 없어 규칙을 만들 수 없습니다."}
return service.create_category_rule( return categorization_gateway.create_category_rule(
match_field="merchant", match_field="merchant",
match_type="contains", match_type="contains",
pattern=merchant, 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)) cols[3].write(recommendation_text(rec))
apply_disabled = not bool(rec.get("category")) 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): 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 group.get("merchant", ""), group.get("raw_summary", ""), group.get("type", ""), db_path=db_path
) )
clear_cache() 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") 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")) 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"): 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() clear_cache()
if res.get("success"): if res.get("success"):
st.success(f"{recommendation_text(res.get('recommendation'))}로 분류했습니다.") 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.caption(f"규칙 ID: `{rule_id}`")
st.write(f"**{pattern}** → **{result_text}**") 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): if not callable(preview_fn):
st.error("현재 실행 중인 서비스에서 단일 규칙 미리보기 함수를 찾지 못했습니다. Streamlit 서버를 완전히 종료한 뒤 다시 시작해 주세요.") st.error("현재 실행 중인 서비스에서 단일 규칙 미리보기 함수를 찾지 못했습니다. Streamlit 서버를 완전히 종료한 뒤 다시 시작해 주세요.")
return return
@@ -922,7 +929,7 @@ def category_rule_preview_dialog(rule: dict[str, Any], db_path: str) -> None:
st.info("이 규칙에 부합하는 거래가 없습니다.") st.info("이 규칙에 부합하는 거래가 없습니다.")
def page_overview(db_path: str) -> None: def page_overview(db_path: str) -> None:
health = service.health_check(db_path) health = overview_gateway.health_check(db_path)
render_page_header( render_page_header(
"가계부 운영 대시보드", "가계부 운영 대시보드",
"이번 달 실제 현금흐름, 자산 유동성, 운영 경고를 우선순위대로 확인합니다.", "이번 달 실제 현금흐름, 자산 유동성, 운영 경고를 우선순위대로 확인합니다.",
@@ -931,14 +938,14 @@ def page_overview(db_path: str) -> None:
) )
st.caption(f"DB: `{health.get('db_path')}`") st.caption(f"DB: `{health.get('db_path')}`")
month = current_kst_month() month = current_kst_month()
cashflow = service.get_monthly_cashflow_report(month, db_path=db_path) cashflow = overview_gateway.get_monthly_cashflow_report(month, db_path=db_path)
asset_report = service.get_asset_liquidity_report(db_path=db_path) asset_report = overview_gateway.get_asset_liquidity_report(db_path=db_path)
budget_report = service.get_budget_vs_actual_report(month, db_path=db_path) budget_report = overview_gateway.get_budget_vs_actual_report(month, db_path=db_path)
uncategorized_res = service.list_uncategorized_transactions(limit=1000, db_path=db_path) uncategorized_res = overview_gateway.list_uncategorized_transactions(limit=1000, db_path=db_path)
transfer_queue_res = service.list_cross_transfer_review_queue(limit=300, db_path=db_path) transfer_queue_res = overview_gateway.list_cross_transfer_review_queue(limit=300, db_path=db_path)
reconciliation_res = service.get_balance_reconciliation_report(db_path=db_path) reconciliation_res = reconciliation_gateway.get_balance_reconciliation_report(db_path=db_path)
backups_res = service.list_ledger_backups(limit=1) backups_res = overview_gateway.list_ledger_backups(limit=1)
imports_res = service.list_ledger_import_batches(limit=1, db_path=db_path) imports_res = overview_gateway.list_ledger_import_batches(limit=1, db_path=db_path)
render_section_label("💎", "이번 달 수입/지출 현황") render_section_label("💎", "이번 달 수입/지출 현황")
st.caption(f"기준: KST 현재 월 {month}, transactions.transaction_date, ledger_categories.cashflow_kind 실제 현금흐름") 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="") memo = st.text_input("운영 메모", value="")
submitted = st.form_submit_button("수기 자산 추가", type="primary") submitted = st.form_submit_button("수기 자산 추가", type="primary")
if submitted: 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"): if res.get("success"):
clear_cache() clear_cache()
st.success("수기 자산을 추가했습니다.") st.success("수기 자산을 추가했습니다.")
@@ -1045,7 +1052,7 @@ def page_assets(db_path: str) -> None:
else: else:
st.error(res.get("error", res)) 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 [] assets = assets_res.get("manual_assets", []) if assets_res.get("success") else []
render_section_label("📋", "수기 자산/부채 목록") render_section_label("📋", "수기 자산/부채 목록")
if assets: if assets:
@@ -1071,13 +1078,13 @@ def page_assets(db_path: str) -> None:
do_update = col_update.form_submit_button("선택 항목 수정", type="primary") do_update = col_update.form_submit_button("선택 항목 수정", type="primary")
do_deactivate = col_deactivate.form_submit_button("선택 항목 비활성", type="secondary") do_deactivate = col_deactivate.form_submit_button("선택 항목 비활성", type="secondary")
if do_update: 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"): if res.get("success"):
clear_cache(); st.success("수정했습니다."); st.rerun() clear_cache(); st.success("수정했습니다."); st.rerun()
else: else:
st.error(res.get("error", res)) st.error(res.get("error", res))
if do_deactivate: 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"): if res.get("success"):
clear_cache(); st.warning("비활성 처리했습니다."); st.rerun() clear_cache(); st.warning("비활성 처리했습니다."); st.rerun()
else: else:
@@ -1094,9 +1101,9 @@ def page_assets(db_path: str) -> None:
correction_reason = c3.text_input("정정 사유(재마감 시 필수)", value="") correction_reason = c3.text_input("정정 사유(재마감 시 필수)", value="")
p1, p2 = st.columns(2) p1, p2 = st.columns(2)
if p1.button("스냅샷 Preview", type="secondary"): 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"): 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() clear_cache()
preview = st.session_state.get("asset_snapshot_preview") preview = st.session_state.get("asset_snapshot_preview")
close_res = st.session_state.get("asset_snapshot_close") 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) c1, c2 = st.columns(2)
with c1: with c1:
if st.button("미리보기(드라이런)"): 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: with c2:
if st.button("자동 매칭 실행"): 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() clear_cache()
st.session_state["cross_transfer_last_run"] = res st.session_state["cross_transfer_last_run"] = res
st.success(f"매칭 {res.get('matched_count', 0)}건 적용, 확인필요 {res.get('review_count', 0)}건") 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() st.divider()
render_section_label("📌", "확인필요 큐") 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): if callable(list_queue_fn):
queue = list_queue_fn(db_path=db_path) queue = list_queue_fn(db_path=db_path)
rows = queue.get("transactions", []) rows = queue.get("transactions", [])
@@ -1242,7 +1249,7 @@ def page_uncategorized(db_path: str) -> None:
sort_label = filter_cols[4].selectbox("작업목록 정렬", ["금액순", "날짜순(최신순)"], index=0) sort_label = filter_cols[4].selectbox("작업목록 정렬", ["금액순", "날짜순(최신순)"], index=0)
sort_by = "date_desc" if sort_label == "날짜순(최신순)" else "amount_desc" 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): if not callable(workbench_fn):
st.error("현재 실행 중인 서비스에서 미분류 작업대 함수를 찾지 못했습니다. Streamlit 서버를 완전히 재시작해 주세요.") st.error("현재 실행 중인 서비스에서 미분류 작업대 함수를 찾지 못했습니다. Streamlit 서버를 완전히 재시작해 주세요.")
return 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): if action_cols[0].button("🔎 적용(미리보기)", key=f"rule_expander_preview_{rule_id}_{idx}", use_container_width=True):
category_rule_preview_dialog(rule, db_path) category_rule_preview_dialog(rule, db_path)
if action_cols[1].button("✅ 적용", key=f"rule_expander_apply_{rule_id}_{idx}", use_container_width=True): 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): if not callable(apply_fn):
st.error("현재 실행 중인 서비스에서 단일 규칙 적용 함수를 찾지 못했습니다. Streamlit 서버를 완전히 종료한 뒤 다시 시작해 주세요.") st.error("현재 실행 중인 서비스에서 단일 규칙 적용 함수를 찾지 못했습니다. Streamlit 서버를 완전히 종료한 뒤 다시 시작해 주세요.")
else: else:
@@ -1465,7 +1472,7 @@ def render_rule_list_items(rules: list[dict[str, Any]], db_path: str) -> None:
with action_cols[3]: with action_cols[3]:
st.markdown('<div class="rule-danger-note">삭제보다 비활성화를 권장합니다.</div>', unsafe_allow_html=True) st.markdown('<div class="rule-danger-note">삭제보다 비활성화를 권장합니다.</div>', unsafe_allow_html=True)
if action_cols[4].button("🗑️ 삭제", key=f"rule_expander_delete_{rule_id}_{idx}"): 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: if delete_rule is None:
st.error("현재 실행 중인 서비스에 delete_category_rule 함수가 없습니다. 앱을 재시작해 최신 코드를 다시 불러오세요.") st.error("현재 실행 중인 서비스에 delete_category_rule 함수가 없습니다. 앱을 재시작해 최신 코드를 다시 불러오세요.")
else: else:
@@ -1491,7 +1498,7 @@ def page_rules(db_path: str) -> None:
with action_cols[1]: with action_cols[1]:
if st.button("추가", key="rule_add_dialog_open", use_container_width=True, type="primary"): if st.button("추가", key="rule_add_dialog_open", use_container_width=True, type="primary"):
category_rule_create_dialog(db_path) 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_section_label("📚", "현재 분류 규칙")
render_rule_list_items(rules, db_path) render_rule_list_items(rules, db_path)
@@ -1499,12 +1506,12 @@ def page_rules(db_path: str) -> None:
c1, c2, c3 = st.columns(3) c1, c2, c3 = st.columns(3)
if c1.button("기본 규칙 seed/update"): 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) clear_cache(); st.success(res)
if c2.button("규칙 preview"): 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("규칙 적용"): 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)}건 적용") clear_cache(); st.success(f"{res.get('applied_count', 0)}건 적용")
if "rule_preview" in st.session_state: if "rule_preview" in st.session_state:
prev = st.session_state["rule_preview"] prev = st.session_state["rule_preview"]
@@ -1528,7 +1535,7 @@ def page_categories(db_path: str) -> None:
c1, c2 = st.columns(2) c1, c2 = st.columns(2)
if c1.button("기본 카테고리 seed/update"): 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() clear_cache()
st.success(f"{res.get('created_or_updated', 0)}개 카테고리를 생성/갱신했습니다.") st.success(f"{res.get('created_or_updated', 0)}개 카테고리를 생성/갱신했습니다.")
st.rerun() st.rerun()
@@ -1545,7 +1552,7 @@ def page_categories(db_path: str) -> None:
submitted = st.form_submit_button("추가/재활성화", type="primary") submitted = st.form_submit_button("추가/재활성화", type="primary")
if submitted: if submitted:
cashflow_kind = next(k for k, v in CASHFLOW_KIND_LABELS.items() if v == cashflow_kind_label) 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() clear_cache()
if res.get("success"): if res.get("success"):
st.success("카테고리를 저장했습니다.") st.success("카테고리를 저장했습니다.")
@@ -1553,7 +1560,7 @@ def page_categories(db_path: str) -> None:
else: else:
st.error(res.get("error", res)) 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", []) rows = result.get("categories", [])
render_section_label("🗂️", "카테고리 목록") render_section_label("🗂️", "카테고리 목록")
if rows: if rows:
@@ -1577,7 +1584,7 @@ def page_categories(db_path: str) -> None:
hard_delete = st.checkbox("DB에서 완전 삭제", value=False, help="보통은 끄고 비활성화만 하세요. 거래 기록은 별도 보존됩니다.") hard_delete = st.checkbox("DB에서 완전 삭제", value=False, help="보통은 끄고 비활성화만 하세요. 거래 기록은 별도 보존됩니다.")
force = st.checkbox("사용 중이어도 강제", value=False, help="거래/예산/규칙에서 사용 중인 카테고리도 삭제/비활성화합니다. 거래 category 값은 변경하지 않습니다.") force = st.checkbox("사용 중이어도 강제", value=False, help="거래/예산/규칙에서 사용 중인 카테고리도 삭제/비활성화합니다. 거래 category 값은 변경하지 않습니다.")
if st.button("삭제/비활성화 실행", type="secondary"): 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() clear_cache()
if res.get("success"): if res.get("success"):
st.warning(f"처리했습니다. 사용 거래 {res.get('transaction_count', 0)}건") st.warning(f"처리했습니다. 사용 거래 {res.get('transaction_count', 0)}건")
@@ -1593,7 +1600,7 @@ def page_accounts(db_path: str) -> None:
kicker="🏦 Accounts", kicker="🏦 Accounts",
pills=["잔액", "기관", "statement_status", "수동 잔액대조"], 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_data = accounts_res.get("accounts", []) if accounts_res.get("success") else []
accounts = pd.DataFrame(accounts_data) accounts = pd.DataFrame(accounts_data)
if not accounts.empty: if not accounts.empty:
@@ -1638,7 +1645,7 @@ def page_accounts(db_path: str) -> None:
except ValueError: except ValueError:
st.error("기준 잔액은 정수로 입력하세요.") st.error("기준 잔액은 정수로 입력하세요.")
return 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() clear_cache()
if res.get("success"): if res.get("success"):
st.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) tolerance = c2.number_input("허용 오차(원)", min_value=0, value=1000, step=100)
source_note = c3.text_input("출처 메모", value="", placeholder="예: 월말 앱 잔액") source_note = c3.text_input("출처 메모", value="", placeholder="예: 월말 앱 잔액")
if st.button("잔액대조 실행", type="secondary"): 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, baseline_at=baseline_at,
source_note=source_note, source_note=source_note,
tolerance=int(tolerance), tolerance=int(tolerance),
@@ -1696,10 +1703,10 @@ def page_imports(db_path: str) -> None:
upload_path.write_bytes(uploaded.getbuffer()) upload_path.write_bytes(uploaded.getbuffer())
c1, c2 = st.columns(2) c1, c2 = st.columns(2)
if c1.button("토스증권 Preview"): 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 st.session_state["toss_preview"] = res
if c2.button("토스증권 Import", type="primary"): 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() clear_cache()
st.session_state["toss_import_result"] = res st.session_state["toss_import_result"] = res
st.rerun() st.rerun()
@@ -1730,10 +1737,10 @@ def page_imports(db_path: str) -> None:
upload_path.write_bytes(toss_bank_uploaded.getbuffer()) upload_path.write_bytes(toss_bank_uploaded.getbuffer())
c1, c2 = st.columns(2) c1, c2 = st.columns(2)
if c1.button("토스뱅크 Preview"): 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 st.session_state["toss_bank_preview"] = res
if c2.button("토스뱅크 Import", type="primary"): 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() clear_cache()
st.session_state["toss_bank_import_result"] = res st.session_state["toss_bank_import_result"] = res
st.rerun() st.rerun()
@@ -1755,7 +1762,7 @@ def page_imports(db_path: str) -> None:
st.divider() st.divider()
render_section_label("🧾", "Import 이력") 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) st.dataframe(pd.DataFrame(batches), width="stretch", height=350)
if batches: if batches:
with st.form("rollback"): with st.form("rollback"):
@@ -1763,7 +1770,7 @@ def page_imports(db_path: str) -> None:
hard = st.checkbox("Hard delete", value=False) hard = st.checkbox("Hard delete", value=False)
submitted = st.form_submit_button("Rollback 실행", type="secondary") submitted = st.form_submit_button("Rollback 실행", type="secondary")
if submitted: 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() clear_cache()
st.warning(res) st.warning(res)
st.rerun() st.rerun()
@@ -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
@@ -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 범위 밖이다.
@@ -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
@@ -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.
@@ -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
@@ -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
@@ -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.
@@ -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
@@ -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.
@@ -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
@@ -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.
@@ -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.
@@ -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을 완료한다.
@@ -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.
@@ -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.
+3
View File
@@ -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-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-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-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/) |
@@ -5,10 +5,10 @@
- 문서 ID: dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing - 문서 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 표면 축소) - 이슈명: 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:10
- 마지막 갱신 시각(KST): 2026-07-03 07:29 - 마지막 갱신 시각(KST): 2026-07-03 08:23
- 대체됨: 없음 - 대체됨: 없음
- 후속 Discovery 참조: 없음 - 후속 Discovery 참조: 없음
- 생성된 REQ 참조: `REQ-015` - 생성된 REQ 참조: `REQ-015`, `REQ-016`, `REQ-017`, `REQ-018`
# 1. 계획 상태 요약 # 1. 계획 상태 요약
현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다. 현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다.
@@ -193,8 +193,8 @@ REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지
- 기준: Phase 4~7 우선 In Scope, Phase 8은 Deferred 후보로만 유지, Discovery는 하나로 두되 REQ/Batch는 Phase별·도메인별로 분할한다는 방침이 문서에 반영되어 있다. - 기준: Phase 4~7 우선 In Scope, Phase 8은 Deferred 후보로만 유지, Discovery는 하나로 두되 REQ/Batch는 Phase별·도메인별로 분할한다는 방침이 문서에 반영되어 있다.
- [x] 성공 지표의 측정 방식/데이터 출처 확정 - [x] 성공 지표의 측정 방식/데이터 출처 확정
- 기준: S-2의 DoD를 broad application module 책임 축소와 compatibility facade/shim 유지로 채택하고 baseline 테스트 목록은 REQ/planning에서 계측 대상으로 명시한다. - 기준: S-2의 DoD를 broad application module 책임 축소와 compatibility facade/shim 유지로 채택하고 baseline 테스트 목록은 REQ/planning에서 계측 대상으로 명시한다.
- [ ] High 영향 리스크 우선순위 및 해소 책임자 지정 - [x] High 영향 리스크 우선순위 및 해소 책임자 지정
- 기준: R-1(계약 drift), R-2(advanced 결합)의 characterization/contract 테스트 선행 책임자는 REQ/Batch planning에서 지정한다. Discovery confirm의 차단 사유는 아니다. - 기준: 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` 상태 없음 확인 - [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
- 기준: OQ 목록에 `Open` 상태 항목이 0개이며, 남은 DATA 항목은 REQ/planning의 세부 실행자료로만 이관된다. - 기준: OQ 목록에 `Open` 상태 항목이 0개이며, 남은 DATA 항목은 REQ/planning의 세부 실행자료로만 이관된다.
- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정 - [x] Freeze 확정 담당자(CONFIRMED_BY) 지정
@@ -203,13 +203,21 @@ REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지
# 9. 파일 처리 결과 # 9. 파일 처리 결과
이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다. 이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다.
- 처리 결과: 생성 - 처리 결과: 생성 후 close-out 갱신
- 생성 경로: docs/discovery/dcy-011_20260703_backend-refactor-phase4-write-import-workflow-adapter-narrowing.md - 생성 경로: 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, 본 문서가 후속 주기) - 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) - .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/project-structure.md (baseline 갱신 논리 템플릿 참조)
- stage-pilot/templates/runtime-flows.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. 사용자 결정 필요 항목 요약 # 10. 사용자 결정 필요 항목 요약
사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다. 사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다.
+49 -11
View File
@@ -5,7 +5,7 @@
- Status: draft - Status: draft
- Owner: Jarca / project maintainer - Owner: Jarca / project maintainer
- Last Updated (KST): 2026-07-03 - 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 ## Project Summary
- Jarca 가계부 전용 MCP 서버와 Streamlit 대시보드로 계좌/카드 거래, 예산, 분류 규칙, import, 자산/스냅샷, 현금흐름 분석을 SQLite에 저장하고 운영한다. - Jarca 가계부 전용 MCP 서버와 Streamlit 대시보드로 계좌/카드 거래, 예산, 분류 규칙, import, 자산/스냅샷, 현금흐름 분석을 SQLite에 저장하고 운영한다.
@@ -61,10 +61,21 @@ jarca-ledger-mcp/
│ │ ├── shared.py │ │ ├── shared.py
│ │ ├── system_services.py │ │ ├── system_services.py
│ │ ├── asset_account_services.py │ │ ├── asset_account_services.py
│ │ ├── assets_service.py
│ │ ├── transaction_report_services.py │ │ ├── transaction_report_services.py
│ │ ├── import_transfer_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/ │ ├── infrastructure/
│ │ ├── parsers/
│ │ │ ├── __init__.py
│ │ │ ├── kb_bank_pdf.py
│ │ │ ├── toss_bank_pdf.py
│ │ │ ├── toss_securities_pdf.py
│ │ │ └── seoulpay_xlsx.py
│ │ └── sqlite/ │ │ └── sqlite/
│ │ ├── __init__.py │ │ ├── __init__.py
│ │ ├── connection.py │ │ ├── connection.py
@@ -72,8 +83,18 @@ jarca-ledger-mcp/
│ │ └── migrations.py │ │ └── migrations.py
│ ├── adapters/ │ ├── adapters/
│ │ ├── mcp/ │ │ ├── mcp/
│ │ │ ├── tool_contracts.py
│ │ │ └── server.py │ │ │ └── server.py
│ │ └── streamlit/ │ │ └── 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 │ │ └── service_gateway.py
│ ├── kb_bank_pdf.py │ ├── kb_bank_pdf.py
│ ├── toss_bank_pdf.py │ ├── toss_bank_pdf.py
@@ -121,31 +142,47 @@ jarca-ledger-mcp/
- `jarca_ledger_mcp/server.py` - `jarca_ledger_mcp/server.py`
- Legacy-compatible MCP entrypoint shim that delegates to `adapters.mcp.server`. - Legacy-compatible MCP entrypoint shim that delegates to `adapters.mcp.server`.
- `jarca_ledger_mcp/application/shared.py` - `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` - `jarca_ledger_mcp/application/system_services.py`
- Health/status/backup/account create-list/update and persistence-adjacent service behavior. - Health/status/backup/account create-list/update and persistence-adjacent service behavior.
- `jarca_ledger_mcp/application/asset_account_services.py` - `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` - `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` - `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` - `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` - `jarca_ledger_mcp/infrastructure/sqlite/connection.py`
- DB path resolution, sqlite connection setup, context manager semantics. - DB path resolution, sqlite connection setup, context manager semantics.
- `jarca_ledger_mcp/infrastructure/sqlite/schema.py` - `jarca_ledger_mcp/infrastructure/sqlite/schema.py`
- Schema version, DDL, additive column definitions. - Schema version, DDL, additive column definitions.
- `jarca_ledger_mcp/infrastructure/sqlite/migrations.py` - `jarca_ledger_mcp/infrastructure/sqlite/migrations.py`
- Additive/idempotent migration routines and DB initialization. - 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` - `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` - `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`) - Parser modules (`kb_bank_pdf.py`, `toss_bank_pdf.py`, `toss_securities_pdf.py`, `seoulpay_xlsx.py`)
- Source-file parsing and preview conversion only. - Source-file parsing and preview conversion only.
- `dashboard/app.py` - `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/` - `tests/`
- Regression tests for facade compatibility, service behavior, parser behavior, categorization, transfer matching, MCP contract compatibility, and Streamlit AppTest smoke. - Regression tests for facade compatibility, service behavior, parser behavior, categorization, transfer matching, MCP contract compatibility, and Streamlit AppTest smoke.
- `.github/workflows/ci.yml` - `.github/workflows/ci.yml`
@@ -178,7 +215,7 @@ jarca-ledger-mcp/
## Current Gaps / Planned Changes ## 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. - 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. - HTTP adapter and remote ops re-architecture remain out of scope.
## Update Triggers ## 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-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-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-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를 반영했다.
+12 -11
View File
@@ -5,7 +5,7 @@
- Status: draft - Status: draft
- Owner: Jarca / project maintainer - Owner: Jarca / project maintainer
- Last Updated (KST): 2026-07-03 - 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 - Primary Runtime: mixed
## Project Summary ## Project Summary
@@ -29,13 +29,13 @@
- SQLite database at `~/.local/share/jarca/ledger.sqlite3` by default. - SQLite database at `~/.local/share/jarca/ledger.sqlite3` by default.
- `jarca_ledger_mcp/service.py` compatibility facade over application modules. - `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/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/mcp/tool_contracts.py` + `adapters/mcp/server.py` for MCP contract and registration seams.
- `jarca_ledger_mcp/adapters/streamlit/service_gateway.py` for dashboard-facing service calls. - `jarca_ledger_mcp/adapters/streamlit/*_gateway.py` + `service_gateway.py` compatibility aggregator for dashboard-facing service calls.
- Parser modules for KB/Toss/SeoulPay sources. - `jarca_ledger_mcp/infrastructure/parsers/*` plus legacy parser modules for KB/Toss/SeoulPay sources.
## Flow: `MCP server tool call` ## Flow: `MCP server tool call`
1. Hermes MCP client invokes a tool exposed by `jarca_ledger_mcp.server` / `jarca_ledger_mcp.adapters.mcp.server`. 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. 3. Service facade routes into the relevant application module.
4. Application code opens SQLite through `infrastructure/sqlite` helpers, validates inputs, and performs the operation. 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. 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`. 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. 2. Dashboard reads the configured/default SQLite database.
3. User navigates to 요약, 자산/마감, 거래내역, 확인필요 큐, 미분류, 분류 규칙, 카테고리, 계좌, or Import pages. 3. User navigates to 요약, 자산/마감, 거래내역, 확인필요 큐, 미분류, 분류 규칙, 카테고리, 계좌, or Import pages.
4. UI calls `jarca_ledger_mcp.adapters.streamlit.service_gateway` rather than giant implementation modules directly. 4. UI calls page/domain gateways under `jarca_ledger_mcp.adapters.streamlit` rather than giant implementation modules directly.
5. Gateway delegates to the service facade/application modules and returns data to the page. 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. 6. UI displays success/error status and refreshes visible tables.
## Flow: `Dashboard operations hub overview` ## Flow: `Dashboard operations hub overview`
@@ -66,8 +66,8 @@
## Flow: `Import preview and import` ## 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. 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. 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, application code creates/fetches the target account, inserts non-duplicate transactions, writes provenance, and records an import batch. 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. 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. 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. 4. Category `cashflow_kind` controls actual cashflow/report inclusion.
## Flow: `Cross-account and cancel/original reconciliation` ## 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. 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 `확인필요`. 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. 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 ## Current Gaps / Planned Changes
- Browser-level evidence exists locally and remains outside required CI gates by design. - 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. - HTTP adapter and remote ops re-architecture remain deferred.
## Update Triggers ## 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-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-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-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를 반영했다.
@@ -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로 전환했다.
@@ -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로 전환했다.
@@ -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로 전환했다.
+10 -1
View File
@@ -33,7 +33,7 @@
## Next Requirement ID ## Next Requirement ID
- Current: `REQ-016` - Current: `REQ-019`
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
## Requirement Register ## 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-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-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-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 ## 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 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: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: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로 전환했다.
+57 -57
View File
@@ -4,7 +4,7 @@ from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from ... import service from . import tool_contracts as contracts
load_dotenv(Path(__file__).resolve().parents[3] / ".env") load_dotenv(Path(__file__).resolve().parents[3] / ".env")
mcp = FastMCP("jarca-ledger-mcp") mcp = FastMCP("jarca-ledger-mcp")
@@ -12,282 +12,282 @@ mcp = FastMCP("jarca-ledger-mcp")
@mcp.tool() @mcp.tool()
def ledger_health_check() -> dict: def ledger_health_check() -> dict:
"""Verify the Jarca Ledger MCP server and SQLite database are reachable.""" """Verify the Jarca Ledger MCP server and SQLite database are reachable."""
return service.health_check() return contracts.health_check()
@mcp.tool() @mcp.tool()
def get_ledger_db_status(include_hash: bool = True, include_integrity: bool = True) -> dict: 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 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() @mcp.tool()
def create_ledger_backup(backup_dir: str = "", retention_days: int = 30, retention_count: int = 20) -> dict: 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.""" """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() @mcp.tool()
def list_ledger_backups(backup_dir: str = "", include_hash: bool = False, limit: int = 50) -> dict: def list_ledger_backups(backup_dir: str = "", include_hash: bool = False, limit: int = 50) -> dict:
"""List ledger DB backups with metadata-only evidence.""" """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() @mcp.tool()
def get_ledger_backup_status(file_path: str, include_hash: bool = True, include_integrity: bool = True) -> dict: 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 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() @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: 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.""" """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() @mcp.tool()
def list_ledger_accounts(active_only: bool = True) -> dict: def list_ledger_accounts(active_only: bool = True) -> dict:
"""List ledger accounts and cards.""" """List ledger accounts and cards."""
return service.list_ledger_accounts(active_only) return contracts.list_ledger_accounts(active_only)
@mcp.tool() @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: 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.""" """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() @mcp.tool()
def get_asset_liquidity_report() -> dict: def get_asset_liquidity_report() -> dict:
"""Return asset liquidity, debt, and net worth from shared account/manual valuation sources.""" """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() @mcp.tool()
def list_manual_assets(active_only: bool = True) -> dict: def list_manual_assets(active_only: bool = True) -> dict:
"""List manual non-transaction assets/liabilities used for net-worth valuation.""" """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() @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: 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.""" """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() @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: 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.""" """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() @mcp.tool()
def deactivate_manual_asset(asset_id: str, hard_delete: bool = False) -> dict: 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.""" """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() @mcp.tool()
def preview_asset_snapshot_close(basis_date: str = "") -> dict: def preview_asset_snapshot_close(basis_date: str = "") -> dict:
"""Preview a month-end asset snapshot close without mutating the database.""" """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() @mcp.tool()
def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "") -> dict: 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.""" """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() @mcp.tool()
def get_asset_snapshot_report(basis_date: str = "") -> dict: def get_asset_snapshot_report(basis_date: str = "") -> dict:
"""Return basis-date asset snapshot/net-worth report with live-authority fallback.""" """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() @mcp.tool()
def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: int = 12) -> dict: 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 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() @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: 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.""" """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() @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: 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.""" """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() @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: 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.""" """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() @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: 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.""" """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() @mcp.tool()
def delete_ledger_transaction(transaction_id: str, hard_delete: bool = False) -> dict: 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.""" """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() @mcp.tool()
def create_ledger_budget(month: str, category: str, amount: int, subcategory: str = "", person: str = "", currency: str = "KRW", memo: str = "") -> dict: 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.""" """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() @mcp.tool()
def list_ledger_budgets(month: str = "") -> dict: def list_ledger_budgets(month: str = "") -> dict:
"""List ledger budgets, optionally for a month YYYY-MM.""" """List ledger budgets, optionally for a month YYYY-MM."""
return service.list_ledger_budgets(month) return contracts.list_ledger_budgets(month)
@mcp.tool() @mcp.tool()
def get_monthly_cashflow_report(month: str = "") -> dict: def get_monthly_cashflow_report(month: str = "") -> dict:
"""Return income, expenses, net cashflow, and savings rate for a month.""" """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() @mcp.tool()
def get_category_spending_report(month: str = "", start_date: str = "", end_date: str = "", person: str = "") -> dict: 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 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() @mcp.tool()
def get_fixed_cost_report(month: str = "") -> dict: def get_fixed_cost_report(month: str = "") -> dict:
"""Return fixed expense transactions and total fixed cost for a month.""" """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() @mcp.tool()
def get_budget_vs_actual_report(month: str = "") -> dict: def get_budget_vs_actual_report(month: str = "") -> dict:
"""Compare monthly budgets against actual spending.""" """Compare monthly budgets against actual spending."""
return service.get_budget_vs_actual_report(month) return contracts.get_budget_vs_actual_report(month)
@mcp.tool() @mcp.tool()
def get_investable_cashflow_report(month: str = "", minimum_cash_buffer: int = 0, planned_extra_expense: int = 0) -> dict: 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.""" """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() @mcp.tool()
def preview_ledger_csv_import(file_path: str, source: str = "generic") -> dict: def preview_ledger_csv_import(file_path: str, source: str = "generic") -> dict:
"""Preview a UTF-8/UTF-8-BOM CSV import before saving transactions.""" """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() @mcp.tool()
def preview_kb_bank_pdf_import(file_path: str, password: str, preview_limit: int = 20) -> dict: 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.""" """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() @mcp.tool()
def import_kb_bank_pdf(file_path: str, password: str, account_id: str = "", skip_duplicates: bool = True, skip_validation: bool = False) -> dict: 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.""" """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() @mcp.tool()
def preview_seoulpay_xlsx_import(file_path: str, password: str = "", preview_limit: int = 20) -> dict: 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.""" """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() @mcp.tool()
def import_seoulpay_xlsx(file_path: str, password: str = "", account_id: str = "", skip_duplicates: bool = True) -> dict: 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.""" """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() @mcp.tool()
def preview_toss_securities_pdf_import(file_path: str, preview_limit: int = 20) -> dict: 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.""" """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() @mcp.tool()
def import_toss_securities_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True) -> dict: 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.""" """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() @mcp.tool()
def preview_toss_bank_pdf_import(file_path: str, preview_limit: int = 20) -> dict: 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.""" """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() @mcp.tool()
def import_toss_bank_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True) -> dict: 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.""" """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() @mcp.tool()
def list_ledger_import_batches(source: str = "", include_rolled_back: bool = True, limit: int = 50) -> dict: def list_ledger_import_batches(source: str = "", include_rolled_back: bool = True, limit: int = 50) -> dict:
"""List CSV/PDF/API import batches.""" """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() @mcp.tool()
def rollback_ledger_import_batch(import_batch_id: str, hard_delete: bool = False) -> dict: 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.""" """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() @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: 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.""" """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() @mcp.tool()
def seed_default_category_rules(overwrite: bool = False) -> dict: def seed_default_category_rules(overwrite: bool = False) -> dict:
"""Seed default Jarca category rules for common Korean household ledger patterns.""" """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() @mcp.tool()
def list_category_rules(active_only: bool = True) -> dict: def list_category_rules(active_only: bool = True) -> dict:
"""List transaction categorization rules.""" """List transaction categorization rules."""
return service.list_category_rules(active_only) return contracts.list_category_rules(active_only)
@mcp.tool() @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: 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.""" """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() @mcp.tool()
def delete_category_rule(rule_id: str) -> dict: def delete_category_rule(rule_id: str) -> dict:
"""Delete a transaction categorization rule.""" """Delete a transaction categorization rule."""
return service.delete_category_rule(rule_id) return contracts.delete_category_rule(rule_id)
@mcp.tool() @mcp.tool()
def preview_categorization(limit: int = 1000, only_uncategorized: bool = True) -> dict: def preview_categorization(limit: int = 1000, only_uncategorized: bool = True) -> dict:
"""Preview category-rule matches before writing categories to transactions.""" """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() @mcp.tool()
def apply_categorization_rules(limit: int = 1000, only_uncategorized: bool = True) -> dict: def apply_categorization_rules(limit: int = 1000, only_uncategorized: bool = True) -> dict:
"""Apply active category rules to matching ledger transactions.""" """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() @mcp.tool()
def list_uncategorized_transactions(limit: int = 100, min_amount: int = 0) -> dict: def list_uncategorized_transactions(limit: int = 100, min_amount: int = 0) -> dict:
"""List active transactions that still have no category.""" """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() @mcp.tool()
def get_uncategorized_merchant_summary(limit: int = 50) -> dict: def get_uncategorized_merchant_summary(limit: int = 50) -> dict:
"""Summarize uncategorized transactions by merchant/raw_summary/type for user review.""" """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() @mcp.tool()
def create_ledger_category(category: str, subcategory: str = "", sort_order: int = 100, memo: str = "", cashflow_kind: str = "") -> dict: 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.""" """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() @mcp.tool()
def seed_default_ledger_categories(overwrite: bool = False) -> dict: def seed_default_ledger_categories(overwrite: bool = False) -> dict:
"""Seed default ledger categories and subcategories.""" """Seed default ledger categories and subcategories."""
return service.seed_default_ledger_categories(overwrite) return contracts.seed_default_ledger_categories(overwrite)
@mcp.tool() @mcp.tool()
def list_ledger_categories(active_only: bool = True, include_usage: bool = True) -> dict: def list_ledger_categories(active_only: bool = True, include_usage: bool = True) -> dict:
"""List ledger categories and subcategories, optionally with usage counts.""" """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() @mcp.tool()
def list_cross_transfer_review_queue(limit: int = 300) -> dict: def list_cross_transfer_review_queue(limit: int = 300) -> dict:
"""List KB↔토스 계좌 이체 확인필요 큐 항목.""" """List KB↔토스 계좌 이체 확인필요 큐 항목."""
return service.list_cross_transfer_review_queue(limit) return contracts.list_cross_transfer_review_queue(limit)
@mcp.tool() @mcp.tool()
def preview_cross_account_transfers(limit: int = 500) -> dict: def preview_cross_account_transfers(limit: int = 500) -> dict:
"""Preview KB↔토스은행 이체 매칭 후보를 반환합니다.""" """Preview KB↔토스은행 이체 매칭 후보를 반환합니다."""
return service.preview_cross_account_transfers(limit) return contracts.preview_cross_account_transfers(limit)
@mcp.tool() @mcp.tool()
def run_cross_account_transfer_match(limit: int = 500) -> dict: def run_cross_account_transfer_match(limit: int = 500) -> dict:
"""Run KB↔토스은행 자동 이체 매칭 및 태그/분류 반영을 수행합니다.""" """Run KB↔토스은행 자동 이체 매칭 및 태그/분류 반영을 수행합니다."""
return service.run_cross_account_transfer_match(limit) return contracts.run_cross_account_transfer_match(limit)
@mcp.tool() @mcp.tool()
def delete_ledger_category(category: str, subcategory: str = "", hard_delete: bool = False, force: bool = False) -> dict: def delete_ledger_category(category: str, subcategory: str = "", hard_delete: bool = False, force: bool = False) -> dict:
"""Deactivate or delete a ledger category/subcategory.""" """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: def main() -> None:
mcp.run() mcp.run()
@@ -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,
)
@@ -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,
)
@@ -0,0 +1,6 @@
from __future__ import annotations
from ...service import (
list_ledger_accounts,
update_ledger_account,
)
@@ -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,
)
@@ -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,
)
@@ -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,
)
@@ -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,
)
@@ -0,0 +1,3 @@
from __future__ import annotations
from ...service import get_balance_reconciliation_report
@@ -1,44 +1,10 @@
from __future__ import annotations from __future__ import annotations
from ...service import ( from .accounts_gateway import *
apply_categorization_rules, from .assets_gateway import *
apply_uncategorized_group_recommendation, from .categorization_gateway import *
apply_uncategorized_recommendation, from .imports_gateway import *
close_asset_snapshot, from .overview_gateway import *
create_category_rule, from .reconciliation_gateway import *
create_ledger_category, from .transactions_gateway import *
create_manual_asset, from .transfers_gateway import *
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,
)
@@ -0,0 +1,3 @@
from __future__ import annotations
from ...service import update_ledger_transaction
@@ -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,
)
+5
View File
@@ -1,10 +1,15 @@
from . import shared from . import shared
from .accounts_write_services import * from .accounts_write_services import *
from .asset_account_services import * from .asset_account_services import *
from .assets_service import *
from .budgets_write_services import * from .budgets_write_services import *
from .category_rules_write_services import * from .category_rules_write_services import *
from .categorization_services import * from .categorization_services import *
from .cross_transfer_service import *
from .imports_service import *
from .import_transfer_services import * from .import_transfer_services import *
from .reconciliation_service import *
from .system_services import * from .system_services import *
from .transactions_write_services import * from .transactions_write_services import *
from .transaction_report_services import * from .transaction_report_services import *
from .uncategorized_workbench_service import *
@@ -1,493 +1,15 @@
from __future__ import annotations from __future__ import annotations
from .shared import * from .assets_service import (
close_asset_snapshot,
def _valuation_policy() -> dict[str, Any]: create_asset_snapshot,
return { create_manual_asset,
"authority_sources": ["accounts.last_balance", "manual_assets"], deactivate_manual_asset,
"snapshot_role": "historical_record_layer", get_asset_liquidity_report,
"formula": "asset_buckets - loan", get_asset_snapshot_report,
"asset_buckets": ASSET_BUCKETS, get_net_worth_timeseries,
"liability_buckets": LIABILITY_BUCKETS, list_manual_assets,
"missing_valuation_policy": "exclude_without_transaction_inference", preview_asset_snapshot_close,
} update_manual_asset,
)
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 .accounts_write_services import update_ledger_account from .accounts_write_services import update_ledger_account
@@ -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,
)
@@ -274,220 +274,13 @@ def apply_categorization_rules(limit: int = 1000, only_uncategorized: bool = Tru
applied += 1 applied += 1
return ok(applied_count=applied, preview=preview) return ok(applied_count=applied, preview=preview)
from .uncategorized_workbench_service import (
def list_uncategorized_transactions(limit: int = 100, min_amount: int = 0, db_path: str | None = None) -> dict[str, Any]: apply_uncategorized_group_recommendation,
with db(db_path) as conn: apply_uncategorized_recommendation,
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() get_uncategorized_merchant_summary,
return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows)) get_uncategorized_workbench,
list_uncategorized_transactions,
)
def get_uncategorized_merchant_summary(limit: int = 50, db_path: str | None = None) -> dict[str, Any]:
with db(db_path) as conn:
rows=conn.execute("""SELECT merchant, raw_summary, type, COUNT(*) count, SUM(amount) total_amount, MAX(amount) max_amount
FROM transactions WHERE status='active' AND COALESCE(category,'')=''
GROUP BY merchant, raw_summary, type ORDER BY total_amount DESC LIMIT ?""", (max(1,min(int(limit),500)),)).fetchall()
return ok(items=[row_to_dict(r) for r in rows], count=len(rows))
def _recommend_category_for_transaction(tx: dict[str, Any], rules: list[dict[str, Any]], history: dict[tuple[str, str], list[dict[str, Any]]]) -> dict[str, Any]:
"""Recommend a category for one transaction using deterministic, explainable signals."""
for rule in rules:
try:
if _rule_matches(rule, tx):
return {
"category": rule.get("category", ""),
"subcategory": rule.get("subcategory", ""),
"confidence": round(float(rule.get("confidence") or 1.0), 2),
"source": "rule",
"reason": f"활성 규칙 `{rule.get('pattern', '')}` 매칭",
"rule_id": rule.get("id", ""),
}
except re.error:
continue
key = (tx.get("merchant", "") or "", tx.get("type", "") or "")
candidates = history.get(key, [])
if candidates:
best = candidates[0]
count = int(best.get("count") or 0)
confidence = min(0.9, 0.55 + min(count, 5) * 0.07)
return {
"category": best.get("category", ""),
"subcategory": best.get("subcategory", ""),
"confidence": round(confidence, 2),
"source": "history",
"reason": f"같은 사용처의 기존 분류 {count}건",
"rule_id": "",
}
return {"category": "", "subcategory": "", "confidence": 0.0, "source": "none", "reason": "추천 근거 없음", "rule_id": ""}
def get_uncategorized_workbench(
limit: int = 100,
min_amount: int = 0,
sort_by: str = "amount_desc",
db_path: str | None = None,
) -> dict[str, Any]:
row_limit = max(1, min(int(limit), 1000))
minimum_amount = int(min_amount)
order_by_map = {
"amount_desc": "amount DESC, transaction_datetime DESC, transaction_date DESC, created_at DESC",
"date_desc": "transaction_datetime DESC, transaction_date DESC, created_at DESC, amount DESC",
}
order_by = order_by_map.get(sort_by, order_by_map["amount_desc"])
with db(db_path) as conn:
tx_rows = [row_to_dict(r) for r in conn.execute(
f"""SELECT id, transaction_date, transaction_datetime, type, amount, currency, merchant, raw_summary,
counterparty, account_id, payment_method, person, is_fixed, source, memo, created_at
FROM transactions
WHERE status='active' AND COALESCE(category,'')='' AND amount>=?
ORDER BY {order_by}
LIMIT ?""",
(minimum_amount, row_limit),
).fetchall()]
total_row = conn.execute(
"""SELECT COUNT(*) AS count, COALESCE(SUM(amount),0) AS total_amount,
COUNT(DISTINCT COALESCE(NULLIF(merchant,''), raw_summary, counterparty, '')) AS merchant_group_count
FROM transactions
WHERE status='active' AND COALESCE(category,'')='' AND amount>=?""",
(minimum_amount,),
).fetchone()
history_rows = [row_to_dict(r) for r in conn.execute(
"""SELECT merchant, type, category, subcategory, COUNT(*) AS count,
SUM(amount) AS total_amount, MAX(transaction_date) AS latest_date
FROM transactions
WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>''
GROUP BY merchant, type, category, subcategory
ORDER BY merchant, type, count DESC, total_amount DESC"""
).fetchall()]
rules = [row_to_dict(r) for r in conn.execute(
"SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id"
).fetchall()]
history: dict[tuple[str, str], list[dict[str, Any]]] = {}
for row in history_rows:
history.setdefault((row.get("merchant", "") or "", row.get("type", "") or ""), []).append(row)
txs: list[dict[str, Any]] = []
groups: dict[tuple[str, str, str], dict[str, Any]] = {}
for tx in tx_rows:
rec = _recommend_category_for_transaction(tx, rules, history)
public_tx = {
**tx,
"recommendation": rec,
"recommended_category": rec.get("category", ""),
"recommended_subcategory": rec.get("subcategory", ""),
"recommendation_confidence": rec.get("confidence", 0.0),
"recommendation_reason": rec.get("reason", ""),
"recommendation_source": rec.get("source", "none"),
}
txs.append(public_tx)
gkey = (tx.get("merchant", "") or "", tx.get("raw_summary", "") or "", tx.get("type", "") or "")
group = groups.setdefault(gkey, {
"merchant": gkey[0],
"raw_summary": gkey[1],
"type": gkey[2],
"count": 0,
"total_amount": 0,
"max_amount": 0,
"latest_date": "",
"sample_transaction_id": tx.get("id", ""),
"recommendation": rec,
})
group["count"] += 1
group["total_amount"] += int(tx.get("amount") or 0)
group["max_amount"] = max(int(group["max_amount"] or 0), int(tx.get("amount") or 0))
date_value = tx.get("transaction_datetime") or tx.get("transaction_date") or ""
if date_value > (group.get("latest_date") or ""):
group["latest_date"] = date_value
if not group["recommendation"].get("category") and rec.get("category"):
group["recommendation"] = rec
group_items = sorted(groups.values(), key=lambda g: (int(g["count"]), int(g["total_amount"])), reverse=True)
recommendable_count = sum(1 for tx in txs if tx.get("recommended_category"))
repeated_group_count = sum(1 for g in group_items if int(g.get("count") or 0) >= 2)
total = row_to_dict(total_row) if total_row else {"count": 0, "total_amount": 0, "merchant_group_count": 0}
return ok(
summary={
"uncategorized_count": int(total.get("count") or 0),
"uncategorized_total_amount": int(total.get("total_amount") or 0),
"merchant_group_count": int(total.get("merchant_group_count") or 0),
"recommendable_count": recommendable_count,
"repeated_group_count": repeated_group_count,
},
groups=group_items,
transactions=txs,
count=len(txs),
)
def apply_uncategorized_recommendation(transaction_id: str, db_path: str | None = None) -> dict[str, Any]:
if not transaction_id:
return fail("validation_error", "transaction_id is required")
with db(db_path) as conn:
row = conn.execute("SELECT * FROM transactions WHERE id=? AND status='active'", (transaction_id,)).fetchone()
if not row:
return fail("not_found", "transaction not found")
tx = row_to_dict(row)
if tx.get("category"):
return fail("validation_error", "transaction is already categorized")
rules = [row_to_dict(r) for r in conn.execute("SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id").fetchall()]
history_rows = [row_to_dict(r) for r in conn.execute(
"""SELECT merchant, type, category, subcategory, COUNT(*) AS count, SUM(amount) AS total_amount
FROM transactions
WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>''
GROUP BY merchant, type, category, subcategory
ORDER BY count DESC, total_amount DESC"""
).fetchall()]
history: dict[tuple[str, str], list[dict[str, Any]]] = {}
for h in history_rows:
history.setdefault((h.get("merchant", "") or "", h.get("type", "") or ""), []).append(h)
rec = _recommend_category_for_transaction(tx, rules, history)
if not rec.get("category"):
return fail("no_recommendation", "추천 분류가 없습니다")
cur = conn.execute(
"""UPDATE transactions
SET category=?, subcategory=?, updated_at=?
WHERE id=? AND status='active' AND COALESCE(category,'')=''""",
(rec.get("category", ""), rec.get("subcategory", ""), now_iso(), transaction_id),
)
return ok(applied_count=cur.rowcount, transaction_id=transaction_id, recommendation=rec)
def apply_uncategorized_group_recommendation(merchant: str, raw_summary: str = "", transaction_type: str = "", db_path: str | None = None) -> dict[str, Any]:
with db(db_path) as conn:
sample = conn.execute(
"""SELECT * FROM transactions
WHERE status='active' AND COALESCE(category,'')='' AND merchant=? AND raw_summary=? AND type=?
ORDER BY amount DESC, transaction_datetime DESC, transaction_date DESC LIMIT 1""",
(merchant, raw_summary, transaction_type),
).fetchone()
if not sample:
return fail("not_found", "uncategorized group not found")
tx = row_to_dict(sample)
rules = [row_to_dict(r) for r in conn.execute("SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id").fetchall()]
history_rows = [row_to_dict(r) for r in conn.execute(
"""SELECT merchant, type, category, subcategory, COUNT(*) AS count, SUM(amount) AS total_amount
FROM transactions
WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>''
GROUP BY merchant, type, category, subcategory
ORDER BY count DESC, total_amount DESC"""
).fetchall()]
history: dict[tuple[str, str], list[dict[str, Any]]] = {}
for h in history_rows:
history.setdefault((h.get("merchant", "") or "", h.get("type", "") or ""), []).append(h)
rec = _recommend_category_for_transaction(tx, rules, history)
if not rec.get("category"):
return fail("no_recommendation", "추천 분류가 없습니다")
cur = conn.execute(
"""UPDATE transactions
SET category=?, subcategory=?, updated_at=?
WHERE status='active' AND COALESCE(category,'')='' AND merchant=? AND raw_summary=? AND type=?""",
(rec.get("category", ""), rec.get("subcategory", ""), now_iso(), merchant, raw_summary, transaction_type),
)
return ok(applied_count=cur.rowcount, merchant=merchant, raw_summary=raw_summary, type=transaction_type, recommendation=rec)
DEFAULT_LEDGER_CATEGORIES: list[dict[str, Any]] = [ DEFAULT_LEDGER_CATEGORIES: list[dict[str, Any]] = [
{"category": "수입", "subcategory": "급여", "sort_order": 10}, {"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"]]: if item["subcategory"] and item["subcategory"] not in grouped[item["category"]]:
grouped[item["category"]].append(item["subcategory"]) grouped[item["category"]].append(item["subcategory"])
return ok(categories=items, grouped=grouped, count=len(items)) return ok(categories=items, grouped=grouped, count=len(items))
@@ -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")
File diff suppressed because it is too large Load Diff
@@ -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)
@@ -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")
+10 -4
View File
@@ -11,11 +11,17 @@ from typing import Any
from uuid import uuid4 from uuid import uuid4
from zoneinfo import ZoneInfo 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 ..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_TRANSACTION_TYPES = {"income", "expense", "transfer", "refund", "adjustment"}
VALID_ACCOUNT_TYPES = {"bank", "card", "cash", "investment", "loan", "other"} VALID_ACCOUNT_TYPES = {"bank", "card", "cash", "investment", "loan", "other"}
@@ -23,98 +23,7 @@ def list_ledger_transactions(start_date: str = "", end_date: str = "", type: str
rows = conn.execute(sql, params).fetchall() rows = conn.execute(sql, params).fetchall()
return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows)) return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows))
from .reconciliation_service import get_balance_reconciliation_report
def _transaction_signed_amount(tx_type: str, amount: int) -> int:
if tx_type in {"income", "refund", "adjustment"}:
return int(amount)
if tx_type in {"expense", "transfer"}:
return -int(amount)
return 0
def _account_balance_row(conn: sqlite3.Connection, account_id: str, baseline_at: str) -> dict[str, Any]:
date_cutoff = normalize_date(baseline_at[:10])
rows = conn.execute(
"""SELECT type, amount, balance_after, transaction_date, transaction_datetime, created_at
FROM transactions
WHERE status='active' AND account_id=? AND transaction_date<=?
ORDER BY transaction_date ASC, transaction_datetime ASC, created_at ASC""",
(account_id, date_cutoff),
).fetchall()
signed_total = sum(_transaction_signed_amount(row["type"], int(row["amount"] or 0)) for row in rows)
latest_balance_row = next((row for row in reversed(rows) if row["balance_after"] is not None), None)
if latest_balance_row is not None:
calculated_balance = int(latest_balance_row["balance_after"])
basis = "latest_transaction_balance_after"
basis_at = latest_balance_row["transaction_datetime"] or latest_balance_row["transaction_date"]
else:
calculated_balance = int(signed_total)
basis = "net_transaction_delta_without_opening_balance"
basis_at = baseline_at
return {
"calculated_balance": calculated_balance,
"calculation_basis": basis,
"calculation_basis_at": basis_at,
"transaction_count": len(rows),
"signed_transaction_total": int(signed_total),
}
def get_balance_reconciliation_report(account_id: str = "", baseline_balance: int | None = None, baseline_at: str = "", source_note: str = "", memo: str = "", tolerance: int = BALANCE_RECONCILIATION_TOLERANCE_KRW, db_path: str | None = None) -> dict[str, Any]:
"""Preview manual/partial-import account balance reconciliation without mutating transactions."""
try:
baseline_at_norm = normalize_snapshot_at(baseline_at.strip(), default_now=False) if baseline_at and baseline_at.strip() else None
tolerance_int = int(tolerance)
if tolerance_int < 0:
raise ValueError("tolerance must be non-negative")
baseline_balance_int = int(baseline_balance) if baseline_balance is not None else None
except Exception as e:
return fail("validation_error", str(e))
with db(db_path) as conn:
clauses = ["active=1"]
params: list[Any] = []
if account_id:
clauses.append("id=?")
params.append(account_id)
else:
placeholders = ",".join("?" for _ in RECONCILIATION_TARGET_STATUSES)
clauses.append(f"statement_status IN ({placeholders})")
params.extend(sorted(RECONCILIATION_TARGET_STATUSES))
accounts = conn.execute(f"SELECT * FROM accounts WHERE {' AND '.join(clauses)} ORDER BY name", params).fetchall()
items: list[dict[str, Any]] = []
for account in accounts:
effective_baseline = baseline_balance_int if baseline_balance_int is not None else account["last_balance"]
effective_at = baseline_at_norm or account["last_synced_at"] or now_iso()
calc = _account_balance_row(conn, account["id"], effective_at)
if effective_baseline is None:
difference = None
ratio = None
status = "BASELINE_REQUIRED"
else:
difference = int(effective_baseline) - int(calc["calculated_balance"])
ratio = abs(difference) / abs(int(effective_baseline)) if int(effective_baseline) else None
status = "OK" if abs(difference) <= tolerance_int else "REVIEW_NEEDED"
items.append({
"account_id": account["id"],
"account_name": account["name"],
"statement_status": account["statement_status"],
"is_reconciliation_target": account["statement_status"] in RECONCILIATION_TARGET_STATUSES,
"baseline_balance": effective_baseline,
"baseline_at": effective_at,
"calculated_balance": calc["calculated_balance"],
"difference": difference,
"tolerance": tolerance_int,
"reference_ratio_threshold": BALANCE_RECONCILIATION_REFERENCE_RATIO,
"difference_ratio": ratio,
"status": status,
"recommended_decisions": ["계좌이동", "실지출", "실수입", "투자이동", "분석제외", "확인필요"] if status == "REVIEW_NEEDED" else [],
"source_note": source_note,
"memo": memo,
**calc,
})
return ok(items=items, count=len(items), tolerance=tolerance_int, baseline_at=baseline_at_norm, routing="preview_only_manual_confirmation_required")
def list_ledger_budgets(month: str = "", db_path: str | None = None) -> dict[str, Any]: def list_ledger_budgets(month: str = "", db_path: str | None = None) -> dict[str, Any]:
clauses=[]; params=[] clauses=[]; params=[]
@@ -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)
@@ -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
@@ -0,0 +1 @@
from ...kb_bank_pdf import parse_kb_bank_pdf, transaction_preview
@@ -0,0 +1 @@
from ...seoulpay_xlsx import parse_seoulpay_xlsx, transaction_preview
@@ -0,0 +1 @@
from ...toss_bank_pdf import parse_toss_bank_pdf, transaction_preview
@@ -0,0 +1 @@
from ...toss_securities_pdf import parse_toss_securities_pdf, transaction_preview
+15
View File
@@ -5,20 +5,30 @@ from types import ModuleType
from .application import accounts_write_services as _accounts_write_services from .application import accounts_write_services as _accounts_write_services
from .application import asset_account_services as _asset_account_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 budgets_write_services as _budgets_write_services
from .application import category_rules_write_services as _category_rules_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 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 import_transfer_services as _import_transfer_services
from .application import reconciliation_service as _reconciliation_service
from .application import shared as _shared from .application import shared as _shared
from .application import system_services as _system_services from .application import system_services as _system_services
from .application import transactions_write_services as _transactions_write_services from .application import transactions_write_services as _transactions_write_services
from .application import transaction_report_services as _transaction_report_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.asset_account_services import *
from .application.assets_service import *
from .application.categorization_services 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.import_transfer_services import *
from .application.reconciliation_service import *
from .application.shared import * from .application.shared import *
from .application.system_services import * from .application.system_services import *
from .application.transaction_report_services import * from .application.transaction_report_services import *
from .application.uncategorized_workbench_service import *
# Keep compatibility for tests/monkeypatches that reach into service internals. # Keep compatibility for tests/monkeypatches that reach into service internals.
_current_kst_datetime = _shared._current_kst_datetime _current_kst_datetime = _shared._current_kst_datetime
@@ -28,11 +38,16 @@ _PROXY_MODULES = (
_accounts_write_services, _accounts_write_services,
_system_services, _system_services,
_asset_account_services, _asset_account_services,
_assets_service,
_budgets_write_services, _budgets_write_services,
_category_rules_write_services, _category_rules_write_services,
_transaction_report_services, _transaction_report_services,
_reconciliation_service,
_imports_service,
_import_transfer_services, _import_transfer_services,
_cross_transfer_service,
_categorization_services, _categorization_services,
_uncategorized_workbench_service,
_transactions_write_services, _transactions_write_services,
) )