Files
website/src/lib/server/db/schema/money-tracker.ts
Stan Runge 2da375451a
All checks were successful
Deploy production / deploy (push) Successful in 2m43s
Money tracker: bank balance is the source of truth for synced accounts
Sync stores the bank-reported balance (ITAV preferred) on the account and keeps
one Bank balance adjustment row so the tracker total equals the bank balance.
Accounts panel shows the bank balance and flags any drift.
2026-10-24 01:20:20 +00:00

251 lines
7.8 KiB
TypeScript

import {
boolean,
check,
date,
index,
integer,
jsonb,
numeric,
text,
unique
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { moneySchema } from './schemas';
import { id, timestamps, timestamptz } from './helpers';
import { user } from './auth';
export const moneyAccounts = moneySchema.table(
'accounts',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
openingBalanceCents: integer('opening_balance_cents').notNull().default(0),
kind: text('kind').notNull().default('spendable'),
color: text('color'),
// Household scope: Stan's own money vs. the joint account with Juhee.
scope: text('scope').notNull().default('personal'),
owner: text('owner'),
iban: text('iban'),
// Bank-side account identifier (Enable Banking account uid) for synced accounts.
externalUid: text('external_uid'),
provider: text('provider').notNull().default('manual'),
// Last balance reported by the bank (synced accounts); the source of truth for the total.
bankBalanceCents: integer('bank_balance_cents'),
bankBalanceType: text('bank_balance_type'),
bankBalanceAt: timestamptz('bank_balance_at'),
...timestamps
},
(table) => [
unique().on(table.userId, table.name),
unique('accounts_external_uid_key').on(table.externalUid),
index().on(table.userId),
check('accounts_kind_check', sql`kind IN ('spendable','investment')`),
check('accounts_scope_check', sql`scope IN ('personal','joint')`),
check('accounts_provider_check', sql`provider IN ('manual','enablebanking')`)
]
);
export const moneyTransactions = moneySchema.table(
'transactions',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
accountId: integer('account_id')
.notNull()
.references(() => moneyAccounts.id),
date: date('date', { mode: 'string' }).notNull(),
description: text('description').notNull(),
amountCents: integer('amount_cents').notNull(),
// Bank transaction id (Enable Banking entry_reference / transaction_id, or a
// content hash for CSV imports). NULLs are distinct, so manual rows never clash.
externalId: text('external_id'),
source: text('source').notNull().default('manual'),
bookingStatus: text('booking_status'),
counterparty: text('counterparty'),
category: text('category'),
raw: jsonb('raw'),
valueDate: date('value_date', { mode: 'string' }),
...timestamps
},
(table) => [
index().on(table.userId, table.date, table.id),
index().on(table.accountId),
unique('transactions_account_external_id_key').on(table.accountId, table.externalId),
check('transactions_source_check', sql`source IN ('manual','csv','enablebanking')`)
]
);
// Open-banking consent sessions (Enable Banking). One row per authorised session;
// `accounts` keeps the bank-side account list returned when the session was created.
export const moneyBankSessions = moneySchema.table(
'bank_sessions',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
provider: text('provider').notNull().default('enablebanking'),
sessionId: text('session_id').notNull(),
aspspName: text('aspsp_name').notNull(),
aspspCountry: text('aspsp_country').notNull(),
psuType: text('psu_type'),
status: text('status').notNull().default('authorized'),
validUntil: timestamptz('valid_until'),
accounts: jsonb('accounts')
.notNull()
.default(sql`'[]'::jsonb`),
lastSyncedAt: timestamptz('last_synced_at'),
lastError: text('last_error'),
...timestamps
},
(table) => [
unique('bank_sessions_session_id_key').on(table.sessionId),
index().on(table.userId),
check('bank_sessions_status_check', sql`status IN ('authorized','expired','revoked','error')`)
]
);
export const moneyRecurring = moneySchema.table(
'recurring',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
amountCents: integer('amount_cents').notNull(),
cadence: text('cadence').notNull(),
startsOn: date('starts_on', { mode: 'string' }),
endsOn: date('ends_on', { mode: 'string' }),
accountId: integer('account_id').references(() => moneyAccounts.id, { onDelete: 'set null' }),
...timestamps
},
(table) => [
index().on(table.userId),
check('recurring_cadence_check', sql`cadence IN ('weekly','monthly','yearly')`)
]
);
export const moneyForecasts = moneySchema.table(
'forecasts',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
date: date('date', { mode: 'string' }).notNull(),
description: text('description').notNull(),
amountCents: integer('amount_cents').notNull(),
accountId: integer('account_id').references(() => moneyAccounts.id, { onDelete: 'set null' }),
...timestamps
},
(table) => [index().on(table.userId, table.date)]
);
export const moneyUtilitySettings = moneySchema.table(
'utility_settings',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
kind: text('kind').notNull(),
ratePerUnit: numeric('rate_per_unit', { precision: 12, scale: 4 }).notNull().default('0'),
monthlyAdvanceCents: integer('monthly_advance_cents').notNull().default(0),
billingStart: date('billing_start', { mode: 'string' }),
...timestamps
},
(table) => [
unique().on(table.userId, table.kind),
check('utility_settings_kind_check', sql`kind IN ('electricity','gas','water')`)
]
);
export const moneyUtilityReadings = moneySchema.table(
'utility_readings',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
kind: text('kind').notNull(),
date: date('date', { mode: 'string' }).notNull(),
reading: numeric('reading', { precision: 12, scale: 3 }).notNull(),
...timestamps
},
(table) => [
index().on(table.userId, table.kind, table.date),
check('utility_readings_kind_check', sql`kind IN ('electricity','gas','water')`)
]
);
export const moneyWishlist = moneySchema.table(
'wishlist',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
estimatedCents: integer('estimated_cents'),
priority: integer('priority').notNull().default(3),
notes: text('notes'),
...timestamps
},
(table) => [index().on(table.userId)]
);
export const moneyProjectionSettings = moneySchema.table(
'projection_settings',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
investmentAccountId: integer('investment_account_id').references(() => moneyAccounts.id, {
onDelete: 'set null'
}),
reserveCents: integer('reserve_cents').notNull().default(100000),
annualReturnPercent: numeric('annual_return_percent', { precision: 5, scale: 2 })
.notNull()
.default('5.00'),
contributionMode: text('contribution_mode').notNull().default('excess'),
includeRecurring: boolean('include_recurring').notNull().default(true),
fixedMonthlyCents: integer('fixed_monthly_cents').notNull().default(0),
endDate: date('end_date', { mode: 'string' }),
...timestamps
},
(table) => [
unique().on(table.userId),
check(
'projection_settings_contribution_mode_check',
sql`contribution_mode IN ('excess','fixed')`
)
]
);
export const moneyInsurances = moneySchema.table(
'insurances',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
provider: text('provider'),
coverage: text('coverage'),
premiumCents: integer('premium_cents'),
cadence: text('cadence').notNull().default('monthly'),
...timestamps
},
(table) => [
index().on(table.userId),
check('insurances_cadence_check', sql`cadence IN ('weekly','monthly','yearly')`)
]
);