Implement BAT-011 transactional write service split
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user