Skip to content

Settlements

Three-step lifecycle on every Vecnet settlement: close → audit → confirm. The composer closes (open → closed in one transaction), finops audits (closed → accepted or closed → failed), and finops confirms once the SPEI receipt lands (accepted → paid).

State machine

StatusUI labelWho flips itWhat happens
opencomposer (skipped today)Reserved; the composer goes straight to closed
closed"Pending audit"composerSettlement row created; entries linked; adjustments allowed
accepted"On the way"finops via Accept buttonAudit passed; SPEI initiated; no more adjustments
paid"Confirmed"finops via Confirm with SPEI refPayout ledger pair written; merchant balance drops
failed"Rejected"finops via RejectAudit failed; settlement locked

UI labels live in lib/settlements/status-labels.ts. Permission gate for all transitions: canEditFees (finops, superadmin).

Per-channel composition

composeSettlement runs once per channel per cycle. A merchant with mixed activity produces two Settlement rows per close — one for online (t1), one for terminal (t2) — each with its own displayId, its own ledger linkage, its own audit. The idempotency key is (merchantId, periodEnd, channel).

The settlement scheduler loops:

ts
for (const channel of ["online", "terminal"] as const) {
  await composeSettlement({ merchantId, periodStart, periodEnd, channel });
}

workers/settlement-scheduler.ts runs hourly. The merchant "Run now" button at /merchants/[id]/settlement runs the same logic on demand.

Synthetic readable display ids

lib/settlements/display-id.ts:synthDisplayId produces:

set-{t1|t2}-{merchantSlug}-{YYYYMMDD}-{msTimestamp}

Example: set-t1-bitso-121-20260527-1779894104192.

The cuid stays as the primary key and route key; the displayId is the human-facing label everywhere (list, header, report filenames).

Audit phase — Accept / Reject

In closed state finops can:

  • Accept → status accepted. Single API call; no body. The settlement is now locked from adjustments.
  • Reject → status failed. Requires a reason (>= 2 chars). The reason is recorded as a $0 SettlementAdjustment for audit trail.

Adjustments — only in closed

SettlementAdjustment rows write a balanced settlement_adjustment ledger pair against vecnet_clearing and merchant_pending (sign-aware: positive amount → merchant gets more, negative → less). The composer already cached Settlement.adjustmentsCents and Settlement.netCents; each adjustment increments them in the same transaction.

UI lives in components/settlements/adjustments-panel.tsx. The form is locked when status !== "closed".

Confirm — SPEI ref + payout pair

In accepted state finops enters the SPEI reference at the "Confirm payment" button. The API:

  1. Flips status accepted → paid.
  2. Stores payoutReference, paidAt, payoutMarkedById.
  3. Writes the settlement_payout ledger pair if netCents > 0.
  4. Opens a settlement_confirm Journal for audit.

After confirm the merchant's merchant_pending balance is shorter by netCents — the journal entry that materialises the payout.

Reports

Three downloads per settlement:

DownloadFileAudienceIncludes IN fees?
PDFlib/settlements/pdf.tsxMerchantNo
Merchant CSVlib/settlements/csv.tsMerchantNo
Resumen XLSXlib/settlements/resumen-xlsx.tsMerchantNo — Spanish "Resumen de Liquidación" format
Internal CSVlib/settlements/internal-csv.tsfinops/superadmin onlyYes + IVA

The Resumen XLSX maps the existing data to the same 2-column CONCEPTO / Cálculo layout used by Tonder's PSP for continuity. Sections: Resumen de Liquidación · Volumen procesado · Comisiones · Reserva · Ajustes · Liquidación.

Vecnet — Build Spec v0.2 · Obsidian Terminal