refactor(jarca-ledger): implement BAT-010 backend core layering
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .shared import *
|
||||
|
||||
def create_ledger_transaction(transaction_date: str = "", type: str = "expense", amount: int | float = 0, category: str = "", subcategory: str = "", merchant: str = "", description: str = "", account_id: str = "", payment_method: str = "", person: str = "", project: str = "", tags: Any = None, is_fixed: bool = False, currency: str = "KRW", posted_date: str = "", source: str = "manual", external_id: str = "", memo: str = "", dedupe: bool = True, transaction_datetime: str = "", counterparty: str = "", balance_after: int | None = None, branch: str = "", raw_summary: str = "", raw_payload: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
tx_date = normalize_date(transaction_date, default_today=True)
|
||||
posted = normalize_date(posted_date, default_today=False) if posted_date else ""
|
||||
amount_int = int(amount)
|
||||
tags_json = normalize_tags(tags)
|
||||
except Exception as e:
|
||||
return fail("validation_error", str(e))
|
||||
if type not in VALID_TRANSACTION_TYPES:
|
||||
return fail("validation_error", f"type must be one of {sorted(VALID_TRANSACTION_TYPES)}")
|
||||
if amount_int <= 0:
|
||||
return fail("validation_error", "amount must be a positive integer in minor units; type determines direction")
|
||||
dedupe_key = make_dedupe_key(tx_date, amount_int, merchant, account_id, external_id) if dedupe else ""
|
||||
ts = now_iso(); tx_id = new_id("tx")
|
||||
try:
|
||||
with db(db_path) as conn:
|
||||
conn.execute("""INSERT INTO transactions(id,transaction_date,posted_date,type,amount,currency,category,subcategory,merchant,description,account_id,payment_method,person,project,tags,is_fixed,source,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (tx_id, tx_date, posted, type, amount_int, currency, category, subcategory, merchant, description, account_id, payment_method, person, project, tags_json, int(is_fixed), source, external_id, dedupe_key, memo, transaction_datetime, counterparty, balance_after, branch, raw_summary, raw_payload, ts, ts))
|
||||
row = conn.execute("SELECT * FROM transactions WHERE id=?", (tx_id,)).fetchone()
|
||||
except Exception as e:
|
||||
if "UNIQUE constraint failed" in str(e):
|
||||
return fail("duplicate_transaction", "transaction appears to be duplicated", dedupe_key=dedupe_key)
|
||||
return fail("db_error", str(e))
|
||||
return ok(transaction=row_to_dict(row))
|
||||
|
||||
def list_ledger_transactions(start_date: str = "", end_date: str = "", type: str = "", category: str = "", person: str = "", account_id: str = "", include_deleted: bool = False, limit: int = 100, db_path: str | None = None) -> dict[str, Any]:
|
||||
clauses=[]; params=[]
|
||||
if not include_deleted:
|
||||
clauses.append("status='active'")
|
||||
for field, value in [("type", type), ("category", category), ("person", person), ("account_id", account_id)]:
|
||||
if value:
|
||||
clauses.append(f"{field}=?"); params.append(value)
|
||||
if start_date:
|
||||
clauses.append("transaction_date>=?"); params.append(normalize_date(start_date))
|
||||
if end_date:
|
||||
clauses.append("transaction_date<=?"); params.append(normalize_date(end_date))
|
||||
where = " WHERE " + " AND ".join(clauses) if clauses else ""
|
||||
sql = f"SELECT * FROM transactions{where} ORDER BY transaction_date DESC, created_at DESC LIMIT ?"
|
||||
params.append(max(1, min(int(limit), 1000)))
|
||||
with db(db_path) as conn:
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows))
|
||||
|
||||
|
||||
def _transaction_signed_amount(tx_type: str, amount: int) -> int:
|
||||
if tx_type in {"income", "refund", "adjustment"}:
|
||||
return int(amount)
|
||||
if tx_type in {"expense", "transfer"}:
|
||||
return -int(amount)
|
||||
return 0
|
||||
|
||||
|
||||
def _account_balance_row(conn: sqlite3.Connection, account_id: str, baseline_at: str) -> dict[str, Any]:
|
||||
date_cutoff = normalize_date(baseline_at[:10])
|
||||
rows = conn.execute(
|
||||
"""SELECT type, amount, balance_after, transaction_date, transaction_datetime, created_at
|
||||
FROM transactions
|
||||
WHERE status='active' AND account_id=? AND transaction_date<=?
|
||||
ORDER BY transaction_date ASC, transaction_datetime ASC, created_at ASC""",
|
||||
(account_id, date_cutoff),
|
||||
).fetchall()
|
||||
signed_total = sum(_transaction_signed_amount(row["type"], int(row["amount"] or 0)) for row in rows)
|
||||
latest_balance_row = next((row for row in reversed(rows) if row["balance_after"] is not None), None)
|
||||
if latest_balance_row is not None:
|
||||
calculated_balance = int(latest_balance_row["balance_after"])
|
||||
basis = "latest_transaction_balance_after"
|
||||
basis_at = latest_balance_row["transaction_datetime"] or latest_balance_row["transaction_date"]
|
||||
else:
|
||||
calculated_balance = int(signed_total)
|
||||
basis = "net_transaction_delta_without_opening_balance"
|
||||
basis_at = baseline_at
|
||||
return {
|
||||
"calculated_balance": calculated_balance,
|
||||
"calculation_basis": basis,
|
||||
"calculation_basis_at": basis_at,
|
||||
"transaction_count": len(rows),
|
||||
"signed_transaction_total": int(signed_total),
|
||||
}
|
||||
|
||||
|
||||
def get_balance_reconciliation_report(account_id: str = "", baseline_balance: int | None = None, baseline_at: str = "", source_note: str = "", memo: str = "", tolerance: int = BALANCE_RECONCILIATION_TOLERANCE_KRW, db_path: str | None = None) -> dict[str, Any]:
|
||||
"""Preview manual/partial-import account balance reconciliation without mutating transactions."""
|
||||
try:
|
||||
baseline_at_norm = normalize_snapshot_at(baseline_at.strip(), default_now=False) if baseline_at and baseline_at.strip() else None
|
||||
tolerance_int = int(tolerance)
|
||||
if tolerance_int < 0:
|
||||
raise ValueError("tolerance must be non-negative")
|
||||
baseline_balance_int = int(baseline_balance) if baseline_balance is not None else None
|
||||
except Exception as e:
|
||||
return fail("validation_error", str(e))
|
||||
with db(db_path) as conn:
|
||||
clauses = ["active=1"]
|
||||
params: list[Any] = []
|
||||
if account_id:
|
||||
clauses.append("id=?")
|
||||
params.append(account_id)
|
||||
else:
|
||||
placeholders = ",".join("?" for _ in RECONCILIATION_TARGET_STATUSES)
|
||||
clauses.append(f"statement_status IN ({placeholders})")
|
||||
params.extend(sorted(RECONCILIATION_TARGET_STATUSES))
|
||||
accounts = conn.execute(f"SELECT * FROM accounts WHERE {' AND '.join(clauses)} ORDER BY name", params).fetchall()
|
||||
items: list[dict[str, Any]] = []
|
||||
for account in accounts:
|
||||
effective_baseline = baseline_balance_int if baseline_balance_int is not None else account["last_balance"]
|
||||
effective_at = baseline_at_norm or account["last_synced_at"] or now_iso()
|
||||
calc = _account_balance_row(conn, account["id"], effective_at)
|
||||
if effective_baseline is None:
|
||||
difference = None
|
||||
ratio = None
|
||||
status = "BASELINE_REQUIRED"
|
||||
else:
|
||||
difference = int(effective_baseline) - int(calc["calculated_balance"])
|
||||
ratio = abs(difference) / abs(int(effective_baseline)) if int(effective_baseline) else None
|
||||
status = "OK" if abs(difference) <= tolerance_int else "REVIEW_NEEDED"
|
||||
items.append({
|
||||
"account_id": account["id"],
|
||||
"account_name": account["name"],
|
||||
"statement_status": account["statement_status"],
|
||||
"is_reconciliation_target": account["statement_status"] in RECONCILIATION_TARGET_STATUSES,
|
||||
"baseline_balance": effective_baseline,
|
||||
"baseline_at": effective_at,
|
||||
"calculated_balance": calc["calculated_balance"],
|
||||
"difference": difference,
|
||||
"tolerance": tolerance_int,
|
||||
"reference_ratio_threshold": BALANCE_RECONCILIATION_REFERENCE_RATIO,
|
||||
"difference_ratio": ratio,
|
||||
"status": status,
|
||||
"recommended_decisions": ["계좌이동", "실지출", "실수입", "투자이동", "분석제외", "확인필요"] if status == "REVIEW_NEEDED" else [],
|
||||
"source_note": source_note,
|
||||
"memo": memo,
|
||||
**calc,
|
||||
})
|
||||
return ok(items=items, count=len(items), tolerance=tolerance_int, baseline_at=baseline_at_norm, routing="preview_only_manual_confirmation_required")
|
||||
|
||||
def update_ledger_transaction(transaction_id: str, db_path: str | None = None, **fields: Any) -> dict[str, Any]:
|
||||
allowed = {"transaction_date","posted_date","type","amount","currency","category","subcategory","merchant","description","account_id","payment_method","person","project","tags","is_fixed","source","external_id","memo","status","transaction_datetime","counterparty","balance_after","branch","raw_summary","raw_payload"}
|
||||
updates=[]; params=[]
|
||||
for k,v in fields.items():
|
||||
if k not in allowed or v is None:
|
||||
continue
|
||||
if k == "tags": v = normalize_tags(v)
|
||||
if k in {"transaction_date","posted_date"} and v: v = normalize_date(v)
|
||||
if k == "amount": v = int(v)
|
||||
if k == "is_fixed": v = int(bool(v))
|
||||
updates.append(f"{k}=?"); params.append(v)
|
||||
if not updates:
|
||||
return fail("validation_error", "no fields to update")
|
||||
updates.append("updated_at=?"); params.append(now_iso()); params.append(transaction_id)
|
||||
with db(db_path) as conn:
|
||||
cur = conn.execute(f"UPDATE transactions SET {', '.join(updates)} WHERE id=?", params)
|
||||
if cur.rowcount == 0:
|
||||
return fail("not_found", "transaction not found")
|
||||
row = conn.execute("SELECT * FROM transactions WHERE id=?", (transaction_id,)).fetchone()
|
||||
return ok(transaction=row_to_dict(row))
|
||||
|
||||
def delete_ledger_transaction(transaction_id: str, hard_delete: bool = False, db_path: str | None = None) -> dict[str, Any]:
|
||||
with db(db_path) as conn:
|
||||
if hard_delete:
|
||||
cur = conn.execute("DELETE FROM transactions WHERE id=?", (transaction_id,))
|
||||
else:
|
||||
cur = conn.execute("UPDATE transactions SET status='deleted', updated_at=? WHERE id=?", (now_iso(), transaction_id))
|
||||
if cur.rowcount == 0:
|
||||
return fail("not_found", "transaction not found")
|
||||
return ok(transaction_id=transaction_id, deleted=True, hard_delete=hard_delete)
|
||||
|
||||
def create_ledger_budget(month: str, category: str, amount: int | float, subcategory: str = "", person: str = "", currency: str = "KRW", memo: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
month_norm = normalize_month(month); amount_int = int(amount)
|
||||
except Exception as e:
|
||||
return fail("validation_error", str(e))
|
||||
if not category:
|
||||
return fail("validation_error", "category is required")
|
||||
if amount_int < 0:
|
||||
return fail("validation_error", "amount must be non-negative")
|
||||
ts = now_iso(); budget_id = new_id("bud")
|
||||
with db(db_path) as conn:
|
||||
conn.execute("""INSERT INTO budgets(id,month,category,subcategory,person,amount,currency,memo,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(month,category,subcategory,person) DO UPDATE SET amount=excluded.amount,currency=excluded.currency,memo=excluded.memo,updated_at=excluded.updated_at""", (budget_id, month_norm, category, subcategory, person, amount_int, currency, memo, ts, ts))
|
||||
row = conn.execute("SELECT * FROM budgets WHERE month=? AND category=? AND subcategory=? AND person=?", (month_norm, category, subcategory, person)).fetchone()
|
||||
return ok(budget=row_to_dict(row))
|
||||
|
||||
def list_ledger_budgets(month: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||
clauses=[]; params=[]
|
||||
if month:
|
||||
clauses.append("month=?"); params.append(normalize_month(month))
|
||||
where = " WHERE " + " AND ".join(clauses) if clauses else ""
|
||||
with db(db_path) as conn:
|
||||
rows = conn.execute(f"SELECT * FROM budgets{where} ORDER BY month DESC, category", params).fetchall()
|
||||
return ok(budgets=[row_to_dict(r) for r in rows], count=len(rows))
|
||||
|
||||
def _month_range(month: str) -> tuple[str, str]:
|
||||
m = normalize_month(month)
|
||||
y, mo = map(int, m.split("-"))
|
||||
start = date(y, mo, 1)
|
||||
end = date(y + (mo == 12), 1 if mo == 12 else mo + 1, 1)
|
||||
return start.isoformat(), end.isoformat()
|
||||
|
||||
def get_monthly_cashflow_report(month: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||
month_norm = normalize_month(month); start, next_month = _month_range(month_norm)
|
||||
with db(db_path) as conn:
|
||||
type_rows = conn.execute("""SELECT type, SUM(amount) AS total FROM transactions
|
||||
WHERE status='active' AND transaction_date>=? AND transaction_date<?
|
||||
GROUP BY type""", (start, next_month)).fetchall()
|
||||
cashflow_rows = conn.execute(f"""SELECT {CASHFLOW_KIND_SQL} AS cashflow_kind, SUM(t.amount) AS total
|
||||
FROM transactions t
|
||||
LEFT JOIN ledger_categories lc
|
||||
ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
|
||||
WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date<?
|
||||
GROUP BY {CASHFLOW_KIND_SQL}""", (start, next_month)).fetchall()
|
||||
type_totals = {r["type"]: int(r["total"] or 0) for r in type_rows}
|
||||
cashflow_totals = {r["cashflow_kind"]: int(r["total"] or 0) for r in cashflow_rows}
|
||||
income = cashflow_totals.get("income", 0)
|
||||
expenses = cashflow_totals.get("expense", 0)
|
||||
net_cashflow = income - expenses
|
||||
savings_rate = round(net_cashflow / income, 4) if income else None
|
||||
return ok(month=month_norm, income=income, expenses=expenses, net_cashflow=net_cashflow, savings_rate=savings_rate, by_type=type_totals, by_cashflow_kind=cashflow_totals)
|
||||
|
||||
def get_category_spending_report(month: str = "", start_date: str = "", end_date: str = "", person: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||
if month:
|
||||
start_date, next_month = _month_range(month); end_op = "<"; end_value = next_month
|
||||
else:
|
||||
start_date = normalize_date(start_date, default_today=False) if start_date else "0001-01-01"
|
||||
end_value = normalize_date(end_date, default_today=False) if end_date else "9999-12-31"; end_op = "<="
|
||||
params=[start_date, end_value]; person_clause=""
|
||||
if person:
|
||||
person_clause=" AND t.person=?"; params.append(person)
|
||||
sql=f"""SELECT t.category, t.subcategory, SUM(t.amount) AS total, COUNT(*) AS count FROM transactions t
|
||||
LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
|
||||
WHERE t.status='active' AND {CASHFLOW_KIND_SQL}='expense' AND t.transaction_date>=? AND t.transaction_date{end_op}?{person_clause}
|
||||
GROUP BY t.category, t.subcategory ORDER BY total DESC"""
|
||||
with db(db_path) as conn:
|
||||
rows=conn.execute(sql, params).fetchall()
|
||||
total=sum(int(r["total"] or 0) for r in rows)
|
||||
items=[{"category": r["category"], "subcategory": r["subcategory"], "amount": int(r["total"] or 0), "count": r["count"], "share": round((int(r["total"] or 0)/total),4) if total else 0} for r in rows]
|
||||
return ok(total_expense=total, items=items, count=len(items))
|
||||
|
||||
def get_fixed_cost_report(month: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||
month_norm=normalize_month(month); start,next_month=_month_range(month_norm)
|
||||
with db(db_path) as conn:
|
||||
rows=conn.execute(f"""SELECT t.category, t.merchant, t.amount, t.transaction_date, t.person, t.memo FROM transactions t
|
||||
LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
|
||||
WHERE t.status='active' AND {CASHFLOW_KIND_SQL}='expense' AND t.is_fixed=1 AND t.transaction_date>=? AND t.transaction_date<?
|
||||
ORDER BY t.amount DESC""", (start,next_month)).fetchall()
|
||||
items=[row_to_dict(r) for r in rows]
|
||||
return ok(month=month_norm, total_fixed_cost=sum(i["amount"] for i in items), items=items, count=len(items))
|
||||
|
||||
def get_budget_vs_actual_report(month: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||
month_norm=normalize_month(month); start,next_month=_month_range(month_norm)
|
||||
with db(db_path) as conn:
|
||||
budgets=conn.execute("SELECT * FROM budgets WHERE month=?", (month_norm,)).fetchall()
|
||||
actuals=conn.execute(f"""SELECT t.category, t.subcategory, t.person, SUM(t.amount) AS amount FROM transactions t
|
||||
LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
|
||||
WHERE t.status='active' AND {CASHFLOW_KIND_SQL}='expense' AND t.transaction_date>=? AND t.transaction_date<?
|
||||
GROUP BY t.category, t.subcategory, t.person""", (start,next_month)).fetchall()
|
||||
actual_map={(r["category"], r["subcategory"], r["person"]): int(r["amount"] or 0) for r in actuals}
|
||||
items=[]
|
||||
for b in budgets:
|
||||
key=(b["category"], b["subcategory"], b["person"]); actual=actual_map.get(key,0); budget=int(b["amount"])
|
||||
items.append({"category": b["category"], "subcategory": b["subcategory"], "person": b["person"], "budget": budget, "actual": actual, "remaining": budget-actual, "usage_rate": round(actual/budget,4) if budget else None, "status": "over" if actual>budget else "ok"})
|
||||
return ok(month=month_norm, items=items, total_budget=sum(i["budget"] for i in items), total_actual=sum(i["actual"] for i in items))
|
||||
|
||||
def get_investable_cashflow_report(month: str = "", minimum_cash_buffer: int = 0, planned_extra_expense: int = 0, db_path: str | None = None) -> dict[str, Any]:
|
||||
cashflow=get_monthly_cashflow_report(month, db_path)
|
||||
if not cashflow.get("success"):
|
||||
return cashflow
|
||||
investable=max(0, int(cashflow["net_cashflow"]) - int(minimum_cash_buffer) - int(planned_extra_expense))
|
||||
return ok(month=cashflow["month"], income=cashflow["income"], expenses=cashflow["expenses"], net_cashflow=cashflow["net_cashflow"], minimum_cash_buffer=int(minimum_cash_buffer), planned_extra_expense=int(planned_extra_expense), investable_cashflow=investable)
|
||||
Reference in New Issue
Block a user