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:
@@ -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
@@ -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;
|
||||||
Reference in New Issue
Block a user