feat(bank): multi-bank chequera — required bankAccountId, per-account scoping
The office keeps more than one operating account (Utilities banks in MXN, Seguros in USD), but bank_transactions was a single implicit MXN register by design. Adds Bank/BankAccount and makes every read and write in the module scoped to exactly one account. Schema: - Bank / BankAccount. Currency is fixed per account and BankTransaction has no currency column of its own — a movement inherits its account's, the way a real bank account doesn't mix currencies. - BankTransaction.bankAccountId, required. A movement with no known account isn't reconcilable against a statement. - @@index([bankAccountId, transactionDate]): every read now filters by account and orders/groups by date. Migration: - backfill_bank_accounts.py seeds Scotiabank + "Utilities — Scotiabank (MXN)" and backfills all 22,669 existing rows onto it, then promotes the column to NOT NULL and attaches the FK. Standalone because prisma db push cannot add a required column to a populated table. Idempotent; re-running once a second account exists does not re-point rows. - run_all.py runs it (both modes) before transform_bank.py, which now resolves the account by label and fails fast if it is missing. API: - ?bankAccountId= required on list/stats/facets/summary — not optional with an "all accounts" default, since summing an MXN and a USD register repeats the currency-collapsing mistake the billing module exists to prevent. Missing is 400, unknown is 404. - facets() had no account clause at all and summary() has two raw-SQL rollups; all three are now parameterised. Scoping only one of summary's queries would leave the year list and its drill-down describing different books. - New bank/accounts + bank/banks sub-resource under a MANAGER bank:manage-accounts ability. currency is absent from the update DTO: booked movements are denominated in it, so editing would re-denominate history. Capture into a closed account is rejected. Web: - /banco gains an account picker (remembered per browser) and reads every figure in the selected account's currency; the "single currency (MXN)" doc-comment and the hardcoded MXN formatting are gone. - New /banco/cuentas for banks and accounts. Accounts are closed, never deleted — the FK is required, so deleting one would destroy its register. - /inicio's chequera card names the account it is reading instead of implying a single register. Verified against dev + browser: a second USD account showed full read/write isolation from the MXN register, whose totals were unchanged (22,669 movements, net 1,014,266.97). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,12 +10,20 @@ Sources:
|
||||
from the spelled-out "cantidad en letra"
|
||||
- TABLA RAMODOS -> business_line_categories (line-of-business lookup)
|
||||
|
||||
Bank account: SCOTHIA is the Utilities MXN chequera and nothing else — DATOS
|
||||
E/I carry no bank or currency column — so every row loads against the single
|
||||
account seeded by `backfill_bank_accounts.py`, which must have run first.
|
||||
`banks` / `bank_accounts` are NOT truncated here; only the movements are. (In
|
||||
full-rebuild mode that still clears app-captured rows on every account, the
|
||||
same whole-database truncate every transform in this pipeline does — use
|
||||
`--sync` to upsert instead.)
|
||||
|
||||
Category link: DATOS E/I have no explicit FK to TABLA RAMODOS — the ramo is
|
||||
inferred from the CONCEPTO text, which is a fuzzy classification, not a stored
|
||||
key. So the categories are loaded but bank_transactions.categoryId is left
|
||||
NULL for now; a concept->ramo classifier is a later enhancement.
|
||||
|
||||
Idempotent (truncate + rebuild). Run:
|
||||
Idempotent (rebuild the legacy rows). Run:
|
||||
./.venv/bin/python transform_bank.py --env dev
|
||||
"""
|
||||
|
||||
@@ -27,6 +35,7 @@ from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backfill_bank_accounts import UTILITIES_ACCOUNT
|
||||
from dbenv import connect, env_arg
|
||||
from sync import parse_mode
|
||||
|
||||
@@ -78,6 +87,19 @@ def main():
|
||||
print(f"[bank] target env: {env}")
|
||||
c = conn.cursor()
|
||||
|
||||
# Every SCOTHIA row belongs to the one Utilities MXN chequera. Resolved by
|
||||
# label rather than created here, so this script can't silently open a
|
||||
# second copy of the account if the backfill hasn't run.
|
||||
c.execute("SELECT id FROM bank_accounts WHERE label = %s", (UTILITIES_ACCOUNT,))
|
||||
row = c.fetchone()
|
||||
if not row:
|
||||
raise SystemExit(
|
||||
f"missing bank account {UTILITIES_ACCOUNT!r} — run "
|
||||
f"backfill_bank_accounts.py --env {env} first"
|
||||
)
|
||||
account_id = row[0]
|
||||
print(f"[bank] account: {UTILITIES_ACCOUNT} ({account_id})")
|
||||
|
||||
# business_line_categories (dedup TABLA RAMODOS)
|
||||
cats, seen = [], set()
|
||||
for _, r in load("tabla_ramodos").iterrows():
|
||||
@@ -96,7 +118,8 @@ def main():
|
||||
skip_date += 1
|
||||
return
|
||||
rows.append((
|
||||
str(uuid.uuid4()), td, s(r["tipo"]), s(r["num"]), s(r["concepto"]),
|
||||
str(uuid.uuid4()), account_id,
|
||||
td, s(r["tipo"]), s(r["num"]), s(r["concepto"]),
|
||||
amount, None, # categoryId left NULL (see header)
|
||||
1 if truthy(r["operado"]) else 0,
|
||||
1 if (income and truthy(r["transferido"])) else 0,
|
||||
@@ -110,9 +133,17 @@ def main():
|
||||
for _, r in load("datos_e").iterrows():
|
||||
add(r, -(dec(r["egreso"], Decimal(0))), income=False)
|
||||
|
||||
COLS = (
|
||||
"id,bankAccountId,transactionDate,transactionType,reference,concept,amount,"
|
||||
"categoryId,cleared,transferred,notes,amountInWords,legacySourceTable,legacyId"
|
||||
)
|
||||
PLACEHOLDERS = ",".join(["%s"] * 14)
|
||||
|
||||
if sync_mode:
|
||||
# bankAccountId is deliberately absent from the UPDATE clause: an
|
||||
# account moved by hand in the app must not be dragged back.
|
||||
for row in rows:
|
||||
c.execute("INSERT INTO bank_transactions (id,transactionDate,transactionType,reference,concept,amount,categoryId,cleared,transferred,notes,amountInWords,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE transactionDate=VALUES(transactionDate),transactionType=VALUES(transactionType),reference=VALUES(reference),concept=VALUES(concept),amount=VALUES(amount),cleared=VALUES(cleared),transferred=VALUES(transferred),notes=VALUES(notes),amountInWords=VALUES(amountInWords),voidedAt=NULL", row)
|
||||
c.execute(f"INSERT INTO bank_transactions ({COLS}) VALUES ({PLACEHOLDERS}) ON DUPLICATE KEY UPDATE transactionDate=VALUES(transactionDate),transactionType=VALUES(transactionType),reference=VALUES(reference),concept=VALUES(concept),amount=VALUES(amount),cleared=VALUES(cleared),transferred=VALUES(transferred),notes=VALUES(notes),amountInWords=VALUES(amountInWords),voidedAt=NULL", row)
|
||||
else:
|
||||
c.execute("SET FOREIGN_KEY_CHECKS=0")
|
||||
for t in ("bank_transactions", "business_line_categories"):
|
||||
@@ -120,7 +151,7 @@ def main():
|
||||
c.execute("SET FOREIGN_KEY_CHECKS=1")
|
||||
c.executemany("INSERT INTO business_line_categories (id,name) VALUES (%s,%s)", cats)
|
||||
c.executemany(
|
||||
"INSERT INTO bank_transactions (id,transactionDate,transactionType,reference,concept,amount,categoryId,cleared,transferred,notes,amountInWords,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", rows)
|
||||
f"INSERT INTO bank_transactions ({COLS}) VALUES ({PLACEHOLDERS})", rows)
|
||||
conn.commit()
|
||||
|
||||
def count(t):
|
||||
@@ -136,6 +167,15 @@ def main():
|
||||
for src, n, tot in by_src:
|
||||
print(f" {(src or '(manual)'):10} {n:6} sum {tot}")
|
||||
print(f" net balance movement : {net}")
|
||||
# Per account, never a cross-account total: the registers are in different
|
||||
# currencies and summing them produces a figure that never existed.
|
||||
c.execute(
|
||||
"SELECT a.label, a.currency, COUNT(t.id), COALESCE(SUM(t.amount), 0) "
|
||||
"FROM bank_accounts a LEFT JOIN bank_transactions t ON t.bankAccountId = a.id "
|
||||
"GROUP BY a.id, a.label, a.currency ORDER BY a.label"
|
||||
)
|
||||
for label, currency, n, total in c.fetchall():
|
||||
print(f" {label:34} {currency} {n:6} neto {total}")
|
||||
print(f" -> business_line_categories: {count('business_line_categories')}")
|
||||
print(" validation: OK")
|
||||
conn.close()
|
||||
|
||||
Reference in New Issue
Block a user