Implement BAT-011 transactional write service split

This commit is contained in:
Jarca
2026-07-03 07:42:16 +09:00
parent 7ac25a6cf7
commit ac78e94571
21 changed files with 844 additions and 228 deletions
+4
View File
@@ -1,6 +1,10 @@
from . import shared
from .accounts_write_services import *
from .asset_account_services import *
from .budgets_write_services import *
from .category_rules_write_services import *
from .categorization_services import *
from .import_transfer_services import *
from .system_services import *
from .transactions_write_services import *
from .transaction_report_services import *
@@ -0,0 +1,55 @@
from __future__ import annotations
from .shared import *
def create_ledger_account(name: str, type: str = "bank", institution: str = "", currency: str = "KRW", active: bool = True, memo: str = "", account_number_masked: str = "", holder_name: str = "", last_balance: int | None = None, last_synced_at: str = "", statement_status: str = "unknown", liquidity_bucket: str = "other", db_path: str | None = None) -> dict[str, Any]:
if not name.strip():
return fail("validation_error", "account name is required")
if type not in VALID_ACCOUNT_TYPES:
return fail("validation_error", f"type must be one of {sorted(VALID_ACCOUNT_TYPES)}")
try:
statement_status_norm = normalize_statement_status(statement_status)
liquidity_bucket_norm = normalize_liquidity_bucket(liquidity_bucket)
except Exception as e:
return fail("validation_error", str(e))
ts = now_iso(); account_id = new_id("acct")
with db(db_path) as conn:
conn.execute("""INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,statement_status,liquidity_bucket,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (account_id, name.strip(), type, institution, currency, int(active), memo, account_number_masked, holder_name, last_balance, last_synced_at, statement_status_norm, liquidity_bucket_norm, ts, ts))
row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
return ok(account=row_to_dict(row))
def update_ledger_account(account_id: str, db_path: str | None = None, **fields: Any) -> dict[str, Any]:
allowed = {"name", "type", "institution", "currency", "active", "memo", "account_number_masked", "holder_name", "last_balance", "last_synced_at", "statement_status", "liquidity_bucket"}
updates: list[str] = []
params: list[Any] = []
for k, v in fields.items():
if k not in allowed or v is None:
continue
try:
if k == "type" and v not in VALID_ACCOUNT_TYPES:
return fail("validation_error", f"type must be one of {sorted(VALID_ACCOUNT_TYPES)}")
if k == "statement_status":
v = normalize_statement_status(str(v))
if k == "liquidity_bucket":
v = normalize_liquidity_bucket(str(v))
if k == "active":
v = int(bool(v))
if k == "last_balance" and v != "":
v = int(v)
except Exception as e:
return fail("validation_error", str(e))
updates.append(f"{k}=?")
params.append(v)
if not updates:
return fail("validation_error", "no fields to update")
updates.append("updated_at=?")
params.extend([now_iso(), account_id])
with db(db_path) as conn:
cur = conn.execute(f"UPDATE accounts SET {', '.join(updates)} WHERE id=?", params)
if cur.rowcount == 0:
return fail("not_found", "account not found")
row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
return ok(account=row_to_dict(row))
@@ -490,35 +490,4 @@ def get_asset_liquidity_report(db_path: str | None = None) -> dict[str, Any]:
)
def update_ledger_account(account_id: str, db_path: str | None = None, **fields: Any) -> dict[str, Any]:
allowed = {"name", "type", "institution", "currency", "active", "memo", "account_number_masked", "holder_name", "last_balance", "last_synced_at", "statement_status", "liquidity_bucket"}
updates: list[str] = []
params: list[Any] = []
for k, v in fields.items():
if k not in allowed or v is None:
continue
try:
if k == "type" and v not in VALID_ACCOUNT_TYPES:
return fail("validation_error", f"type must be one of {sorted(VALID_ACCOUNT_TYPES)}")
if k == "statement_status":
v = normalize_statement_status(str(v))
if k == "liquidity_bucket":
v = normalize_liquidity_bucket(str(v))
if k == "active":
v = int(bool(v))
if k == "last_balance" and v != "":
v = int(v)
except Exception as e:
return fail("validation_error", str(e))
updates.append(f"{k}=?")
params.append(v)
if not updates:
return fail("validation_error", "no fields to update")
updates.append("updated_at=?")
params.extend([now_iso(), account_id])
with db(db_path) as conn:
cur = conn.execute(f"UPDATE accounts SET {', '.join(updates)} WHERE id=?", params)
if cur.rowcount == 0:
return fail("not_found", "account not found")
row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
return ok(account=row_to_dict(row))
from .accounts_write_services import update_ledger_account
@@ -0,0 +1,21 @@
from __future__ import annotations
from .shared import *
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))
@@ -67,27 +67,13 @@ def _rule_matches(rule: dict[str, Any], tx: dict[str, Any]) -> bool:
return False
def create_category_rule(match_field: str, pattern: str, category: str, subcategory: str = "", transaction_type: str = "", match_type: str = "contains", priority: int = 100, person: str = "", is_fixed: bool | None = None, confidence: float = 1.0, memo: str = "", amount_min: int | None = None, amount_max: int | None = None, day_of_month: int | None = None, db_path: str | None = None) -> dict[str, Any]:
if match_field not in RULE_FIELDS:
return fail("validation_error", f"match_field must be one of {sorted(RULE_FIELDS)}")
if match_type not in RULE_MATCH_TYPES:
return fail("validation_error", f"match_type must be one of {sorted(RULE_MATCH_TYPES)}")
if transaction_type and transaction_type not in VALID_TRANSACTION_TYPES:
return fail("validation_error", f"transaction_type must be empty or one of {sorted(VALID_TRANSACTION_TYPES)}")
if day_of_month is not None and not 1 <= int(day_of_month) <= 31:
return fail("validation_error", "day_of_month must be between 1 and 31")
if amount_min is not None and amount_max is not None and int(amount_min) > int(amount_max):
return fail("validation_error", "amount_min must be <= amount_max")
if not pattern or not category:
return fail("validation_error", "pattern and category are required")
ts=now_iso(); rule_id=new_id("rule")
with db(db_path) as conn:
conn.execute("""INSERT INTO category_rules(id,priority,match_field,match_type,pattern,amount_min,amount_max,day_of_month,transaction_type,category,subcategory,person,is_fixed,confidence,active,memo,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(match_field,match_type,pattern,transaction_type,category,subcategory,person) DO UPDATE SET priority=excluded.priority,amount_min=excluded.amount_min,amount_max=excluded.amount_max,day_of_month=excluded.day_of_month,is_fixed=excluded.is_fixed,confidence=excluded.confidence,active=1,memo=excluded.memo,updated_at=excluded.updated_at""",
(rule_id, int(priority), match_field, match_type, pattern, None if amount_min is None else int(amount_min), None if amount_max is None else int(amount_max), None if day_of_month is None else int(day_of_month), transaction_type, category, subcategory, person, None if is_fixed is None else int(is_fixed), float(confidence), 1, memo, ts, ts))
row=conn.execute("SELECT * FROM category_rules WHERE match_field=? AND match_type=? AND pattern=? AND transaction_type=? AND category=? AND subcategory=? AND person=?", (match_field, match_type, pattern, transaction_type, category, subcategory, person)).fetchone()
return ok(rule=row_to_dict(row))
from .category_rules_write_services import (
create_category_rule,
create_ledger_category,
delete_category_rule,
delete_ledger_category,
update_category_rule,
)
def seed_default_category_rules(overwrite: bool = False, db_path: str | None = None) -> dict[str, Any]:
@@ -112,59 +98,8 @@ def list_category_rules(active_only: bool = True, db_path: str | None = None) ->
return ok(rules=[row_to_dict(r) for r in rows], count=len(rows))
def update_category_rule(rule_id: str, match_field: str, pattern: str, category: str, subcategory: str = "", transaction_type: str = "", match_type: str = "contains", priority: int = 100, person: str = "", is_fixed: bool | None = None, confidence: float = 1.0, memo: str = "", amount_min: int | None = None, amount_max: int | None = None, day_of_month: int | None = None, active: bool = True, db_path: str | None = None) -> dict[str, Any]:
if match_field not in RULE_FIELDS:
return fail("validation_error", f"match_field must be one of {sorted(RULE_FIELDS)}")
if match_type not in RULE_MATCH_TYPES:
return fail("validation_error", f"match_type must be one of {sorted(RULE_MATCH_TYPES)}")
if transaction_type and transaction_type not in VALID_TRANSACTION_TYPES:
return fail("validation_error", f"transaction_type must be empty or one of {sorted(VALID_TRANSACTION_TYPES)}")
if day_of_month is not None and not 1 <= int(day_of_month) <= 31:
return fail("validation_error", "day_of_month must be between 1 and 31")
if amount_min is not None and amount_max is not None and int(amount_min) > int(amount_max):
return fail("validation_error", "amount_min must be <= amount_max")
if not pattern or not category:
return fail("validation_error", "pattern and category are required")
ts = now_iso()
with db(db_path) as conn:
existing = conn.execute("SELECT * FROM category_rules WHERE id=?", (rule_id,)).fetchone()
if not existing:
return fail("not_found", f"category rule not found: {rule_id}")
duplicate = conn.execute(
"SELECT id FROM category_rules WHERE match_field=? AND match_type=? AND pattern=? AND transaction_type=? AND category=? AND subcategory=? AND person=? AND id<>?",
(match_field, match_type, pattern, transaction_type, category, subcategory, person, rule_id),
).fetchone()
if duplicate:
return fail("conflict", f"another rule already exists with the same unique fields: {duplicate['id']}")
conn.execute(
"""UPDATE category_rules
SET priority=?, match_field=?, match_type=?, pattern=?, amount_min=?, amount_max=?, day_of_month=?,
transaction_type=?, category=?, subcategory=?, person=?, is_fixed=?, confidence=?, active=?, memo=?, updated_at=?
WHERE id=?""",
(
int(priority), match_field, match_type, pattern,
None if amount_min is None else int(amount_min),
None if amount_max is None else int(amount_max),
None if day_of_month is None else int(day_of_month),
transaction_type, category, subcategory, person,
None if is_fixed is None else int(is_fixed),
float(confidence), int(bool(active)), memo, ts, rule_id,
),
)
row = conn.execute("SELECT * FROM category_rules WHERE id=?", (rule_id,)).fetchone()
return ok(rule=row_to_dict(row))
def delete_category_rule(rule_id: str, db_path: str | None = None) -> dict[str, Any]:
if not rule_id:
return fail("validation_error", "rule_id is required")
with db(db_path) as conn:
existing = conn.execute("SELECT * FROM category_rules WHERE id=?", (rule_id,)).fetchone()
if not existing:
return fail("not_found", f"category rule not found: {rule_id}")
conn.execute("DELETE FROM category_rules WHERE id=?", (rule_id,))
return ok(deleted_rule_id=rule_id)
def _load_active_rules(conn: Any) -> list[dict[str, Any]]:
return [row_to_dict(r) for r in conn.execute("SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id").fetchall()]
@@ -628,22 +563,7 @@ def infer_cashflow_kind(category: str, subcategory: str = "") -> str:
return "expense"
def create_ledger_category(category: str, subcategory: str = "", sort_order: int = 100, memo: str = "", cashflow_kind: str = "", db_path: str | None = None) -> dict[str, Any]:
category = (category or "").strip()
subcategory = (subcategory or "").strip()
cashflow_kind = (cashflow_kind or infer_cashflow_kind(category, subcategory)).strip()
if not category:
return fail("validation_error", "category is required")
if cashflow_kind not in VALID_CASHFLOW_KINDS:
return fail("validation_error", f"cashflow_kind must be one of {sorted(VALID_CASHFLOW_KINDS)}")
ts = now_iso(); category_id = new_id("cat")
with db(db_path) as conn:
conn.execute("""INSERT INTO ledger_categories(id,category,subcategory,cashflow_kind,active,sort_order,memo,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)
ON CONFLICT(category, subcategory) DO UPDATE SET active=1, cashflow_kind=excluded.cashflow_kind, sort_order=excluded.sort_order, memo=excluded.memo, updated_at=excluded.updated_at""",
(category_id, category, subcategory, cashflow_kind, 1, int(sort_order), memo, ts, ts))
row = conn.execute("SELECT * FROM ledger_categories WHERE category=? AND subcategory=?", (category, subcategory)).fetchone()
return ok(category=row_to_dict(row))
def seed_default_ledger_categories(overwrite: bool = False, db_path: str | None = None) -> dict[str, Any]:
@@ -681,23 +601,4 @@ def list_ledger_categories(active_only: bool = True, include_usage: bool = True,
return ok(categories=items, grouped=grouped, count=len(items))
def delete_ledger_category(category: str, subcategory: str = "", hard_delete: bool = False, force: bool = False, db_path: str | None = None) -> dict[str, Any]:
category = (category or "").strip()
subcategory = (subcategory or "").strip()
if not category:
return fail("validation_error", "category is required")
with db(db_path) as conn:
row = conn.execute("SELECT * FROM ledger_categories WHERE category=? AND subcategory=?", (category, subcategory)).fetchone()
if not row:
return fail("not_found", "category not found")
tx_count = conn.execute("SELECT COUNT(*) FROM transactions WHERE status='active' AND category=? AND subcategory=?", (category, subcategory)).fetchone()[0]
budget_count = conn.execute("SELECT COUNT(*) FROM budgets WHERE category=? AND subcategory=?", (category, subcategory)).fetchone()[0]
rule_count = conn.execute("SELECT COUNT(*) FROM category_rules WHERE active=1 AND category=? AND subcategory=?", (category, subcategory)).fetchone()[0]
in_use = int(tx_count or 0) + int(budget_count or 0) + int(rule_count or 0)
if in_use and hard_delete and not force:
return fail("category_in_use", "category is referenced; use soft delete or force=true", transaction_count=tx_count, budget_count=budget_count, rule_count=rule_count)
if hard_delete:
conn.execute("DELETE FROM ledger_categories WHERE id=?", (row["id"],))
else:
conn.execute("UPDATE ledger_categories SET active=0, updated_at=? WHERE id=?", (now_iso(), row["id"]))
return ok(category=category, subcategory=subcategory, deleted=True, hard_delete=hard_delete, transaction_count=tx_count, budget_count=budget_count, rule_count=rule_count)
@@ -0,0 +1,145 @@
from __future__ import annotations
from .shared import *
RULE_FIELDS = {"merchant", "counterparty", "description", "payment_method", "raw_summary", "branch", "memo"}
RULE_MATCH_TYPES = {"contains", "equals", "regex", "starts_with", "ends_with"}
def infer_cashflow_kind(category: str, subcategory: str = "") -> str:
if category == "수입":
return "income"
if category == "이체":
if subcategory in {"현금/계좌이동", "저축이동", "투자이동", "토스증권이체"}:
return "transfer"
return "exclude"
if category == "투자":
if subcategory in {"배당", "이자"}:
return "income"
return "exclude"
if category == "금융":
if subcategory == "대출상환":
return "debt_payment"
if subcategory in {"카드대금", "수수료"}:
return "exclude"
return "exclude"
if category == "미분류":
return "unknown"
return "expense"
def create_category_rule(match_field: str, pattern: str, category: str, subcategory: str = "", transaction_type: str = "", match_type: str = "contains", priority: int = 100, person: str = "", is_fixed: bool | None = None, confidence: float = 1.0, memo: str = "", amount_min: int | None = None, amount_max: int | None = None, day_of_month: int | None = None, db_path: str | None = None) -> dict[str, Any]:
if match_field not in RULE_FIELDS:
return fail("validation_error", f"match_field must be one of {sorted(RULE_FIELDS)}")
if match_type not in RULE_MATCH_TYPES:
return fail("validation_error", f"match_type must be one of {sorted(RULE_MATCH_TYPES)}")
if transaction_type and transaction_type not in VALID_TRANSACTION_TYPES:
return fail("validation_error", f"transaction_type must be empty or one of {sorted(VALID_TRANSACTION_TYPES)}")
if day_of_month is not None and not 1 <= int(day_of_month) <= 31:
return fail("validation_error", "day_of_month must be between 1 and 31")
if amount_min is not None and amount_max is not None and int(amount_min) > int(amount_max):
return fail("validation_error", "amount_min must be <= amount_max")
if not pattern or not category:
return fail("validation_error", "pattern and category are required")
ts=now_iso(); rule_id=new_id("rule")
with db(db_path) as conn:
conn.execute("""INSERT INTO category_rules(id,priority,match_field,match_type,pattern,amount_min,amount_max,day_of_month,transaction_type,category,subcategory,person,is_fixed,confidence,active,memo,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(match_field,match_type,pattern,transaction_type,category,subcategory,person) DO UPDATE SET priority=excluded.priority,amount_min=excluded.amount_min,amount_max=excluded.amount_max,day_of_month=excluded.day_of_month,is_fixed=excluded.is_fixed,confidence=excluded.confidence,active=1,memo=excluded.memo,updated_at=excluded.updated_at""",
(rule_id, int(priority), match_field, match_type, pattern, None if amount_min is None else int(amount_min), None if amount_max is None else int(amount_max), None if day_of_month is None else int(day_of_month), transaction_type, category, subcategory, person, None if is_fixed is None else int(is_fixed), float(confidence), 1, memo, ts, ts))
row=conn.execute("SELECT * FROM category_rules WHERE match_field=? AND match_type=? AND pattern=? AND transaction_type=? AND category=? AND subcategory=? AND person=?", (match_field, match_type, pattern, transaction_type, category, subcategory, person)).fetchone()
return ok(rule=row_to_dict(row))
def update_category_rule(rule_id: str, match_field: str, pattern: str, category: str, subcategory: str = "", transaction_type: str = "", match_type: str = "contains", priority: int = 100, person: str = "", is_fixed: bool | None = None, confidence: float = 1.0, memo: str = "", amount_min: int | None = None, amount_max: int | None = None, day_of_month: int | None = None, active: bool = True, db_path: str | None = None) -> dict[str, Any]:
if match_field not in RULE_FIELDS:
return fail("validation_error", f"match_field must be one of {sorted(RULE_FIELDS)}")
if match_type not in RULE_MATCH_TYPES:
return fail("validation_error", f"match_type must be one of {sorted(RULE_MATCH_TYPES)}")
if transaction_type and transaction_type not in VALID_TRANSACTION_TYPES:
return fail("validation_error", f"transaction_type must be empty or one of {sorted(VALID_TRANSACTION_TYPES)}")
if day_of_month is not None and not 1 <= int(day_of_month) <= 31:
return fail("validation_error", "day_of_month must be between 1 and 31")
if amount_min is not None and amount_max is not None and int(amount_min) > int(amount_max):
return fail("validation_error", "amount_min must be <= amount_max")
if not pattern or not category:
return fail("validation_error", "pattern and category are required")
ts = now_iso()
with db(db_path) as conn:
existing = conn.execute("SELECT * FROM category_rules WHERE id=?", (rule_id,)).fetchone()
if not existing:
return fail("not_found", f"category rule not found: {rule_id}")
duplicate = conn.execute(
"SELECT id FROM category_rules WHERE match_field=? AND match_type=? AND pattern=? AND transaction_type=? AND category=? AND subcategory=? AND person=? AND id<>?",
(match_field, match_type, pattern, transaction_type, category, subcategory, person, rule_id),
).fetchone()
if duplicate:
return fail("conflict", f"another rule already exists with the same unique fields: {duplicate['id']}")
conn.execute(
"""UPDATE category_rules
SET priority=?, match_field=?, match_type=?, pattern=?, amount_min=?, amount_max=?, day_of_month=?,
transaction_type=?, category=?, subcategory=?, person=?, is_fixed=?, confidence=?, active=?, memo=?, updated_at=?
WHERE id=?""",
(
int(priority), match_field, match_type, pattern,
None if amount_min is None else int(amount_min),
None if amount_max is None else int(amount_max),
None if day_of_month is None else int(day_of_month),
transaction_type, category, subcategory, person,
None if is_fixed is None else int(is_fixed),
float(confidence), int(bool(active)), memo, ts, rule_id,
),
)
row = conn.execute("SELECT * FROM category_rules WHERE id=?", (rule_id,)).fetchone()
return ok(rule=row_to_dict(row))
def delete_category_rule(rule_id: str, db_path: str | None = None) -> dict[str, Any]:
if not rule_id:
return fail("validation_error", "rule_id is required")
with db(db_path) as conn:
existing = conn.execute("SELECT * FROM category_rules WHERE id=?", (rule_id,)).fetchone()
if not existing:
return fail("not_found", f"category rule not found: {rule_id}")
conn.execute("DELETE FROM category_rules WHERE id=?", (rule_id,))
return ok(deleted_rule_id=rule_id)
def create_ledger_category(category: str, subcategory: str = "", sort_order: int = 100, memo: str = "", cashflow_kind: str = "", db_path: str | None = None) -> dict[str, Any]:
category = (category or "").strip()
subcategory = (subcategory or "").strip()
cashflow_kind = (cashflow_kind or infer_cashflow_kind(category, subcategory)).strip()
if not category:
return fail("validation_error", "category is required")
if cashflow_kind not in VALID_CASHFLOW_KINDS:
return fail("validation_error", f"cashflow_kind must be one of {sorted(VALID_CASHFLOW_KINDS)}")
ts = now_iso(); category_id = new_id("cat")
with db(db_path) as conn:
conn.execute("""INSERT INTO ledger_categories(id,category,subcategory,cashflow_kind,active,sort_order,memo,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)
ON CONFLICT(category, subcategory) DO UPDATE SET active=1, cashflow_kind=excluded.cashflow_kind, sort_order=excluded.sort_order, memo=excluded.memo, updated_at=excluded.updated_at""",
(category_id, category, subcategory, cashflow_kind, 1, int(sort_order), memo, ts, ts))
row = conn.execute("SELECT * FROM ledger_categories WHERE category=? AND subcategory=?", (category, subcategory)).fetchone()
return ok(category=row_to_dict(row))
def delete_ledger_category(category: str, subcategory: str = "", hard_delete: bool = False, force: bool = False, db_path: str | None = None) -> dict[str, Any]:
category = (category or "").strip()
subcategory = (subcategory or "").strip()
if not category:
return fail("validation_error", "category is required")
with db(db_path) as conn:
row = conn.execute("SELECT * FROM ledger_categories WHERE category=? AND subcategory=?", (category, subcategory)).fetchone()
if not row:
return fail("not_found", "category not found")
tx_count = conn.execute("SELECT COUNT(*) FROM transactions WHERE status='active' AND category=? AND subcategory=?", (category, subcategory)).fetchone()[0]
budget_count = conn.execute("SELECT COUNT(*) FROM budgets WHERE category=? AND subcategory=?", (category, subcategory)).fetchone()[0]
rule_count = conn.execute("SELECT COUNT(*) FROM category_rules WHERE active=1 AND category=? AND subcategory=?", (category, subcategory)).fetchone()[0]
in_use = int(tx_count or 0) + int(budget_count or 0) + int(rule_count or 0)
if in_use and hard_delete and not force:
return fail("category_in_use", "category is referenced; use soft delete or force=true", transaction_count=tx_count, budget_count=budget_count, rule_count=rule_count)
if hard_delete:
conn.execute("DELETE FROM ledger_categories WHERE id=?", (row["id"],))
else:
conn.execute("UPDATE ledger_categories SET active=0, updated_at=? WHERE id=?", (now_iso(), row["id"]))
return ok(category=category, subcategory=subcategory, deleted=True, hard_delete=hard_delete, transaction_count=tx_count, budget_count=budget_count, rule_count=rule_count)
@@ -166,22 +166,7 @@ def get_ledger_backup_status(file_path: str, include_hash: bool = True, include_
return fail("not_found", f"backup file not found: {path}", path=str(path), exists=False)
return ok(backup=_ledger_db_status_for_path(path, include_hash=include_hash, include_integrity=include_integrity), restore_policy=BACKUP_RESTORE_POLICY)
def create_ledger_account(name: str, type: str = "bank", institution: str = "", currency: str = "KRW", active: bool = True, memo: str = "", account_number_masked: str = "", holder_name: str = "", last_balance: int | None = None, last_synced_at: str = "", statement_status: str = "unknown", liquidity_bucket: str = "other", db_path: str | None = None) -> dict[str, Any]:
if not name.strip():
return fail("validation_error", "account name is required")
if type not in VALID_ACCOUNT_TYPES:
return fail("validation_error", f"type must be one of {sorted(VALID_ACCOUNT_TYPES)}")
try:
statement_status_norm = normalize_statement_status(statement_status)
liquidity_bucket_norm = normalize_liquidity_bucket(liquidity_bucket)
except Exception as e:
return fail("validation_error", str(e))
ts = now_iso(); account_id = new_id("acct")
with db(db_path) as conn:
conn.execute("""INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,statement_status,liquidity_bucket,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (account_id, name.strip(), type, institution, currency, int(active), memo, account_number_masked, holder_name, last_balance, last_synced_at, statement_status_norm, liquidity_bucket_norm, ts, ts))
row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
return ok(account=row_to_dict(row))
from .accounts_write_services import create_ledger_account
def list_ledger_accounts(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]:
sql = "SELECT * FROM accounts" + (" WHERE active=1" if active_only else "") + " ORDER BY name"
@@ -2,30 +2,8 @@ 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))
from .budgets_write_services import create_ledger_budget
from .transactions_write_services import create_ledger_transaction, delete_ledger_transaction, update_ledger_transaction
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=[]
@@ -136,53 +114,7 @@ def get_balance_reconciliation_report(account_id: str = "", baseline_balance: in
})
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=[]
@@ -0,0 +1,66 @@
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 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)
+8
View File
@@ -3,11 +3,15 @@ from __future__ import annotations
import sys
from types import ModuleType
from .application import accounts_write_services as _accounts_write_services
from .application import asset_account_services as _asset_account_services
from .application import budgets_write_services as _budgets_write_services
from .application import category_rules_write_services as _category_rules_write_services
from .application import categorization_services as _categorization_services
from .application import import_transfer_services as _import_transfer_services
from .application import shared as _shared
from .application import system_services as _system_services
from .application import transactions_write_services as _transactions_write_services
from .application import transaction_report_services as _transaction_report_services
from .application.asset_account_services import *
from .application.categorization_services import *
@@ -21,11 +25,15 @@ _current_kst_datetime = _shared._current_kst_datetime
_PROXY_MODULES = (
_shared,
_accounts_write_services,
_system_services,
_asset_account_services,
_budgets_write_services,
_category_rules_write_services,
_transaction_report_services,
_import_transfer_services,
_categorization_services,
_transactions_write_services,
)