Appearance
Ledger tables
The double-entry core: Account, Journal, LedgerEntry. Plus BalanceSnapshot for day-end aggregates and Settlement for the payout header. balance is never stored on Account — it's always derived from the ledger.
These four models replaced the original LedgerAccount enum + flat LedgerEntry table during Phase 1 of the Tonder usrv-finances port. The schema lives at prisma/schema.prisma.
Account — chart of accounts
Replaces the old enum. Per-merchant accounts and Vecnet house accounts coexist in this single table. Resolution is lazy via lib/accounts/resolve.ts:resolveAccountId({ kind, merchantId?, acquirer? }) — (kind, merchantId, acquirer, currency) is the unique compound key.
| Column | Type | Notes |
|---|---|---|
id | cuid | PK |
accountCode | String @unique | Stable human label: MERCHANT_PENDING:cuid:MXN, VECNET_REVENUE::MXN, ACQUIRER_CLEARING:TONDER:MXN |
name | String | Display label |
type | AccountType | asset · liability · revenue · expense · equity |
kind | AccountKind | See enum below |
merchantId | String? | Null for house accounts |
acquirer | TransactionProvider? | Only set for per-acquirer house accounts |
currency | String | "MXN" default |
active | Bool |
AccountKind:
| Kind | Type | Who owns it |
|---|---|---|
merchant_pending | liability | per-merchant |
merchant_available | liability | per-merchant |
merchant_rolling_reserve | liability | per-merchant |
vecnet_revenue | revenue | Vecnet house |
vecnet_cost | expense | Vecnet house |
vecnet_clearing | asset | Vecnet house |
chargeback_reserve | liability | Vecnet house |
acquirer_clearing | asset | per-acquirer house (tonder, menta) |
vecnet_iva_payable | liability | Vecnet house — IVA owed to SAT |
Type → balance convention:
asset/expense: balance = DR − CRliability/revenue/equity: balance = CR − DR
Journal — event header
One row per financial event. Groups the LedgerEntries it wrote so reports never re-aggregate. Summary fields are denormalised at write time and never mutated (the db-extensions.ts immutability check doesn't touch Journals — they're not append-only, but in practice composers update them once and forget).
| Column | Type | Notes |
|---|---|---|
id | cuid | PK |
category | JournalCategory | See enum |
type | JournalType | in (merchant gains value) or out (loses) |
merchantId | String | Required — every event belongs to a merchant |
transactionId | String? | If the event was triggered by a Transaction |
settlementId | String? | If the event belongs to a Settlement |
grossCents, feeCents, ivaCents, netCents, rrCents | BigInt | Denormalised summary |
description | String? | Free text |
generatedAt | DateTime | Wall-clock when row was inserted |
createdAt | DateTime | Wall-clock the event represents (defaults to generatedAt; differs during bulk reprocess) |
JournalCategory: payment · refund · chargeback · void · dispute_in_review · dispute_won · withdrawal · topup · settlement_approve · settlement_confirm · settlement_adjustment · rolling_reserve_held · rolling_reserve_released · fee · internal_transfer.
Helper: lib/journals/open.ts:openJournal() returns the new id; callers attach it to every LedgerEntry they write.
LedgerEntry — debit / credit line
Append-only. Every event writes 2+ rows whose debits sum equals credits sum.
| Column | Type | Notes |
|---|---|---|
id | cuid | PK |
transactionId | String? | If linked to a Transaction |
merchantId | String | Required |
accountId | String? | FK → Account (resolved lazily) |
journalId | String? | FK → Journal (set at write time) |
direction | LedgerDirection | debit · credit |
amountCents | BigInt | The leg amount |
ivaCents | BigInt @default(0) | Denormalised IVA portion for fee/IVA legs |
currency | String @default("MXN") | |
category | LedgerCategory | See below |
releasedAt | DateTime? | For rolling-reserve held entries — when they become due |
settlementId | String? | Set by the composer when entries get linked to a Settlement |
createdAt | DateTime @default(now()) |
LedgerCategory: gross · fee_acceptance_(out\|in) · fee_refund_(out\|in) · fee_chargeback_(out\|in) · fee_decline_(out\|in) · fee_three_ds_(out\|in) · fee_antifraud_(out\|in) · fee_settlement_(out\|in) · fee_iva_out (NEW) · rolling_reserve_held · rolling_reserve_released · settlement_payout · settlement_adjustment.
Immutability
lib/db-extensions.ts:applyImmutableLedger blocks update / updateMany unless the data payload sets only one of: settlementId, journalId, accountId. upsert, delete, deleteMany always throw on LedgerEntry.
Corrections are new entries, never edits.
BalanceSnapshot — day-end aggregates
Written by workers/daily-snapshots.ts every night at 23:30 America/Mexico_City. One row per (accountId, date) — @@unique. The worker also supports --run-once for manual triggers and tests.
| Column | Type | Notes |
|---|---|---|
accountId | String | FK → Account |
date | DateTime | Local midnight |
balanceCents | BigInt | End-of-day balance (DR − CR or CR − DR per type) |
inflowsCents | BigInt @default(0) | Day's inflow magnitude |
outflowsCents | BigInt @default(0) | Day's outflow magnitude |
Treasury KPIs at /finances and the per-account sparkline at /finances/accounts/[id] both read from this table.
Settlement — payout header
The aggregate row created when a settlement cycle closes. One per (merchantId, periodEnd, channel) triple — that's the composer's idempotency key.
| Column | Type | Notes |
|---|---|---|
id | cuid | PK |
displayId | String? @unique | Human-readable: set-t1-bitso-121-20260527-1779894104192 |
merchantId | String | |
channel | TransactionChannel | online or terminal — drives t1/t2 |
periodStart, periodEnd | DateTime | Cycle window |
status | SettlementStatus | open → closed (Pending audit) → accepted (On the way) → paid (Confirmed) — or closed → failed (Rejected) |
grossCents, totalOutFeesCents, totalInFeesCents, marginCents, rollingReserveHeldCents, rollingReserveReleasedCents, netCents, adjustmentsCents | BigInt | Cached at close time from the aggregated ledger |
transactionCount | Int | |
payoutReference | String? | SPEI reference filled at "Confirm payment" |
paidAt | DateTime? | |
payoutMarkedById | String? | Actor (AdminUser.id) |
closedAt, createdAt | DateTime |
Settlement has a journals back-relation — the settlement-approve, fee, adjustment, and payout journals all link via journal.settlementId.
SettlementAdjustment — manual signed corrections
Append-only. Only allowed while a Settlement is closed. Each adjustment writes a balanced LedgerEntry pair + a Journal with category settlement_adjustment, then bumps Settlement.adjustmentsCents and Settlement.netCents.
prisma
model SettlementAdjustment {
id String @id @default(cuid())
settlementId String
amountCents BigInt // signed: positive = increase merchant payout
reason String
createdById String // AdminUser cuid
createdAt DateTime @default(now())
}