the ledger schema — accounts, transactions, imports

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.
This commit is contained in:
2026-08-27 17:30:49 +01:00
parent 6e88d849e7
commit 7a4859fc28
2 changed files with 187 additions and 4 deletions
+15 -4
View File
@@ -2,13 +2,24 @@
Money, on your own machine. Money, on your own machine.
**This is a skeleton.** The repository, the mount prefix (`/finances`, `/api/finances`) and the permission **This is a skeleton with a data model.** The repository, the mount prefix (`/finances`, `/api/finances`)
key (`finances`) are real and settled. What the plugin actually does is not written yet — the two panels and the permission key (`finances`) are settled. `db/schema.ts` proposes the ledger — accounts,
render a placeholder and the one route answers `GET /api/finances/ping`. 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 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. 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 ## What it ships
| Part | Present | Note | | 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 | | `manifest.ts` | yes | identity, one permission |
| `api/router.ts` | yes | one route, mounted at `/api/finances` | | `api/router.ts` | yes | one route, mounted at `/api/finances` |
| `web/` | yes | two placeholder panels and a layout | | `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 | | `sidecar/` | no | nothing long-lived yet |
| `assets/icon.png` | no | the dock falls back to the manifest's lucide `Wallet` | | `assets/icon.png` | no | the dock falls back to the manifest's lucide `Wallet` |
+172
View File
@@ -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;