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>
74 lines
2.3 KiB
TypeScript
74 lines
2.3 KiB
TypeScript
// Server-authoritative permission matrix. Roles form an ordered rank
|
|
// (ADMIN > MANAGER > STAFF > VIEWER — this is the "level" concept); every
|
|
// write action carries a minimum rank. VIEWER holds rank 0 and is the
|
|
// read-only role. Reads are not listed here — they stay on AuthenticatedGuard
|
|
// alone, so any logged-in user (including VIEWER) can read.
|
|
//
|
|
// This is the single source of truth: the API enforces it via AbilityGuard and
|
|
// ships the resolved per-user map to the web through /auth/me, so the UI never
|
|
// keeps its own copy of the rules.
|
|
|
|
export type Role = "ADMIN" | "MANAGER" | "STAFF" | "VIEWER";
|
|
|
|
export const ROLE_RANK: Record<Role, number> = {
|
|
VIEWER: 0,
|
|
STAFF: 1,
|
|
MANAGER: 2,
|
|
ADMIN: 3,
|
|
};
|
|
|
|
export type Ability =
|
|
| "customer:create"
|
|
| "customer:update"
|
|
| "customer:delete"
|
|
| "policy:create"
|
|
| "policy:update"
|
|
| "policy:delete"
|
|
| "property:create"
|
|
| "property:update"
|
|
| "property:delete"
|
|
| "ledger:create"
|
|
| "ledger:void"
|
|
| "bank:create"
|
|
| "bank:void"
|
|
| "bank:manage-accounts"
|
|
| "lookup:manage"
|
|
| "user:manage"
|
|
| "db:manage";
|
|
|
|
/** Minimum role required for each ability. */
|
|
export const ABILITY_MIN: Record<Ability, Role> = {
|
|
"customer:create": "STAFF",
|
|
"customer:update": "STAFF",
|
|
"customer:delete": "ADMIN",
|
|
"policy:create": "STAFF",
|
|
"policy:update": "STAFF",
|
|
"policy:delete": "MANAGER",
|
|
"property:create": "STAFF",
|
|
"property:update": "STAFF",
|
|
"property:delete": "MANAGER",
|
|
"ledger:create": "STAFF",
|
|
"ledger:void": "MANAGER",
|
|
"bank:create": "STAFF",
|
|
"bank:void": "MANAGER",
|
|
// Opening or renaming a chequera is rarer and higher-stakes than posting a
|
|
// movement into one — a wrong account silently mixes two sets of books.
|
|
"bank:manage-accounts": "MANAGER",
|
|
"lookup:manage": "MANAGER",
|
|
"user:manage": "ADMIN",
|
|
"db:manage": "ADMIN",
|
|
};
|
|
|
|
export const ALL_ABILITIES = Object.keys(ABILITY_MIN) as Ability[];
|
|
|
|
export function can(role: Role, ability: Ability): boolean {
|
|
return ROLE_RANK[role] >= ROLE_RANK[ABILITY_MIN[ability]];
|
|
}
|
|
|
|
/** Resolved {ability: boolean} map for a role — sent to the web via /auth/me. */
|
|
export function abilitiesFor(role: Role): Record<Ability, boolean> {
|
|
return Object.fromEntries(
|
|
ALL_ABILITIES.map((a) => [a, can(role, a)]),
|
|
) as Record<Ability, boolean>;
|
|
}
|