From 7a4859fc2850ea65625a8ce101a33c0c7859a3b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 27 Aug 2026 17:30:49 +0100 Subject: [PATCH] =?UTF-8?q?the=20ledger=20schema=20=E2=80=94=20accounts,?= =?UTF-8?q?=20transactions,=20imports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Money is a bigint of minor units and the sign carries the direction. No stored balance, no user_id denormalised onto transactions, category as free text until budgets need it to be a table. Not pushed to the database yet. --- README.md | 19 ++++-- db/schema.ts | 172 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 db/schema.ts diff --git a/README.md b/README.md index 6d7441e..458dafd 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,24 @@ Money, on your own machine. -**This is a skeleton.** The repository, the mount prefix (`/finances`, `/api/finances`) and the permission -key (`finances`) are real and settled. What the plugin actually does is not written yet — the two panels -render a placeholder and the one route answers `GET /api/finances/ping`. +**This is a skeleton with a data model.** The repository, the mount prefix (`/finances`, `/api/finances`) +and the permission key (`finances`) are settled. `db/schema.ts` proposes the ledger — accounts, +transactions and imports — and has **not** been pushed to the database. The two panels still render a +placeholder and the one route answers `GET /api/finances/ping`. Published in this state on purpose: the identity of a plugin is the part that is expensive to change later, and the feature is the part that is cheap to add. +## The ledger, in three tables + +- **`finances_accounts`** — what a balance is *of*. No stored balance: it is `opening_balance` plus the + transactions, computed on read, because a stored one drifts silently. +- **`finances_transactions`** — the lines. Money is a `bigint` of minor units and the sign carries the + direction; negative leaves the account. +- **`finances_imports`** — one row per CSV import, so a run mapped wrong can be undone in one delete + rather than by hand. `external_id` on a transaction makes re-importing an overlapping statement safe, + which is the normal case rather than the exception. + ## What it ships | Part | Present | Note | @@ -16,7 +27,7 @@ later, and the feature is the part that is cheap to add. | `manifest.ts` | yes | identity, one permission | | `api/router.ts` | yes | one route, mounted at `/api/finances` | | `web/` | yes | two placeholder panels and a layout | -| `db/schema.ts` | no | no tables until the data model is settled | +| `db/schema.ts` | yes | accounts, transactions, imports — written, NOT pushed yet | | `sidecar/` | no | nothing long-lived yet | | `assets/icon.png` | no | the dock falls back to the manifest's lucide `Wallet` | diff --git a/db/schema.ts b/db/schema.ts new file mode 100644 index 0000000..edc8a92 --- /dev/null +++ b/db/schema.ts @@ -0,0 +1,172 @@ +import { + pgTable, + serial, + integer, + bigint, + text, + boolean, + date, + timestamp, + uniqueIndex, + index, +} from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; +import { users } from "officerdb/auth/schema"; + +/** + * The ledger: accounts, the transactions in them, and a record of each import. + * + * ── Money is an integer of minor units, everywhere ── + * + * `bigint` holding cents, never `numeric` and never a float. A float cannot represent 0.10, so a column of + * them adds up to a balance that is wrong by an amount nobody can find. `numeric` is exact but arrives in + * JavaScript as a string and leaves as one, so every sum is either a string concatenation bug waiting to + * happen or a parse somebody forgot. An integer of the smallest unit is what every ledger that has been + * got right uses, and the only cost is that the UI multiplies by 100 on the way in. + * + * `mode: 'number'` is safe here: 2^53 minor units is ninety trillion euros. + * + * ── The sign carries the direction ── + * + * Negative is money leaving the account, positive is money arriving. Not a `type` column beside a positive + * amount, because then every sum needs a CASE and one query somewhere will forget it. + */ + +/** + * An account. What the balance is OF. + * + * There is no `balance` column. The balance is `openingBalance` plus the transactions, computed on read — + * a stored balance is a second source of truth that drifts the first time an import half-fails, and the + * drift is silent because a wrong number looks exactly like a right one. + */ +export const financesAccounts = pgTable( + "finances_accounts", + { + id: serial("id").primaryKey(), + userId: integer("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + /** What the owner calls it. The only thing the account list shows, so it has to be theirs to set. */ + label: text("label").notNull(), + /** + * `checking` | `savings` | `credit` | `cash` | `investment`. + * + * A TS-only union with no DB CHECK, the same choice `pipeline_jobs.mode` makes: adding a kind should + * be a type change and a UI change, not a migration. + */ + kind: text("kind").notNull().default("checking"), + /** ISO 4217, uppercase. On the account, not the transaction — a transaction is in its account's money. */ + currency: text("currency").notNull().default("EUR"), + /** + * The balance before the first transaction in this table, in minor units. + * + * Every real account starts mid-life: you import a statement from March and the account did not begin + * in March. Without this the ledger is internally consistent and disagrees with the bank forever. + */ + openingBalance: bigint("opening_balance", { mode: "number" }) + .notNull() + .default(0), + /** Closed, not deleted — its transactions are still history. Hidden from the list, kept in totals. */ + archived: boolean("archived").notNull().default(false), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [uniqueIndex("uq_finances_accounts_user_label").on(t.userId, t.label)], +); + +/** + * One import run, so that a bad one can be undone. + * + * The first CSV import is always mapped wrong — a date format, a decimal comma, a sign convention — and + * without this row the only undo is picking three hundred transactions out by hand. Deleting an import + * cascades to the transactions it created. + */ +export const financesImports = pgTable("finances_imports", { + id: serial("id").primaryKey(), + accountId: integer("account_id") + .notNull() + .references(() => financesAccounts.id, { onDelete: "cascade" }), + /** The file it came from, for recognising it in a list of imports. */ + filename: text("filename").notNull(), + /** How many rows were written. Rows skipped as duplicates are not counted here. */ + rowCount: integer("row_count").notNull().default(0), + /** How many rows the file held that were already present. Shown after an import, never acted on. */ + skippedCount: integer("skipped_count").notNull().default(0), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +/** A line in the ledger. */ +export const financesTransactions = pgTable( + "finances_transactions", + { + id: serial("id").primaryKey(), + /** + * Scoping goes through the account rather than a `user_id` column beside it. + * + * Denormalising the owner onto every row would save a join and introduce a way for the two to + * disagree — and a transaction whose row says one owner and whose account says another is a + * permission bug wearing a data bug's clothes. + */ + accountId: integer("account_id") + .notNull() + .references(() => financesAccounts.id, { onDelete: "cascade" }), + /** The day it hit the account. A date, not a timestamp — a bank statement has no time of day. */ + bookedOn: date("booked_on").notNull(), + /** Minor units, signed. Negative leaves the account. */ + amount: bigint("amount", { mode: "number" }).notNull(), + /** What the statement called it. Kept verbatim, however unhelpful — it is the only original record. */ + description: text("description").notNull(), + /** Who was on the other end, when the source says. Free text; there is no counterparty table yet. */ + counterparty: text("counterparty"), + /** + * Free text, deliberately not a foreign key. + * + * Categories become a table when budgets arrive and need to be named, coloured and rolled up. Until + * then a string is the honest shape, and turning it into a table later is a migration over values + * that already exist rather than a guess at what they will be. + */ + category: text("category"), + /** The owner's own note. Never written by an import. */ + notes: text("notes"), + /** + * The id this row had at its source — a bank reference, or a hash of the CSV line. + * + * This is what makes re-importing an overlapping statement safe, which matters because overlapping + * statements are the normal case: you export "last 90 days" every month. + */ + externalId: text("external_id"), + /** The import that created it. Null means it was entered by hand. */ + importId: integer("import_id").references(() => financesImports.id, { + onDelete: "cascade", + }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + /** + * Import idempotency, enforced by the database. + * + * Partial, over the rows that HAVE an external id: two hand-entered transactions with no id are two + * legitimate rows, and a plain unique index would refuse the second one. + */ + uniqueIndex("uq_finances_transactions_external") + .on(t.accountId, t.externalId) + .where(sql`${t.externalId} is not null`), + /** Every view of this table is one account, newest first. */ + index("ix_finances_transactions_account_date").on(t.accountId, t.bookedOn), + ], +); + +export type FinancesAccount = typeof financesAccounts.$inferSelect; +export type FinancesTransaction = typeof financesTransactions.$inferSelect; +export type FinancesImport = typeof financesImports.$inferSelect;