diff --git a/backend/.env.example b/backend/.env.example index 9af97fac..a1aae29d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -135,3 +135,38 @@ WALLET_KMS_MASTER_KEY=change-me-32-byte-base64-key # How long a non-custodial linking challenge (nonce) stays valid before it # must be re-requested. WALLET_LINK_CHALLENGE_TTL_SECONDS=300 + +# Micropayment credit ledger & revenue distribution (issue #1575) +# Currency used when a caller does not name one. Every ledger account, +# entry and transaction is scoped to a currency and they never mix. +CREDITS_DEFAULT_CURRENCY=USD +# OVERDRAFT POLICY. How far below zero a charge may take a member's credit +# balance, in minor units, applied when their account is first created. +# 0 (the default) means charges are rejected the moment they would +# overdraw — the safe choice. Raise it only if you want graceful +# degradation (letting a session finish and go slightly negative rather +# than cutting it off mid-use); the ceiling is then per-account and an +# admin can also raise it for one member via +# PATCH /credits/admin/accounts/:id. The check runs inside the account's +# row lock, so concurrent charges can never overdraw past it together. +CREDITS_DEFAULT_OVERDRAFT_LIMIT=0 +# Set to false to stop the hourly settlement job from running at all +# (batches can still be created and executed by hand through the admin +# API). Anything other than "true" disables it. +CREDITS_SETTLEMENT_ENABLED=true +# Name of a RevenueSplitConfig the settlement job distributes the platform +# revenue account across each pass. Leave unset to skip distribution +# entirely and only net already-payable accounts. +CREDITS_SETTLEMENT_SPLIT_CONFIG= +# Balances below this (minor units) are left to accumulate rather than +# paid out — one on-chain transfer for a handful of stroops costs more in +# fees than it moves. +CREDITS_SETTLEMENT_MIN_PAYOUT=1 +# How many times one payout may be submitted before it is marked FAILED +# and left for an admin to retry or abandon. Reaching this limit never +# marks a ledger entry settled — a payout that did not happen leaves the +# balance shown as still owed. +CREDITS_SETTLEMENT_MAX_PAYOUT_ATTEMPTS=5 +# Upper bound on how many CONFIRMED payments one pass of the credit sweep +# (top-ups and payment revenue splits) will apply. +CREDITS_PAYMENT_SWEEP_MAX_BATCH=200 diff --git a/backend/README.md b/backend/README.md index f3e79f56..da844f4f 100644 --- a/backend/README.md +++ b/backend/README.md @@ -128,6 +128,31 @@ This module manages consumable stock items (stationery, spare parts, printer car - `inventoryItem` - Reference to inventory item - `createdAt` - Movement timestamp +## Credits Module — micropayment ledger & revenue distribution + +An internal double-entry credit ledger for high-frequency, low-value +charges (per-minute resource usage, printing, meeting-room overage) that are +too small to settle on-chain individually, plus a configurable multi-party +revenue split engine and a batch settlement job that moves netted balances +off-platform over the Soroban rail. + +- **Spend path** — `POST /credits/charge` debits a member's balance + synchronously with no rail or chain call in the hot path, refusing + anything that would breach the account's overdraft ceiling. +- **Top-up path** — a CONFIRMED payment carrying + `metadata.purpose = "CREDIT_TOP_UP"` funds the payer's balance; one + payment funds many micro-charges. +- **Split engine** — basis-point recipients validated to sum to exactly + 100% at configuration time, allocated by the largest-remainder method so + rounding never loses or duplicates a minor unit. +- **Settlement** — hourly netting into at most one on-chain transfer per + recipient, resumable after a crash and never marking a ledger entry + settled before the rail confirms the payout. + +Configuration lives under `CREDITS_*` in `.env.example`. For the schema, +the overdraft and rounding policies, the failure semantics and the full API +surface, see [Credits Module README](./src/credits/README.md). + ## User Profile Management Module This module provides comprehensive user profile management capabilities with avatar upload support. diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index d337aa47..1922e0ac 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -8,6 +8,7 @@ import { AppService } from './app.service'; import { AuthModule } from './auth/auth.module'; import { PaymentsModule } from './payments/payments.module'; import { WalletsModule } from './wallets/wallets.module'; +import { CreditsModule } from './credits/credits.module'; @Module({ imports: [ @@ -48,6 +49,9 @@ import { WalletsModule } from './wallets/wallets.module'; AuthModule, PaymentsModule, WalletsModule, + // Micropayment credit ledger, revenue splits and batch settlement + // (issue #1575). Its own @Cron jobs ride the ScheduleModule above. + CreditsModule, ], controllers: [AppController], providers: [AppService], diff --git a/backend/src/credits/README.md b/backend/src/credits/README.md new file mode 100644 index 00000000..d859bc5e --- /dev/null +++ b/backend/src/credits/README.md @@ -0,0 +1,267 @@ +# Credits — micropayment ledger & multi-party revenue distribution + +Issue #1575, the sixth issue in the payment track. Depends on #1570 (a +payment funds a top-up) and #1574 (the on-chain leg of settlement). + +## Why this module exists + +Not every coworking charge is booking-sized. Per-minute resource usage, +printing and meeting-room overage are too small and too frequent to settle +individually on-chain — the fee and the latency would dwarf the charge. +Separately, some payments need to split across several recipients (platform +fee, hub operator payout, referral reward) instead of landing in one +account. + +Both problems have the same answer: an internal double-entry ledger that +batches value movement instead of transacting per event. + +## The ledger + +Two tables carry the truth: + +- **`ledger_accounts`** — one per member, plus the singleton system + accounts. `balance` is a *materialized cache* of the entries below; it + exists so an overdraft check is O(1) and so a charge has one row to lock. +- **`ledger_entries`** — append-only debit/credit halves, grouped by + `ledger_transactions`. Never updated (except the two settlement markers) + and never deleted: a correction is a new REVERSAL or ADJUSTMENT + transaction, so the audit trail only grows. + +Three invariants everything else rests on: + +1. **Every transaction balances.** Debits equal credits, validated before + anything is written, so the sum of all balances in a currency is always + exactly zero and the ledger can be audited by addition alone. +2. **`ledger_transactions.reference` is unique.** A replayed charge, a + re-run settlement pass, a resumed batch job — all collide on it and get + the original transaction back (`posted: false`). Callers never have to + reason about whether their retry posted twice. +3. **Accounts are locked in ascending id order** before any balance is read + or written, so concurrent transactions serialize instead of deadlocking. + +`GET /credits/admin/ledger/integrity` re-derives every balance from the +entries and reports drift — that is what keeps the cache honest. + +### Account kinds and what TREASURY means + +`USER` is a member's spendable credit. `REVENUE` is where micro-charges +accumulate before distribution. `PLATFORM_FEE`, `HUB_OPERATOR` and +`REFERRAL` are payable balances. `TREASURY` is the **contra/clearing +account** standing in for the outside world: it is debited when money +enters (a top-up) and credited when money leaves (a confirmed payout). Its +balance is therefore the negation of what the platform owes, and it is +*expected* to sit deeply negative. + +## The spend path + +`POST /credits/charge` — internal, ADMIN-guarded, meant for resource-usage +features running with a service identity rather than for end users (a +member must not be able to name the amount they are charged). It debits the +member and credits `REVENUE` synchronously. No payment rail, no chain call, +nothing in the hot path but one locked row. + +### Overdraft policy + +A charge is refused the moment it would take the account below +`-overdraftLimit`. That limit defaults to `CREDITS_DEFAULT_OVERDRAFT_LIMIT` +(0 — no overdraft at all) and can be raised per account by an admin for +graceful degradation, e.g. letting a session finish slightly negative +rather than cutting it off mid-use. + +The check runs inside the transaction holding the account's row lock, which +is what makes the **overdraft race** safe: two charges that are each +individually affordable but not affordable together can never both succeed. +One wins, the other gets a 409. `credits.service.spec.ts` fires 25 +concurrent charges at a fixed balance and asserts exactly ten land. + +The policy is enforced for `USER` accounts only. System accounts are the +other side of movements that have already happened — constraining them +would only make correct bookkeeping impossible. + +### The metered call site + +`POST /credits/usage` (`MeteredUsageService`) is the in-repo caller of the +charge path: it prices a meter reading (`units × unitPrice`), records it in +`metered_usage_events`, and charges it. Idempotent on the caller's +`usageReference`, via two independent unique keys pointing at the same +natural reference — so a retried delivery records once and charges once +even if it fails between the two writes. + +This is the shape a resource-usage feature has: it owns the pricing and the +usage audit record and hands the ledger nothing but an amount and a dedupe +key. **Note:** this backend has no per-minute usage / printing / room +module yet, so this is the metering surface until one lands; a real one +should call `CreditsService.charge` (or this endpoint) the same way. + +## The top-up path + +A `#1570` fiat or `#1574` on-chain payment funds a member's balance: one +payment funds many future micro-charges. A payment declares itself a top-up +by carrying `metadata.purpose = "CREDIT_TOP_UP"` at initiation time, or by +being marked afterwards via `POST /credits/admin/payments/:id/top-up`. + +`PaymentCreditsService` **sweeps CONFIRMED payments** rather than hooking +into the confirmation path. Three reasons: the dependency stays +one-directional (credits reads payments, never the reverse — which is also +why the payment/credit link table lives here rather than as columns on +`payments`); a payment confirmed while this service was down is still +picked up next pass; and it is idempotent by construction, since the unique +ledger reference is the real guard. `POST /credits/payments/:id/apply` is +the synchronous fast path for a checkout return, so a top-up is spendable +immediately. + +## The split engine + +A `RevenueSplitConfig` is a set of basis-point recipients, attachable to a +Payment or computed over a settlement batch. Each recipient is **either** +an internal ledger account **or** a bare external address — that choice is +what decides whether the share ever leaves the ledger. + +### Two rules, both enforced before money moves + +**Basis points must sum to exactly 10000 at configuration time.** A config +that could not distribute 100% of an amount is rejected with a 400 when it +is created or edited, so a settlement run never has to decide what to do +with a 97%-complete split. It is re-validated at computation time too, in +case a config was mutated by something that bypassed the service. + +**Rounding never loses or duplicates value.** Any percentage split of an +integer leaves a remainder: 1000 across 3333/3333/3334 basis points floors +to 333/333/333 and loses one minor unit. Dropping it makes the ledger stop +balancing; rounding each share up can *create* value. So +`split-allocation.ts` uses the **largest-remainder method**: + +1. everyone gets `floor(amount × basisPoints / 10000)`; +2. the leftover (always strictly fewer units than there are recipients) goes + one minor unit at a time to the largest fractional remainders; +3. ties break deterministically — lower `sortOrder` first, then input + position — so identical inputs always allocate identically and no + auditor has to chase run-to-run drift. + +The result sums to exactly the input amount, which is also what lets a +split be posted as balanced double-entry legs (the ledger refuses +unbalanced ones). `POST /credits/admin/splits/preview` shows the allocation +and each recipient's share of the remainder without posting anything. + +A config attached to a **Payment** must be entirely internal: moving value +off-platform is the settlement batch's job, so an operator's share lands in +their payable account and leaves in one netted transfer instead of one per +payment. + +## Batch settlement + +`SettlementService` runs hourly (`CREDITS_SETTLEMENT_ENABLED`) and can be +driven by hand from `POST /credits/admin/settlement/run`. A pass resumes +every open batch *before* creating new work, then creates up to two kinds of +batch per currency: + +- **DISTRIBUTION** — splits the `REVENUE` account's undistributed balance + across `CREDITS_SETTLEMENT_SPLIT_CONFIG`. Internal shares post as ledger + entries immediately (nothing to wait for); external shares become payouts + the rail has to confirm. +- **NET_PAYABLE** — nets each account that has an + `external_payout_address` and pays that address. One on-chain transfer per + account per cycle, however many micro-movements went into it. + +### Why it is safe to crash mid-run + +1. **Amounts come from account balances, not a running tally.** A payout + that never happened leaves the balance untouched, so the next pass sees + the same amount still owed. Nothing has to be rolled back. +2. **One in-flight payout per account.** A batch is never created for an + account that already has a PENDING or SUBMITTED payout, so the same + balance cannot be committed to two batches. Batch *creation* also takes + a transaction-scoped advisory lock, so two passes never interleave. +3. **Per-payout idempotency keys.** Re-executing a batch hands the rail the + same key — which the rail dedupes on — so a crash between "submitted" + and "recorded as submitted" cannot pay twice. Retries deliberately reuse + the key rather than minting a new one. + +And the rule those exist to protect: **a submission is not a settlement.** +The ledger drawdown and the per-entry `settled_at` marker are written only +after the rail confirms the payout from fresh state. If the on-chain leg +fails, the ledger still shows the balance as owed — never as paid. An +unreachable rail is treated as indeterminate, not as failure. + +`ledger_entries` carries two separate markers for this: +`settlement_batch_id` is the **claim** (this movement was accounted for by +batch X, and no other), `settled_at` is the **settled** marker, written only +on confirmation. `POST .../abandon` releases the claims a batch never +settled and posts nothing, because a payout that never happened has no +ledger effect to undo. + +`GET /credits/admin/settlement/batches/:id` is the full audit view: entries +in, recipients out, and the on-chain transaction reference for every +off-platform leg. + +## The on-chain leg (#1574) + +Settlement depends on the `ExternalPayoutRail` port, not on a chain. The +adapter (`payments/soroban/soroban-payout.adapter.ts`) implements it over +the existing escrow contract: a treasury-funded escrow created for, and +released to, the recipient. The escrow id is derived by hash from the +payout's idempotency key (domain-prefixed so it can never collide with a +payment's escrow), and the queue job id is derived from it too — so a +duplicate enqueue collapses and re-submitting is a no-op. + +`getPayoutStatus` is a fresh contract-state read: only `RELEASED` is +`confirmed`. `LOCKED` and `NOT_FOUND` are both `pending` — `NOT_FOUND` +covers "still queued" as much as "never created", so calling it a failure +would race settlement against its own queue. + +When the Soroban rail is disabled the port resolves to null, and settlement +says so explicitly: payouts stay PENDING and the run summary reports +`payoutsAwaitingRail` rather than quietly marking anything settled. + +## API surface + +| Endpoint | Who | What | +| --- | --- | --- | +| `POST /credits/charge` | admin/service | Debit a member's balance (idempotent on `reference`) | +| `POST /credits/usage` | admin/service | Price and charge a metered reading | +| `GET /credits/balance` | member | Own balance, ceiling and spendable amount | +| `GET /credits/statement` | member | Own append-only entries | +| `GET /credits/usage` | member | Own metered usage history | +| `POST /credits/payments/:id/apply` | owner/admin | Apply a confirmed payment now | +| `GET/POST/PATCH /credits/admin/accounts...` | admin | Account policy: overdraft, payout address, freeze | +| `POST /credits/admin/adjustments` | admin | Correct a balance (reason required, audited) | +| `GET /credits/admin/ledger/integrity` | admin | Balance-vs-entries drift report | +| `POST/GET/PUT /credits/admin/splits...` | admin | Split config CRUD, activation, preview | +| `POST /credits/admin/payments/:id/split-config` | admin | Attach a split to a payment | +| `POST /credits/admin/settlement/...` | admin | Run, create, execute, retry, abandon, inspect batches | + +## Tests + +- `split-allocation.spec.ts` — the rounding rule across many configs and + amounts: the allocation always sums to exactly the input, the remainder is + never dropped or duplicated, and ties break deterministically. +- `credits.service.spec.ts` — N concurrent charges never overdraw past the + ceiling; replays charge once; the materialized balance always agrees with + the entries. +- `ledger.service.spec.ts` — double-entry validation, account resolution + races, multi-leg splits, replay semantics. +- `revenue-split.service.spec.ts` — configuration-time rejection of a + broken split, and balanced posting of a payment distribution. +- `settlement.service.spec.ts` — submission is not settlement, a failed leg + leaves nothing settled, and re-running a batch never double-pays. +- `metered-usage.service.spec.ts` — pricing and double-write recovery. +- `payment-credits.service.spec.ts` — top-ups and payment splits apply once. +- `payments/soroban/soroban-payout.adapter.spec.ts` and the `payout` cases + in `escrow-submission.processor.spec.ts` — the on-chain leg's determinism + and its refusal to re-create or re-release an escrow. + +## Known gaps + +- No dedicated resource-usage module exists in this backend yet, so the + metered call site is `POST /credits/usage` (see above) rather than a + per-minute session tracker. +- `POST /credits/charge` and `POST /credits/usage` are guarded by the ADMIN + role because that is the closest primitive this codebase has to a service + identity. In a deployment with a service mesh, these are the endpoints to + put behind a service token. +- Settlement executes one step per pass (submit, then poll on the next + pass). That keeps each pass bounded and predictable; it also means a + payout takes at least two passes to reach CONFIRMED. +- Multi-currency is modelled (accounts, entries and transactions are all + currency-scoped and never mix) but there is no FX conversion anywhere: a + charge and the balance it draws on must be in the same currency. diff --git a/backend/src/credits/credits-admin.controller.ts b/backend/src/credits/credits-admin.controller.ts new file mode 100644 index 00000000..88edb49d --- /dev/null +++ b/backend/src/credits/credits-admin.controller.ts @@ -0,0 +1,424 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Put, + Query, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequestUser } from '../auth/interfaces/authenticated-request.interface'; +import { UserRole } from '../auth/enums/user-role.enum'; +import { CreditsService } from './credits.service'; +import { LedgerService } from './ledger.service'; +import { PaymentCreditsService } from './payment-credits.service'; +import { RevenueSplitService } from './revenue-split.service'; +import { SettlementService } from './settlement.service'; +import { SettlementBatchStatus } from './enums/settlement-batch-status.enum'; +import { + AbandonSettlementBatchDto, + AdjustCreditsDto, + AttachSplitConfigDto, + CreateLedgerAccountDto, + CreateSettlementBatchDto, + UpdateLedgerAccountDto, +} from './dto/ledger-admin.dto'; +import { + CreateRevenueSplitConfigDto, + PreviewSplitDto, + ReplaceSplitRecipientsDto, + SetSplitConfigActiveDto, +} from './dto/revenue-split-config.dto'; +import { + CreditBalanceResponseDto, + LedgerAccountResponseDto, + LedgerTransactionResponseDto, + PaymentCreditApplicationResponseDto, +} from './dto/credits-response.dto'; +import { + RevenueSplitConfigResponseDto, + SplitPreviewResponseDto, +} from './dto/revenue-split-response.dto'; +import { + SettlementBatchBreakdownResponseDto, + SettlementBatchResponseDto, +} from './dto/settlement-response.dto'; + +/** + * Admin surface for the credit ledger (issue #1575): account policy, + * revenue split configuration, and settlement visibility/recovery. + * + * Everything that can move value is either idempotent or requires a + * reason, and nothing here can edit a posted entry — a correction is + * always a new transaction, so the audit trail only ever grows. + */ +@ApiTags('credits-admin') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.ADMIN) +@Controller('credits/admin') +export class CreditsAdminController { + constructor( + private readonly credits: CreditsService, + private readonly ledger: LedgerService, + private readonly splits: RevenueSplitService, + private readonly settlement: SettlementService, + private readonly paymentCredits: PaymentCreditsService, + ) {} + + // ── accounts ─────────────────────────────────────────────────────────── + + @Get('accounts') + @ApiOperation({ summary: 'List ledger accounts' }) + @ApiResponse({ status: 200, type: [LedgerAccountResponseDto] }) + async listAccounts( + @Query('currency') currency?: string, + ): Promise { + const accounts = await this.ledger.listAccounts(currency); + return accounts.map((account) => + LedgerAccountResponseDto.fromEntity(account), + ); + } + + @Post('accounts') + @ApiOperation({ + summary: 'Create (or fetch) a ledger account', + description: + 'Idempotent on (kind, ownerId, currency). Give a payable account an ' + + 'externalPayoutAddress to make its balance eligible for an ' + + 'off-platform settlement batch.', + }) + @ApiResponse({ status: 201, type: LedgerAccountResponseDto }) + async createAccount( + @Body() dto: CreateLedgerAccountDto, + ): Promise { + const account = await this.ledger.getOrCreateAccount({ + kind: dto.kind, + ownerId: dto.ownerId ?? null, + currency: dto.currency ?? this.credits.defaultCurrency(), + overdraftLimit: dto.overdraftLimit, + externalPayoutAddress: dto.externalPayoutAddress ?? null, + label: dto.label ?? null, + }); + return LedgerAccountResponseDto.fromEntity(account); + } + + @Patch('accounts/:id') + @ApiOperation({ + summary: 'Update an account’s policy (overdraft, payout address, freeze)', + description: + 'Balance is deliberately not settable here — it moves only by posting ' + + 'ledger entries, so no code path can change it without an audit trail.', + }) + @ApiResponse({ status: 200, type: LedgerAccountResponseDto }) + async updateAccount( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateLedgerAccountDto, + ): Promise { + const account = await this.ledger.updateAccountPolicy(id, { + overdraftLimit: dto.overdraftLimit, + externalPayoutAddress: + dto.externalPayoutAddress === undefined + ? undefined + : dto.externalPayoutAddress || null, + frozen: dto.frozen, + label: dto.label, + }); + return LedgerAccountResponseDto.fromEntity(account); + } + + @Get('balances/:userId') + @ApiOperation({ summary: 'A member’s credit balance' }) + @ApiResponse({ status: 200, type: CreditBalanceResponseDto }) + async getBalance( + @Param('userId', ParseUUIDPipe) userId: string, + @Query('currency') currency?: string, + ): Promise { + const view = await this.credits.getBalance(userId, currency); + return CreditBalanceResponseDto.fromView(view); + } + + @Post('adjustments') + @ApiOperation({ + summary: 'Adjust a member’s credit balance (reason required, audited)', + }) + @ApiResponse({ status: 201, type: LedgerTransactionResponseDto }) + async adjust( + @Body() dto: AdjustCreditsDto, + @CurrentUser() currentUser: RequestUser, + ): Promise { + const { transaction } = await this.credits.adjust({ + userId: dto.userId, + delta: dto.delta, + currency: dto.currency, + reference: dto.reference, + reason: dto.reason, + actorId: currentUser.id, + }); + return LedgerTransactionResponseDto.fromEntity(transaction); + } + + @Get('ledger/integrity') + @ApiOperation({ + summary: 'Ledger integrity report', + description: + 'Re-derives every account balance from the append-only entries and ' + + 'reports drift, plus any transaction whose debits and credits do not ' + + 'cancel. Both lists empty is the healthy state.', + }) + checkIntegrity(@Query('currency') currency?: string) { + return this.ledger.checkIntegrity(currency); + } + + // ── revenue splits ───────────────────────────────────────────────────── + + @Post('splits') + @ApiOperation({ + summary: 'Create a revenue split config', + description: + 'Basis points must sum to exactly 10000 and every recipient must have ' + + 'exactly one of accountId / externalAddress — both rejected here with ' + + 'a 400 rather than discovered during a settlement run.', + }) + @ApiResponse({ status: 201, type: RevenueSplitConfigResponseDto }) + async createSplit( + @Body() dto: CreateRevenueSplitConfigDto, + ): Promise { + const config = await this.splits.createConfig({ + name: dto.name, + description: dto.description ?? null, + recipients: dto.recipients, + }); + return RevenueSplitConfigResponseDto.fromEntity(config); + } + + @Get('splits') + @ApiOperation({ summary: 'List revenue split configs' }) + @ApiResponse({ status: 200, type: [RevenueSplitConfigResponseDto] }) + async listSplits(): Promise { + const configs = await this.splits.listConfigs(); + return configs.map((config) => + RevenueSplitConfigResponseDto.fromEntity(config), + ); + } + + @Get('splits/:id') + @ApiOperation({ summary: 'Get one revenue split config' }) + @ApiResponse({ status: 200, type: RevenueSplitConfigResponseDto }) + async getSplit( + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + const config = await this.splits.getConfig(id); + return RevenueSplitConfigResponseDto.fromEntity(config); + } + + @Put('splits/:id/recipients') + @ApiOperation({ + summary: 'Replace a config’s recipients', + description: + 'Wholesale replacement, because "sums to 10000" is a property of the ' + + 'set — there is no valid way to edit one share in isolation.', + }) + @ApiResponse({ status: 200, type: RevenueSplitConfigResponseDto }) + async replaceRecipients( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ReplaceSplitRecipientsDto, + ): Promise { + const config = await this.splits.replaceRecipients(id, dto.recipients); + return RevenueSplitConfigResponseDto.fromEntity(config); + } + + @Post('splits/:id/active') + @ApiOperation({ summary: 'Activate or deactivate a config' }) + @ApiResponse({ status: 200, type: RevenueSplitConfigResponseDto }) + async setActive( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SetSplitConfigActiveDto, + ): Promise { + const config = await this.splits.setActive(id, dto.active); + return RevenueSplitConfigResponseDto.fromEntity(config); + } + + @Post('splits/preview') + @ApiOperation({ + summary: 'Preview what a config would allocate for an amount', + description: + 'Posts nothing. Shows each recipient’s share and how many minor units ' + + 'of the rounding remainder it received, so the largest-remainder rule ' + + 'is inspectable — the total always equals the input amount exactly.', + }) + @ApiResponse({ status: 200, type: SplitPreviewResponseDto }) + async previewSplit( + @Body() dto: PreviewSplitDto, + ): Promise { + const shares = await this.splits.computeForAmount(dto.configId, dto.amount); + return SplitPreviewResponseDto.fromShares(dto.amount, shares); + } + + // ── payment integration ──────────────────────────────────────────────── + + @Post('payments/:paymentId/split-config') + @ApiOperation({ + summary: 'Attach a revenue split config to a payment', + description: + 'Once the payment CONFIRMS, its amount is distributed across the ' + + 'config as ledger entries. Refused if the payment has already been ' + + 'applied, or if the config has external-address recipients (those ' + + 'belong to a settlement batch, not to a payment).', + }) + @ApiResponse({ status: 201, type: PaymentCreditApplicationResponseDto }) + async attachSplitConfig( + @Param('paymentId', ParseUUIDPipe) paymentId: string, + @Body() dto: AttachSplitConfigDto, + ): Promise { + const application = await this.paymentCredits.attachSplitConfig( + paymentId, + dto.splitConfigId, + ); + return PaymentCreditApplicationResponseDto.fromEntity(application); + } + + @Post('payments/:paymentId/top-up') + @ApiOperation({ + summary: 'Mark a payment as funding the payer’s credit balance', + description: + 'The alternative to setting `metadata.purpose = "CREDIT_TOP_UP"` at ' + + 'initiation time, for a caller that could not.', + }) + @ApiResponse({ status: 201, type: PaymentCreditApplicationResponseDto }) + async markAsTopUp( + @Param('paymentId', ParseUUIDPipe) paymentId: string, + ): Promise { + const application = await this.paymentCredits.markAsTopUp(paymentId); + return PaymentCreditApplicationResponseDto.fromEntity(application); + } + + @Post('payments/sweep') + @ApiOperation({ + summary: 'Run the confirmed-payment credit sweep immediately', + }) + sweepPayments() { + return this.paymentCredits.sweepConfirmedPayments(); + } + + // ── settlement ───────────────────────────────────────────────────────── + + @Post('settlement/run') + @ApiOperation({ + summary: 'Run a full settlement pass now', + description: + 'Resumes every open batch first, then creates and executes new ones. ' + + 'Safe to call at any time — the same guarantees the scheduled job ' + + 'relies on.', + }) + runSettlement() { + return this.settlement.runSettlement(); + } + + @Post('settlement/batches') + @ApiOperation({ + summary: 'Create one settlement batch explicitly', + description: + 'With a splitConfigName the batch distributes the revenue account ' + + 'across that config; without one it nets each payable account and ' + + 'pays its own address. Responds with an empty body when there was ' + + 'nothing to settle.', + }) + @ApiResponse({ status: 201, type: SettlementBatchResponseDto }) + async createBatch( + @Body() dto: CreateSettlementBatchDto, + ): Promise { + const currency = ( + dto.currency ?? this.credits.defaultCurrency() + ).toUpperCase(); + const batch = dto.splitConfigName + ? await this.settlement.createDistributionBatch( + currency, + dto.splitConfigName, + ) + : await this.settlement.createNetPayableBatch(currency); + return batch ? SettlementBatchResponseDto.fromEntity(batch) : null; + } + + @Get('settlement/batches') + @ApiOperation({ summary: 'List settlement batches, newest first' }) + @ApiResponse({ status: 200, type: [SettlementBatchResponseDto] }) + async listBatches( + @Query('status') status?: SettlementBatchStatus, + ): Promise { + const batches = await this.settlement.listBatches(status); + return batches.map((batch) => SettlementBatchResponseDto.fromEntity(batch)); + } + + @Get('settlement/batches/:id') + @ApiOperation({ + summary: 'Full breakdown of one batch', + description: + 'Entries in, recipients out, and the on-chain transaction reference ' + + 'for every off-platform leg.', + }) + @ApiResponse({ status: 200, type: SettlementBatchBreakdownResponseDto }) + async getBatch( + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + const breakdown = await this.settlement.getBatchBreakdown(id); + return SettlementBatchBreakdownResponseDto.fromBreakdown(breakdown); + } + + @Post('settlement/batches/:id/execute') + @ApiOperation({ + summary: 'Advance one batch by a step (submit pending, poll submitted)', + }) + @ApiResponse({ status: 201, type: SettlementBatchResponseDto }) + async executeBatch( + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + const batch = await this.settlement.executeBatch(id); + return SettlementBatchResponseDto.fromEntity(batch); + } + + @Post('settlement/batches/:id/retry') + @ApiOperation({ + summary: 'Re-queue a batch’s failed payouts', + description: + 'The idempotency keys are deliberately reused, so the rail dedupes a ' + + 'transfer that actually did land — a retry can never double-pay.', + }) + @ApiResponse({ status: 201, type: SettlementBatchResponseDto }) + async retryBatch( + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + const batch = await this.settlement.retryBatch(id); + return SettlementBatchResponseDto.fromEntity(batch); + } + + @Post('settlement/batches/:id/abandon') + @ApiOperation({ + summary: 'Give up on a batch and release its unsettled claims', + description: + 'Posts nothing: a payout that never happened has no ledger effect to ' + + 'undo, so the balance is still shown as owed and a future batch can ' + + 'pick the entries up again.', + }) + @ApiResponse({ status: 201, type: SettlementBatchResponseDto }) + async abandonBatch( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AbandonSettlementBatchDto, + ): Promise { + const batch = await this.settlement.abandonBatch(id, dto.reason); + return SettlementBatchResponseDto.fromEntity(batch); + } +} diff --git a/backend/src/credits/credits.controller.ts b/backend/src/credits/credits.controller.ts new file mode 100644 index 00000000..e11a2481 --- /dev/null +++ b/backend/src/credits/credits.controller.ts @@ -0,0 +1,189 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequestUser } from '../auth/interfaces/authenticated-request.interface'; +import { UserRole } from '../auth/enums/user-role.enum'; +import { PaymentsService } from '../payments/payments.service'; +import { CreditsService } from './credits.service'; +import { LedgerService } from './ledger.service'; +import { MeteredUsageService } from './metered-usage.service'; +import { PaymentCreditsService } from './payment-credits.service'; +import { ChargeCreditsDto } from './dto/charge-credits.dto'; +import { RecordMeteredUsageDto } from './dto/record-metered-usage.dto'; +import { + ChargeCreditsResponseDto, + CreditBalanceResponseDto, + LedgerEntryResponseDto, + LedgerTransactionResponseDto, + MeteredUsageResponseDto, + PaymentCreditApplicationResponseDto, +} from './dto/credits-response.dto'; + +@ApiTags('credits') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Controller('credits') +export class CreditsController { + constructor( + private readonly credits: CreditsService, + private readonly ledger: LedgerService, + private readonly usage: MeteredUsageService, + private readonly paymentCredits: PaymentCreditsService, + private readonly payments: PaymentsService, + ) {} + + /** + * The internal spend boundary. Restricted to the ADMIN role because it + * is meant to be called by resource-usage features running with a + * service identity, never by an end user against their own balance — a + * member must not be able to name the amount they are charged. (In a + * deployment with a service-mesh identity, this is the endpoint to move + * behind a service token; the role guard is the closest primitive this + * codebase has today.) + */ + @Post('charge') + @Roles(UserRole.ADMIN) + @ApiOperation({ + summary: 'Charge a member’s credit balance (internal, service-to-service)', + description: + 'Synchronous, cheap, and never touches a payment rail. Idempotent on ' + + '`reference`: replaying it returns the original charge with ' + + '`posted: false`. Rejected with 409 if it would breach the account’s ' + + 'overdraft ceiling — including when it only breaches it in ' + + 'combination with a concurrent charge, since the check is made under ' + + 'the account’s row lock.', + }) + @ApiResponse({ status: 201, type: ChargeCreditsResponseDto }) + async charge( + @Body() dto: ChargeCreditsDto, + @CurrentUser() currentUser: RequestUser, + ): Promise { + const result = await this.credits.charge({ + userId: dto.userId, + amount: dto.amount, + currency: dto.currency, + reference: dto.reference, + reason: dto.reason, + metadata: dto.metadata ?? null, + actorId: currentUser.id, + }); + + return { + transaction: LedgerTransactionResponseDto.fromEntity(result.transaction), + posted: result.posted, + balanceAfter: result.balanceAfter, + currency: result.currency, + }; + } + + /** + * The metered call site for the charge path: prices a usage reading and + * charges it. Same service-identity reasoning as `charge` above. + */ + @Post('usage') + @Roles(UserRole.ADMIN) + @ApiOperation({ + summary: 'Record metered resource usage and charge it to credit', + description: + 'Priced as units × unitPrice in minor units. Idempotent on ' + + '`usageReference` — a retried meter reading records once and charges ' + + 'once.', + }) + @ApiResponse({ status: 201, type: MeteredUsageResponseDto }) + async recordUsage( + @Body() dto: RecordMeteredUsageDto, + ): Promise { + const { event, charged } = await this.usage.recordUsage({ + userId: dto.userId, + resource: dto.resource, + units: dto.units, + unitPrice: dto.unitPrice, + currency: dto.currency, + usageReference: dto.usageReference, + }); + return MeteredUsageResponseDto.fromEntity(event, charged); + } + + @Get('balance') + @ApiOperation({ summary: 'Your own credit balance' }) + @ApiResponse({ status: 200, type: CreditBalanceResponseDto }) + async myBalance( + @CurrentUser() currentUser: RequestUser, + @Query('currency') currency?: string, + ): Promise { + const view = await this.credits.getBalance(currentUser.id, currency); + return CreditBalanceResponseDto.fromView(view); + } + + @Get('statement') + @ApiOperation({ + summary: 'Your own recent ledger entries, newest first', + description: + 'The append-only movements behind the balance — every charge, top-up ' + + 'and adjustment, with nothing ever edited or deleted.', + }) + @ApiResponse({ status: 200, type: [LedgerEntryResponseDto] }) + async myStatement( + @CurrentUser() currentUser: RequestUser, + @Query('currency') currency?: string, + ): Promise { + const view = await this.credits.getBalance(currentUser.id, currency); + if (!view.accountId) { + return []; + } + const entries = await this.ledger.listEntries(view.accountId); + return entries.map((entry) => LedgerEntryResponseDto.fromEntity(entry)); + } + + @Get('usage') + @ApiOperation({ summary: 'Your own metered usage history, newest first' }) + @ApiResponse({ status: 200, type: [MeteredUsageResponseDto] }) + async myUsage( + @CurrentUser() currentUser: RequestUser, + ): Promise { + const events = await this.usage.listForUser(currentUser.id); + return events.map((event) => MeteredUsageResponseDto.fromEntity(event)); + } + + /** + * Applies a CONFIRMED payment's credit-ledger effect right now instead + * of waiting for the sweep — the fast path for a checkout return, so a + * top-up is spendable immediately. Authorization reuses + * PaymentsService.findOne, so a member can only ever apply their own + * payment (an admin, any). + */ + @Post('payments/:paymentId/apply') + @ApiOperation({ + summary: 'Apply a confirmed payment to the credit ledger now', + description: + 'Idempotent: a payment already applied is returned unchanged. Only ' + + 'works on a CONFIRMED payment that is either marked as a credit ' + + 'top-up or has a revenue split attached.', + }) + @ApiResponse({ status: 201, type: PaymentCreditApplicationResponseDto }) + async applyPayment( + @Param('paymentId', ParseUUIDPipe) paymentId: string, + @CurrentUser() currentUser: RequestUser, + ): Promise { + const payment = await this.payments.findOne(paymentId, currentUser); + const application = await this.paymentCredits.applyPayment(payment.id); + return PaymentCreditApplicationResponseDto.fromEntity(application); + } +} diff --git a/backend/src/credits/credits.module.ts b/backend/src/credits/credits.module.ts new file mode 100644 index 00000000..28b5f55c --- /dev/null +++ b/backend/src/credits/credits.module.ts @@ -0,0 +1,64 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Payment } from '../payments/entities/payment.entity'; +import { PaymentsModule } from '../payments/payments.module'; +import { LedgerAccount } from './entities/ledger-account.entity'; +import { LedgerEntry } from './entities/ledger-entry.entity'; +import { LedgerTransaction } from './entities/ledger-transaction.entity'; +import { MeteredUsageEvent } from './entities/metered-usage-event.entity'; +import { PaymentCreditApplication } from './entities/payment-credit-application.entity'; +import { RevenueSplitConfig } from './entities/revenue-split-config.entity'; +import { RevenueSplitRecipient } from './entities/revenue-split-recipient.entity'; +import { SettlementBatch } from './entities/settlement-batch.entity'; +import { SettlementPayout } from './entities/settlement-payout.entity'; +import { CreditsController } from './credits.controller'; +import { CreditsAdminController } from './credits-admin.controller'; +import { CreditsService } from './credits.service'; +import { LedgerService } from './ledger.service'; +import { MeteredUsageService } from './metered-usage.service'; +import { PaymentCreditsService } from './payment-credits.service'; +import { RevenueSplitService } from './revenue-split.service'; +import { SettlementService } from './settlement.service'; + +/** + * Micropayment credit ledger and multi-party revenue distribution + * (issue #1575). + * + * The dependency on PaymentsModule is one-directional and deliberate: + * this module reads Payment rows (to fund a top-up or distribute a + * confirmed payment) and consumes the EXTERNAL_PAYOUT_RAIL that module + * provides over the #1574 escrow rail. The payments module knows nothing + * about credits, which is why the payment/credit link table lives here + * rather than as columns on `payments`. + */ +@Module({ + imports: [ + TypeOrmModule.forFeature([ + LedgerAccount, + LedgerTransaction, + LedgerEntry, + RevenueSplitConfig, + RevenueSplitRecipient, + SettlementBatch, + SettlementPayout, + MeteredUsageEvent, + PaymentCreditApplication, + // Read-only from this module's point of view — nothing here ever + // changes a Payment's status; that stays the payments module's + // guarded state machine. + Payment, + ]), + PaymentsModule, + ], + controllers: [CreditsController, CreditsAdminController], + providers: [ + LedgerService, + CreditsService, + RevenueSplitService, + SettlementService, + PaymentCreditsService, + MeteredUsageService, + ], + exports: [LedgerService, CreditsService, RevenueSplitService], +}) +export class CreditsModule {} diff --git a/backend/src/credits/credits.service.spec.ts b/backend/src/credits/credits.service.spec.ts new file mode 100644 index 00000000..212df440 --- /dev/null +++ b/backend/src/credits/credits.service.spec.ts @@ -0,0 +1,409 @@ +import { BadRequestException, ConflictException } from '@nestjs/common'; +import { CreditsService } from './credits.service'; +import { InsufficientCreditException, LedgerService } from './ledger.service'; +import { LedgerAccountKind } from './enums/ledger-account-kind.enum'; +import { LedgerEntryDirection } from './enums/ledger-entry-direction.enum'; +import { LedgerTransactionKind } from './enums/ledger-transaction-kind.enum'; +import { + createLedgerHarness, + fakeConfigService, + LedgerHarness, +} from './testing/in-memory-ledger'; + +function build(config: Record = {}) { + const harness = createLedgerHarness(); + const ledger = new LedgerService( + harness.accounts as any, + harness.transactions as any, + harness.entries as any, + ); + const credits = new CreditsService( + ledger, + fakeConfigService({ + CREDITS_DEFAULT_CURRENCY: 'USD', + CREDITS_DEFAULT_OVERDRAFT_LIMIT: 0, + ...config, + }), + ); + return { harness, ledger, credits }; +} + +async function fund( + credits: CreditsService, + userId: string, + amount: number, + paymentId = `payment-${userId}`, +): Promise { + await credits.topUpFromPayment({ + paymentId, + userId, + amount, + currency: 'USD', + }); +} + +function assertLedgerBalances(harness: LedgerHarness, accountIds: string[]) { + for (const accountId of accountIds) { + expect(harness.balanceOf(accountId)).toBe( + harness.derivedBalanceOf(accountId), + ); + } +} + +describe('CreditsService', () => { + describe('top-up path', () => { + it('credits the member and debits treasury, balancing to zero', async () => { + const { credits, harness } = build(); + await fund(credits, 'user-1', 5000); + + const balance = await credits.getBalance('user-1'); + expect(balance.balance).toBe(5000); + expect(balance.spendable).toBe(5000); + + const treasury = await credits.getSystemAccount( + LedgerAccountKind.TREASURY, + ); + expect(harness.balanceOf(treasury.id)).toBe(-5000); + assertLedgerBalances(harness, [balance.accountId!, treasury.id]); + }); + + it('credits a payment only once, however often it is applied', async () => { + const { credits, harness } = build(); + const first = await credits.topUpFromPayment({ + paymentId: 'payment-9', + userId: 'user-1', + amount: 2500, + currency: 'USD', + }); + const second = await credits.topUpFromPayment({ + paymentId: 'payment-9', + userId: 'user-1', + amount: 2500, + currency: 'USD', + }); + + expect(first.posted).toBe(true); + expect(second.posted).toBe(false); + expect(second.transaction.id).toBe(first.transaction.id); + expect((await credits.getBalance('user-1')).balance).toBe(2500); + expect(harness.transactions.rows).toHaveLength(1); + }); + + it('reports a zero balance for a member with no account yet', async () => { + const { credits } = build(); + const balance = await credits.getBalance('nobody'); + expect(balance).toMatchObject({ + accountId: null, + balance: 0, + spendable: 0, + }); + }); + }); + + describe('charge path', () => { + it('debits the member and credits revenue', async () => { + const { credits, harness } = build(); + await fund(credits, 'user-1', 1000); + + const result = await credits.charge({ + userId: 'user-1', + amount: 250, + reference: 'print-job-1', + reason: 'printing x50', + }); + + expect(result.posted).toBe(true); + expect(result.balanceAfter).toBe(750); + expect(result.transaction.kind).toBe(LedgerTransactionKind.CHARGE); + expect(result.transaction.reference).toBe('charge:print-job-1'); + + const revenue = await credits.getSystemAccount(LedgerAccountKind.REVENUE); + expect(harness.balanceOf(revenue.id)).toBe(250); + + const legs = harness.entries.rows.filter( + (entry) => entry.transactionId === result.transaction.id, + ); + expect(legs).toHaveLength(2); + expect( + legs.filter((leg) => leg.direction === LedgerEntryDirection.DEBIT)[0] + .amount, + ).toBe(250); + }); + + it('charges a replayed reference exactly once', async () => { + const { credits } = build(); + await fund(credits, 'user-1', 1000); + + const first = await credits.charge({ + userId: 'user-1', + amount: 250, + reference: 'session-42', + reason: 'resource minutes', + }); + const replay = await credits.charge({ + userId: 'user-1', + amount: 250, + reference: 'session-42', + reason: 'resource minutes', + }); + + expect(first.posted).toBe(true); + expect(replay.posted).toBe(false); + expect(replay.transaction.id).toBe(first.transaction.id); + expect((await credits.getBalance('user-1')).balance).toBe(750); + }); + + it('rejects a charge that would overdraw an account with no ceiling', async () => { + const { credits } = build(); + await fund(credits, 'user-1', 100); + + await expect( + credits.charge({ + userId: 'user-1', + amount: 101, + reference: 'too-big', + reason: 'overage', + }), + ).rejects.toThrow(InsufficientCreditException); + expect((await credits.getBalance('user-1')).balance).toBe(100); + }); + + it('allows a charge inside a configured overdraft ceiling, and not past it', async () => { + const { credits, ledger } = build(); + await fund(credits, 'user-1', 100); + const account = await credits.getUserAccount('user-1'); + await ledger.updateAccountPolicy(account.id, { overdraftLimit: 500 }); + + await credits.charge({ + userId: 'user-1', + amount: 600, + reference: 'within-ceiling', + reason: 'graceful degradation', + }); + expect((await credits.getBalance('user-1')).balance).toBe(-500); + + await expect( + credits.charge({ + userId: 'user-1', + amount: 1, + reference: 'past-ceiling', + reason: 'one too many', + }), + ).rejects.toThrow(InsufficientCreditException); + expect((await credits.getBalance('user-1')).balance).toBe(-500); + }); + + it('refuses to debit a frozen account', async () => { + const { credits, ledger } = build(); + await fund(credits, 'user-1', 1000); + const account = await credits.getUserAccount('user-1'); + await ledger.updateAccountPolicy(account.id, { frozen: true }); + + await expect( + credits.charge({ + userId: 'user-1', + amount: 10, + reference: 'frozen', + reason: 'nope', + }), + ).rejects.toThrow(ConflictException); + }); + + it('rejects a non-positive or fractional amount', async () => { + const { credits } = build(); + for (const amount of [0, -5, 1.5]) { + await expect( + credits.charge({ + userId: 'user-1', + amount, + reference: `bad-${amount}`, + reason: 'bad amount', + }), + ).rejects.toThrow(BadRequestException); + } + }); + + it('requires a reason and a reference', async () => { + const { credits } = build(); + await expect( + credits.charge({ + userId: 'user-1', + amount: 10, + reference: 'has-ref', + reason: ' ', + }), + ).rejects.toThrow(BadRequestException); + await expect( + credits.charge({ + userId: 'user-1', + amount: 10, + reference: '', + reason: 'has reason', + }), + ).rejects.toThrow(BadRequestException); + }); + }); + + /** + * Issue #1575's concurrency acceptance criterion. The harness serializes + * transactions exactly as the accounts' `FOR UPDATE` locks do, so each + * charge evaluates the overdraft rule against the balance the previous + * one committed — which is the difference between "each charge is + * individually affordable" and "all of them are affordable together". + */ + describe('concurrent charges against one balance', () => { + it('never overdraws past a zero ceiling, whoever wins the race', async () => { + const { credits, harness } = build(); + await fund(credits, 'user-1', 1000); + + const results = await Promise.allSettled( + Array.from({ length: 25 }, (_, index) => + credits.charge({ + userId: 'user-1', + amount: 100, + reference: `concurrent-${index}`, + reason: 'per-minute usage', + }), + ), + ); + + const succeeded = results.filter((r) => r.status === 'fulfilled'); + const rejected = results.filter( + (r): r is PromiseRejectedResult => r.status === 'rejected', + ); + + // 1000 / 100 — exactly ten charges are affordable, no more, no fewer. + expect(succeeded).toHaveLength(10); + expect(rejected).toHaveLength(15); + expect( + rejected.every((r) => r.reason instanceof InsufficientCreditException), + ).toBe(true); + + const balance = await credits.getBalance('user-1'); + expect(balance.balance).toBe(0); + + // Nothing was lost or double-applied: the materialized balance still + // agrees with the append-only entries it is a cache of. + const revenue = await credits.getSystemAccount(LedgerAccountKind.REVENUE); + assertLedgerBalances(harness, [balance.accountId!, revenue.id]); + expect(harness.balanceOf(revenue.id)).toBe(1000); + }); + + it('overdraws to exactly the ceiling and no further', async () => { + const { credits, ledger, harness } = build(); + await fund(credits, 'user-1', 500); + const account = await credits.getUserAccount('user-1'); + await ledger.updateAccountPolicy(account.id, { overdraftLimit: 200 }); + + const results = await Promise.allSettled( + Array.from({ length: 20 }, (_, index) => + credits.charge({ + userId: 'user-1', + amount: 100, + reference: `ceiling-${index}`, + reason: 'per-minute usage', + }), + ), + ); + + // 500 of balance + 200 of ceiling = 7 affordable charges of 100. + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(7); + expect(harness.balanceOf(account.id)).toBe(-200); + assertLedgerBalances(harness, [account.id]); + }); + + it('applies a replayed reference once even when replays race', async () => { + const { credits } = build(); + await fund(credits, 'user-1', 1000); + + const results = await Promise.all( + Array.from({ length: 5 }, () => + credits.charge({ + userId: 'user-1', + amount: 100, + reference: 'same-usage-event', + reason: 'duplicate delivery', + }), + ), + ); + + expect(results.filter((result) => result.posted)).toHaveLength(1); + expect(new Set(results.map((r) => r.transaction.id)).size).toBe(1); + expect((await credits.getBalance('user-1')).balance).toBe(900); + }); + }); + + describe('adjustments', () => { + it('credits a member with a balanced ADJUSTMENT transaction', async () => { + const { credits, harness } = build(); + const { transaction } = await credits.adjust({ + userId: 'user-1', + delta: 750, + reference: 'goodwill-1', + reason: 'goodwill credit', + actorId: 'admin-1', + }); + + expect(transaction.kind).toBe(LedgerTransactionKind.ADJUSTMENT); + expect(transaction.actorId).toBe('admin-1'); + expect((await credits.getBalance('user-1')).balance).toBe(750); + + const legs = harness.entries.rows.filter( + (entry) => entry.transactionId === transaction.id, + ); + const debits = legs + .filter((leg) => leg.direction === LedgerEntryDirection.DEBIT) + .reduce((sum, leg) => sum + leg.amount, 0); + const creditsTotal = legs + .filter((leg) => leg.direction === LedgerEntryDirection.CREDIT) + .reduce((sum, leg) => sum + leg.amount, 0); + expect(debits).toBe(creditsTotal); + }); + + it('debits a member for a negative delta, honouring the overdraft rule', async () => { + const { credits } = build(); + await fund(credits, 'user-1', 300); + + await credits.adjust({ + userId: 'user-1', + delta: -100, + reference: 'correction-1', + reason: 'mis-charged usage', + actorId: 'admin-1', + }); + expect((await credits.getBalance('user-1')).balance).toBe(200); + + await expect( + credits.adjust({ + userId: 'user-1', + delta: -1000, + reference: 'correction-2', + reason: 'too big', + actorId: 'admin-1', + }), + ).rejects.toThrow(InsufficientCreditException); + }); + + it('rejects a zero delta and a missing reason', async () => { + const { credits } = build(); + await expect( + credits.adjust({ + userId: 'user-1', + delta: 0, + reference: 'zero', + reason: 'nothing', + actorId: 'admin-1', + }), + ).rejects.toThrow(BadRequestException); + await expect( + credits.adjust({ + userId: 'user-1', + delta: 100, + reference: 'no-reason', + reason: '', + actorId: 'admin-1', + }), + ).rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/backend/src/credits/credits.service.ts b/backend/src/credits/credits.service.ts new file mode 100644 index 00000000..ad6bfa57 --- /dev/null +++ b/backend/src/credits/credits.service.ts @@ -0,0 +1,348 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { EntityManager } from 'typeorm'; +import { LedgerAccount } from './entities/ledger-account.entity'; +import { LedgerTransaction } from './entities/ledger-transaction.entity'; +import { LedgerAccountKind } from './enums/ledger-account-kind.enum'; +import { LedgerEntryDirection } from './enums/ledger-entry-direction.enum'; +import { LedgerTransactionKind } from './enums/ledger-transaction-kind.enum'; +import { LedgerService } from './ledger.service'; + +export interface ChargeCreditsInput { + userId: string; + /** Minor units, positive. */ + amount: number; + currency?: string; + /** + * The caller's own natural key for the thing being charged (a usage + * event id, a print job id). Made unique per charge by the `charge:` + * prefix below — a retried delivery charges exactly once. + */ + reference: string; + reason: string; + metadata?: Record | null; + actorId?: string | null; +} + +export interface ChargeCreditsResult { + transaction: LedgerTransaction; + /** False when this reference had already been charged. */ + posted: boolean; + balanceAfter: number; + currency: string; +} + +export interface CreditBalanceView { + accountId: string | null; + userId: string; + currency: string; + balance: number; + overdraftLimit: number; + /** balance + overdraftLimit — what a charge may actually consume. */ + spendable: number; + frozen: boolean; +} + +/** + * The credit-balance domain API (issue #1575): the spend path, the top-up + * path, and the account resolution both need. + * + * Why this exists at all: per-minute resource usage, printing and + * meeting-room overage are too small and too frequent to settle on-chain + * per event — the fee and the latency would dwarf the charge. So a charge + * here is a synchronous, cheap, purely-internal ledger movement with no + * blockchain call anywhere in the hot path; value only crosses the + * platform boundary later, in one netted batch (see SettlementService). + * + * ## Overdraft policy + * + * A charge is refused the moment it would take the account below + * `-overdraftLimit`, which defaults to 0 (`CREDITS_DEFAULT_OVERDRAFT_LIMIT`) + * — i.e. no overdraft at all unless an operator deliberately grants a + * ceiling for graceful degradation. The check happens inside the same + * transaction that holds the account's row lock, so two concurrent charges + * that are each individually affordable but not affordable together can + * never both succeed: one wins, the other gets a 409. + */ +@Injectable() +export class CreditsService { + constructor( + private readonly ledger: LedgerService, + private readonly config: ConfigService, + ) {} + + defaultCurrency(): string { + return this.config + .get('CREDITS_DEFAULT_CURRENCY', 'USD') + .toUpperCase(); + } + + /** + * Resolves (creating on first use) a member's credit account. The + * overdraft ceiling is seeded from config at creation time and can be + * raised per account afterwards by an admin — it is not re-read from + * config on every charge, so changing the default never silently + * re-authorizes an existing account. + */ + async getUserAccount( + userId: string, + currency?: string, + manager?: EntityManager, + ): Promise { + return this.ledger.getOrCreateAccount( + { + kind: LedgerAccountKind.USER, + ownerId: userId, + currency: currency ?? this.defaultCurrency(), + overdraftLimit: this.config.get( + 'CREDITS_DEFAULT_OVERDRAFT_LIMIT', + 0, + ), + label: `user credit balance`, + }, + manager, + ); + } + + /** Resolves (creating on first use) one of the singleton system accounts. */ + async getSystemAccount( + kind: LedgerAccountKind, + currency?: string, + manager?: EntityManager, + ): Promise { + if (kind === LedgerAccountKind.USER) { + throw new BadRequestException( + 'USER accounts are owned, not system accounts', + ); + } + return this.ledger.getOrCreateAccount( + { + kind, + ownerId: null, + currency: currency ?? this.defaultCurrency(), + label: kind.toLowerCase().replace(/_/g, ' '), + }, + manager, + ); + } + + /** + * Resolves (creating on first use) an owned payable account — a hub + * operator or a referrer. `externalPayoutAddress` is what makes its + * balance eligible to leave the platform in a NET_PAYABLE settlement + * batch; without one the balance simply accumulates in the ledger. + */ + async getPayableAccount( + kind: LedgerAccountKind.HUB_OPERATOR | LedgerAccountKind.REFERRAL, + ownerId: string, + currency?: string, + externalPayoutAddress?: string | null, + manager?: EntityManager, + ): Promise { + return this.ledger.getOrCreateAccount( + { + kind, + ownerId, + currency: currency ?? this.defaultCurrency(), + externalPayoutAddress: externalPayoutAddress ?? null, + label: `${kind.toLowerCase().replace(/_/g, ' ')} payable`, + }, + manager, + ); + } + + async getBalance( + userId: string, + currency?: string, + ): Promise { + const resolvedCurrency = (currency ?? this.defaultCurrency()).toUpperCase(); + const account = await this.ledger.findAccount({ + kind: LedgerAccountKind.USER, + ownerId: userId, + currency: resolvedCurrency, + }); + + if (!account) { + // No account yet simply means no movement yet — a zero balance, not + // an error. The account is created lazily by the first charge or + // top-up. + return { + accountId: null, + userId, + currency: resolvedCurrency, + balance: 0, + overdraftLimit: 0, + spendable: 0, + frozen: false, + }; + } + + return { + accountId: account.id, + userId, + currency: account.currency, + balance: account.balance, + overdraftLimit: account.overdraftLimit, + spendable: account.balance + account.overdraftLimit, + frozen: account.frozen, + }; + } + + /** + * Debits a member's credit balance and credits the platform revenue + * account. Synchronous and cheap by design — no payment rail, no + * on-chain call. Rejected (409) if it would breach the account's + * overdraft ceiling; idempotent on `reference`. + */ + async charge(input: ChargeCreditsInput): Promise { + if (!Number.isInteger(input.amount) || input.amount <= 0) { + throw new BadRequestException( + 'Charge amount must be a positive integer (minor units)', + ); + } + if (!input.reason?.trim()) { + throw new BadRequestException('A charge reason is required'); + } + if (!input.reference?.trim()) { + throw new BadRequestException('A charge reference is required'); + } + + const currency = (input.currency ?? this.defaultCurrency()).toUpperCase(); + const userAccount = await this.getUserAccount(input.userId, currency); + const revenueAccount = await this.getSystemAccount( + LedgerAccountKind.REVENUE, + currency, + ); + + const { transaction, posted } = await this.ledger.post({ + reference: `charge:${input.reference.trim()}`, + kind: LedgerTransactionKind.CHARGE, + currency, + description: input.reason.trim(), + metadata: input.metadata ?? null, + actorId: input.actorId ?? null, + legs: [ + { + accountId: userAccount.id, + direction: LedgerEntryDirection.DEBIT, + amount: input.amount, + }, + { + accountId: revenueAccount.id, + direction: LedgerEntryDirection.CREDIT, + amount: input.amount, + }, + ], + }); + + const after = await this.ledger.getAccount(userAccount.id); + return { transaction, posted, balanceAfter: after.balance, currency }; + } + + /** + * Credits a member's balance from money that already arrived over a + * payment rail — the top-up path. TREASURY is debited as the + * counterparty because the value crossed the platform boundary: one + * fiat or on-chain payment funds many future micro-charges. + * + * Kept idempotent on the payment id rather than on a caller-supplied + * key, so a sweep that re-examines the same CONFIRMED payment (or two + * sweeps racing) can only ever credit it once. + */ + async topUpFromPayment(input: { + paymentId: string; + userId: string; + amount: number; + currency: string; + manager?: EntityManager; + }): Promise<{ transaction: LedgerTransaction; posted: boolean }> { + const currency = input.currency.toUpperCase(); + const userAccount = await this.getUserAccount( + input.userId, + currency, + input.manager, + ); + const treasury = await this.getSystemAccount( + LedgerAccountKind.TREASURY, + currency, + input.manager, + ); + + return this.ledger.post( + { + reference: `top-up:payment:${input.paymentId}`, + kind: LedgerTransactionKind.TOP_UP, + currency, + description: `Credit top-up funded by payment ${input.paymentId}`, + metadata: { paymentId: input.paymentId }, + legs: [ + { + accountId: treasury.id, + direction: LedgerEntryDirection.DEBIT, + amount: input.amount, + }, + { + accountId: userAccount.id, + direction: LedgerEntryDirection.CREDIT, + amount: input.amount, + }, + ], + }, + input.manager, + ); + } + + /** + * Admin correction. Always a fresh ADJUSTMENT transaction against + * TREASURY rather than an edit of anything already posted — the entries + * are append-only, so the audit trail keeps both the original and the + * correction. + */ + async adjust(input: { + userId: string; + /** Positive credits the member, negative debits them. */ + delta: number; + currency?: string; + reference: string; + reason: string; + actorId: string; + }): Promise<{ transaction: LedgerTransaction; posted: boolean }> { + if (!Number.isInteger(input.delta) || input.delta === 0) { + throw new BadRequestException( + 'Adjustment delta must be a non-zero integer (minor units)', + ); + } + if (!input.reason?.trim()) { + throw new BadRequestException('An adjustment reason is required'); + } + + const currency = (input.currency ?? this.defaultCurrency()).toUpperCase(); + const userAccount = await this.getUserAccount(input.userId, currency); + const treasury = await this.getSystemAccount( + LedgerAccountKind.TREASURY, + currency, + ); + const amount = Math.abs(input.delta); + const creditsUser = input.delta > 0; + + return this.ledger.post({ + reference: `adjustment:${input.reference.trim()}`, + kind: LedgerTransactionKind.ADJUSTMENT, + currency, + description: input.reason.trim(), + actorId: input.actorId, + legs: [ + { + accountId: creditsUser ? treasury.id : userAccount.id, + direction: LedgerEntryDirection.DEBIT, + amount, + }, + { + accountId: creditsUser ? userAccount.id : treasury.id, + direction: LedgerEntryDirection.CREDIT, + amount, + }, + ], + }); + } +} diff --git a/backend/src/credits/credits.tokens.ts b/backend/src/credits/credits.tokens.ts new file mode 100644 index 00000000..645d3267 --- /dev/null +++ b/backend/src/credits/credits.tokens.ts @@ -0,0 +1,12 @@ +/** + * DI token for the off-platform payout rail (issue #1575). The port is + * declared by the credits module because that is who needs it; the + * adapter that implements it over the #1574 Soroban escrow rail lives in + * the payments module and is registered there, resolving to null whenever + * the on-chain rail is disabled (SOROBAN_ENABLED is not true). + * + * Keeping the token in its own dependency-free file is what lets the + * payments module provide it without either module importing the other's + * module class. + */ +export const EXTERNAL_PAYOUT_RAIL = Symbol('EXTERNAL_PAYOUT_RAIL'); diff --git a/backend/src/credits/dto/charge-credits.dto.ts b/backend/src/credits/dto/charge-credits.dto.ts new file mode 100644 index 00000000..44a6c3c1 --- /dev/null +++ b/backend/src/credits/dto/charge-credits.dto.ts @@ -0,0 +1,58 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsInt, + IsNotEmpty, + IsObject, + IsOptional, + IsPositive, + IsString, + IsUUID, + Length, +} from 'class-validator'; + +export class ChargeCreditsDto { + @ApiProperty({ description: 'The member whose credit balance is charged' }) + @IsUUID() + userId: string; + + @ApiProperty({ + description: 'Amount in minor units (e.g. cents) — never a float', + example: 250, + }) + @IsInt() + @IsPositive() + amount: number; + + @ApiPropertyOptional({ + description: + 'ISO 4217 currency code. Defaults to CREDITS_DEFAULT_CURRENCY.', + example: 'USD', + }) + @IsOptional() + @IsString() + @Length(3, 3) + currency?: string; + + @ApiProperty({ + description: + 'The caller-owned natural key for what is being charged (a usage ' + + 'event id, a print job id). Replaying the same reference returns the ' + + 'original charge instead of charging twice.', + example: 'print-job-8f21', + }) + @IsString() + @IsNotEmpty() + reference: string; + + @ApiProperty({ + description: 'Why this charge happened — stored on the ledger', + }) + @IsString() + @IsNotEmpty() + reason: string; + + @ApiPropertyOptional({ type: 'object' }) + @IsOptional() + @IsObject() + metadata?: Record; +} diff --git a/backend/src/credits/dto/credits-response.dto.ts b/backend/src/credits/dto/credits-response.dto.ts new file mode 100644 index 00000000..699b67b9 --- /dev/null +++ b/backend/src/credits/dto/credits-response.dto.ts @@ -0,0 +1,177 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { LedgerAccount } from '../entities/ledger-account.entity'; +import { LedgerEntry } from '../entities/ledger-entry.entity'; +import { LedgerTransaction } from '../entities/ledger-transaction.entity'; +import { MeteredUsageEvent } from '../entities/metered-usage-event.entity'; +import { PaymentCreditApplication } from '../entities/payment-credit-application.entity'; +import { LedgerAccountKind } from '../enums/ledger-account-kind.enum'; +import { LedgerEntryDirection } from '../enums/ledger-entry-direction.enum'; +import { LedgerTransactionKind } from '../enums/ledger-transaction-kind.enum'; +import { MeteredResource } from '../enums/metered-resource.enum'; +import { PaymentCreditApplicationKind } from '../enums/payment-credit-application-kind.enum'; +import { CreditBalanceView } from '../credits.service'; + +export class CreditBalanceResponseDto { + @ApiProperty({ nullable: true }) accountId: string | null; + @ApiProperty() userId: string; + @ApiProperty() currency: string; + @ApiProperty({ description: 'Minor units; may be negative if overdrawn' }) + balance: number; + @ApiProperty() overdraftLimit: number; + @ApiProperty({ description: 'balance + overdraftLimit' }) spendable: number; + @ApiProperty() frozen: boolean; + + static fromView(view: CreditBalanceView): CreditBalanceResponseDto { + return Object.assign(new CreditBalanceResponseDto(), view); + } +} + +export class LedgerTransactionResponseDto { + @ApiProperty() id: string; + @ApiProperty({ enum: LedgerTransactionKind }) kind: LedgerTransactionKind; + @ApiProperty() reference: string; + @ApiProperty() currency: string; + @ApiProperty() amount: number; + @ApiProperty({ nullable: true }) description: string | null; + @ApiProperty() createdAt: Date; + + static fromEntity( + transaction: LedgerTransaction, + ): LedgerTransactionResponseDto { + const dto = new LedgerTransactionResponseDto(); + dto.id = transaction.id; + dto.kind = transaction.kind; + dto.reference = transaction.reference; + dto.currency = transaction.currency; + dto.amount = transaction.amount; + dto.description = transaction.description; + dto.createdAt = transaction.createdAt; + return dto; + } +} + +export class ChargeCreditsResponseDto { + @ApiProperty({ type: LedgerTransactionResponseDto }) + transaction: LedgerTransactionResponseDto; + @ApiProperty({ + description: + 'False when this reference had already been charged — the response ' + + 'describes the original charge and nothing new was posted.', + }) + posted: boolean; + @ApiProperty() balanceAfter: number; + @ApiProperty() currency: string; +} + +export class LedgerEntryResponseDto { + @ApiProperty() id: string; + @ApiProperty() transactionId: string; + @ApiProperty() accountId: string; + @ApiProperty({ enum: LedgerEntryDirection }) direction: LedgerEntryDirection; + @ApiProperty() amount: number; + @ApiProperty() currency: string; + @ApiProperty({ nullable: true }) settlementBatchId: string | null; + @ApiProperty({ nullable: true }) settledAt: Date | null; + @ApiProperty() createdAt: Date; + + static fromEntity(entry: LedgerEntry): LedgerEntryResponseDto { + const dto = new LedgerEntryResponseDto(); + dto.id = entry.id; + dto.transactionId = entry.transactionId; + dto.accountId = entry.accountId; + dto.direction = entry.direction; + dto.amount = entry.amount; + dto.currency = entry.currency; + dto.settlementBatchId = entry.settlementBatchId; + dto.settledAt = entry.settledAt; + dto.createdAt = entry.createdAt; + return dto; + } +} + +export class LedgerAccountResponseDto { + @ApiProperty() id: string; + @ApiProperty({ enum: LedgerAccountKind }) kind: LedgerAccountKind; + @ApiProperty({ nullable: true }) ownerId: string | null; + @ApiProperty() currency: string; + @ApiProperty() balance: number; + @ApiProperty() overdraftLimit: number; + @ApiProperty({ nullable: true }) externalPayoutAddress: string | null; + @ApiProperty() frozen: boolean; + @ApiProperty({ nullable: true }) label: string | null; + + static fromEntity(account: LedgerAccount): LedgerAccountResponseDto { + const dto = new LedgerAccountResponseDto(); + dto.id = account.id; + dto.kind = account.kind; + dto.ownerId = account.ownerId; + dto.currency = account.currency; + dto.balance = account.balance; + dto.overdraftLimit = account.overdraftLimit; + dto.externalPayoutAddress = account.externalPayoutAddress; + dto.frozen = account.frozen; + dto.label = account.label; + return dto; + } +} + +export class MeteredUsageResponseDto { + @ApiProperty() id: string; + @ApiProperty() userId: string; + @ApiProperty({ enum: MeteredResource }) resource: MeteredResource; + @ApiProperty() units: number; + @ApiProperty() unitPrice: number; + @ApiProperty() amount: number; + @ApiProperty() currency: string; + @ApiProperty() usageReference: string; + @ApiProperty() ledgerTransactionId: string; + @ApiProperty() createdAt: Date; + @ApiPropertyOptional({ + description: 'False when this usage event had already been charged.', + }) + charged?: boolean; + + static fromEntity( + event: MeteredUsageEvent, + charged?: boolean, + ): MeteredUsageResponseDto { + const dto = new MeteredUsageResponseDto(); + dto.id = event.id; + dto.userId = event.userId; + dto.resource = event.resource; + dto.units = event.units; + dto.unitPrice = event.unitPrice; + dto.amount = event.amount; + dto.currency = event.currency; + dto.usageReference = event.usageReference; + dto.ledgerTransactionId = event.ledgerTransactionId; + dto.createdAt = event.createdAt; + dto.charged = charged; + return dto; + } +} + +export class PaymentCreditApplicationResponseDto { + @ApiProperty() id: string; + @ApiProperty() paymentId: string; + @ApiProperty({ enum: PaymentCreditApplicationKind }) + kind: PaymentCreditApplicationKind; + @ApiProperty({ nullable: true }) splitConfigId: string | null; + @ApiProperty({ nullable: true }) ledgerTransactionId: string | null; + @ApiProperty({ nullable: true }) appliedAt: Date | null; + @ApiProperty({ nullable: true }) lastError: string | null; + + static fromEntity( + application: PaymentCreditApplication, + ): PaymentCreditApplicationResponseDto { + const dto = new PaymentCreditApplicationResponseDto(); + dto.id = application.id; + dto.paymentId = application.paymentId; + dto.kind = application.kind; + dto.splitConfigId = application.splitConfigId; + dto.ledgerTransactionId = application.ledgerTransactionId; + dto.appliedAt = application.appliedAt; + dto.lastError = application.lastError; + return dto; + } +} diff --git a/backend/src/credits/dto/ledger-admin.dto.ts b/backend/src/credits/dto/ledger-admin.dto.ts new file mode 100644 index 00000000..680dcd67 --- /dev/null +++ b/backend/src/credits/dto/ledger-admin.dto.ts @@ -0,0 +1,154 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsBoolean, + IsEnum, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + Length, + Min, +} from 'class-validator'; +import { LedgerAccountKind } from '../enums/ledger-account-kind.enum'; + +export class CreateLedgerAccountDto { + @ApiProperty({ enum: LedgerAccountKind }) + @IsEnum(LedgerAccountKind) + kind: LedgerAccountKind; + + @ApiPropertyOptional({ + description: + 'The user / hub / referrer this account belongs to. Omit for the ' + + 'singleton system accounts (TREASURY, REVENUE, PLATFORM_FEE).', + }) + @IsOptional() + @IsUUID() + ownerId?: string; + + @ApiPropertyOptional({ example: 'USD' }) + @IsOptional() + @IsString() + @Length(3, 3) + currency?: string; + + @ApiPropertyOptional({ + description: + 'How far below zero a charge may take this account, in minor units. ' + + 'Only meaningful for USER accounts.', + }) + @IsOptional() + @IsInt() + @Min(0) + overdraftLimit?: number; + + @ApiPropertyOptional({ + description: + 'Where this balance goes when settled off-platform. Setting one is ' + + 'what makes the account eligible for a NET_PAYABLE settlement batch.', + }) + @IsOptional() + @IsString() + @IsNotEmpty() + externalPayoutAddress?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + label?: string; +} + +export class UpdateLedgerAccountDto { + @ApiPropertyOptional() + @IsOptional() + @IsInt() + @Min(0) + overdraftLimit?: number; + + @ApiPropertyOptional({ + description: 'Pass an empty string to clear the payout address.', + }) + @IsOptional() + @IsString() + externalPayoutAddress?: string; + + @ApiPropertyOptional({ + description: 'A frozen account rejects debits but still accepts credits.', + }) + @IsOptional() + @IsBoolean() + frozen?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + label?: string; +} + +export class AdjustCreditsDto { + @ApiProperty() + @IsUUID() + userId: string; + + @ApiProperty({ + description: + 'Minor units. Positive credits the member, negative debits them. ' + + 'Posted as a new ADJUSTMENT transaction — nothing already in the ' + + 'ledger is ever edited.', + example: -500, + }) + @IsInt() + delta: number; + + @ApiPropertyOptional({ example: 'USD' }) + @IsOptional() + @IsString() + @Length(3, 3) + currency?: string; + + @ApiProperty({ description: 'Natural key making this adjustment idempotent' }) + @IsString() + @IsNotEmpty() + reference: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + reason: string; +} + +export class AttachSplitConfigDto { + @ApiProperty() + @IsUUID() + splitConfigId: string; +} + +export class AbandonSettlementBatchDto { + @ApiProperty({ + description: + 'Why this batch is being given up on — recorded on the batch and on ' + + 'every payout it fails.', + }) + @IsString() + @IsNotEmpty() + reason: string; +} + +export class CreateSettlementBatchDto { + @ApiPropertyOptional({ example: 'USD' }) + @IsOptional() + @IsString() + @Length(3, 3) + currency?: string; + + @ApiPropertyOptional({ + description: + 'Name of a RevenueSplitConfig. Supplied: the batch distributes the ' + + 'revenue account across that config. Omitted: the batch nets each ' + + 'payable account and pays its own address.', + }) + @IsOptional() + @IsString() + @IsNotEmpty() + splitConfigName?: string; +} diff --git a/backend/src/credits/dto/record-metered-usage.dto.ts b/backend/src/credits/dto/record-metered-usage.dto.ts new file mode 100644 index 00000000..cc00ae0b --- /dev/null +++ b/backend/src/credits/dto/record-metered-usage.dto.ts @@ -0,0 +1,54 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsEnum, + IsInt, + IsNotEmpty, + IsOptional, + IsPositive, + IsString, + IsUUID, + Length, +} from 'class-validator'; +import { MeteredResource } from '../enums/metered-resource.enum'; + +export class RecordMeteredUsageDto { + @ApiProperty() + @IsUUID() + userId: string; + + @ApiProperty({ enum: MeteredResource }) + @IsEnum(MeteredResource) + resource: MeteredResource; + + @ApiProperty({ + description: 'Metered quantity — minutes, pages, ...', + example: 12, + }) + @IsInt() + @IsPositive() + units: number; + + @ApiProperty({ + description: 'Price per unit in minor units', + example: 5, + }) + @IsInt() + @IsPositive() + unitPrice: number; + + @ApiPropertyOptional({ example: 'USD' }) + @IsOptional() + @IsString() + @Length(3, 3) + currency?: string; + + @ApiProperty({ + description: + 'Natural key for this usage event. A retried delivery of the same ' + + 'reading records once and charges once.', + example: 'session-4711-minutes-12', + }) + @IsString() + @IsNotEmpty() + usageReference: string; +} diff --git a/backend/src/credits/dto/revenue-split-config.dto.ts b/backend/src/credits/dto/revenue-split-config.dto.ts new file mode 100644 index 00000000..a89bc067 --- /dev/null +++ b/backend/src/credits/dto/revenue-split-config.dto.ts @@ -0,0 +1,112 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsBoolean, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + Max, + Min, + ValidateNested, +} from 'class-validator'; +import { TOTAL_BASIS_POINTS } from '../split-allocation'; + +export class RevenueSplitRecipientDto { + @ApiProperty({ example: 'platform fee' }) + @IsString() + @IsNotEmpty() + label: string; + + @ApiProperty({ + description: + `Share in basis points (1/100th of a percent). Every recipient of a ` + + `config must sum to exactly ${TOTAL_BASIS_POINTS} — a config that ` + + `does not is rejected here, not at settlement time.`, + example: 1500, + }) + @IsInt() + @Min(1) + @Max(TOTAL_BASIS_POINTS) + basisPoints: number; + + @ApiPropertyOptional({ + description: + 'Internal recipient: the ledger account credited with this share. ' + + 'Exactly one of accountId or externalAddress must be set.', + }) + @IsOptional() + @IsUUID() + accountId?: string; + + @ApiPropertyOptional({ + description: + 'External recipient: the on-chain address a settlement batch pays ' + + 'this share to. Not usable for a split attached to a Payment.', + }) + @IsOptional() + @IsString() + @IsNotEmpty() + externalAddress?: string; + + @ApiPropertyOptional({ + description: + 'Deterministic tie-breaker for largest-remainder rounding; lower ' + + 'wins. Defaults to the position in the list.', + }) + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; +} + +export class CreateRevenueSplitConfigDto { + @ApiProperty({ example: 'standard-hub-split' }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + description?: string; + + @ApiProperty({ type: [RevenueSplitRecipientDto] }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => RevenueSplitRecipientDto) + recipients: RevenueSplitRecipientDto[]; +} + +export class ReplaceSplitRecipientsDto { + @ApiProperty({ type: [RevenueSplitRecipientDto] }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => RevenueSplitRecipientDto) + recipients: RevenueSplitRecipientDto[]; +} + +export class SetSplitConfigActiveDto { + @ApiProperty() + @IsBoolean() + active: boolean; +} + +export class PreviewSplitDto { + @ApiProperty() + @IsUUID() + configId: string; + + @ApiProperty({ + description: 'Amount in minor units to apportion', + example: 10000, + }) + @IsInt() + @Min(0) + amount: number; +} diff --git a/backend/src/credits/dto/revenue-split-response.dto.ts b/backend/src/credits/dto/revenue-split-response.dto.ts new file mode 100644 index 00000000..c170c707 --- /dev/null +++ b/backend/src/credits/dto/revenue-split-response.dto.ts @@ -0,0 +1,109 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { RevenueSplitConfig } from '../entities/revenue-split-config.entity'; +import { RevenueSplitRecipient } from '../entities/revenue-split-recipient.entity'; +import { ComputedSplitShare } from '../revenue-split.service'; + +export class RevenueSplitRecipientResponseDto { + @ApiProperty() id: string; + @ApiProperty() label: string; + @ApiProperty() basisPoints: number; + @ApiProperty({ nullable: true }) accountId: string | null; + @ApiProperty({ nullable: true }) externalAddress: string | null; + @ApiProperty() sortOrder: number; + + static fromEntity( + recipient: RevenueSplitRecipient, + ): RevenueSplitRecipientResponseDto { + const dto = new RevenueSplitRecipientResponseDto(); + dto.id = recipient.id; + dto.label = recipient.label; + dto.basisPoints = recipient.basisPoints; + dto.accountId = recipient.accountId; + dto.externalAddress = recipient.externalAddress; + dto.sortOrder = recipient.sortOrder; + return dto; + } +} + +export class RevenueSplitConfigResponseDto { + @ApiProperty() id: string; + @ApiProperty() name: string; + @ApiProperty({ nullable: true }) description: string | null; + @ApiProperty() active: boolean; + @ApiProperty({ type: [RevenueSplitRecipientResponseDto] }) + recipients: RevenueSplitRecipientResponseDto[]; + @ApiProperty({ + description: + 'Sum of the recipients’ basis points. Always 10000 for a config ' + + 'this API accepted — surfaced so an operator can see it at a glance.', + }) + totalBasisPoints: number; + @ApiProperty() createdAt: Date; + + static fromEntity(config: RevenueSplitConfig): RevenueSplitConfigResponseDto { + const recipients = config.recipients ?? []; + const dto = new RevenueSplitConfigResponseDto(); + dto.id = config.id; + dto.name = config.name; + dto.description = config.description; + dto.active = config.active; + dto.recipients = recipients.map((recipient) => + RevenueSplitRecipientResponseDto.fromEntity(recipient), + ); + dto.totalBasisPoints = recipients.reduce( + (sum, recipient) => sum + recipient.basisPoints, + 0, + ); + dto.createdAt = config.createdAt; + return dto; + } +} + +/** + * What a config would allocate for a given amount, without posting + * anything — including how many minor units of the rounding remainder each + * recipient received, so the largest-remainder rule is inspectable rather + * than merely documented. + */ +export class SplitPreviewResponseDto { + @ApiProperty() amount: number; + @ApiProperty({ + description: 'Always equal to `amount` — no remainder is ever dropped.', + }) + allocatedTotal: number; + @ApiProperty({ + example: [ + { + recipientId: 'uuid', + label: 'platform fee', + basisPoints: 1500, + amount: 1500, + remainderUnits: 0, + }, + ], + }) + shares: Array<{ + recipientId: string; + label: string; + basisPoints: number; + amount: number; + remainderUnits: number; + }>; + + static fromShares( + amount: number, + shares: ComputedSplitShare[], + ): SplitPreviewResponseDto { + const dto = new SplitPreviewResponseDto(); + dto.amount = amount; + dto.allocatedTotal = shares.reduce((sum, share) => sum + share.amount, 0); + dto.shares = shares.map((share) => ({ + recipientId: share.recipient.id, + label: share.recipient.label, + basisPoints: share.recipient.basisPoints, + amount: share.amount, + remainderUnits: share.remainderUnits, + })); + return dto; + } +} diff --git a/backend/src/credits/dto/settlement-response.dto.ts b/backend/src/credits/dto/settlement-response.dto.ts new file mode 100644 index 00000000..25dbb60a --- /dev/null +++ b/backend/src/credits/dto/settlement-response.dto.ts @@ -0,0 +1,119 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { SettlementBatch } from '../entities/settlement-batch.entity'; +import { SettlementPayout } from '../entities/settlement-payout.entity'; +import { SettlementBatchMode } from '../enums/settlement-batch-mode.enum'; +import { SettlementBatchStatus } from '../enums/settlement-batch-status.enum'; +import { SettlementPayoutStatus } from '../enums/settlement-payout-status.enum'; +import { SettlementBatchBreakdown } from '../settlement.service'; +import { LedgerEntryResponseDto } from './credits-response.dto'; + +export class SettlementPayoutResponseDto { + @ApiProperty() id: string; + @ApiProperty() label: string; + @ApiProperty({ nullable: true }) accountId: string | null; + @ApiProperty({ nullable: true }) externalAddress: string | null; + @ApiProperty({ nullable: true }) basisPoints: number | null; + @ApiProperty() amount: number; + @ApiProperty() currency: string; + @ApiProperty({ enum: SettlementPayoutStatus }) + status: SettlementPayoutStatus; + @ApiProperty() idempotencyKey: string; + @ApiProperty({ + nullable: true, + description: 'The on-chain transaction reference for this payout leg', + }) + onChainReference: string | null; + @ApiProperty({ nullable: true }) ledgerTransactionId: string | null; + @ApiProperty() attempts: number; + @ApiProperty({ nullable: true }) lastError: string | null; + @ApiProperty({ nullable: true }) confirmedAt: Date | null; + + static fromEntity(payout: SettlementPayout): SettlementPayoutResponseDto { + const dto = new SettlementPayoutResponseDto(); + dto.id = payout.id; + dto.label = payout.label; + dto.accountId = payout.accountId; + dto.externalAddress = payout.externalAddress; + dto.basisPoints = payout.basisPoints; + dto.amount = payout.amount; + dto.currency = payout.currency; + dto.status = payout.status; + dto.idempotencyKey = payout.idempotencyKey; + dto.onChainReference = payout.onChainReference; + dto.ledgerTransactionId = payout.ledgerTransactionId; + dto.attempts = payout.attempts; + dto.lastError = payout.lastError; + dto.confirmedAt = payout.confirmedAt; + return dto; + } +} + +export class SettlementBatchResponseDto { + @ApiProperty() id: string; + @ApiProperty({ enum: SettlementBatchStatus }) status: SettlementBatchStatus; + @ApiProperty() currency: string; + @ApiProperty({ enum: SettlementBatchMode }) mode: SettlementBatchMode; + @ApiProperty({ nullable: true }) splitConfigId: string | null; + @ApiProperty() periodEnd: Date; + @ApiProperty() totalAmount: number; + @ApiProperty() claimedEntryCount: number; + @ApiProperty({ nullable: true }) notes: string | null; + @ApiProperty() createdAt: Date; + @ApiProperty() updatedAt: Date; + + static fromEntity(batch: SettlementBatch): SettlementBatchResponseDto { + const dto = new SettlementBatchResponseDto(); + dto.id = batch.id; + dto.status = batch.status; + dto.currency = batch.currency; + dto.mode = batch.mode; + dto.splitConfigId = batch.splitConfigId; + dto.periodEnd = batch.periodEnd; + dto.totalAmount = batch.totalAmount; + dto.claimedEntryCount = batch.claimedEntryCount; + dto.notes = batch.notes; + dto.createdAt = batch.createdAt; + dto.updatedAt = batch.updatedAt; + return dto; + } +} + +/** + * The whole audit picture for one batch: what went in (the claimed ledger + * entries), what came out (the payouts, per recipient), and the on-chain + * reference for every leg that left the platform. + */ +export class SettlementBatchBreakdownResponseDto { + @ApiProperty({ type: SettlementBatchResponseDto }) + batch: SettlementBatchResponseDto; + + @ApiProperty({ type: [SettlementPayoutResponseDto] }) + payouts: SettlementPayoutResponseDto[]; + + @ApiProperty({ + type: [LedgerEntryResponseDto], + description: 'The ledger entries this batch claimed — the "entries in"', + }) + entries: LedgerEntryResponseDto[]; + + @ApiProperty({ + description: 'Payout id -> on-chain transaction reference', + example: [{ payoutId: 'uuid', reference: 'a1b2c3' }], + }) + onChainReferences: Array<{ payoutId: string; reference: string }>; + + static fromBreakdown( + breakdown: SettlementBatchBreakdown, + ): SettlementBatchBreakdownResponseDto { + const dto = new SettlementBatchBreakdownResponseDto(); + dto.batch = SettlementBatchResponseDto.fromEntity(breakdown.batch); + dto.payouts = breakdown.payouts.map((payout) => + SettlementPayoutResponseDto.fromEntity(payout), + ); + dto.entries = breakdown.entries.map((entry) => + LedgerEntryResponseDto.fromEntity(entry), + ); + dto.onChainReferences = breakdown.onChainReferences; + return dto; + } +} diff --git a/backend/src/credits/entities/ledger-account.entity.ts b/backend/src/credits/entities/ledger-account.entity.ts new file mode 100644 index 00000000..99954613 --- /dev/null +++ b/backend/src/credits/entities/ledger-account.entity.ts @@ -0,0 +1,98 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { LedgerAccountKind } from '../enums/ledger-account-kind.enum'; + +/** Shared minor-units transformer — bigint arrives from pg as a string. */ +const MINOR_UNITS = { + to: (v: number) => v, + from: (v: string | number) => (typeof v === 'number' ? v : parseInt(v, 10)), +}; + +/** + * One account in the double-entry credit ledger (issue #1575): a user's + * spendable credit balance, or a system account (platform fee, hub + * operator payable, treasury clearing). + * + * `balance` is materialized here rather than recomputed from + * ledger_entries on every read. That is deliberate: the spend path is + * high-frequency and low-value, so an overdraft check must be O(1), and + * the row is what a charge takes a `FOR UPDATE` lock on — the lock that + * makes concurrent charges against the same account safe. The append-only + * entries remain the source of truth; LedgerService.checkIntegrity() + * re-derives the balance from them and reports any drift. + */ +@Entity('ledger_accounts') +@Index('uq_ledger_accounts_owned', ['kind', 'ownerId', 'currency'], { + unique: true, + where: `"owner_id" IS NOT NULL`, +}) +@Index('uq_ledger_accounts_system', ['kind', 'currency'], { + unique: true, + where: `"owner_id" IS NULL`, +}) +export class LedgerAccount { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'enum', enum: LedgerAccountKind }) + kind: LedgerAccountKind; + + /** + * The user / hub / referrer this account belongs to. NULL for a + * singleton system account (one per kind per currency). + */ + @Index('idx_ledger_accounts_owner_id') + @Column({ type: 'uuid', name: 'owner_id', nullable: true }) + ownerId: string | null; + + @Column({ type: 'varchar', length: 3 }) + currency: string; + + /** SUM(CREDIT) - SUM(DEBIT) over this account's entries. May be negative. */ + @Column({ type: 'bigint', default: 0, transformer: MINOR_UNITS }) + balance: number; + + /** + * How far below zero a charge may take this account, in minor units. + * 0 (the default) means charges are rejected the moment they would + * overdraw — see CreditsService for the documented policy. + */ + @Column({ + type: 'bigint', + name: 'overdraft_limit', + default: 0, + transformer: MINOR_UNITS, + }) + overdraftLimit: number; + + /** + * Where this account's balance goes when it is settled off-platform + * (a Stellar address for the #1574 rail). NULL means the balance never + * leaves the ledger — nothing to pay out. + */ + @Column({ + type: 'varchar', + name: 'external_payout_address', + nullable: true, + }) + externalPayoutAddress: string | null; + + /** A frozen account rejects charges and payouts but still accepts credits. */ + @Column({ type: 'boolean', default: false }) + frozen: boolean; + + @Column({ type: 'varchar', nullable: true }) + label: string | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/backend/src/credits/entities/ledger-entry.entity.ts b/backend/src/credits/entities/ledger-entry.entity.ts new file mode 100644 index 00000000..7387d770 --- /dev/null +++ b/backend/src/credits/entities/ledger-entry.entity.ts @@ -0,0 +1,64 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { LedgerEntryDirection } from '../enums/ledger-entry-direction.enum'; + +/** + * Append-only half of a double-entry pair (issue #1575). Never updated + * except by the two settlement markers below, and never deleted — a + * correction is a new REVERSAL transaction, not an edit. + * + * The two markers are deliberately separate: + * - `settlementBatchId` is the CLAIM: this entry belongs to one batch and + * can never be claimed by another, which is what makes a crashed batch + * job resumable rather than double-paying. + * - `settledAt` is the SETTLED marker: set only after the batch's payout + * has been confirmed by the rail. A submitted-but-unconfirmed payout + * leaves the entry claimed and unsettled, so a failed on-chain leg can + * never leave the ledger claiming money already moved. + */ +@Entity('ledger_entries') +export class LedgerEntry { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index('idx_ledger_entries_transaction_id') + @Column({ type: 'uuid', name: 'transaction_id' }) + transactionId: string; + + @Index('idx_ledger_entries_account_id') + @Column({ type: 'uuid', name: 'account_id' }) + accountId: string; + + @Column({ type: 'enum', enum: LedgerEntryDirection }) + direction: LedgerEntryDirection; + + /** Always positive minor units; `direction` carries the sign. */ + @Column({ + type: 'bigint', + transformer: { + to: (v: number) => v, + from: (v: string | number) => + typeof v === 'number' ? v : parseInt(v, 10), + }, + }) + amount: number; + + @Column({ type: 'varchar', length: 3 }) + currency: string; + + /** Claim marker — see the class doc. */ + @Column({ type: 'uuid', name: 'settlement_batch_id', nullable: true }) + settlementBatchId: string | null; + + /** Settled marker — see the class doc. */ + @Column({ type: 'timestamptz', name: 'settled_at', nullable: true }) + settledAt: Date | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/backend/src/credits/entities/ledger-transaction.entity.ts b/backend/src/credits/entities/ledger-transaction.entity.ts new file mode 100644 index 00000000..07a8ef78 --- /dev/null +++ b/backend/src/credits/entities/ledger-transaction.entity.ts @@ -0,0 +1,59 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { LedgerTransactionKind } from '../enums/ledger-transaction-kind.enum'; + +/** + * Groups the debit/credit entries that must balance to zero together + * (issue #1575). `reference` is the idempotency key for the whole + * transaction and is UNIQUE: a replayed charge, a re-run settlement pass + * or a resumed batch job all collide on it and get the original + * transaction back instead of posting a duplicate. + */ +@Entity('ledger_transactions') +@Index('uq_ledger_transactions_reference', ['reference'], { unique: true }) +export class LedgerTransaction { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'enum', enum: LedgerTransactionKind }) + kind: LedgerTransactionKind; + + /** + * Caller-supplied natural key, e.g. `charge:usage:`, + * `top-up:payment:`, `settlement::`. + */ + @Column({ type: 'varchar' }) + reference: string; + + @Column({ type: 'varchar', length: 3 }) + currency: string; + + /** Sum of one side of the transaction (debits == credits == this). */ + @Column({ + type: 'bigint', + transformer: { + to: (v: number) => v, + from: (v: string | number) => + typeof v === 'number' ? v : parseInt(v, 10), + }, + }) + amount: number; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record | null; + + /** Null for a system-initiated transaction; the acting user otherwise. */ + @Column({ type: 'uuid', name: 'actor_id', nullable: true }) + actorId: string | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/backend/src/credits/entities/metered-usage-event.entity.ts b/backend/src/credits/entities/metered-usage-event.entity.ts new file mode 100644 index 00000000..fcf3f786 --- /dev/null +++ b/backend/src/credits/entities/metered-usage-event.entity.ts @@ -0,0 +1,73 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { MeteredResource } from '../enums/metered-resource.enum'; + +/** + * The audit record for one metered usage event that charged the credit + * ledger (issue #1575) — the call site that exercises the spend path end + * to end without ever touching a payment rail. + * + * `usageReference` is the caller's own natural key for the event (a + * session id, a print job id) and is unique: a retried delivery of the + * same usage event records once and charges once. + */ +@Entity('metered_usage_events') +@Index('uq_metered_usage_events_usage_reference', ['usageReference'], { + unique: true, +}) +export class MeteredUsageEvent { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index('idx_metered_usage_events_user_id') + @Column({ type: 'uuid', name: 'user_id' }) + userId: string; + + @Column({ type: 'enum', enum: MeteredResource }) + resource: MeteredResource; + + /** Minutes, pages, ... — whatever this resource meters. */ + @Column({ type: 'int' }) + units: number; + + /** Minor units per unit. */ + @Column({ + type: 'bigint', + name: 'unit_price', + transformer: { + to: (v: number) => v, + from: (v: string | number) => + typeof v === 'number' ? v : parseInt(v, 10), + }, + }) + unitPrice: number; + + /** units * unitPrice — stored so a later repricing cannot rewrite history. */ + @Column({ + type: 'bigint', + transformer: { + to: (v: number) => v, + from: (v: string | number) => + typeof v === 'number' ? v : parseInt(v, 10), + }, + }) + amount: number; + + @Column({ type: 'varchar', length: 3 }) + currency: string; + + @Column({ type: 'varchar', name: 'usage_reference' }) + usageReference: string; + + /** The CHARGE transaction this event posted. */ + @Column({ type: 'uuid', name: 'ledger_transaction_id' }) + ledgerTransactionId: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/backend/src/credits/entities/payment-credit-application.entity.ts b/backend/src/credits/entities/payment-credit-application.entity.ts new file mode 100644 index 00000000..5efc2f91 --- /dev/null +++ b/backend/src/credits/entities/payment-credit-application.entity.ts @@ -0,0 +1,57 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { PaymentCreditApplicationKind } from '../enums/payment-credit-application-kind.enum'; + +/** + * Links a #1570/#1574 Payment to what the credit ledger does with it once + * it CONFIRMS (issue #1575) — either funding the payer's credit balance + * or distributing the amount across a RevenueSplitConfig. + * + * This table lives on the credits side on purpose: it lets a split config + * be attached to a Payment, and the application be marked done, without + * the payments module having to know the credits module exists. The + * dependency stays one-directional — credits reads payments, never the + * reverse. + * + * `appliedAt` only makes finding candidates cheap; the real idempotency + * guard is the UNIQUE ledger transaction reference, so a crash between + * posting and marking cannot double-apply. + */ +@Entity('payment_credit_applications') +@Index('uq_payment_credit_applications_payment_id', ['paymentId'], { + unique: true, +}) +export class PaymentCreditApplication { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', name: 'payment_id' }) + paymentId: string; + + @Column({ type: 'enum', enum: PaymentCreditApplicationKind }) + kind: PaymentCreditApplicationKind; + + @Column({ type: 'uuid', name: 'split_config_id', nullable: true }) + splitConfigId: string | null; + + @Column({ type: 'uuid', name: 'ledger_transaction_id', nullable: true }) + ledgerTransactionId: string | null; + + @Column({ type: 'timestamptz', name: 'applied_at', nullable: true }) + appliedAt: Date | null; + + @Column({ type: 'text', name: 'last_error', nullable: true }) + lastError: string | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/backend/src/credits/entities/revenue-split-config.entity.ts b/backend/src/credits/entities/revenue-split-config.entity.ts new file mode 100644 index 00000000..5438fa2f --- /dev/null +++ b/backend/src/credits/entities/revenue-split-config.entity.ts @@ -0,0 +1,46 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { RevenueSplitRecipient } from './revenue-split-recipient.entity'; + +/** + * A reusable multi-party distribution rule (issue #1575) — attachable to + * a Payment or to a settlement batch, and computed at the moment value + * actually moves rather than baked in when it was configured. + * + * Its recipients' basis points must sum to exactly 10000. That is + * validated at CONFIGURATION time (RevenueSplitService.createConfig / + * replaceRecipients) so a broken split is a 400 on the admin's request, + * not a surprise discovered halfway through a settlement run. + */ +@Entity('revenue_split_configs') +@Index('uq_revenue_split_configs_name', ['name'], { unique: true }) +export class RevenueSplitConfig { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar' }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + /** An inactive config is rejected when something tries to compute with it. */ + @Column({ type: 'boolean', default: true }) + active: boolean; + + @OneToMany(() => RevenueSplitRecipient, (recipient) => recipient.config) + recipients: RevenueSplitRecipient[]; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/backend/src/credits/entities/revenue-split-recipient.entity.ts b/backend/src/credits/entities/revenue-split-recipient.entity.ts new file mode 100644 index 00000000..4fffb1ac --- /dev/null +++ b/backend/src/credits/entities/revenue-split-recipient.entity.ts @@ -0,0 +1,54 @@ +import { + Column, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { RevenueSplitConfig } from './revenue-split-config.entity'; + +/** + * One share of a RevenueSplitConfig (issue #1575). Exactly one of + * `accountId` (distribute internally as ledger entries) or + * `externalAddress` (pay out off-platform over the #1574 rail) is set — + * that choice is what decides whether this share ever leaves the ledger. + * + * `sortOrder` is not cosmetic: it is the documented tie-breaker for + * largest-remainder rounding, so an identical config over an identical + * amount always allocates the remainder to the same recipients. + */ +@Entity('revenue_split_recipients') +export class RevenueSplitRecipient { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index('idx_revenue_split_recipients_config_id') + @Column({ type: 'uuid', name: 'config_id' }) + configId: string; + + @ManyToOne(() => RevenueSplitConfig, (config) => config.recipients, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'config_id' }) + config: RevenueSplitConfig; + + /** Human-readable share name, e.g. "platform fee", "hub operator". */ + @Column({ type: 'varchar' }) + label: string; + + /** 1..10000; a config's recipients must sum to exactly 10000. */ + @Column({ type: 'int', name: 'basis_points' }) + basisPoints: number; + + /** Internal recipient: a ledger account credited with this share. */ + @Column({ type: 'uuid', name: 'account_id', nullable: true }) + accountId: string | null; + + /** External recipient: an on-chain address paid this share. */ + @Column({ type: 'varchar', name: 'external_address', nullable: true }) + externalAddress: string | null; + + @Column({ type: 'int', name: 'sort_order', default: 0 }) + sortOrder: number; +} diff --git a/backend/src/credits/entities/settlement-batch.entity.ts b/backend/src/credits/entities/settlement-batch.entity.ts new file mode 100644 index 00000000..891a8545 --- /dev/null +++ b/backend/src/credits/entities/settlement-batch.entity.ts @@ -0,0 +1,76 @@ +import { + Column, + CreateDateColumn, + Entity, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { SettlementBatchStatus } from '../enums/settlement-batch-status.enum'; +import { SettlementBatchMode } from '../enums/settlement-batch-mode.enum'; +import { SettlementPayout } from './settlement-payout.entity'; + +/** + * One netting/distribution run (issue #1575). Creating a batch claims a + * set of ledger entries (see LedgerEntry's two markers) and computes the + * payouts that must move value off-platform; executing it submits those + * payouts and only marks the claimed entries settled once the rail + * confirms them. + * + * A batch is therefore always safe to re-execute: every payout carries + * its own idempotency key, and the claim means a resumed run can never + * pull the same entries into a second batch. + */ +@Entity('settlement_batches') +export class SettlementBatch { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ + type: 'enum', + enum: SettlementBatchStatus, + default: SettlementBatchStatus.PENDING, + }) + status: SettlementBatchStatus; + + @Column({ type: 'varchar', length: 3 }) + currency: string; + + @Column({ type: 'enum', enum: SettlementBatchMode }) + mode: SettlementBatchMode; + + @Column({ type: 'uuid', name: 'split_config_id', nullable: true }) + splitConfigId: string | null; + + /** Everything claimed by this batch was created at or before this instant. */ + @Column({ type: 'timestamptz', name: 'period_end' }) + periodEnd: Date; + + /** Net value claimed by this batch, in minor units. */ + @Column({ + type: 'bigint', + name: 'total_amount', + default: 0, + transformer: { + to: (v: number) => v, + from: (v: string | number) => + typeof v === 'number' ? v : parseInt(v, 10), + }, + }) + totalAmount: number; + + @Column({ type: 'int', name: 'claimed_entry_count', default: 0 }) + claimedEntryCount: number; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @OneToMany(() => SettlementPayout, (payout) => payout.batch) + payouts: SettlementPayout[]; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/backend/src/credits/entities/settlement-payout.entity.ts b/backend/src/credits/entities/settlement-payout.entity.ts new file mode 100644 index 00000000..83383ff8 --- /dev/null +++ b/backend/src/credits/entities/settlement-payout.entity.ts @@ -0,0 +1,104 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { SettlementPayoutStatus } from '../enums/settlement-payout-status.enum'; +import { SettlementBatch } from './settlement-batch.entity'; + +/** + * One recipient's slice of a settlement batch (issue #1575). + * + * `idempotencyKey` is unique and derived deterministically from the batch + * and the recipient, so re-executing a batch after a crash hands the + * payout rail the same key and can never double-pay: the rail dedupes on + * it, and this row's own status guards the ledger side. + */ +@Entity('settlement_payouts') +@Index('uq_settlement_payouts_idempotency_key', ['idempotencyKey'], { + unique: true, +}) +export class SettlementPayout { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index('idx_settlement_payouts_batch_id') + @Column({ type: 'uuid', name: 'batch_id' }) + batchId: string; + + @ManyToOne(() => SettlementBatch, (batch) => batch.payouts, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'batch_id' }) + batch: SettlementBatch; + + @Column({ type: 'varchar' }) + label: string; + + /** + * The ledger account this payout draws down. Set for both internal and + * external payouts — an external payout still has to debit the account + * whose balance left the platform. + */ + @Column({ type: 'uuid', name: 'account_id', nullable: true }) + accountId: string | null; + + /** Set for an off-platform payout; NULL for an internal ledger-only share. */ + @Column({ type: 'varchar', name: 'external_address', nullable: true }) + externalAddress: string | null; + + /** NULL in NET_PAYABLE mode — that mode nets, it does not apportion. */ + @Column({ type: 'int', name: 'basis_points', nullable: true }) + basisPoints: number | null; + + @Column({ + type: 'bigint', + transformer: { + to: (v: number) => v, + from: (v: string | number) => + typeof v === 'number' ? v : parseInt(v, 10), + }, + }) + amount: number; + + @Column({ type: 'varchar', length: 3 }) + currency: string; + + @Column({ + type: 'enum', + enum: SettlementPayoutStatus, + default: SettlementPayoutStatus.PENDING, + }) + status: SettlementPayoutStatus; + + @Column({ type: 'varchar', name: 'idempotency_key' }) + idempotencyKey: string; + + /** On-chain reference from the #1574 rail (the escrow id it derives). */ + @Column({ type: 'varchar', name: 'on_chain_reference', nullable: true }) + onChainReference: string | null; + + /** The ledger transaction this payout posted once it was confirmed. */ + @Column({ type: 'uuid', name: 'ledger_transaction_id', nullable: true }) + ledgerTransactionId: string | null; + + @Column({ type: 'int', default: 0 }) + attempts: number; + + @Column({ type: 'text', name: 'last_error', nullable: true }) + lastError: string | null; + + @Column({ type: 'timestamptz', name: 'confirmed_at', nullable: true }) + confirmedAt: Date | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/backend/src/credits/enums/ledger-account-kind.enum.ts b/backend/src/credits/enums/ledger-account-kind.enum.ts new file mode 100644 index 00000000..2a1f2865 --- /dev/null +++ b/backend/src/credits/enums/ledger-account-kind.enum.ts @@ -0,0 +1,42 @@ +/** + * Every account in the double-entry ledger (issue #1575) is one of these + * kinds. `balance` always means SUM(CREDIT) - SUM(DEBIT) for the account, + * and every ledger transaction has equal debits and credits — so the sum + * of every account's balance in a currency is always exactly zero. That + * invariant is what makes TREASURY meaningful: it is the contra/clearing + * account standing in for the outside world (fiat rails, on-chain + * transfers), so its balance is the negation of what the platform owes. + */ +export enum LedgerAccountKind { + /** One per user: their spendable credit balance. */ + USER = 'USER', + /** + * Clearing account for value crossing the platform boundary — debited + * when a top-up brings money in, credited when a settlement pays money + * out. Never has a payout address; it *is* the outside world. + */ + TREASURY = 'TREASURY', + /** Where micro-charges accumulate before revenue distribution. */ + REVENUE = 'REVENUE', + /** The platform's own fee take, after distribution. */ + PLATFORM_FEE = 'PLATFORM_FEE', + /** A hub operator's payable balance — settled off-platform. */ + HUB_OPERATOR = 'HUB_OPERATOR', + /** A referrer's reward payable balance — settled off-platform. */ + REFERRAL = 'REFERRAL', +} + +/** Kinds that belong to a specific owner (user id, hub id, referrer id). */ +export const OWNED_LEDGER_ACCOUNT_KINDS: readonly LedgerAccountKind[] = [ + LedgerAccountKind.USER, + LedgerAccountKind.HUB_OPERATOR, + LedgerAccountKind.REFERRAL, +]; + +/** + * Kinds whose accumulated balance is what settlement distributes. A + * distribution batch reads these accounts, never a USER account — a + * member's credit balance is a liability to them, not platform revenue. + */ +export const DISTRIBUTABLE_LEDGER_ACCOUNT_KINDS: readonly LedgerAccountKind[] = + [LedgerAccountKind.REVENUE]; diff --git a/backend/src/credits/enums/ledger-entry-direction.enum.ts b/backend/src/credits/enums/ledger-entry-direction.enum.ts new file mode 100644 index 00000000..5dc219ae --- /dev/null +++ b/backend/src/credits/enums/ledger-entry-direction.enum.ts @@ -0,0 +1,4 @@ +export enum LedgerEntryDirection { + DEBIT = 'DEBIT', + CREDIT = 'CREDIT', +} diff --git a/backend/src/credits/enums/ledger-transaction-kind.enum.ts b/backend/src/credits/enums/ledger-transaction-kind.enum.ts new file mode 100644 index 00000000..0240771e --- /dev/null +++ b/backend/src/credits/enums/ledger-transaction-kind.enum.ts @@ -0,0 +1,15 @@ +/** Why a balanced set of ledger entries was posted (issue #1575). */ +export enum LedgerTransactionKind { + /** A completed #1570/#1574 payment funding a user's credit balance. */ + TOP_UP = 'TOP_UP', + /** A high-frequency, low-value charge against a user's credit balance. */ + CHARGE = 'CHARGE', + /** A RevenueSplitConfig computed over a payment or a settlement batch. */ + REVENUE_SPLIT = 'REVENUE_SPLIT', + /** Clears a payable account once its off-platform payout is confirmed. */ + SETTLEMENT = 'SETTLEMENT', + /** Compensating entries that undo an earlier transaction. */ + REVERSAL = 'REVERSAL', + /** Manual admin correction — always carries a reason. */ + ADJUSTMENT = 'ADJUSTMENT', +} diff --git a/backend/src/credits/enums/metered-resource.enum.ts b/backend/src/credits/enums/metered-resource.enum.ts new file mode 100644 index 00000000..ed32b024 --- /dev/null +++ b/backend/src/credits/enums/metered-resource.enum.ts @@ -0,0 +1,13 @@ +/** + * The metered resources that charge through the credit ledger instead of + * settling on-chain per event (issue #1575). Priced in minor units per + * unit by the caller — the ledger only ever sees the resulting amount. + */ +export enum MeteredResource { + /** Per-minute usage of a bookable resource (desk, booth, equipment). */ + RESOURCE_MINUTES = 'RESOURCE_MINUTES', + /** Per-page printing. */ + PRINTING = 'PRINTING', + /** Minutes used beyond a meeting-room booking's window. */ + MEETING_ROOM_OVERAGE = 'MEETING_ROOM_OVERAGE', +} diff --git a/backend/src/credits/enums/payment-credit-application-kind.enum.ts b/backend/src/credits/enums/payment-credit-application-kind.enum.ts new file mode 100644 index 00000000..de14d0e7 --- /dev/null +++ b/backend/src/credits/enums/payment-credit-application-kind.enum.ts @@ -0,0 +1,11 @@ +/** + * What the credits module does with a #1570/#1574 Payment once it + * CONFIRMS. Mutually exclusive: money that funds a member's credit + * balance is a liability to that member, never platform revenue to split. + */ +export enum PaymentCreditApplicationKind { + /** Credit the payer's ledger balance with the payment amount. */ + TOP_UP = 'TOP_UP', + /** Distribute the payment amount across a RevenueSplitConfig. */ + REVENUE_SPLIT = 'REVENUE_SPLIT', +} diff --git a/backend/src/credits/enums/settlement-batch-mode.enum.ts b/backend/src/credits/enums/settlement-batch-mode.enum.ts new file mode 100644 index 00000000..6535cd37 --- /dev/null +++ b/backend/src/credits/enums/settlement-batch-mode.enum.ts @@ -0,0 +1,22 @@ +/** + * How a settlement batch decides who gets paid (issue #1575). + * + * The two modes exist because there are two genuinely different questions + * a settlement run answers, and a single pass runs both in sequence: + * "how should accumulated revenue be apportioned?" and "which accounts + * already have a balance owed to them off-platform?". + */ +export enum SettlementBatchMode { + /** + * Splits a revenue account's accumulated (unclaimed) balance across a + * RevenueSplitConfig: internal shares become ledger entries, external + * shares become on-chain payouts. + */ + DISTRIBUTION = 'DISTRIBUTION', + /** + * Nets each payable account's own unclaimed movements and pays that + * account's own external address — one on-chain transfer per account + * instead of one per micro-event. + */ + NET_PAYABLE = 'NET_PAYABLE', +} diff --git a/backend/src/credits/enums/settlement-batch-status.enum.ts b/backend/src/credits/enums/settlement-batch-status.enum.ts new file mode 100644 index 00000000..ad8d8df6 --- /dev/null +++ b/backend/src/credits/enums/settlement-batch-status.enum.ts @@ -0,0 +1,14 @@ +export enum SettlementBatchStatus { + /** Entries claimed, payouts computed, nothing submitted yet. */ + PENDING = 'PENDING', + /** At least one payout submitted, not all confirmed. */ + IN_PROGRESS = 'IN_PROGRESS', + /** Every payout confirmed; every claimed entry marked settled. */ + SETTLED = 'SETTLED', + /** Some payouts confirmed, at least one terminally failed. */ + PARTIALLY_SETTLED = 'PARTIALLY_SETTLED', + /** No payout confirmed and at least one terminally failed. */ + FAILED = 'FAILED', + /** Admin gave up on the failed payouts; their claims were released. */ + ABANDONED = 'ABANDONED', +} diff --git a/backend/src/credits/enums/settlement-payout-status.enum.ts b/backend/src/credits/enums/settlement-payout-status.enum.ts new file mode 100644 index 00000000..466844f3 --- /dev/null +++ b/backend/src/credits/enums/settlement-payout-status.enum.ts @@ -0,0 +1,12 @@ +/** + * One payout leg of a settlement batch. The gap between SUBMITTED and + * CONFIRMED is the whole point (issue #1575): a submitted on-chain + * transfer is NOT a settled one, so the claimed ledger entries stay + * unsettled until the rail confirms from fresh chain state. + */ +export enum SettlementPayoutStatus { + PENDING = 'PENDING', + SUBMITTED = 'SUBMITTED', + CONFIRMED = 'CONFIRMED', + FAILED = 'FAILED', +} diff --git a/backend/src/credits/interfaces/external-payout-rail.interface.ts b/backend/src/credits/interfaces/external-payout-rail.interface.ts new file mode 100644 index 00000000..381449e2 --- /dev/null +++ b/backend/src/credits/interfaces/external-payout-rail.interface.ts @@ -0,0 +1,38 @@ +/** + * The port settlement uses to move a payable balance off-platform (issue + * #1575) — implemented over the #1574 Soroban escrow rail, but stated as + * a port so the ledger never depends on a specific chain, and so + * settlement can be tested without one. + * + * Two rules the ledger relies on, and any implementation must honour: + * + * 1. `submitPayout` is idempotent on `idempotencyKey`. Re-submitting the + * same key must never move value twice — that is what makes a batch + * that crashed mid-run safe to re-execute. + * 2. `submitPayout` returning does NOT mean the payout happened. Only + * `getPayoutStatus` returning 'confirmed', read from fresh rail + * state, does. Settlement never marks a ledger entry settled on the + * strength of a submission. + */ +export interface PayoutSubmission { + /** Opaque handle to look the payout up again later. */ + reference: string; +} + +export interface SubmitPayoutInput { + destinationAddress: string; + /** Minor units. */ + amount: number; + currency: string; + /** Stable natural key for this payout; the dedupe key on the rail. */ + idempotencyKey: string; +} + +export type PayoutStatus = 'confirmed' | 'failed' | 'pending'; + +export interface ExternalPayoutRail { + submitPayout(input: SubmitPayoutInput): Promise; + + /** Fresh read of rail state — never derived from a submission response. */ + getPayoutStatus(reference: string): Promise; +} diff --git a/backend/src/credits/ledger.service.spec.ts b/backend/src/credits/ledger.service.spec.ts new file mode 100644 index 00000000..24a9152d --- /dev/null +++ b/backend/src/credits/ledger.service.spec.ts @@ -0,0 +1,433 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { LedgerService } from './ledger.service'; +import { LedgerAccountKind } from './enums/ledger-account-kind.enum'; +import { LedgerEntryDirection } from './enums/ledger-entry-direction.enum'; +import { LedgerTransactionKind } from './enums/ledger-transaction-kind.enum'; +import { createLedgerHarness } from './testing/in-memory-ledger'; + +function build() { + const harness = createLedgerHarness(); + const ledger = new LedgerService( + harness.accounts as any, + harness.transactions as any, + harness.entries as any, + ); + return { harness, ledger }; +} + +async function twoAccounts(ledger: LedgerService, currency = 'USD') { + const treasury = await ledger.getOrCreateAccount({ + kind: LedgerAccountKind.TREASURY, + currency, + }); + const revenue = await ledger.getOrCreateAccount({ + kind: LedgerAccountKind.REVENUE, + currency, + }); + return { treasury, revenue }; +} + +describe('LedgerService', () => { + describe('account resolution', () => { + it('is idempotent for a system account (one per kind per currency)', async () => { + const { ledger, harness } = build(); + const first = await ledger.getOrCreateAccount({ + kind: LedgerAccountKind.REVENUE, + currency: 'USD', + }); + const second = await ledger.getOrCreateAccount({ + kind: LedgerAccountKind.REVENUE, + currency: 'usd', + }); + + expect(second.id).toBe(first.id); + expect(harness.accounts.rows).toHaveLength(1); + expect(first.currency).toBe('USD'); + }); + + it('keeps one account per owner per currency', async () => { + const { ledger, harness } = build(); + await ledger.getOrCreateAccount({ + kind: LedgerAccountKind.USER, + ownerId: 'user-1', + currency: 'USD', + }); + await ledger.getOrCreateAccount({ + kind: LedgerAccountKind.USER, + ownerId: 'user-1', + currency: 'EUR', + }); + await ledger.getOrCreateAccount({ + kind: LedgerAccountKind.USER, + ownerId: 'user-2', + currency: 'USD', + }); + expect(harness.accounts.rows).toHaveLength(3); + }); + + it('recovers the winner when concurrent callers race to create one', async () => { + const { ledger, harness } = build(); + const accounts = await Promise.all( + Array.from({ length: 5 }, () => + ledger.getOrCreateAccount({ + kind: LedgerAccountKind.USER, + ownerId: 'user-1', + currency: 'USD', + }), + ), + ); + + expect(new Set(accounts.map((account) => account.id)).size).toBe(1); + expect(harness.accounts.rows).toHaveLength(1); + }); + + it('never lets an account policy update touch the balance', async () => { + const { ledger } = build(); + const account = await ledger.getOrCreateAccount({ + kind: LedgerAccountKind.USER, + ownerId: 'user-1', + currency: 'USD', + }); + + const updated = await ledger.updateAccountPolicy(account.id, { + overdraftLimit: 250, + externalPayoutAddress: 'GADDRESS', + frozen: true, + } as any); + + expect(updated).toMatchObject({ + overdraftLimit: 250, + externalPayoutAddress: 'GADDRESS', + frozen: true, + balance: 0, + }); + }); + + it('rejects a negative overdraft limit', async () => { + const { ledger } = build(); + const account = await ledger.getOrCreateAccount({ + kind: LedgerAccountKind.USER, + ownerId: 'user-1', + currency: 'USD', + }); + await expect( + ledger.updateAccountPolicy(account.id, { overdraftLimit: -1 }), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('posting a transaction', () => { + it('moves both sides and keeps the currency’s balances summing to zero', async () => { + const { ledger, harness } = build(); + const { treasury, revenue } = await twoAccounts(ledger); + + const { posted } = await ledger.post({ + reference: 'movement-1', + kind: LedgerTransactionKind.CHARGE, + currency: 'USD', + legs: [ + { + accountId: treasury.id, + direction: LedgerEntryDirection.DEBIT, + amount: 700, + }, + { + accountId: revenue.id, + direction: LedgerEntryDirection.CREDIT, + amount: 700, + }, + ], + }); + + expect(posted).toBe(true); + expect(harness.balanceOf(treasury.id)).toBe(-700); + expect(harness.balanceOf(revenue.id)).toBe(700); + expect( + harness.balanceOf(treasury.id) + harness.balanceOf(revenue.id), + ).toBe(0); + }); + + it('rejects legs that do not balance', async () => { + const { ledger } = build(); + const { treasury, revenue } = await twoAccounts(ledger); + + await expect( + ledger.post({ + reference: 'lopsided', + kind: LedgerTransactionKind.CHARGE, + currency: 'USD', + legs: [ + { + accountId: treasury.id, + direction: LedgerEntryDirection.DEBIT, + amount: 700, + }, + { + accountId: revenue.id, + direction: LedgerEntryDirection.CREDIT, + amount: 300, + }, + ], + }), + ).rejects.toThrow(/does not balance/); + }); + + it('rejects a single-sided, zero, negative or fractional transaction', async () => { + const { ledger } = build(); + const { treasury, revenue } = await twoAccounts(ledger); + const leg = (amount: number, direction: LedgerEntryDirection) => ({ + accountId: + direction === LedgerEntryDirection.DEBIT ? treasury.id : revenue.id, + direction, + amount, + }); + + await expect( + ledger.post({ + reference: 'one-sided', + kind: LedgerTransactionKind.CHARGE, + currency: 'USD', + legs: [leg(100, LedgerEntryDirection.DEBIT)], + }), + ).rejects.toThrow(/at least one debit and one credit/); + + for (const amount of [0, -100, 10.5]) { + await expect( + ledger.post({ + reference: `bad-${amount}`, + kind: LedgerTransactionKind.CHARGE, + currency: 'USD', + legs: [ + leg(amount, LedgerEntryDirection.DEBIT), + leg(amount, LedgerEntryDirection.CREDIT), + ], + }), + ).rejects.toThrow(BadRequestException); + } + }); + + it('rejects a leg against an account in another currency', async () => { + const { ledger } = build(); + const { treasury } = await twoAccounts(ledger, 'USD'); + const euroRevenue = await ledger.getOrCreateAccount({ + kind: LedgerAccountKind.REVENUE, + currency: 'EUR', + }); + + await expect( + ledger.post({ + reference: 'mixed-currency', + kind: LedgerTransactionKind.CHARGE, + currency: 'USD', + legs: [ + { + accountId: treasury.id, + direction: LedgerEntryDirection.DEBIT, + amount: 100, + }, + { + accountId: euroRevenue.id, + direction: LedgerEntryDirection.CREDIT, + amount: 100, + }, + ], + }), + ).rejects.toThrow(/is in EUR/); + }); + + it('rejects a reference that is missing or blank', async () => { + const { ledger } = build(); + const { treasury, revenue } = await twoAccounts(ledger); + await expect( + ledger.post({ + reference: ' ', + kind: LedgerTransactionKind.CHARGE, + currency: 'USD', + legs: [ + { + accountId: treasury.id, + direction: LedgerEntryDirection.DEBIT, + amount: 100, + }, + { + accountId: revenue.id, + direction: LedgerEntryDirection.CREDIT, + amount: 100, + }, + ], + }), + ).rejects.toThrow(/reference is required/); + }); + + it('rejects a leg against an account that does not exist', async () => { + const { ledger } = build(); + const { treasury } = await twoAccounts(ledger); + await expect( + ledger.post({ + reference: 'ghost', + kind: LedgerTransactionKind.CHARGE, + currency: 'USD', + legs: [ + { + accountId: treasury.id, + direction: LedgerEntryDirection.DEBIT, + amount: 100, + }, + { + accountId: 'does-not-exist', + direction: LedgerEntryDirection.CREDIT, + amount: 100, + }, + ], + }), + ).rejects.toThrow(NotFoundException); + }); + + it('supports a multi-leg transaction, as a revenue split needs', async () => { + const { ledger, harness } = build(); + const { treasury } = await twoAccounts(ledger); + const platform = await ledger.getOrCreateAccount({ + kind: LedgerAccountKind.PLATFORM_FEE, + currency: 'USD', + }); + const operator = await ledger.getOrCreateAccount({ + kind: LedgerAccountKind.HUB_OPERATOR, + ownerId: 'hub-1', + currency: 'USD', + }); + + await ledger.post({ + reference: 'split-1', + kind: LedgerTransactionKind.REVENUE_SPLIT, + currency: 'USD', + legs: [ + { + accountId: treasury.id, + direction: LedgerEntryDirection.DEBIT, + amount: 1000, + }, + { + accountId: platform.id, + direction: LedgerEntryDirection.CREDIT, + amount: 150, + }, + { + accountId: operator.id, + direction: LedgerEntryDirection.CREDIT, + amount: 850, + }, + ], + }); + + expect(harness.balanceOf(platform.id)).toBe(150); + expect(harness.balanceOf(operator.id)).toBe(850); + expect(harness.balanceOf(treasury.id)).toBe(-1000); + }); + + it('nets legs that touch the same account twice', async () => { + const { ledger, harness } = build(); + const { treasury, revenue } = await twoAccounts(ledger); + + await ledger.post({ + reference: 'self-netting', + kind: LedgerTransactionKind.ADJUSTMENT, + currency: 'USD', + legs: [ + { + accountId: treasury.id, + direction: LedgerEntryDirection.DEBIT, + amount: 500, + }, + { + accountId: revenue.id, + direction: LedgerEntryDirection.CREDIT, + amount: 500, + }, + { + accountId: revenue.id, + direction: LedgerEntryDirection.DEBIT, + amount: 200, + }, + { + accountId: treasury.id, + direction: LedgerEntryDirection.CREDIT, + amount: 200, + }, + ], + }); + + expect(harness.balanceOf(revenue.id)).toBe(300); + expect(harness.balanceOf(treasury.id)).toBe(-300); + expect(harness.balanceOf(revenue.id)).toBe( + harness.derivedBalanceOf(revenue.id), + ); + }); + + it('writes nothing on a replay, and reports posted: false', async () => { + const { ledger, harness } = build(); + const { treasury, revenue } = await twoAccounts(ledger); + const legs = [ + { + accountId: treasury.id, + direction: LedgerEntryDirection.DEBIT, + amount: 100, + }, + { + accountId: revenue.id, + direction: LedgerEntryDirection.CREDIT, + amount: 100, + }, + ]; + + const first = await ledger.post({ + reference: 'replayed', + kind: LedgerTransactionKind.CHARGE, + currency: 'USD', + legs, + }); + const second = await ledger.post({ + reference: 'replayed', + kind: LedgerTransactionKind.CHARGE, + currency: 'USD', + legs, + }); + + expect(first.posted).toBe(true); + expect(second.posted).toBe(false); + expect(second.transaction.id).toBe(first.transaction.id); + expect(harness.entries.rows).toHaveLength(2); + expect(harness.balanceOf(revenue.id)).toBe(100); + }); + + it('trims the reference so a padded replay still collides', async () => { + const { ledger } = build(); + const { treasury, revenue } = await twoAccounts(ledger); + const legs = [ + { + accountId: treasury.id, + direction: LedgerEntryDirection.DEBIT, + amount: 100, + }, + { + accountId: revenue.id, + direction: LedgerEntryDirection.CREDIT, + amount: 100, + }, + ]; + + await ledger.post({ + reference: 'padded', + kind: LedgerTransactionKind.CHARGE, + currency: 'USD', + legs, + }); + const replay = await ledger.post({ + reference: ' padded ', + kind: LedgerTransactionKind.CHARGE, + currency: 'USD', + legs, + }); + expect(replay.posted).toBe(false); + }); + }); +}); diff --git a/backend/src/credits/ledger.service.ts b/backend/src/credits/ledger.service.ts new file mode 100644 index 00000000..36f0912e --- /dev/null +++ b/backend/src/credits/ledger.service.ts @@ -0,0 +1,601 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { EntityManager, In, Repository } from 'typeorm'; +import { LedgerAccount } from './entities/ledger-account.entity'; +import { LedgerEntry } from './entities/ledger-entry.entity'; +import { LedgerTransaction } from './entities/ledger-transaction.entity'; +import { LedgerAccountKind } from './enums/ledger-account-kind.enum'; +import { LedgerEntryDirection } from './enums/ledger-entry-direction.enum'; +import { LedgerTransactionKind } from './enums/ledger-transaction-kind.enum'; + +const POSTGRES_UNIQUE_VIOLATION = '23505'; +const LEDGER_TRANSACTION_REFERENCE_CONSTRAINT = + 'uq_ledger_transactions_reference'; + +/** + * Raised when a debit would take a member's credit balance past its + * overdraft ceiling. A ConflictException (409) rather than a 400: the + * request was well-formed, it lost a race with the account's current + * state — the same shape the refund ledger uses for its own + * exceeds-remaining case (issue #1572). + */ +export class InsufficientCreditException extends ConflictException { + constructor( + readonly accountId: string, + readonly balance: number, + readonly requested: number, + readonly overdraftLimit: number, + ) { + super( + `Insufficient credit: balance ${balance}, requested ${requested}, ` + + `overdraft limit ${overdraftLimit}`, + ); + } +} + +export interface LedgerLeg { + accountId: string; + direction: LedgerEntryDirection; + /** Positive minor units — `direction` carries the sign. */ + amount: number; + /** + * Stamps this entry as claimed by (and settled within) a settlement + * batch at write time. Used for the legs that ARE a settlement — the + * drawdown of a distributed revenue account, the clearing of a paid-out + * payable — so a later batch can never re-claim them and net the same + * movement twice. Left unset for ordinary legs, which stay claimable. + */ + settlementBatchId?: string; + settledAt?: Date; +} + +export interface PostTransactionInput { + /** Unique natural key — the transaction-level idempotency guard. */ + reference: string; + kind: LedgerTransactionKind; + currency: string; + legs: LedgerLeg[]; + description?: string | null; + metadata?: Record | null; + actorId?: string | null; +} + +export interface PostTransactionResult { + transaction: LedgerTransaction; + /** False when `reference` had already been posted — nothing was written. */ + posted: boolean; +} + +export interface FindAccountInput { + kind: LedgerAccountKind; + ownerId?: string | null; + currency: string; +} + +export interface CreateAccountInput extends FindAccountInput { + overdraftLimit?: number; + externalPayoutAddress?: string | null; + label?: string | null; +} + +export interface LedgerIntegrityReport { + accountsChecked: number; + /** Accounts whose materialized balance disagrees with their entries. */ + balanceDrift: Array<{ + accountId: string; + materialized: number; + derived: number; + }>; + /** Transactions whose debits and credits do not cancel out. */ + unbalancedTransactions: Array<{ + transactionId: string; + debits: number; + credits: number; + }>; +} + +/** + * The double-entry primitive the whole credits module is built on (issue + * #1575): balanced sets of append-only entries, plus the row-level + * locking that makes many small concurrent movements against one account + * safe. + * + * Three invariants everything else relies on: + * + * 1. **Every transaction balances.** Debits equal credits, always + * validated before anything is written, so the sum of all balances in + * a currency stays exactly zero and the ledger can be audited by + * addition alone. + * 2. **`reference` is unique.** A replayed charge, a re-run settlement + * pass, a resumed batch job — all collide on it and get the original + * transaction back. Callers never have to reason about "did my retry + * post twice"; they get `posted: false`. + * 3. **Accounts are locked in a deterministic order** (ascending id) + * before any balance is read or written, so two transactions touching + * the same pair of accounts serialize instead of deadlocking, and an + * overdraft check can never be made against a stale balance. + */ +@Injectable() +export class LedgerService { + constructor( + @InjectRepository(LedgerAccount) + private readonly accountRepository: Repository, + @InjectRepository(LedgerTransaction) + private readonly transactionRepository: Repository, + @InjectRepository(LedgerEntry) + private readonly entryRepository: Repository, + ) {} + + /** + * Posts a balanced transaction. Pass `manager` to join a transaction the + * caller already owns (settlement does this so claiming entries and + * posting their ledger effect commit together); omit it and one is + * opened here. + */ + async post( + input: PostTransactionInput, + manager?: EntityManager, + ): Promise { + const normalized = this.validate(input); + + if (manager) { + return this.postWithin(manager, normalized); + } + return this.accountRepository.manager.transaction((tx) => + this.postWithin(tx, normalized), + ); + } + + async getAccount( + id: string, + manager?: EntityManager, + ): Promise { + const account = await this.accounts(manager).findOne({ where: { id } }); + if (!account) { + throw new NotFoundException(`Ledger account ${id} not found`); + } + return account; + } + + async findAccount( + input: FindAccountInput, + manager?: EntityManager, + ): Promise { + return this.accounts(manager).findOne({ + where: { + kind: input.kind, + ownerId: input.ownerId ?? null, + currency: input.currency.toUpperCase(), + }, + }); + } + + /** + * Idempotent under concurrency: the partial unique indexes on + * (kind, owner_id, currency) — one for owned accounts, one for the + * singleton system accounts — are the actual source of truth, and the + * loser of a race recovers by re-reading rather than erroring. + */ + async getOrCreateAccount( + input: CreateAccountInput, + manager?: EntityManager, + ): Promise { + const currency = input.currency.toUpperCase(); + const existing = await this.findAccount({ ...input, currency }, manager); + if (existing) { + return existing; + } + + const repository = this.accounts(manager); + try { + return await repository.save( + repository.create({ + kind: input.kind, + ownerId: input.ownerId ?? null, + currency, + balance: 0, + overdraftLimit: input.overdraftLimit ?? 0, + externalPayoutAddress: input.externalPayoutAddress ?? null, + frozen: false, + label: input.label ?? null, + }), + ); + } catch (error) { + if (!this.isUniqueViolation(error)) { + throw error; + } + const winner = await this.findAccount({ ...input, currency }, manager); + if (!winner) { + throw error; + } + return winner; + } + } + + async listAccounts(currency?: string): Promise { + return this.accountRepository.find({ + where: currency ? { currency: currency.toUpperCase() } : {}, + order: { kind: 'ASC', createdAt: 'ASC' }, + }); + } + + /** + * The only sanctioned way to change an account's policy fields. Balance + * is deliberately not among them: it moves only by posting entries, so + * there is no code path that can set a balance without an audit trail. + */ + async updateAccountPolicy( + accountId: string, + changes: { + overdraftLimit?: number; + externalPayoutAddress?: string | null; + frozen?: boolean; + label?: string | null; + }, + ): Promise { + const account = await this.getAccount(accountId); + if (changes.overdraftLimit !== undefined) { + if ( + !Number.isInteger(changes.overdraftLimit) || + changes.overdraftLimit < 0 + ) { + throw new BadRequestException( + 'An overdraft limit must be a non-negative integer (minor units)', + ); + } + account.overdraftLimit = changes.overdraftLimit; + } + if (changes.externalPayoutAddress !== undefined) { + account.externalPayoutAddress = changes.externalPayoutAddress; + } + if (changes.frozen !== undefined) { + account.frozen = changes.frozen; + } + if (changes.label !== undefined) { + account.label = changes.label; + } + return this.accountRepository.save(account); + } + + /** + * Re-derives every account's balance from its append-only entries and + * reports any disagreement with the materialized column, plus any + * transaction whose legs do not cancel out. The entries are the source + * of truth; the column is a cache that exists so an overdraft check can + * be O(1) — this is what proves the cache is honest. + */ + async checkIntegrity(currency?: string): Promise { + const accounts = await this.accountRepository.find({ + where: currency ? { currency: currency.toUpperCase() } : {}, + }); + + const derivedRows = await this.entryRepository + .createQueryBuilder('entry') + .select('entry.account_id', 'accountId') + .addSelect( + `COALESCE(SUM(CASE WHEN entry.direction = 'CREDIT' ` + + `THEN entry.amount ELSE -entry.amount END), 0)`, + 'derived', + ) + .groupBy('entry.account_id') + .getRawMany<{ accountId: string; derived: string }>(); + const derivedByAccount = new Map( + derivedRows.map((row) => [row.accountId, Number(row.derived)]), + ); + + const balanceDrift = accounts + .map((account) => ({ + accountId: account.id, + materialized: account.balance, + derived: derivedByAccount.get(account.id) ?? 0, + })) + .filter((row) => row.materialized !== row.derived); + + const unbalancedRows = await this.entryRepository + .createQueryBuilder('entry') + .select('entry.transaction_id', 'transactionId') + .addSelect( + `COALESCE(SUM(CASE WHEN entry.direction = 'DEBIT' ` + + `THEN entry.amount ELSE 0 END), 0)`, + 'debits', + ) + .addSelect( + `COALESCE(SUM(CASE WHEN entry.direction = 'CREDIT' ` + + `THEN entry.amount ELSE 0 END), 0)`, + 'credits', + ) + .groupBy('entry.transaction_id') + .having( + `SUM(CASE WHEN entry.direction = 'DEBIT' THEN entry.amount ELSE 0 END) ` + + `<> SUM(CASE WHEN entry.direction = 'CREDIT' THEN entry.amount ELSE 0 END)`, + ) + .getRawMany<{ transactionId: string; debits: string; credits: string }>(); + + return { + accountsChecked: accounts.length, + balanceDrift, + unbalancedTransactions: unbalancedRows.map((row) => ({ + transactionId: row.transactionId, + debits: Number(row.debits), + credits: Number(row.credits), + })), + }; + } + + async getTransactionByReference( + reference: string, + manager?: EntityManager, + ): Promise { + return this.transactions(manager).findOne({ where: { reference } }); + } + + async listEntries(accountId: string, limit = 100): Promise { + return this.entryRepository.find({ + where: { accountId }, + order: { createdAt: 'DESC' }, + take: limit, + }); + } + + async listEntriesForTransactions( + transactionIds: string[], + ): Promise { + if (transactionIds.length === 0) { + return []; + } + return this.entryRepository.find({ + where: { transactionId: In(transactionIds) }, + }); + } + + // ── internals ────────────────────────────────────────────────────────── + + private async postWithin( + manager: EntityManager, + input: PostTransactionInput, + ): Promise { + const existing = await this.getTransactionByReference( + input.reference, + manager, + ); + if (existing) { + return { transaction: existing, posted: false }; + } + + const accounts = await this.lockAccounts( + manager, + input.legs.map((leg) => leg.accountId), + ); + this.assertLegsPostable(input, accounts); + + let transaction: LedgerTransaction; + try { + transaction = await manager.getRepository(LedgerTransaction).save( + manager.getRepository(LedgerTransaction).create({ + kind: input.kind, + reference: input.reference, + currency: input.currency, + amount: this.sideTotal(input.legs, LedgerEntryDirection.DEBIT), + description: input.description ?? null, + metadata: input.metadata ?? null, + actorId: input.actorId ?? null, + }), + ); + } catch (error) { + // Two callers raced on the same reference; the unique index is the + // arbiter and the loser reports the winner's transaction. + if ( + this.isUniqueViolation(error) && + this.violatedConstraint(error) === + LEDGER_TRANSACTION_REFERENCE_CONSTRAINT + ) { + const winner = await this.getTransactionByReference( + input.reference, + manager, + ); + if (winner) { + return { transaction: winner, posted: false }; + } + } + throw error; + } + + const entryRepository = manager.getRepository(LedgerEntry); + await entryRepository.save( + input.legs.map((leg) => + entryRepository.create({ + transactionId: transaction.id, + accountId: leg.accountId, + direction: leg.direction, + amount: leg.amount, + currency: input.currency, + settlementBatchId: leg.settlementBatchId ?? null, + settledAt: leg.settledAt ?? null, + }), + ), + ); + + for (const [accountId, delta] of this.netByAccount(input.legs)) { + // Relative arithmetic in SQL (`balance = balance + delta`) rather + // than writing back a value read in JS. The lock above already + // serializes us, so both are correct here — but a relative update + // stays correct even if a future caller reaches this without the + // lock, which a read-modify-write would not. + await manager.increment( + LedgerAccount, + { id: accountId }, + 'balance', + delta, + ); + } + + return { transaction, posted: true }; + } + + /** + * Locks every account the transaction touches, in ascending id order. + * The ordering is the deadlock guard: two transactions that touch the + * same accounts in opposite order would otherwise each hold one lock and + * wait on the other forever. + */ + private async lockAccounts( + manager: EntityManager, + accountIds: string[], + ): Promise> { + const unique = [...new Set(accountIds)].sort(); + const accounts = await manager + .getRepository(LedgerAccount) + .createQueryBuilder('account') + .setLock('pessimistic_write') + .where('account.id IN (:...ids)', { ids: unique }) + .orderBy('account.id', 'ASC') + .getMany(); + + if (accounts.length !== unique.length) { + const found = new Set(accounts.map((account) => account.id)); + const missing = unique.filter((id) => !found.has(id)); + throw new NotFoundException( + `Ledger account(s) not found: ${missing.join(', ')}`, + ); + } + return new Map(accounts.map((account) => [account.id, account])); + } + + /** + * The overdraft and freeze policy, applied against balances just read + * under lock. + * + * It is enforced for USER accounts only, and that is a deliberate line: + * a member's credit balance is real spendable value, so a debit past + * `overdraftLimit` (0 by default — reject the moment it would overdraw) + * is refused. System accounts are the other side of movements that have + * already happened — TREASURY in particular is the clearing account for + * value that crossed the platform boundary and is *expected* to sit + * deeply negative — so constraining them would only make correct + * bookkeeping impossible. + */ + private assertLegsPostable( + input: PostTransactionInput, + accounts: Map, + ): void { + for (const [accountId, delta] of this.netByAccount(input.legs)) { + const account = accounts.get(accountId)!; + if (account.currency !== input.currency) { + throw new BadRequestException( + `Ledger account ${accountId} is in ${account.currency}, ` + + `but the transaction is in ${input.currency}`, + ); + } + if (delta >= 0 || account.kind !== LedgerAccountKind.USER) { + continue; + } + if (account.frozen) { + throw new ConflictException( + `Ledger account ${accountId} is frozen and cannot be debited`, + ); + } + const resulting = account.balance + delta; + if (resulting < -account.overdraftLimit) { + throw new InsufficientCreditException( + accountId, + account.balance, + -delta, + account.overdraftLimit, + ); + } + } + } + + private validate(input: PostTransactionInput): PostTransactionInput { + if (!input.reference?.trim()) { + throw new BadRequestException( + 'A ledger transaction reference is required', + ); + } + if (input.legs.length < 2) { + throw new BadRequestException( + 'A ledger transaction needs at least one debit and one credit leg', + ); + } + for (const leg of input.legs) { + if (!Number.isInteger(leg.amount) || leg.amount <= 0) { + throw new BadRequestException( + 'Ledger leg amounts must be positive integers (minor units), ' + + `got ${leg.amount}`, + ); + } + } + + const debits = this.sideTotal(input.legs, LedgerEntryDirection.DEBIT); + const credits = this.sideTotal(input.legs, LedgerEntryDirection.CREDIT); + if (debits !== credits) { + throw new BadRequestException( + `Ledger transaction does not balance: debits ${debits}, credits ${credits}`, + ); + } + if (debits === 0) { + throw new BadRequestException( + 'A ledger transaction must move a non-zero amount', + ); + } + + const currency = input.currency?.toUpperCase(); + if (!currency || currency.length !== 3) { + throw new BadRequestException( + `Ledger transaction currency must be a 3-letter code, got ${input.currency}`, + ); + } + + return { ...input, currency, reference: input.reference.trim() }; + } + + private sideTotal( + legs: readonly LedgerLeg[], + direction: LedgerEntryDirection, + ): number { + return legs + .filter((leg) => leg.direction === direction) + .reduce((sum, leg) => sum + leg.amount, 0); + } + + /** Net balance delta per account (credits positive, debits negative). */ + private netByAccount(legs: readonly LedgerLeg[]): Map { + const net = new Map(); + for (const leg of legs) { + const signed = + leg.direction === LedgerEntryDirection.CREDIT + ? leg.amount + : -leg.amount; + net.set(leg.accountId, (net.get(leg.accountId) ?? 0) + signed); + } + return net; + } + + private accounts(manager?: EntityManager): Repository { + return manager + ? manager.getRepository(LedgerAccount) + : this.accountRepository; + } + + private transactions(manager?: EntityManager): Repository { + return manager + ? manager.getRepository(LedgerTransaction) + : this.transactionRepository; + } + + private isUniqueViolation(error: unknown): boolean { + const code = (error as any)?.code ?? (error as any)?.driverError?.code; + return code === POSTGRES_UNIQUE_VIOLATION; + } + + private violatedConstraint(error: unknown): string | undefined { + return ( + (error as any)?.constraint ?? (error as any)?.driverError?.constraint + ); + } +} diff --git a/backend/src/credits/metered-usage.service.spec.ts b/backend/src/credits/metered-usage.service.spec.ts new file mode 100644 index 00000000..e3ddcec5 --- /dev/null +++ b/backend/src/credits/metered-usage.service.spec.ts @@ -0,0 +1,166 @@ +import { BadRequestException } from '@nestjs/common'; +import { CreditsService } from './credits.service'; +import { InsufficientCreditException, LedgerService } from './ledger.service'; +import { MeteredUsageService } from './metered-usage.service'; +import { LedgerAccountKind } from './enums/ledger-account-kind.enum'; +import { MeteredResource } from './enums/metered-resource.enum'; +import { + createLedgerHarness, + fakeConfigService, +} from './testing/in-memory-ledger'; + +function build() { + const harness = createLedgerHarness(); + const ledger = new LedgerService( + harness.accounts as any, + harness.transactions as any, + harness.entries as any, + ); + const credits = new CreditsService( + ledger, + fakeConfigService({ + CREDITS_DEFAULT_CURRENCY: 'USD', + CREDITS_DEFAULT_OVERDRAFT_LIMIT: 0, + }), + ); + const usage = new MeteredUsageService(harness.usageEvents as any, credits); + return { harness, ledger, credits, usage }; +} + +async function fund(credits: CreditsService, amount: number) { + await credits.topUpFromPayment({ + paymentId: 'payment-1', + userId: 'user-1', + amount, + currency: 'USD', + }); +} + +const meterReading = { + userId: 'user-1', + resource: MeteredResource.RESOURCE_MINUTES, + units: 12, + unitPrice: 5, + usageReference: 'session-4711-minutes-12', +}; + +describe('MeteredUsageService', () => { + it('prices the reading and charges it against the credit balance', async () => { + const { usage, credits, harness } = build(); + await fund(credits, 1000); + + const { event, charged } = await usage.recordUsage(meterReading); + + expect(charged).toBe(true); + expect(event.amount).toBe(60); + expect((await credits.getBalance('user-1')).balance).toBe(940); + + const revenue = await credits.getSystemAccount(LedgerAccountKind.REVENUE); + expect(harness.balanceOf(revenue.id)).toBe(60); + + // The charge is linked to the usage record, both ways. + const transaction = harness.transactions.rows.find( + (row) => row.id === event.ledgerTransactionId, + ); + expect(transaction.reference).toBe( + `charge:usage:${meterReading.usageReference}`, + ); + expect(transaction.metadata).toMatchObject({ + resource: MeteredResource.RESOURCE_MINUTES, + units: 12, + unitPrice: 5, + }); + }); + + it('charges a retried meter reading exactly once', async () => { + const { usage, credits, harness } = build(); + await fund(credits, 1000); + + const first = await usage.recordUsage(meterReading); + const retry = await usage.recordUsage(meterReading); + + expect(first.charged).toBe(true); + expect(retry.charged).toBe(false); + expect(retry.event.id).toBe(first.event.id); + expect(harness.usageEvents.rows).toHaveLength(1); + expect((await credits.getBalance('user-1')).balance).toBe(940); + }); + + /** + * The crash between the two writes: the ledger already holds the charge + * but the usage record was lost. A retry must reconcile to one charge and + * one record, not two of either. + */ + it('recovers when the usage record was lost after the charge was posted', async () => { + const { usage, credits, harness } = build(); + await fund(credits, 1000); + await usage.recordUsage(meterReading); + + // Simulate the lost write. + harness.usageEvents.rows.splice(0, harness.usageEvents.rows.length); + const retry = await usage.recordUsage(meterReading); + + expect(retry.charged).toBe(false); + expect(harness.usageEvents.rows).toHaveLength(1); + expect((await credits.getBalance('user-1')).balance).toBe(940); + }); + + it('surfaces an insufficient balance rather than metering for free', async () => { + const { usage, credits, harness } = build(); + await fund(credits, 10); + + await expect(usage.recordUsage(meterReading)).rejects.toThrow( + InsufficientCreditException, + ); + expect(harness.usageEvents.rows).toHaveLength(0); + expect((await credits.getBalance('user-1')).balance).toBe(10); + }); + + it('rejects a non-positive quantity or price', async () => { + const { usage } = build(); + for (const patch of [ + { units: 0 }, + { units: -1 }, + { units: 1.5 }, + { unitPrice: 0 }, + { unitPrice: -5 }, + ]) { + await expect( + usage.recordUsage({ ...meterReading, ...patch }), + ).rejects.toThrow(BadRequestException); + } + }); + + it('requires a usage reference', async () => { + const { usage } = build(); + await expect( + usage.recordUsage({ ...meterReading, usageReference: ' ' }), + ).rejects.toThrow(/usage reference is required/); + }); + + it('accumulates several readings into the revenue account', async () => { + const { usage, credits, harness } = build(); + await fund(credits, 1000); + + await usage.recordUsage(meterReading); + await usage.recordUsage({ + ...meterReading, + resource: MeteredResource.PRINTING, + units: 20, + unitPrice: 2, + usageReference: 'print-job-1', + }); + await usage.recordUsage({ + ...meterReading, + resource: MeteredResource.MEETING_ROOM_OVERAGE, + units: 15, + unitPrice: 10, + usageReference: 'overage-1', + }); + + const revenue = await credits.getSystemAccount(LedgerAccountKind.REVENUE); + expect(harness.balanceOf(revenue.id)).toBe(60 + 40 + 150); + expect((await credits.getBalance('user-1')).balance).toBe(1000 - 250); + expect(await usage.listForUser('user-1')).toHaveLength(3); + }); +}); diff --git a/backend/src/credits/metered-usage.service.ts b/backend/src/credits/metered-usage.service.ts new file mode 100644 index 00000000..05e45d70 --- /dev/null +++ b/backend/src/credits/metered-usage.service.ts @@ -0,0 +1,139 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { MeteredUsageEvent } from './entities/metered-usage-event.entity'; +import { MeteredResource } from './enums/metered-resource.enum'; +import { CreditsService } from './credits.service'; + +const POSTGRES_UNIQUE_VIOLATION = '23505'; + +export interface RecordUsageInput { + userId: string; + resource: MeteredResource; + /** Minutes, pages, ... — whatever this resource meters. */ + units: number; + /** Minor units per unit. */ + unitPrice: number; + currency?: string; + /** The caller's natural key for this usage event — the dedupe key. */ + usageReference: string; + actorId?: string | null; +} + +export interface RecordUsageResult { + event: MeteredUsageEvent; + /** False when this usage event had already been recorded and charged. */ + charged: boolean; +} + +/** + * The metered call site for the credit ledger's spend path (issue #1575): + * per-minute resource usage, printing and meeting-room overage priced in + * minor units and charged straight against a member's credit balance. + * + * This is the shape a resource-usage feature is expected to have — it owns + * the pricing and the usage audit record, and it hands the ledger nothing + * but an amount and a dedupe key. No payment rail and no chain call is + * involved: settling a two-cent print job individually would cost more in + * fees and latency than the job itself, which is the whole reason this + * module exists. + * + * A charge and its usage record are made idempotent by two independent + * unique keys pointing at the same natural reference — the ledger + * transaction's `charge:usage:` and this table's `usageReference` — + * so a retried delivery of the same meter reading charges exactly once + * even if it fails between the two writes. + */ +@Injectable() +export class MeteredUsageService { + constructor( + @InjectRepository(MeteredUsageEvent) + private readonly usageRepository: Repository, + private readonly credits: CreditsService, + ) {} + + async recordUsage(input: RecordUsageInput): Promise { + if (!Number.isInteger(input.units) || input.units <= 0) { + throw new BadRequestException('Usage units must be a positive integer'); + } + if (!Number.isInteger(input.unitPrice) || input.unitPrice <= 0) { + throw new BadRequestException( + 'Unit price must be a positive integer (minor units)', + ); + } + if (!input.usageReference?.trim()) { + throw new BadRequestException('A usage reference is required'); + } + + const usageReference = input.usageReference.trim(); + const existing = await this.usageRepository.findOne({ + where: { usageReference }, + }); + if (existing) { + return { event: existing, charged: false }; + } + + const amount = input.units * input.unitPrice; + const currency = input.currency ?? this.credits.defaultCurrency(); + + // Charge first: the ledger is the thing that must not be wrong. If + // recording the event below fails, a retry re-charges against the same + // reference and the ledger returns the original transaction instead of + // posting a second one. + const charge = await this.credits.charge({ + userId: input.userId, + amount, + currency, + reference: `usage:${usageReference}`, + reason: `${input.resource} x${input.units} @ ${input.unitPrice}`, + metadata: { + resource: input.resource, + units: input.units, + unitPrice: input.unitPrice, + usageReference, + }, + actorId: input.actorId ?? null, + }); + + try { + const event = await this.usageRepository.save( + this.usageRepository.create({ + userId: input.userId, + resource: input.resource, + units: input.units, + unitPrice: input.unitPrice, + amount, + currency: charge.currency, + usageReference, + ledgerTransactionId: charge.transaction.id, + }), + ); + return { event, charged: charge.posted }; + } catch (error) { + // Two deliveries of the same meter reading raced. The ledger already + // deduped the charge, so the loser just reports the winner's event. + if (this.isUniqueViolation(error)) { + const winner = await this.usageRepository.findOne({ + where: { usageReference }, + }); + if (winner) { + return { event: winner, charged: false }; + } + } + throw error; + } + } + + async listForUser(userId: string, limit = 100): Promise { + return this.usageRepository.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + take: limit, + }); + } + + private isUniqueViolation(error: unknown): boolean { + const code = (error as any)?.code ?? (error as any)?.driverError?.code; + return code === POSTGRES_UNIQUE_VIOLATION; + } +} diff --git a/backend/src/credits/payment-credits.service.spec.ts b/backend/src/credits/payment-credits.service.spec.ts new file mode 100644 index 00000000..89012139 --- /dev/null +++ b/backend/src/credits/payment-credits.service.spec.ts @@ -0,0 +1,313 @@ +import { UnprocessableEntityException } from '@nestjs/common'; +import { PaymentStatus } from '../payments/enums/payment-status.enum'; +import { CreditsService } from './credits.service'; +import { LedgerService } from './ledger.service'; +import { RevenueSplitService } from './revenue-split.service'; +import { + CREDIT_TOP_UP_PURPOSE, + PaymentCreditsService, +} from './payment-credits.service'; +import { LedgerAccountKind } from './enums/ledger-account-kind.enum'; +import { LedgerTransactionKind } from './enums/ledger-transaction-kind.enum'; +import { PaymentCreditApplicationKind } from './enums/payment-credit-application-kind.enum'; +import { + createLedgerHarness, + fakeConfigService, +} from './testing/in-memory-ledger'; + +/** + * Stands in for the payments table. `createQueryBuilder` replicates the + * sweep's SQL predicate in JS — confirmed, not yet applied, and either + * marked as a top-up or carrying a split config. (The SQL itself is + * exercised against a real database, not here; what this proves is the + * behaviour that hangs off the candidate set.) + */ +function fakePaymentRepository(payments: any[], applications: any[]) { + const isApplied = (paymentId: string) => + Boolean( + applications.find((application) => application.paymentId === paymentId) + ?.appliedAt, + ); + const hasApplication = (paymentId: string) => + applications.some((application) => application.paymentId === paymentId); + + return { + findOne: async ({ where }: any) => + payments.find((payment) => payment.id === where.id) ?? null, + createQueryBuilder: () => { + const builder: any = { + leftJoin: () => builder, + where: () => builder, + andWhere: () => builder, + orderBy: () => builder, + take: () => builder, + getMany: async () => + payments.filter( + (payment) => + payment.status === PaymentStatus.CONFIRMED && + !isApplied(payment.id) && + (payment.metadata?.purpose === CREDIT_TOP_UP_PURPOSE || + hasApplication(payment.id)), + ), + }; + return builder; + }, + }; +} + +function build() { + const harness = createLedgerHarness(); + const payments: any[] = []; + const ledger = new LedgerService( + harness.accounts as any, + harness.transactions as any, + harness.entries as any, + ); + const credits = new CreditsService( + ledger, + fakeConfigService({ CREDITS_DEFAULT_CURRENCY: 'USD' }), + ); + const splits = new RevenueSplitService( + harness.splitConfigs as any, + harness.splitRecipients as any, + ledger, + ); + const paymentCredits = new PaymentCreditsService( + fakePaymentRepository(payments, harness.paymentApplications.rows) as any, + harness.paymentApplications as any, + credits, + splits, + fakeConfigService({ CREDITS_PAYMENT_SWEEP_MAX_BATCH: 100 }), + ); + + function addPayment(overrides: Record = {}) { + const payment = { + id: `payment-${payments.length + 1}`, + userId: 'user-1', + amount: 10_000, + currency: 'USD', + status: PaymentStatus.CONFIRMED, + metadata: null, + updatedAt: new Date(), + ...overrides, + }; + payments.push(payment); + return payment; + } + + return { + harness, + ledger, + credits, + splits, + paymentCredits, + payments, + addPayment, + }; +} + +describe('PaymentCreditsService — top-ups', () => { + it('credits the payer when the payment declares itself a top-up', async () => { + const { paymentCredits, credits, addPayment } = build(); + const payment = addPayment({ + amount: 5000, + metadata: { purpose: CREDIT_TOP_UP_PURPOSE }, + }); + + const application = await paymentCredits.applyPayment(payment.id); + + expect(application.kind).toBe(PaymentCreditApplicationKind.TOP_UP); + expect(application.appliedAt).toBeTruthy(); + expect((await credits.getBalance('user-1')).balance).toBe(5000); + }); + + it('credits the payer for an explicitly marked payment', async () => { + const { paymentCredits, credits, addPayment } = build(); + const payment = addPayment({ amount: 2500 }); + + await paymentCredits.markAsTopUp(payment.id); + await paymentCredits.applyPayment(payment.id); + + expect((await credits.getBalance('user-1')).balance).toBe(2500); + }); + + it('applies a payment only once, however often it is asked', async () => { + const { paymentCredits, credits, harness, addPayment } = build(); + const payment = addPayment({ + amount: 5000, + metadata: { purpose: CREDIT_TOP_UP_PURPOSE }, + }); + + await paymentCredits.applyPayment(payment.id); + await paymentCredits.applyPayment(payment.id); + await paymentCredits.applyPayment(payment.id); + + expect((await credits.getBalance('user-1')).balance).toBe(5000); + expect( + harness.transactions.rows.filter( + (transaction) => transaction.kind === LedgerTransactionKind.TOP_UP, + ), + ).toHaveLength(1); + }); + + it('refuses a payment that is not CONFIRMED', async () => { + const { paymentCredits, addPayment } = build(); + const payment = addPayment({ + status: PaymentStatus.AWAITING_CONFIRMATION, + metadata: { purpose: CREDIT_TOP_UP_PURPOSE }, + }); + + await expect(paymentCredits.applyPayment(payment.id)).rejects.toThrow( + UnprocessableEntityException, + ); + }); + + it('refuses a payment with no declared credit effect', async () => { + const { paymentCredits, addPayment } = build(); + const payment = addPayment(); + await expect(paymentCredits.applyPayment(payment.id)).rejects.toThrow( + /no credit-ledger effect/, + ); + }); + + it('honours the payment’s own currency', async () => { + const { paymentCredits, credits, addPayment } = build(); + const payment = addPayment({ + amount: 4000, + currency: 'EUR', + metadata: { purpose: CREDIT_TOP_UP_PURPOSE }, + }); + + await paymentCredits.applyPayment(payment.id); + + expect((await credits.getBalance('user-1', 'EUR')).balance).toBe(4000); + expect((await credits.getBalance('user-1', 'USD')).balance).toBe(0); + }); +}); + +describe('PaymentCreditsService — revenue splits on a payment', () => { + async function withSplitConfig() { + const context = build(); + const platform = await context.credits.getSystemAccount( + LedgerAccountKind.PLATFORM_FEE, + ); + const operator = await context.credits.getPayableAccount( + LedgerAccountKind.HUB_OPERATOR, + 'hub-1', + 'USD', + 'GOPERATORADDRESS', + ); + const config = await context.splits.createConfig({ + name: 'hub-split', + recipients: [ + { label: 'platform fee', basisPoints: 1500, accountId: platform.id }, + { label: 'hub operator', basisPoints: 8500, accountId: operator.id }, + ], + }); + return { ...context, platform, operator, config }; + } + + it('distributes the amount across the attached config once confirmed', async () => { + const { paymentCredits, addPayment, config, harness, platform, operator } = + await withSplitConfig(); + const payment = addPayment({ amount: 10_000 }); + + await paymentCredits.attachSplitConfig(payment.id, config.id); + const application = await paymentCredits.applyPayment(payment.id); + + expect(application.kind).toBe(PaymentCreditApplicationKind.REVENUE_SPLIT); + expect(harness.balanceOf(platform.id)).toBe(1500); + expect(harness.balanceOf(operator.id)).toBe(8500); + }); + + it('refuses to attach a config to an already-applied payment', async () => { + const { paymentCredits, addPayment, config } = await withSplitConfig(); + const payment = addPayment({ + metadata: { purpose: CREDIT_TOP_UP_PURPOSE }, + }); + await paymentCredits.applyPayment(payment.id); + + await expect( + paymentCredits.attachSplitConfig(payment.id, config.id), + ).rejects.toThrow(/already been applied/); + }); + + it('refuses a config with external-address recipients', async () => { + const { paymentCredits, addPayment, splits, platform } = + await withSplitConfig(); + const external = await splits.createConfig({ + name: 'external-partner', + recipients: [ + { label: 'platform fee', basisPoints: 5000, accountId: platform.id }, + { + label: 'partner', + basisPoints: 5000, + externalAddress: 'GPARTNERADDRESS', + }, + ], + }); + const payment = addPayment(); + + await expect( + paymentCredits.attachSplitConfig(payment.id, external.id), + ).rejects.toThrow(/payment split cannot post internally/); + }); + + it('records the failure on the application when applying throws', async () => { + const { paymentCredits, addPayment, config, splits } = + await withSplitConfig(); + const payment = addPayment(); + await paymentCredits.attachSplitConfig(payment.id, config.id); + await splits.setActive(config.id, false); + + await expect(paymentCredits.applyPayment(payment.id)).rejects.toThrow( + /inactive/, + ); + const application = await paymentCredits.getApplication(payment.id); + expect(application!.appliedAt).toBeFalsy(); + expect(application!.lastError).toMatch(/inactive/); + }); +}); + +describe('PaymentCreditsService — the sweep', () => { + it('applies every candidate and leaves nothing for the next pass', async () => { + const { paymentCredits, credits, addPayment } = build(); + addPayment({ amount: 1000, metadata: { purpose: CREDIT_TOP_UP_PURPOSE } }); + addPayment({ amount: 2000, metadata: { purpose: CREDIT_TOP_UP_PURPOSE } }); + addPayment({ amount: 4000, status: PaymentStatus.AWAITING_CONFIRMATION }); + addPayment({ amount: 8000 }); + + const first = await paymentCredits.sweepConfirmedPayments(); + expect(first).toMatchObject({ candidates: 2, applied: 2, failed: 0 }); + expect((await credits.getBalance('user-1')).balance).toBe(3000); + + const second = await paymentCredits.sweepConfirmedPayments(); + expect(second.candidates).toBe(0); + expect((await credits.getBalance('user-1')).balance).toBe(3000); + }); + + it('keeps going after one candidate fails, and reports it', async () => { + const { paymentCredits, credits, splits, addPayment } = build(); + const broken = addPayment({ amount: 1000 }); + const config = await splits.createConfig({ + name: 'will-be-inactive', + recipients: [ + { + label: 'platform fee', + basisPoints: 10000, + accountId: ( + await credits.getSystemAccount(LedgerAccountKind.PLATFORM_FEE) + ).id, + }, + ], + }); + await paymentCredits.attachSplitConfig(broken.id, config.id); + await splits.setActive(config.id, false); + addPayment({ amount: 2000, metadata: { purpose: CREDIT_TOP_UP_PURPOSE } }); + + const summary = await paymentCredits.sweepConfirmedPayments(); + + expect(summary).toMatchObject({ candidates: 2, applied: 1, failed: 1 }); + expect((await credits.getBalance('user-1')).balance).toBe(2000); + }); +}); diff --git a/backend/src/credits/payment-credits.service.ts b/backend/src/credits/payment-credits.service.ts new file mode 100644 index 00000000..b5b776ff --- /dev/null +++ b/backend/src/credits/payment-credits.service.ts @@ -0,0 +1,299 @@ +import { + Injectable, + Logger, + NotFoundException, + UnprocessableEntityException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Payment } from '../payments/entities/payment.entity'; +import { PaymentStatus } from '../payments/enums/payment-status.enum'; +import { PaymentCreditApplication } from './entities/payment-credit-application.entity'; +import { PaymentCreditApplicationKind } from './enums/payment-credit-application-kind.enum'; +import { CreditsService } from './credits.service'; +import { RevenueSplitService } from './revenue-split.service'; + +/** + * A Payment declares itself a credit top-up by carrying this in its + * `metadata.purpose` at initiation time (issue #1575). Chosen over a new + * column on `payments` so the credits module stays additive: nothing in + * the payments module has to know this module exists. + */ +export const CREDIT_TOP_UP_PURPOSE = 'CREDIT_TOP_UP'; + +export interface PaymentSweepSummary { + candidates: number; + applied: number; + skipped: number; + failed: number; +} + +/** + * The bridge between the payment rails (#1570 fiat, #1574 on-chain) and + * the credit ledger (issue #1575). + * + * It works by **sweeping CONFIRMED payments** rather than by being called + * from the confirmation path, and that is a design choice with three + * payoffs: + * + * - the dependency stays one-directional (credits reads payments), so + * neither module has to be wired into the other's lifecycle; + * - a payment confirmed while this service was down is still picked up on + * the next pass — the effect is self-healing, not fire-and-forget; + * - it is idempotent by construction. The unique ledger transaction + * reference (`top-up:payment:` / `payment-split:`) is the real + * guard, so a crash between posting the ledger effect and marking the + * application applied cannot double-credit anyone. + */ +@Injectable() +export class PaymentCreditsService { + private readonly logger = new Logger(PaymentCreditsService.name); + + constructor( + @InjectRepository(Payment) + private readonly paymentRepository: Repository, + @InjectRepository(PaymentCreditApplication) + private readonly applicationRepository: Repository, + private readonly credits: CreditsService, + private readonly splits: RevenueSplitService, + private readonly config: ConfigService, + ) {} + + @Cron(CronExpression.EVERY_5_MINUTES) + async handleCron(): Promise { + const summary = await this.sweepConfirmedPayments(); + if (summary.candidates > 0) { + this.logger.log(`Payment credit sweep: ${JSON.stringify(summary)}`); + } + } + + /** + * Attaches a revenue split config to a Payment so the amount is + * distributed across its recipients once the payment confirms. Allowed + * before OR after confirmation, as long as it has not been applied yet + * — attaching to an already-applied payment would silently do nothing, + * so it is refused instead. + */ + async attachSplitConfig( + paymentId: string, + splitConfigId: string, + ): Promise { + const payment = await this.getPayment(paymentId); + const config = await this.splits.getConfig(splitConfigId); + this.splits.assertUsableForPayment(config); + + const existing = await this.applicationRepository.findOne({ + where: { paymentId }, + }); + if (existing?.appliedAt) { + throw new UnprocessableEntityException( + `Payment ${paymentId} has already been applied to the credit ledger`, + ); + } + + const application = + existing ?? + this.applicationRepository.create({ + paymentId: payment.id, + kind: PaymentCreditApplicationKind.REVENUE_SPLIT, + }); + application.kind = PaymentCreditApplicationKind.REVENUE_SPLIT; + application.splitConfigId = config.id; + return this.applicationRepository.save(application); + } + + /** + * Explicitly marks a Payment as funding the payer's credit balance — + * the alternative to the `metadata.purpose` convention, for a caller + * that could not set metadata at initiation time. + */ + async markAsTopUp(paymentId: string): Promise { + const payment = await this.getPayment(paymentId); + const existing = await this.applicationRepository.findOne({ + where: { paymentId }, + }); + if (existing?.appliedAt) { + throw new UnprocessableEntityException( + `Payment ${paymentId} has already been applied to the credit ledger`, + ); + } + + const application = + existing ?? + this.applicationRepository.create({ + paymentId: payment.id, + kind: PaymentCreditApplicationKind.TOP_UP, + }); + application.kind = PaymentCreditApplicationKind.TOP_UP; + application.splitConfigId = null; + return this.applicationRepository.save(application); + } + + async getApplication( + paymentId: string, + ): Promise { + return this.applicationRepository.findOne({ where: { paymentId } }); + } + + /** + * Applies one CONFIRMED payment's ledger effect. Called by the sweep and + * by the synchronous endpoint the checkout-return flow can hit so a + * top-up is spendable immediately rather than at the next cron tick. + */ + async applyPayment(paymentId: string): Promise { + const payment = await this.getPayment(paymentId); + if (payment.status !== PaymentStatus.CONFIRMED) { + throw new UnprocessableEntityException( + `Payment ${paymentId} is ${payment.status}; only a CONFIRMED payment ` + + 'can be applied to the credit ledger', + ); + } + + const application = await this.resolveApplication(payment); + if (application.appliedAt) { + return application; + } + + try { + const transactionId = await this.postEffect(payment, application); + application.ledgerTransactionId = transactionId; + application.appliedAt = new Date(); + application.lastError = null; + return await this.applicationRepository.save(application); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + application.lastError = message; + await this.applicationRepository.save(application); + throw error; + } + } + + /** + * Finds CONFIRMED payments with an unapplied credit effect and applies + * them. Bounded per pass, and the "not yet applied" filter is part of + * the SQL — so a backlog drains over successive passes instead of the + * same rows being re-examined forever. + */ + async sweepConfirmedPayments(): Promise { + const max = this.config.get('CREDITS_PAYMENT_SWEEP_MAX_BATCH', 200); + const candidates = await this.paymentRepository + .createQueryBuilder('payment') + .leftJoin( + PaymentCreditApplication, + 'application', + 'application.payment_id = payment.id', + ) + .where('payment.status = :status', { status: PaymentStatus.CONFIRMED }) + .andWhere('application.applied_at IS NULL') + .andWhere( + `(payment.metadata ->> 'purpose' = :purpose OR ` + + `application.split_config_id IS NOT NULL OR ` + + `application.kind IS NOT NULL)`, + { purpose: CREDIT_TOP_UP_PURPOSE }, + ) + .orderBy('payment.updated_at', 'ASC') + .take(max) + .getMany(); + + const summary: PaymentSweepSummary = { + candidates: candidates.length, + applied: 0, + skipped: 0, + failed: 0, + }; + + for (const payment of candidates) { + try { + const application = await this.applyPayment(payment.id); + if (application.appliedAt) { + summary.applied++; + } else { + summary.skipped++; + } + } catch (error) { + summary.failed++; + this.logger.error( + `Could not apply payment ${payment.id} to the credit ledger: ` + + (error instanceof Error ? error.message : String(error)), + ); + } + } + + return summary; + } + + // ── internals ────────────────────────────────────────────────────────── + + private async postEffect( + payment: Payment, + application: PaymentCreditApplication, + ): Promise { + if (application.kind === PaymentCreditApplicationKind.TOP_UP) { + const { transaction } = await this.credits.topUpFromPayment({ + paymentId: payment.id, + userId: payment.userId, + amount: payment.amount, + currency: payment.currency, + }); + return transaction.id; + } + + if (!application.splitConfigId) { + throw new UnprocessableEntityException( + `Payment ${payment.id} is marked for a revenue split but has no ` + + 'split config attached', + ); + } + const { transaction } = await this.splits.distributePayment({ + paymentId: payment.id, + configId: application.splitConfigId, + amount: payment.amount, + currency: payment.currency, + }); + return transaction.id; + } + + /** + * Resolves what to do with a payment: an explicit application row wins, + * otherwise the `metadata.purpose` convention creates a TOP_UP row. A + * payment with neither has no credit-ledger effect at all, which is not + * an error to sweep past but IS an error to ask for by id. + */ + private async resolveApplication( + payment: Payment, + ): Promise { + const existing = await this.applicationRepository.findOne({ + where: { paymentId: payment.id }, + }); + if (existing) { + return existing; + } + + const purpose = (payment.metadata ?? {})['purpose']; + if (purpose !== CREDIT_TOP_UP_PURPOSE) { + throw new UnprocessableEntityException( + `Payment ${payment.id} has no credit-ledger effect: it is not marked ` + + `as a ${CREDIT_TOP_UP_PURPOSE} and has no revenue split attached`, + ); + } + + return this.applicationRepository.save( + this.applicationRepository.create({ + paymentId: payment.id, + kind: PaymentCreditApplicationKind.TOP_UP, + }), + ); + } + + private async getPayment(paymentId: string): Promise { + const payment = await this.paymentRepository.findOne({ + where: { id: paymentId }, + }); + if (!payment) { + throw new NotFoundException(`Payment ${paymentId} not found`); + } + return payment; + } +} diff --git a/backend/src/credits/revenue-split.service.spec.ts b/backend/src/credits/revenue-split.service.spec.ts new file mode 100644 index 00000000..236d5a0f --- /dev/null +++ b/backend/src/credits/revenue-split.service.spec.ts @@ -0,0 +1,371 @@ +import { + BadRequestException, + ConflictException, + UnprocessableEntityException, +} from '@nestjs/common'; +import { CreditsService } from './credits.service'; +import { LedgerService } from './ledger.service'; +import { RevenueSplitService } from './revenue-split.service'; +import { LedgerAccountKind } from './enums/ledger-account-kind.enum'; +import { LedgerEntryDirection } from './enums/ledger-entry-direction.enum'; +import { LedgerTransactionKind } from './enums/ledger-transaction-kind.enum'; +import { + createLedgerHarness, + fakeConfigService, +} from './testing/in-memory-ledger'; + +async function build() { + const harness = createLedgerHarness(); + const ledger = new LedgerService( + harness.accounts as any, + harness.transactions as any, + harness.entries as any, + ); + const credits = new CreditsService( + ledger, + fakeConfigService({ CREDITS_DEFAULT_CURRENCY: 'USD' }), + ); + const splits = new RevenueSplitService( + harness.splitConfigs as any, + harness.splitRecipients as any, + ledger, + ); + + const platform = await credits.getSystemAccount( + LedgerAccountKind.PLATFORM_FEE, + ); + const operator = await credits.getPayableAccount( + LedgerAccountKind.HUB_OPERATOR, + 'hub-1', + 'USD', + 'GOPERATOR', + ); + const referrer = await credits.getPayableAccount( + LedgerAccountKind.REFERRAL, + 'referrer-1', + 'USD', + 'GREFERRER', + ); + + return { harness, ledger, credits, splits, platform, operator, referrer }; +} + +describe('RevenueSplitService', () => { + describe('configuration-time validation', () => { + it('accepts a config whose shares sum to exactly 10000', async () => { + const { splits, platform, operator } = await build(); + const config = await splits.createConfig({ + name: 'standard', + recipients: [ + { label: 'platform fee', basisPoints: 1500, accountId: platform.id }, + { label: 'hub operator', basisPoints: 8500, accountId: operator.id }, + ], + }); + + expect(config.recipients).toHaveLength(2); + expect(config.recipients.reduce((sum, r) => sum + r.basisPoints, 0)).toBe( + 10000, + ); + }); + + /** + * The edge case the issue calls out by name: a config error must be a + * 400 on the request that introduced it, never something a settlement + * run discovers halfway through distributing money. + */ + it('rejects shares that do not sum to 10000, at configuration time', async () => { + const { splits, platform, operator } = await build(); + await expect( + splits.createConfig({ + name: 'short', + recipients: [ + { + label: 'platform fee', + basisPoints: 1500, + accountId: platform.id, + }, + { + label: 'hub operator', + basisPoints: 8000, + accountId: operator.id, + }, + ], + }), + ).rejects.toThrow(BadRequestException); + + await expect( + splits.createConfig({ + name: 'over', + recipients: [ + { + label: 'platform fee', + basisPoints: 5000, + accountId: platform.id, + }, + { + label: 'hub operator', + basisPoints: 6000, + accountId: operator.id, + }, + ], + }), + ).rejects.toThrow(/must sum to 10000/); + }); + + it('rejects a recipient that is neither internal nor external', async () => { + const { splits } = await build(); + await expect( + splits.createConfig({ + name: 'targetless', + recipients: [{ label: 'nowhere', basisPoints: 10000 }], + }), + ).rejects.toThrow(/exactly one of accountId/); + }); + + it('rejects a recipient that is both internal and external', async () => { + const { splits, platform } = await build(); + await expect( + splits.createConfig({ + name: 'both', + recipients: [ + { + label: 'ambiguous', + basisPoints: 10000, + accountId: platform.id, + externalAddress: 'GSOMEWHERE', + }, + ], + }), + ).rejects.toThrow(/exactly one of accountId/); + }); + + it('rejects a recipient pointing at an account that does not exist', async () => { + const { splits } = await build(); + await expect( + splits.createConfig({ + name: 'ghost-account', + recipients: [ + { + label: 'ghost', + basisPoints: 10000, + accountId: '00000000-0000-0000-0000-000000000000', + }, + ], + }), + ).rejects.toThrow(/not found/); + }); + + it('rejects a duplicate config name', async () => { + const { splits, platform } = await build(); + const recipients = [ + { label: 'platform fee', basisPoints: 10000, accountId: platform.id }, + ]; + await splits.createConfig({ name: 'dupe', recipients }); + await expect( + splits.createConfig({ name: 'dupe', recipients }), + ).rejects.toThrow(ConflictException); + }); + + it('validates replacement recipients as a set', async () => { + const { splits, platform, operator } = await build(); + const config = await splits.createConfig({ + name: 'replaceable', + recipients: [ + { label: 'platform fee', basisPoints: 10000, accountId: platform.id }, + ], + }); + + await expect( + splits.replaceRecipients(config.id, [ + { label: 'platform fee', basisPoints: 2000, accountId: platform.id }, + ]), + ).rejects.toThrow(BadRequestException); + + const updated = await splits.replaceRecipients(config.id, [ + { label: 'platform fee', basisPoints: 2000, accountId: platform.id }, + { label: 'hub operator', basisPoints: 8000, accountId: operator.id }, + ]); + expect(updated.recipients).toHaveLength(2); + }); + }); + + describe('computation', () => { + it('allocates an amount across recipients with nothing lost', async () => { + const { splits, platform, operator, referrer } = await build(); + const config = await splits.createConfig({ + name: 'three-way', + recipients: [ + { label: 'platform fee', basisPoints: 3333, accountId: platform.id }, + { label: 'hub operator', basisPoints: 3333, accountId: operator.id }, + { label: 'referral', basisPoints: 3334, accountId: referrer.id }, + ], + }); + + for (const amount of [0, 1, 7, 1000, 99_999]) { + const shares = await splits.computeForAmount(config.id, amount); + expect(shares.reduce((sum, share) => sum + share.amount, 0)).toBe( + amount, + ); + } + }); + + it('refuses to compute with an inactive config', async () => { + const { splits, platform } = await build(); + const config = await splits.createConfig({ + name: 'retired', + recipients: [ + { label: 'platform fee', basisPoints: 10000, accountId: platform.id }, + ], + }); + await splits.setActive(config.id, false); + + await expect(splits.computeForAmount(config.id, 1000)).rejects.toThrow( + UnprocessableEntityException, + ); + }); + }); + + describe('distributing a confirmed payment', () => { + it('debits treasury and credits every recipient, balancing exactly', async () => { + const { splits, credits, harness, platform, operator } = await build(); + const config = await splits.createConfig({ + name: 'payment-split', + recipients: [ + { label: 'platform fee', basisPoints: 1500, accountId: platform.id }, + { label: 'hub operator', basisPoints: 8500, accountId: operator.id }, + ], + }); + + const { transaction, shares } = await splits.distributePayment({ + paymentId: 'payment-1', + configId: config.id, + amount: 10_001, + currency: 'USD', + }); + + expect(transaction.kind).toBe(LedgerTransactionKind.REVENUE_SPLIT); + expect(shares.reduce((sum, share) => sum + share.amount, 0)).toBe(10_001); + + const treasury = await credits.getSystemAccount( + LedgerAccountKind.TREASURY, + ); + expect(harness.balanceOf(treasury.id)).toBe(-10_001); + expect( + harness.balanceOf(platform.id) + harness.balanceOf(operator.id), + ).toBe(10_001); + + const legs = harness.entries.rows.filter( + (entry) => entry.transactionId === transaction.id, + ); + const debits = legs + .filter((leg) => leg.direction === LedgerEntryDirection.DEBIT) + .reduce((sum, leg) => sum + leg.amount, 0); + const creditsTotal = legs + .filter((leg) => leg.direction === LedgerEntryDirection.CREDIT) + .reduce((sum, leg) => sum + leg.amount, 0); + expect(debits).toBe(creditsTotal); + }); + + it('distributes a payment only once', async () => { + const { splits, platform } = await build(); + const config = await splits.createConfig({ + name: 'once', + recipients: [ + { label: 'platform fee', basisPoints: 10000, accountId: platform.id }, + ], + }); + + const first = await splits.distributePayment({ + paymentId: 'payment-2', + configId: config.id, + amount: 500, + currency: 'USD', + }); + const second = await splits.distributePayment({ + paymentId: 'payment-2', + configId: config.id, + amount: 500, + currency: 'USD', + }); + + expect(first.posted).toBe(true); + expect(second.posted).toBe(false); + expect(second.transaction.id).toBe(first.transaction.id); + }); + + /** + * A share that rounds to zero must post no leg at all — a zero-amount + * entry would be refused by the ledger, and the remaining legs still + * add up to the full amount. + */ + it('omits zero shares while still balancing to the full amount', async () => { + const { splits, harness, platform, operator, referrer } = await build(); + const config = await splits.createConfig({ + name: 'tiny', + recipients: [ + { label: 'platform fee', basisPoints: 9998, accountId: platform.id }, + { label: 'hub operator', basisPoints: 1, accountId: operator.id }, + { label: 'referral', basisPoints: 1, accountId: referrer.id }, + ], + }); + + const { transaction, shares } = await splits.distributePayment({ + paymentId: 'payment-3', + configId: config.id, + amount: 1, + currency: 'USD', + }); + + expect(shares.reduce((sum, share) => sum + share.amount, 0)).toBe(1); + const legs = harness.entries.rows.filter( + (entry) => entry.transactionId === transaction.id, + ); + expect(legs).toHaveLength(2); + expect(legs.every((leg) => leg.amount > 0)).toBe(true); + }); + + it('refuses a config with external-address recipients', async () => { + const { splits, platform } = await build(); + const config = await splits.createConfig({ + name: 'external-heavy', + recipients: [ + { label: 'platform fee', basisPoints: 5000, accountId: platform.id }, + { + label: 'partner', + basisPoints: 5000, + externalAddress: 'GPARTNERADDRESS', + }, + ], + }); + + await expect( + splits.distributePayment({ + paymentId: 'payment-4', + configId: config.id, + amount: 1000, + currency: 'USD', + }), + ).rejects.toThrow(/payment split cannot post internally/); + }); + + it('refuses an inactive config', async () => { + const { splits, platform } = await build(); + const config = await splits.createConfig({ + name: 'inactive-payment-split', + recipients: [ + { label: 'platform fee', basisPoints: 10000, accountId: platform.id }, + ], + }); + await splits.setActive(config.id, false); + + await expect( + splits.distributePayment({ + paymentId: 'payment-5', + configId: config.id, + amount: 1000, + currency: 'USD', + }), + ).rejects.toThrow(/inactive/); + }); + }); +}); diff --git a/backend/src/credits/revenue-split.service.ts b/backend/src/credits/revenue-split.service.ts new file mode 100644 index 00000000..c0326e2d --- /dev/null +++ b/backend/src/credits/revenue-split.service.ts @@ -0,0 +1,402 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, + UnprocessableEntityException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { EntityManager, Repository } from 'typeorm'; +import { LedgerAccount } from './entities/ledger-account.entity'; +import { LedgerTransaction } from './entities/ledger-transaction.entity'; +import { RevenueSplitConfig } from './entities/revenue-split-config.entity'; +import { RevenueSplitRecipient } from './entities/revenue-split-recipient.entity'; +import { LedgerEntryDirection } from './enums/ledger-entry-direction.enum'; +import { LedgerTransactionKind } from './enums/ledger-transaction-kind.enum'; +import { LedgerAccountKind } from './enums/ledger-account-kind.enum'; +import { LedgerService } from './ledger.service'; +import { + allocateByBasisPoints, + assertBasisPointsSumToTotal, + SplitAllocationError, +} from './split-allocation'; + +const POSTGRES_UNIQUE_VIOLATION = '23505'; + +export interface RevenueSplitRecipientInput { + label: string; + basisPoints: number; + /** Internal recipient — a ledger account credited with this share. */ + accountId?: string | null; + /** External recipient — an address a settlement batch pays on-chain. */ + externalAddress?: string | null; + sortOrder?: number; +} + +export interface ComputedSplitShare { + recipient: RevenueSplitRecipient; + amount: number; + remainderUnits: number; +} + +/** + * Configuration and computation of multi-party revenue distribution + * (issue #1575). + * + * Two hard rules, both enforced here rather than discovered later: + * + * 1. **Basis points must sum to exactly 10000 at configuration time.** A + * config that could not distribute 100% of an amount is rejected when + * it is created or edited, so a settlement run never has to decide + * what to do with a 97%-complete split. + * 2. **Rounding never loses or duplicates value.** Allocation goes + * through the largest-remainder method in `split-allocation.ts`, whose + * output is guaranteed to sum to exactly the input amount — which is + * also precisely what lets a split be posted as balanced double-entry + * legs, since an unbalanced set of legs is refused by LedgerService. + */ +@Injectable() +export class RevenueSplitService { + constructor( + @InjectRepository(RevenueSplitConfig) + private readonly configRepository: Repository, + @InjectRepository(RevenueSplitRecipient) + private readonly recipientRepository: Repository, + private readonly ledger: LedgerService, + ) {} + + async createConfig(input: { + name: string; + description?: string | null; + recipients: RevenueSplitRecipientInput[]; + }): Promise { + if (!input.name?.trim()) { + throw new BadRequestException('A revenue split config needs a name'); + } + await this.assertRecipientsValid(input.recipients); + + try { + return await this.configRepository.manager.transaction( + async (manager) => { + const config = await manager.getRepository(RevenueSplitConfig).save( + manager.getRepository(RevenueSplitConfig).create({ + name: input.name.trim(), + description: input.description ?? null, + active: true, + }), + ); + await this.insertRecipients(manager, config.id, input.recipients); + return this.getConfig(config.id); + }, + ); + } catch (error) { + if (this.isUniqueViolation(error)) { + throw new ConflictException( + `A revenue split config named "${input.name.trim()}" already exists`, + ); + } + throw error; + } + } + + /** + * Replaces a config's recipients wholesale. Validated as a set, because + * "sums to 10000" is a property of the set — there is no valid way to + * edit one recipient's share in isolation. + */ + async replaceRecipients( + configId: string, + recipients: RevenueSplitRecipientInput[], + ): Promise { + await this.getConfig(configId); + await this.assertRecipientsValid(recipients); + + return this.configRepository.manager.transaction(async (manager) => { + await manager.getRepository(RevenueSplitRecipient).delete({ configId }); + await this.insertRecipients(manager, configId, recipients); + return this.getConfig(configId); + }); + } + + async setActive( + configId: string, + active: boolean, + ): Promise { + const config = await this.getConfig(configId); + config.active = active; + await this.configRepository.save(config); + return this.getConfig(configId); + } + + async listConfigs(): Promise { + return this.configRepository.find({ + relations: { recipients: true }, + order: { createdAt: 'DESC' }, + }); + } + + async getConfig(configId: string): Promise { + const config = await this.configRepository.findOne({ + where: { id: configId }, + relations: { recipients: true }, + }); + if (!config) { + throw new NotFoundException(`Revenue split config ${configId} not found`); + } + config.recipients = this.sortRecipients(config.recipients ?? []); + return config; + } + + async findConfigByName(name: string): Promise { + const config = await this.configRepository.findOne({ + where: { name }, + relations: { recipients: true }, + }); + if (config) { + config.recipients = this.sortRecipients(config.recipients ?? []); + } + return config; + } + + /** + * Applies a config to an amount. Re-validates the basis points even + * though creation already did — cheap, and it means a config mutated by + * anything that bypassed this service fails loudly here instead of + * quietly distributing the wrong total. + */ + async computeForAmount( + configId: string, + amount: number, + ): Promise { + const config = await this.getConfig(configId); + if (!config.active) { + throw new UnprocessableEntityException( + `Revenue split config "${config.name}" is inactive`, + ); + } + return this.compute(config, amount); + } + + /** Same as computeForAmount, for an already-loaded config. */ + compute(config: RevenueSplitConfig, amount: number): ComputedSplitShare[] { + const recipients = this.sortRecipients(config.recipients ?? []); + try { + const allocations = allocateByBasisPoints( + amount, + recipients.map((recipient) => ({ + key: recipient.id, + basisPoints: recipient.basisPoints, + sortOrder: recipient.sortOrder, + })), + ); + return allocations.map((allocation, index) => ({ + recipient: recipients[index], + amount: allocation.amount, + remainderUnits: allocation.remainderUnits, + })); + } catch (error) { + if (error instanceof SplitAllocationError) { + throw new UnprocessableEntityException( + `Revenue split config "${config.name}" cannot distribute ${amount}: ` + + error.message, + ); + } + throw error; + } + } + + /** + * Distributes a confirmed payment's amount across a config as ledger + * entries (issue #1575's "usable by ordinary #1570 payments" leg). + * TREASURY is the debited counterparty: the money arrived from outside + * the platform over a payment rail, and the split decides who inside + * the platform is now owed it. + * + * Deliberately internal-only. A bare external-address recipient is + * refused for a payment split (see assertUsableForPayment) — moving + * value off-platform is the settlement batch's job, so an operator's + * share lands in their payable ledger account and leaves in one netted + * on-chain transfer instead of one per payment. + */ + async distributePayment(input: { + paymentId: string; + configId: string; + amount: number; + currency: string; + manager?: EntityManager; + }): Promise<{ + transaction: LedgerTransaction; + posted: boolean; + shares: ComputedSplitShare[]; + }> { + const config = await this.getConfig(input.configId); + this.assertUsableForPayment(config); + if (!config.active) { + throw new UnprocessableEntityException( + `Revenue split config "${config.name}" is inactive`, + ); + } + + const shares = this.compute(config, input.amount); + const currency = input.currency.toUpperCase(); + const treasury = await this.ledger.getOrCreateAccount( + { + kind: LedgerAccountKind.TREASURY, + ownerId: null, + currency, + label: 'treasury', + }, + input.manager, + ); + + const { transaction, posted } = await this.ledger.post( + { + reference: `payment-split:${input.paymentId}`, + kind: LedgerTransactionKind.REVENUE_SPLIT, + currency, + description: `Revenue split "${config.name}" over payment ${input.paymentId}`, + metadata: { + paymentId: input.paymentId, + configId: config.id, + configName: config.name, + }, + legs: [ + { + accountId: treasury.id, + direction: LedgerEntryDirection.DEBIT, + amount: input.amount, + }, + // A zero share (a tiny amount over many recipients) posts no + // leg at all rather than a zero-amount entry; the remaining + // legs still sum to the full amount, so the transaction + // balances. + ...shares + .filter((share) => share.amount > 0) + .map((share) => ({ + accountId: share.recipient.accountId!, + direction: LedgerEntryDirection.CREDIT, + amount: share.amount, + })), + ], + }, + input.manager, + ); + + return { transaction, posted, shares }; + } + + /** + * A config attached to a Payment must distribute entirely into ledger + * accounts. Checked when the config is attached AND again when it is + * applied, so an operator cannot make a payment silently unsplittable + * by editing the config in between. + */ + assertUsableForPayment(config: RevenueSplitConfig): void { + const external = (config.recipients ?? []).filter( + (recipient) => !recipient.accountId, + ); + if (external.length > 0) { + throw new UnprocessableEntityException( + `Revenue split config "${config.name}" has external-address ` + + `recipients (${external.map((r) => r.label).join(', ')}), which a ` + + 'payment split cannot post internally — give those recipients a ' + + 'payable ledger account instead, and let a settlement batch pay ' + + 'them off-platform.', + ); + } + } + + // ── internals ────────────────────────────────────────────────────────── + + private async insertRecipients( + manager: EntityManager, + configId: string, + recipients: RevenueSplitRecipientInput[], + ): Promise { + const repository = manager.getRepository(RevenueSplitRecipient); + await repository.save( + recipients.map((recipient, index) => + repository.create({ + configId, + label: recipient.label.trim(), + basisPoints: recipient.basisPoints, + accountId: recipient.accountId ?? null, + externalAddress: recipient.externalAddress ?? null, + sortOrder: recipient.sortOrder ?? index, + }), + ), + ); + } + + private async assertRecipientsValid( + recipients: RevenueSplitRecipientInput[], + ): Promise { + if (!Array.isArray(recipients) || recipients.length === 0) { + throw new BadRequestException( + 'A revenue split config needs at least one recipient', + ); + } + + for (const recipient of recipients) { + if (!recipient.label?.trim()) { + throw new BadRequestException('Every split recipient needs a label'); + } + const hasAccount = Boolean(recipient.accountId); + const hasAddress = Boolean(recipient.externalAddress); + if (hasAccount === hasAddress) { + throw new BadRequestException( + `Split recipient "${recipient.label}" must have exactly one of ` + + 'accountId (internal) or externalAddress (on-chain payout)', + ); + } + } + + // The whole point of validating here: a config whose shares do not add + // up to 100% is an operator error, and it becomes a 400 on the request + // that introduced it rather than a half-distributed settlement run. + try { + assertBasisPointsSumToTotal( + recipients.map((recipient) => ({ + key: recipient.label, + basisPoints: recipient.basisPoints, + sortOrder: recipient.sortOrder, + })), + ); + } catch (error) { + if (error instanceof SplitAllocationError) { + throw new BadRequestException(error.message); + } + throw error; + } + + for (const recipient of recipients) { + if (recipient.accountId) { + // Fails now, loudly, rather than at settlement time against a + // ledger account that never existed. + await this.assertAccountExists(recipient.accountId); + } + } + } + + private async assertAccountExists(accountId: string): Promise { + const found = await this.recipientRepository.manager + .getRepository(LedgerAccount) + .count({ where: { id: accountId } }); + if (found === 0) { + throw new BadRequestException(`Ledger account ${accountId} not found`); + } + } + + private sortRecipients( + recipients: RevenueSplitRecipient[], + ): RevenueSplitRecipient[] { + return [...recipients].sort( + (a, b) => a.sortOrder - b.sortOrder || a.label.localeCompare(b.label), + ); + } + + private isUniqueViolation(error: unknown): boolean { + const code = (error as any)?.code ?? (error as any)?.driverError?.code; + return code === POSTGRES_UNIQUE_VIOLATION; + } +} diff --git a/backend/src/credits/settlement.service.spec.ts b/backend/src/credits/settlement.service.spec.ts new file mode 100644 index 00000000..11bddd0b --- /dev/null +++ b/backend/src/credits/settlement.service.spec.ts @@ -0,0 +1,645 @@ +import { CreditsService } from './credits.service'; +import { LedgerService } from './ledger.service'; +import { RevenueSplitService } from './revenue-split.service'; +import { SettlementService } from './settlement.service'; +import { LedgerAccountKind } from './enums/ledger-account-kind.enum'; +import { LedgerEntryDirection } from './enums/ledger-entry-direction.enum'; +import { LedgerTransactionKind } from './enums/ledger-transaction-kind.enum'; +import { SettlementBatchMode } from './enums/settlement-batch-mode.enum'; +import { SettlementBatchStatus } from './enums/settlement-batch-status.enum'; +import { SettlementPayoutStatus } from './enums/settlement-payout-status.enum'; +import { + PayoutStatus, + SubmitPayoutInput, +} from './interfaces/external-payout-rail.interface'; +import { + createLedgerHarness, + fakeConfigService, +} from './testing/in-memory-ledger'; + +/** + * A rail whose reported status is under the test's control — the point + * being that submitting and confirming are separate events, and + * settlement must only ever believe the second one. + */ +function fakeRail() { + const submissions: SubmitPayoutInput[] = []; + let status: PayoutStatus = 'pending'; + let submitError: Error | null = null; + let statusError: Error | null = null; + + return { + submissions, + setStatus(next: PayoutStatus) { + status = next; + }, + failSubmitWith(error: Error | null) { + submitError = error; + }, + failStatusWith(error: Error | null) { + statusError = error; + }, + submitPayout: jest.fn(async (input: SubmitPayoutInput) => { + if (submitError) { + throw submitError; + } + submissions.push(input); + return { reference: `chain-ref:${input.idempotencyKey}` }; + }), + getPayoutStatus: jest.fn(async () => { + if (statusError) { + throw statusError; + } + return status; + }), + }; +} + +/** + * `railOverride: null` builds a service with NO payout rail — note that it + * has to be an explicit null, since passing `undefined` would fall back to + * the default parameter. + */ +function build( + config: Record = {}, + railOverride?: ReturnType | null, +) { + const rail = railOverride === undefined ? fakeRail() : railOverride; + const harness = createLedgerHarness(); + const ledger = new LedgerService( + harness.accounts as any, + harness.transactions as any, + harness.entries as any, + ); + const credits = new CreditsService( + ledger, + fakeConfigService({ CREDITS_DEFAULT_CURRENCY: 'USD' }), + ); + const splits = new RevenueSplitService( + harness.splitConfigs as any, + harness.splitRecipients as any, + ledger, + ); + const settlement = new SettlementService( + harness.batches as any, + harness.payouts as any, + harness.accounts as any, + harness.entries as any, + ledger, + splits, + fakeConfigService({ + CREDITS_SETTLEMENT_MIN_PAYOUT: 1, + CREDITS_SETTLEMENT_MAX_PAYOUT_ATTEMPTS: 3, + ...config, + }), + rail as any, + ); + + async function credit(accountId: string, amount: number, reference: string) { + const treasury = await credits.getSystemAccount( + LedgerAccountKind.TREASURY, + 'USD', + ); + await ledger.post({ + reference, + kind: LedgerTransactionKind.ADJUSTMENT, + currency: 'USD', + legs: [ + { + accountId: treasury.id, + direction: LedgerEntryDirection.DEBIT, + amount, + }, + { accountId, direction: LedgerEntryDirection.CREDIT, amount }, + ], + }); + } + + return { harness, ledger, credits, splits, settlement, rail, credit }; +} + +async function payableOperator(credits: CreditsService, hubId = 'hub-1') { + return credits.getPayableAccount( + LedgerAccountKind.HUB_OPERATOR, + hubId, + 'USD', + 'GOPERATORADDRESS', + ); +} + +describe('SettlementService — NET_PAYABLE batches', () => { + it('nets an account’s balance into a single pending payout', async () => { + const { credits, settlement, harness, credit } = build(); + const operator = await payableOperator(credits); + await credit(operator.id, 400, 'movement-1'); + await credit(operator.id, 600, 'movement-2'); + + const batch = await settlement.createNetPayableBatch('USD'); + + expect(batch).not.toBeNull(); + expect(batch!.mode).toBe(SettlementBatchMode.NET_PAYABLE); + expect(batch!.totalAmount).toBe(1000); + // Two micro-movements in, one on-chain transfer out — the whole point. + expect(batch!.claimedEntryCount).toBe(2); + + const payouts = await harness.payouts.find({ + where: { batchId: batch!.id }, + }); + expect(payouts).toHaveLength(1); + expect(payouts[0]).toMatchObject({ + amount: 1000, + externalAddress: 'GOPERATORADDRESS', + status: SettlementPayoutStatus.PENDING, + }); + }); + + it('creates nothing when there is no payable balance', async () => { + const { credits, settlement } = build(); + await payableOperator(credits); + expect(await settlement.createNetPayableBatch('USD')).toBeNull(); + }); + + it('ignores an account with no payout address — nothing to move off-platform', async () => { + const { credits, settlement, credit } = build(); + const internalOnly = await credits.getSystemAccount( + LedgerAccountKind.PLATFORM_FEE, + 'USD', + ); + await credit(internalOnly.id, 5000, 'movement-1'); + expect(await settlement.createNetPayableBatch('USD')).toBeNull(); + }); + + it('skips a frozen account', async () => { + const { credits, ledger, settlement, credit } = build(); + const operator = await payableOperator(credits); + await credit(operator.id, 1000, 'movement-1'); + await ledger.updateAccountPolicy(operator.id, { frozen: true }); + + expect(await settlement.createNetPayableBatch('USD')).toBeNull(); + }); + + /** + * The in-flight guard. Amounts are derived from the account balance, so + * a second batch created while the first payout is still unresolved + * would commit the same balance twice. + */ + it('refuses a second batch while an account has an in-flight payout', async () => { + const { credits, settlement, credit } = build(); + const operator = await payableOperator(credits); + await credit(operator.id, 1000, 'movement-1'); + + expect(await settlement.createNetPayableBatch('USD')).not.toBeNull(); + expect(await settlement.createNetPayableBatch('USD')).toBeNull(); + }); +}); + +describe('SettlementService — executing a batch', () => { + it('submits, then only settles once the rail confirms from fresh state', async () => { + const { credits, settlement, harness, rail, credit } = build(); + const operator = await payableOperator(credits); + await credit(operator.id, 1000, 'movement-1'); + const batch = await settlement.createNetPayableBatch('USD'); + + // Pass one: submitted, but the rail still says pending. + const afterSubmit = await settlement.executeBatch(batch!.id); + expect(rail!.submitPayout).toHaveBeenCalledTimes(1); + expect(afterSubmit.status).toBe(SettlementBatchStatus.IN_PROGRESS); + + let payout = ( + await harness.payouts.find({ where: { batchId: batch!.id } }) + )[0]; + expect(payout.status).toBe(SettlementPayoutStatus.SUBMITTED); + expect(payout.onChainReference).toBe(`chain-ref:${payout.idempotencyKey}`); + // Crucially: nothing settled, and the balance is still shown as owed. + expect(harness.balanceOf(operator.id)).toBe(1000); + expect( + harness.entries.rows.filter( + (entry) => entry.settlementBatchId === batch!.id && entry.settledAt, + ), + ).toHaveLength(0); + + // Pass two: the rail confirms. + rail!.setStatus('confirmed'); + const settled = await settlement.executeBatch(batch!.id); + + expect(settled.status).toBe(SettlementBatchStatus.SETTLED); + payout = (await harness.payouts.find({ where: { batchId: batch!.id } }))[0]; + expect(payout.status).toBe(SettlementPayoutStatus.CONFIRMED); + expect(payout.ledgerTransactionId).toBeTruthy(); + + // The drawdown is posted, treasury is the counterparty, and every + // claimed entry now carries a settled marker. + expect(harness.balanceOf(operator.id)).toBe(0); + const treasury = await credits.getSystemAccount(LedgerAccountKind.TREASURY); + expect(harness.balanceOf(treasury.id)).toBe(0); + expect( + harness.entries.rows.filter( + (entry) => entry.settlementBatchId === batch!.id && !entry.settledAt, + ), + ).toHaveLength(0); + }); + + it('leaves the ledger untouched when the on-chain leg fails', async () => { + const { credits, settlement, harness, rail, credit } = build({ + CREDITS_SETTLEMENT_MAX_PAYOUT_ATTEMPTS: 1, + }); + const operator = await payableOperator(credits); + await credit(operator.id, 1000, 'movement-1'); + const batch = await settlement.createNetPayableBatch('USD'); + + rail!.failSubmitWith(new Error('escrow simulation failed')); + const result = await settlement.executeBatch(batch!.id); + + expect(result.status).toBe(SettlementBatchStatus.FAILED); + const payout = ( + await harness.payouts.find({ where: { batchId: batch!.id } }) + )[0]; + expect(payout.status).toBe(SettlementPayoutStatus.FAILED); + expect(payout.lastError).toMatch(/escrow simulation failed/); + + // Never "assume success": the balance is still owed and no entry is + // marked settled. + expect(harness.balanceOf(operator.id)).toBe(1000); + expect( + harness.entries.rows.filter((entry) => entry.settledAt), + ).toHaveLength(0); + }); + + it('keeps a payout retryable until its attempt budget is spent', async () => { + const { credits, settlement, harness, rail, credit } = build({ + CREDITS_SETTLEMENT_MAX_PAYOUT_ATTEMPTS: 3, + }); + const operator = await payableOperator(credits); + await credit(operator.id, 1000, 'movement-1'); + const batch = await settlement.createNetPayableBatch('USD'); + rail!.failSubmitWith(new Error('rpc unreachable')); + + await settlement.executeBatch(batch!.id); + let payout = ( + await harness.payouts.find({ where: { batchId: batch!.id } }) + )[0]; + expect(payout).toMatchObject({ + status: SettlementPayoutStatus.PENDING, + attempts: 1, + }); + + await settlement.executeBatch(batch!.id); + await settlement.executeBatch(batch!.id); + payout = (await harness.payouts.find({ where: { batchId: batch!.id } }))[0]; + expect(payout).toMatchObject({ + status: SettlementPayoutStatus.FAILED, + attempts: 3, + }); + }); + + it('treats an unreachable rail as indeterminate, not as failure', async () => { + const { credits, settlement, harness, rail, credit } = build(); + const operator = await payableOperator(credits); + await credit(operator.id, 1000, 'movement-1'); + const batch = await settlement.createNetPayableBatch('USD'); + await settlement.executeBatch(batch!.id); + + rail!.failStatusWith(new Error('rpc timeout')); + await settlement.executeBatch(batch!.id); + + const payout = ( + await harness.payouts.find({ where: { batchId: batch!.id } }) + )[0]; + expect(payout.status).toBe(SettlementPayoutStatus.SUBMITTED); + expect(harness.balanceOf(operator.id)).toBe(1000); + }); + + it('marks a payout failed when the rail reports the transfer failed', async () => { + const { credits, settlement, harness, rail, credit } = build(); + const operator = await payableOperator(credits); + await credit(operator.id, 1000, 'movement-1'); + const batch = await settlement.createNetPayableBatch('USD'); + await settlement.executeBatch(batch!.id); + + rail!.setStatus('failed'); + const result = await settlement.executeBatch(batch!.id); + + expect(result.status).toBe(SettlementBatchStatus.FAILED); + expect(harness.balanceOf(operator.id)).toBe(1000); + }); + + it('keeps payouts pending, and says so, when no rail is configured', async () => { + const { credits, settlement, harness, credit } = build({}, null); + const operator = await payableOperator(credits); + await credit(operator.id, 1000, 'movement-1'); + + const summary = await settlement.runSettlement(); + + expect(summary.payoutsAwaitingRail).toBeGreaterThan(0); + expect(summary.notes.join(' ')).toMatch(/no external payout rail/i); + expect(harness.payouts.rows[0].status).toBe(SettlementPayoutStatus.PENDING); + expect(harness.balanceOf(operator.id)).toBe(1000); + }); +}); + +/** + * Issue #1575's idempotency acceptance criterion: the batch job must be + * safe to re-run mid-failure without double-paying any recipient. + */ +describe('SettlementService — re-running a batch never double-pays', () => { + it('submits once however many times the batch is executed', async () => { + const { credits, settlement, rail, credit } = build(); + const operator = await payableOperator(credits); + await credit(operator.id, 1000, 'movement-1'); + const batch = await settlement.createNetPayableBatch('USD'); + + await settlement.executeBatch(batch!.id); + await settlement.executeBatch(batch!.id); + await settlement.executeBatch(batch!.id); + + expect(rail!.submitPayout).toHaveBeenCalledTimes(1); + }); + + it('posts the ledger drawdown once however many times it is confirmed', async () => { + const { credits, settlement, harness, rail, credit } = build(); + const operator = await payableOperator(credits); + await credit(operator.id, 1000, 'movement-1'); + const batch = await settlement.createNetPayableBatch('USD'); + await settlement.executeBatch(batch!.id); + + rail!.setStatus('confirmed'); + await settlement.executeBatch(batch!.id); + // A second (and third) confirmation, as a resumed run would produce. + await settlement.executeBatch(batch!.id); + await settlement.executeBatch(batch!.id); + + const settlementTransactions = harness.transactions.rows.filter( + (transaction) => transaction.kind === LedgerTransactionKind.SETTLEMENT, + ); + expect(settlementTransactions).toHaveLength(1); + expect(harness.balanceOf(operator.id)).toBe(0); + expect(harness.balanceOf(operator.id)).toBe( + harness.derivedBalanceOf(operator.id), + ); + }); + + /** + * The crash the issue describes: the transfer reached the rail but our + * record of it did not. Re-running re-submits with the SAME idempotency + * key, which is what the rail dedupes on — so the recipient is paid once + * even though we asked twice. + */ + it('re-submits with the same idempotency key after losing the submission record', async () => { + const { credits, settlement, harness, rail, credit } = build(); + const operator = await payableOperator(credits); + await credit(operator.id, 1000, 'movement-1'); + const batch = await settlement.createNetPayableBatch('USD'); + + await settlement.executeBatch(batch!.id); + // Simulate the crash: the payout row never recorded the submission. + await harness.payouts.update( + { batchId: batch!.id }, + { + status: SettlementPayoutStatus.PENDING, + onChainReference: null, + attempts: 0, + }, + ); + await settlement.executeBatch(batch!.id); + + expect(rail!.submissions).toHaveLength(2); + expect(rail!.submissions[0].idempotencyKey).toBe( + rail!.submissions[1].idempotencyKey, + ); + + rail!.setStatus('confirmed'); + await settlement.executeBatch(batch!.id); + expect( + harness.transactions.rows.filter( + (transaction) => transaction.kind === LedgerTransactionKind.SETTLEMENT, + ), + ).toHaveLength(1); + expect(harness.balanceOf(operator.id)).toBe(0); + }); + + it('retries only the failed payouts, reusing their keys', async () => { + const { credits, settlement, harness, rail, credit } = build({ + CREDITS_SETTLEMENT_MAX_PAYOUT_ATTEMPTS: 1, + }); + const operator = await payableOperator(credits); + await credit(operator.id, 1000, 'movement-1'); + const batch = await settlement.createNetPayableBatch('USD'); + + rail!.failSubmitWith(new Error('boom')); + await settlement.executeBatch(batch!.id); + const failedKey = harness.payouts.rows[0].idempotencyKey; + + rail!.failSubmitWith(null); + rail!.setStatus('confirmed'); + // A retry re-submits (one step per pass); the pass after that is what + // sees the confirmation and settles. + const retried = await settlement.retryBatch(batch!.id); + expect(retried.status).toBe(SettlementBatchStatus.IN_PROGRESS); + expect(await settlement.executeBatch(batch!.id)).toMatchObject({ + status: SettlementBatchStatus.SETTLED, + }); + + // The same key the failed attempt used — so a transfer that actually + // did land is deduped by the rail rather than repeated. + expect(rail!.submissions).toHaveLength(1); + expect(rail!.submissions[0].idempotencyKey).toBe(failedKey); + }); + + it('does nothing further once a batch is settled', async () => { + const { credits, settlement, harness, rail, credit } = build(); + const operator = await payableOperator(credits); + await credit(operator.id, 1000, 'movement-1'); + const batch = await settlement.createNetPayableBatch('USD'); + await settlement.executeBatch(batch!.id); + rail!.setStatus('confirmed'); + await settlement.executeBatch(batch!.id); + + const transactionsBefore = harness.transactions.rows.length; + const statusChecksBefore = rail!.getPayoutStatus.mock.calls.length; + + await settlement.executeBatch(batch!.id); + + expect(harness.transactions.rows.length).toBe(transactionsBefore); + // A settled batch short-circuits: it does not even ask the rail again. + expect(rail!.getPayoutStatus.mock.calls.length).toBe(statusChecksBefore); + }); +}); + +describe('SettlementService — DISTRIBUTION batches', () => { + async function withConfig() { + const context = build(); + const platform = await context.credits.getSystemAccount( + LedgerAccountKind.PLATFORM_FEE, + 'USD', + ); + const config = await context.splits.createConfig({ + name: 'hub-split', + recipients: [ + { label: 'platform fee', basisPoints: 1500, accountId: platform.id }, + { + label: 'partner payout', + basisPoints: 8500, + externalAddress: 'GPARTNERADDRESS', + }, + ], + }); + const revenue = await context.credits.getSystemAccount( + LedgerAccountKind.REVENUE, + 'USD', + ); + return { ...context, platform, config, revenue }; + } + + it('posts internal shares immediately and leaves external ones to the rail', async () => { + const { settlement, harness, credit, platform, revenue } = + await withConfig(); + await credit(revenue.id, 10_000, 'charges-1'); + + const batch = await settlement.createDistributionBatch('USD', 'hub-split'); + + expect(batch!.mode).toBe(SettlementBatchMode.DISTRIBUTION); + expect(batch!.totalAmount).toBe(10_000); + + const payouts = await harness.payouts.find({ + where: { batchId: batch!.id }, + }); + expect(payouts).toHaveLength(2); + const internal = payouts.find((payout) => !payout.externalAddress)!; + const external = payouts.find((payout) => payout.externalAddress)!; + expect(internal).toMatchObject({ + amount: 1500, + status: SettlementPayoutStatus.CONFIRMED, + }); + expect(external).toMatchObject({ + amount: 8500, + status: SettlementPayoutStatus.PENDING, + }); + + // The internal share has already moved; the external one has not. + expect(harness.balanceOf(platform.id)).toBe(1500); + expect(harness.balanceOf(revenue.id)).toBe(8500); + }); + + it('completes the distribution once the external leg confirms', async () => { + const { settlement, harness, rail, credit, revenue, credits } = + await withConfig(); + await credit(revenue.id, 10_000, 'charges-1'); + const batch = await settlement.createDistributionBatch('USD', 'hub-split'); + + await settlement.executeBatch(batch!.id); + rail!.setStatus('confirmed'); + const settled = await settlement.executeBatch(batch!.id); + + expect(settled.status).toBe(SettlementBatchStatus.SETTLED); + expect(harness.balanceOf(revenue.id)).toBe(0); + const treasury = await credits.getSystemAccount(LedgerAccountKind.TREASURY); + // Treasury started at -10000 (it funded the revenue credit) and is + // credited back the 8500 that actually left the platform. + expect(harness.balanceOf(treasury.id)).toBe(-1500); + expect(harness.balanceOf(revenue.id)).toBe( + harness.derivedBalanceOf(revenue.id), + ); + }); + + it('does not re-distribute an already-distributed balance', async () => { + const { settlement, credit, revenue } = await withConfig(); + await credit(revenue.id, 10_000, 'charges-1'); + + expect( + await settlement.createDistributionBatch('USD', 'hub-split'), + ).not.toBeNull(); + // The revenue account still holds the undistributed external share, + // but it has an in-flight payout — so nothing is committed twice. + expect( + await settlement.createDistributionBatch('USD', 'hub-split'), + ).toBeNull(); + }); + + it('skips a missing or inactive config instead of guessing', async () => { + const { settlement, splits, config, credit, revenue } = await withConfig(); + await credit(revenue.id, 10_000, 'charges-1'); + + const missing = await settlement.createDistributionBatch('USD', 'nope'); + expect(missing).toBeNull(); + + await splits.setActive(config.id, false); + expect( + await settlement.createDistributionBatch('USD', 'hub-split'), + ).toBeNull(); + }); +}); + +describe('SettlementService — admin visibility and recovery', () => { + it('exposes entries in, recipients out, and on-chain references', async () => { + const { credits, settlement, rail, credit } = build(); + const operator = await payableOperator(credits); + await credit(operator.id, 400, 'movement-1'); + await credit(operator.id, 600, 'movement-2'); + const batch = await settlement.createNetPayableBatch('USD'); + await settlement.executeBatch(batch!.id); + rail!.setStatus('confirmed'); + await settlement.executeBatch(batch!.id); + + const breakdown = await settlement.getBatchBreakdown(batch!.id); + + expect(breakdown.batch.status).toBe(SettlementBatchStatus.SETTLED); + expect(breakdown.payouts).toHaveLength(1); + expect(breakdown.payouts[0].amount).toBe(1000); + // The two claimed movements plus the two legs of the settlement + // drawdown this batch posted. + expect(breakdown.entries.length).toBe(4); + expect(breakdown.onChainReferences).toHaveLength(1); + expect(breakdown.onChainReferences[0].reference).toMatch(/^chain-ref:/); + }); + + it('releases the claims of an abandoned batch without posting anything', async () => { + const { credits, settlement, harness, rail, credit } = build({ + CREDITS_SETTLEMENT_MAX_PAYOUT_ATTEMPTS: 1, + }); + const operator = await payableOperator(credits); + await credit(operator.id, 1000, 'movement-1'); + const batch = await settlement.createNetPayableBatch('USD'); + rail!.failSubmitWith(new Error('wrong address')); + await settlement.executeBatch(batch!.id); + + const transactionsBefore = harness.transactions.rows.length; + const abandoned = await settlement.abandonBatch( + batch!.id, + 'operator address was decommissioned', + ); + + expect(abandoned.status).toBe(SettlementBatchStatus.ABANDONED); + expect(harness.transactions.rows.length).toBe(transactionsBefore); + // The claim is released, so the balance is available to a future batch. + expect( + harness.entries.rows.filter( + (entry) => entry.settlementBatchId === batch!.id, + ), + ).toHaveLength(0); + expect(harness.balanceOf(operator.id)).toBe(1000); + expect(await settlement.createNetPayableBatch('USD')).not.toBeNull(); + }); + + it('resumes open batches before creating new work', async () => { + const { credits, settlement, rail, credit } = build(); + const operator = await payableOperator(credits); + await credit(operator.id, 1000, 'movement-1'); + const batch = await settlement.createNetPayableBatch('USD'); + + rail!.setStatus('confirmed'); + + // The first pass picks the already-open batch up and submits it, and + // creates no competing batch for the same account. + const first = await settlement.runSettlement(); + expect(first.batchesExecuted).toBeGreaterThanOrEqual(1); + expect(first.batchesCreated).toBe(0); + expect(first.payoutsSubmitted).toBe(1); + + // The next pass sees the confirmation and finishes it. + const second = await settlement.runSettlement(); + expect(second.payoutsConfirmed).toBe(1); + expect(second.entriesSettled).toBe(1); + const resumed = await settlement.getBatch(batch!.id); + expect(resumed.status).toBe(SettlementBatchStatus.SETTLED); + }); +}); diff --git a/backend/src/credits/settlement.service.ts b/backend/src/credits/settlement.service.ts new file mode 100644 index 00000000..815c4859 --- /dev/null +++ b/backend/src/credits/settlement.service.ts @@ -0,0 +1,914 @@ +import { + Inject, + Injectable, + Logger, + NotFoundException, + Optional, + UnprocessableEntityException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { + EntityManager, + In, + IsNull, + LessThanOrEqual, + Not, + Repository, +} from 'typeorm'; +import { LedgerAccount } from './entities/ledger-account.entity'; +import { LedgerEntry } from './entities/ledger-entry.entity'; +import { SettlementBatch } from './entities/settlement-batch.entity'; +import { SettlementPayout } from './entities/settlement-payout.entity'; +import { LedgerAccountKind } from './enums/ledger-account-kind.enum'; +import { LedgerEntryDirection } from './enums/ledger-entry-direction.enum'; +import { LedgerTransactionKind } from './enums/ledger-transaction-kind.enum'; +import { SettlementBatchMode } from './enums/settlement-batch-mode.enum'; +import { SettlementBatchStatus } from './enums/settlement-batch-status.enum'; +import { SettlementPayoutStatus } from './enums/settlement-payout-status.enum'; +import { LedgerService } from './ledger.service'; +import { RevenueSplitService } from './revenue-split.service'; +import { EXTERNAL_PAYOUT_RAIL } from './credits.tokens'; +import { ExternalPayoutRail } from './interfaces/external-payout-rail.interface'; + +/** + * Serializes settlement batch CREATION platform-wide. Netting reads an + * account's balance and then commits payouts against it, so two runs + * overlapping on the same account would each net the same balance. The + * per-account in-flight guard below already refuses the second batch, but + * taking this transaction-scoped advisory lock first means the two runs + * never even interleave — much easier to reason about than a retry loop. + */ +const SETTLEMENT_CREATE_LOCK_KEY = 1575000001; + +const NON_TERMINAL_PAYOUT_STATUSES = [ + SettlementPayoutStatus.PENDING, + SettlementPayoutStatus.SUBMITTED, +]; + +const OPEN_BATCH_STATUSES = [ + SettlementBatchStatus.PENDING, + SettlementBatchStatus.IN_PROGRESS, +]; + +export interface SettlementRunSummary { + batchesCreated: number; + batchesExecuted: number; + payoutsSubmitted: number; + payoutsConfirmed: number; + payoutsFailed: number; + payoutsAwaitingRail: number; + entriesSettled: number; + notes: string[]; +} + +export interface SettlementBatchBreakdown { + batch: SettlementBatch; + payouts: SettlementPayout[]; + /** The claimed ledger entries — the "entries in" side of the batch. */ + entries: LedgerEntry[]; + /** On-chain references for the payouts that have one, for quick lookup. */ + onChainReferences: Array<{ payoutId: string; reference: string }>; +} + +/** + * Batch settlement (issue #1575): the job that turns many small internal + * ledger movements into at most one on-chain transfer per recipient. + * + * ## What makes it safe to crash + * + * Three separate guards, none of which relies on the previous run having + * finished cleanly: + * + * 1. **Amounts come from account balances, not from a running tally.** A + * payout that never happened leaves the balance untouched, so the next + * run simply sees the same amount still owed. Nothing has to be + * "rolled back". + * 2. **One in-flight payout per account.** A batch is never created for + * an account that already has a PENDING or SUBMITTED payout, so the + * same balance can never be committed to two batches. + * 3. **Per-payout idempotency keys.** Re-executing a batch hands the rail + * the same key, which the rail must dedupe on — so a crash between + * "submitted" and "recorded as submitted" cannot pay twice. + * + * And the rule those three exist to protect: **a submission is not a + * settlement.** The ledger drawdown and the per-entry `settledAt` marker + * are written only after the rail confirms the payout from fresh state. If + * the on-chain leg fails, the ledger still shows the balance as owed — + * never as paid. + */ +@Injectable() +export class SettlementService { + private readonly logger = new Logger(SettlementService.name); + + constructor( + @InjectRepository(SettlementBatch) + private readonly batchRepository: Repository, + @InjectRepository(SettlementPayout) + private readonly payoutRepository: Repository, + @InjectRepository(LedgerAccount) + private readonly accountRepository: Repository, + @InjectRepository(LedgerEntry) + private readonly entryRepository: Repository, + private readonly ledger: LedgerService, + private readonly splits: RevenueSplitService, + private readonly config: ConfigService, + @Optional() + @Inject(EXTERNAL_PAYOUT_RAIL) + private readonly payoutRail: ExternalPayoutRail | undefined, + ) {} + + @Cron(CronExpression.EVERY_HOUR) + async handleCron(): Promise { + if ( + this.config.get('CREDITS_SETTLEMENT_ENABLED', 'true') !== 'true' + ) { + return; + } + const summary = await this.runSettlement(); + this.logger.log(`Settlement pass: ${JSON.stringify(summary)}`); + } + + /** + * The directly-testable core. Resumes anything already open BEFORE + * creating new work — a batch left mid-flight by a crash or a restart is + * always finished (or at least advanced) first, so a stuck payout can + * never be lapped by a fresh batch for the same account. + */ + async runSettlement(now: Date = new Date()): Promise { + const summary = this.emptySummary(); + + const open = await this.batchRepository.find({ + where: { status: In(OPEN_BATCH_STATUSES) }, + order: { createdAt: 'ASC' }, + }); + for (const batch of open) { + await this.executeBatch(batch.id, now, summary); + } + + for (const currency of await this.currenciesWithAccounts()) { + const splitConfigName = this.config.get( + 'CREDITS_SETTLEMENT_SPLIT_CONFIG', + ); + if (splitConfigName) { + const created = await this.createDistributionBatch( + currency, + splitConfigName, + now, + summary, + ); + if (created) { + summary.batchesCreated++; + await this.executeBatch(created.id, now, summary); + } + } + + const netted = await this.createNetPayableBatch(currency, now, summary); + if (netted) { + summary.batchesCreated++; + await this.executeBatch(netted.id, now, summary); + } + } + + return summary; + } + + /** + * Splits the platform revenue account's undistributed balance across a + * RevenueSplitConfig. Internal shares are posted as ledger entries + * immediately (they never leave the platform, so there is nothing to + * wait for); external shares become payouts the rail has to confirm. + */ + async createDistributionBatch( + currency: string, + splitConfigName: string, + now: Date = new Date(), + summary?: SettlementRunSummary, + ): Promise { + const config = await this.splits.findConfigByName(splitConfigName); + if (!config) { + this.note( + summary, + `Split config "${splitConfigName}" not found — skipped distribution for ${currency}`, + ); + return null; + } + if (!config.active) { + this.note( + summary, + `Split config "${splitConfigName}" is inactive — skipped distribution for ${currency}`, + ); + return null; + } + + return this.batchRepository.manager.transaction(async (manager) => { + await this.acquireCreateLock(manager); + + const source = await this.ledger.findAccount( + { kind: LedgerAccountKind.REVENUE, ownerId: null, currency }, + manager, + ); + if (!source) { + return null; + } + if (await this.hasInFlightPayout(manager, source.id)) { + this.note( + summary, + `Revenue account ${source.id} still has an in-flight payout — distribution deferred`, + ); + return null; + } + + const locked = await this.lockAccount(manager, source.id); + const distributable = locked.balance; + if (distributable < this.minPayout()) { + return null; + } + + const shares = this.splits.compute(config, distributable); + const batch = await manager.getRepository(SettlementBatch).save( + manager.getRepository(SettlementBatch).create({ + status: SettlementBatchStatus.PENDING, + currency, + mode: SettlementBatchMode.DISTRIBUTION, + splitConfigId: config.id, + periodEnd: now, + totalAmount: distributable, + notes: `Distribution of ${distributable} ${currency} via "${config.name}"`, + }), + ); + batch.claimedEntryCount = await this.claimEntries( + manager, + batch.id, + [source.id], + now, + ); + + const internal = shares.filter( + (share) => share.recipient.accountId && share.amount > 0, + ); + const external = shares.filter( + (share) => !share.recipient.accountId && share.amount > 0, + ); + + const payoutRepository = manager.getRepository(SettlementPayout); + const payouts: SettlementPayout[] = []; + + if (internal.length > 0) { + const internalTotal = internal.reduce( + (sum, share) => sum + share.amount, + 0, + ); + const { transaction } = await this.ledger.post( + { + reference: `settlement:${batch.id}:internal`, + kind: LedgerTransactionKind.REVENUE_SPLIT, + currency, + description: `Internal shares of settlement batch ${batch.id}`, + metadata: { batchId: batch.id, configId: config.id }, + legs: [ + { + // Stamped as settled by this batch: this leg IS the + // drawdown, so no later batch should ever net it again. + accountId: source.id, + direction: LedgerEntryDirection.DEBIT, + amount: internalTotal, + settlementBatchId: batch.id, + settledAt: now, + }, + // Recipient credits are deliberately NOT stamped — an + // operator's internal share is exactly what a later + // NET_PAYABLE batch is supposed to pick up and pay out. + ...internal.map((share) => ({ + accountId: share.recipient.accountId!, + direction: LedgerEntryDirection.CREDIT, + amount: share.amount, + })), + ], + }, + manager, + ); + + for (const share of internal) { + payouts.push( + payoutRepository.create({ + batchId: batch.id, + label: share.recipient.label, + accountId: share.recipient.accountId, + externalAddress: null, + basisPoints: share.recipient.basisPoints, + amount: share.amount, + currency, + // Internal shares are complete the moment they are posted — + // there is no rail in the path to confirm. + status: SettlementPayoutStatus.CONFIRMED, + idempotencyKey: `settlement:${batch.id}:account:${share.recipient.accountId}`, + attempts: 0, + ledgerTransactionId: transaction.id, + confirmedAt: now, + }), + ); + } + } + + for (const share of external) { + payouts.push( + payoutRepository.create({ + batchId: batch.id, + label: share.recipient.label, + // The revenue account is what gets drawn down when this + // off-platform payout is confirmed. + accountId: source.id, + externalAddress: share.recipient.externalAddress, + basisPoints: share.recipient.basisPoints, + amount: share.amount, + currency, + status: SettlementPayoutStatus.PENDING, + idempotencyKey: `settlement:${batch.id}:address:${share.recipient.externalAddress}`, + attempts: 0, + }), + ); + } + + if (payouts.length === 0) { + // Nothing was actually apportionable (every share rounded to + // zero) — release the claim rather than leaving a no-op batch + // holding entries hostage. + await this.releaseClaims(manager, batch.id); + await manager.getRepository(SettlementBatch).remove(batch); + return null; + } + + await payoutRepository.save(payouts); + await manager.getRepository(SettlementBatch).save(batch); + return batch; + }); + } + + /** + * Nets each payable account's balance and pays that account's own + * external address — one on-chain transfer per account per cycle, + * however many micro-movements went into it. + */ + async createNetPayableBatch( + currency: string, + now: Date = new Date(), + summary?: SettlementRunSummary, + ): Promise { + return this.batchRepository.manager.transaction(async (manager) => { + await this.acquireCreateLock(manager); + + const candidates = await manager.getRepository(LedgerAccount).find({ + where: { + currency, + externalPayoutAddress: Not(IsNull()), + frozen: false, + }, + }); + + const payable: Array<{ account: LedgerAccount; amount: number }> = []; + for (const candidate of candidates) { + if (await this.hasInFlightPayout(manager, candidate.id)) { + this.note( + summary, + `Account ${candidate.id} still has an in-flight payout — netting deferred`, + ); + continue; + } + const locked = await this.lockAccount(manager, candidate.id); + if (locked.balance >= this.minPayout()) { + payable.push({ account: locked, amount: locked.balance }); + } + } + + if (payable.length === 0) { + return null; + } + + const total = payable.reduce((sum, row) => sum + row.amount, 0); + const batch = await manager.getRepository(SettlementBatch).save( + manager.getRepository(SettlementBatch).create({ + status: SettlementBatchStatus.PENDING, + currency, + mode: SettlementBatchMode.NET_PAYABLE, + splitConfigId: null, + periodEnd: now, + totalAmount: total, + notes: `Netted payouts for ${payable.length} account(s)`, + }), + ); + batch.claimedEntryCount = await this.claimEntries( + manager, + batch.id, + payable.map((row) => row.account.id), + now, + ); + + const payoutRepository = manager.getRepository(SettlementPayout); + await payoutRepository.save( + payable.map((row) => + payoutRepository.create({ + batchId: batch.id, + label: row.account.label ?? `${row.account.kind} ${row.account.id}`, + accountId: row.account.id, + externalAddress: row.account.externalPayoutAddress, + basisPoints: null, + amount: row.amount, + currency, + status: SettlementPayoutStatus.PENDING, + idempotencyKey: `settlement:${batch.id}:account:${row.account.id}`, + attempts: 0, + }), + ), + ); + await manager.getRepository(SettlementBatch).save(batch); + return batch; + }); + } + + /** + * Advances every payout of a batch by exactly one step: submit what is + * pending, poll what is submitted, and only then post the ledger + * drawdown for what the rail confirms. Safe to call repeatedly — that + * is the whole recovery mechanism for an interrupted run. + */ + async executeBatch( + batchId: string, + now: Date = new Date(), + summary?: SettlementRunSummary, + ): Promise { + const batch = await this.getBatch(batchId); + if ( + batch.status === SettlementBatchStatus.SETTLED || + batch.status === SettlementBatchStatus.ABANDONED + ) { + return batch; + } + + const payouts = await this.payoutRepository.find({ + where: { batchId }, + order: { createdAt: 'ASC' }, + }); + + for (const payout of payouts) { + if (payout.status === SettlementPayoutStatus.PENDING) { + await this.submitPayout(payout, summary); + continue; + } + if (payout.status === SettlementPayoutStatus.SUBMITTED) { + await this.pollPayout(payout, now, summary); + } + } + + if (summary) { + summary.batchesExecuted++; + } + return this.finalizeBatch(batchId, now, summary); + } + + /** + * Puts a batch's failed payouts back in the queue. The idempotency key + * is deliberately NOT regenerated: handing the rail the same key is + * what makes a retry safe when the previous attempt's outcome is + * genuinely unknown — the rail dedupes it if the value already moved. + */ + async retryBatch(batchId: string): Promise { + const batch = await this.getBatch(batchId); + if (batch.status === SettlementBatchStatus.SETTLED) { + throw new UnprocessableEntityException( + 'This batch is already fully settled', + ); + } + + await this.payoutRepository.update( + { batchId, status: SettlementPayoutStatus.FAILED }, + { status: SettlementPayoutStatus.PENDING, lastError: null }, + ); + await this.batchRepository.update(batchId, { + status: SettlementBatchStatus.IN_PROGRESS, + }); + return this.executeBatch(batchId); + } + + /** + * Admin escape hatch for a payout that will never succeed (a wrong + * address, a decommissioned account). Releases the claim on the entries + * this batch never actually settled so a future batch can pick them up + * again — and posts nothing, because a payout that never happened has + * no ledger effect to undo: the balance is still shown as owed. + */ + async abandonBatch( + batchId: string, + reason: string, + ): Promise { + const batch = await this.getBatch(batchId); + if (batch.status === SettlementBatchStatus.SETTLED) { + throw new UnprocessableEntityException( + 'This batch is already fully settled', + ); + } + + await this.batchRepository.manager.transaction(async (manager) => { + await manager.getRepository(SettlementPayout).update( + { batchId, status: In(NON_TERMINAL_PAYOUT_STATUSES) }, + { + status: SettlementPayoutStatus.FAILED, + lastError: `Abandoned: ${reason}`, + }, + ); + await this.releaseClaims(manager, batchId); + await manager.getRepository(SettlementBatch).update(batchId, { + status: SettlementBatchStatus.ABANDONED, + notes: `${batch.notes ?? ''}\nAbandoned: ${reason}`.trim(), + }); + }); + + this.logger.warn(`Settlement batch ${batchId} abandoned: ${reason}`); + return this.getBatch(batchId); + } + + async listBatches( + status?: SettlementBatchStatus, + ): Promise { + return this.batchRepository.find({ + where: status ? { status } : {}, + order: { createdAt: 'DESC' }, + take: 100, + }); + } + + /** + * The full audit view an admin needs (issue #1575's acceptance + * criterion): which entries went in, which recipients came out, and the + * on-chain transaction reference for each off-platform leg. + */ + async getBatchBreakdown(batchId: string): Promise { + const batch = await this.getBatch(batchId); + const payouts = await this.payoutRepository.find({ + where: { batchId }, + order: { createdAt: 'ASC' }, + }); + const entries = await this.entryRepository.find({ + where: { settlementBatchId: batchId }, + order: { createdAt: 'ASC' }, + }); + + return { + batch, + payouts, + entries, + onChainReferences: payouts + .filter((payout) => payout.onChainReference) + .map((payout) => ({ + payoutId: payout.id, + reference: payout.onChainReference!, + })), + }; + } + + async getBatch(batchId: string): Promise { + const batch = await this.batchRepository.findOne({ + where: { id: batchId }, + }); + if (!batch) { + throw new NotFoundException(`Settlement batch ${batchId} not found`); + } + return batch; + } + + // ── internals ────────────────────────────────────────────────────────── + + private async submitPayout( + payout: SettlementPayout, + summary?: SettlementRunSummary, + ): Promise { + if (!payout.externalAddress) { + // An internal share reaches CONFIRMED at creation; a PENDING payout + // with no address is a bug, not a payout to guess at. + this.logger.error( + `Payout ${payout.id} is PENDING with no external address — skipping`, + ); + return; + } + if (!this.payoutRail) { + this.note( + summary, + `No external payout rail configured (SOROBAN_ENABLED is not true) — ` + + `payout ${payout.id} stays PENDING`, + ); + if (summary) { + summary.payoutsAwaitingRail++; + } + return; + } + + const attempts = payout.attempts + 1; + try { + const submission = await this.payoutRail.submitPayout({ + destinationAddress: payout.externalAddress, + amount: payout.amount, + currency: payout.currency, + idempotencyKey: payout.idempotencyKey, + }); + await this.payoutRepository.update(payout.id, { + status: SettlementPayoutStatus.SUBMITTED, + onChainReference: submission.reference, + attempts, + lastError: null, + }); + if (summary) { + summary.payoutsSubmitted++; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const exhausted = attempts >= this.maxAttempts(); + await this.payoutRepository.update(payout.id, { + status: exhausted + ? SettlementPayoutStatus.FAILED + : SettlementPayoutStatus.PENDING, + attempts, + lastError: message, + }); + if (summary && exhausted) { + summary.payoutsFailed++; + } + this.logger.error( + `Payout ${payout.id} submission failed (attempt ${attempts}): ${message}`, + ); + } + } + + private async pollPayout( + payout: SettlementPayout, + now: Date, + summary?: SettlementRunSummary, + ): Promise { + if (!this.payoutRail || !payout.onChainReference) { + return; + } + + let status: 'confirmed' | 'failed' | 'pending'; + try { + status = await this.payoutRail.getPayoutStatus(payout.onChainReference); + } catch (error) { + // Indeterminate: the rail could not be reached. NOT a verdict that + // the payout failed, so nothing changes — the next pass asks again. + this.logger.warn( + `Payout ${payout.id} status check failed: ` + + (error instanceof Error ? error.message : String(error)), + ); + return; + } + + if (status === 'pending') { + return; + } + if (status === 'failed') { + await this.payoutRepository.update(payout.id, { + status: SettlementPayoutStatus.FAILED, + lastError: 'The payout rail reported the transfer as failed', + }); + if (summary) { + summary.payoutsFailed++; + } + return; + } + + await this.confirmPayout(payout, now); + if (summary) { + summary.payoutsConfirmed++; + } + } + + /** + * The only place a payout becomes real in the ledger. Posts the + * drawdown (debit the payable account, credit TREASURY as the + * counterparty for value that left the platform) and marks the payout + * confirmed in the same transaction, so the two can never disagree. + */ + private async confirmPayout( + payout: SettlementPayout, + now: Date, + ): Promise { + await this.batchRepository.manager.transaction(async (manager) => { + const treasury = await this.ledger.getOrCreateAccount( + { + kind: LedgerAccountKind.TREASURY, + ownerId: null, + currency: payout.currency, + label: 'treasury', + }, + manager, + ); + + const { transaction } = await this.ledger.post( + { + reference: `settlement:payout:${payout.id}`, + kind: LedgerTransactionKind.SETTLEMENT, + currency: payout.currency, + description: + `Off-platform payout of ${payout.amount} ${payout.currency} ` + + `to ${payout.externalAddress}`, + metadata: { + batchId: payout.batchId, + payoutId: payout.id, + onChainReference: payout.onChainReference, + }, + legs: [ + { + accountId: payout.accountId!, + direction: LedgerEntryDirection.DEBIT, + amount: payout.amount, + settlementBatchId: payout.batchId, + settledAt: now, + }, + { + accountId: treasury.id, + direction: LedgerEntryDirection.CREDIT, + amount: payout.amount, + settlementBatchId: payout.batchId, + settledAt: now, + }, + ], + }, + manager, + ); + + await manager.getRepository(SettlementPayout).update( + // The status guard makes this update the idempotency latch: a + // second confirmation of the same payout matches no row, and the + // ledger post above is itself idempotent on its reference. + { id: payout.id, status: SettlementPayoutStatus.SUBMITTED }, + { + status: SettlementPayoutStatus.CONFIRMED, + confirmedAt: now, + ledgerTransactionId: transaction.id, + lastError: null, + }, + ); + }); + } + + /** + * Recomputes the batch status from its payouts and, only when every + * payout is CONFIRMED, stamps the claimed entries as settled. Marking + * them any earlier is exactly the failure mode issue #1575 calls out: + * the ledger claiming money has moved when the on-chain leg has not. + */ + private async finalizeBatch( + batchId: string, + now: Date, + summary?: SettlementRunSummary, + ): Promise { + const payouts = await this.payoutRepository.find({ where: { batchId } }); + const confirmed = payouts.filter( + (payout) => payout.status === SettlementPayoutStatus.CONFIRMED, + ).length; + const failed = payouts.filter( + (payout) => payout.status === SettlementPayoutStatus.FAILED, + ).length; + const outstanding = payouts.length - confirmed - failed; + + let status: SettlementBatchStatus; + if (confirmed === payouts.length && payouts.length > 0) { + status = SettlementBatchStatus.SETTLED; + } else if (outstanding > 0) { + status = + confirmed > 0 || payouts.some((payout) => payout.attempts > 0) + ? SettlementBatchStatus.IN_PROGRESS + : SettlementBatchStatus.PENDING; + } else { + status = + confirmed > 0 + ? SettlementBatchStatus.PARTIALLY_SETTLED + : SettlementBatchStatus.FAILED; + } + + if (status === SettlementBatchStatus.SETTLED) { + const settled = await this.entryRepository.update( + { settlementBatchId: batchId, settledAt: IsNull() }, + { settledAt: now }, + ); + if (summary) { + summary.entriesSettled += settled.affected ?? 0; + } + } + + await this.batchRepository.update(batchId, { status }); + return this.getBatch(batchId); + } + + /** + * Marks the accounts' currently-unclaimed entries as belonging to this + * batch. This is the per-entry audit trail — "these movements were + * accounted for by batch X" — and the reason a resumed run can show + * exactly what it already covered. + */ + private async claimEntries( + manager: EntityManager, + batchId: string, + accountIds: string[], + periodEnd: Date, + ): Promise { + if (accountIds.length === 0) { + return 0; + } + const result = await manager.getRepository(LedgerEntry).update( + { + accountId: In(accountIds), + settlementBatchId: IsNull(), + createdAt: LessThanOrEqual(periodEnd), + }, + { settlementBatchId: batchId }, + ); + return result.affected ?? 0; + } + + private async releaseClaims( + manager: EntityManager, + batchId: string, + ): Promise { + await manager + .getRepository(LedgerEntry) + .update( + { settlementBatchId: batchId, settledAt: IsNull() }, + { settlementBatchId: null }, + ); + } + + private async hasInFlightPayout( + manager: EntityManager, + accountId: string, + ): Promise { + const count = await manager.getRepository(SettlementPayout).count({ + where: { + accountId, + status: In(NON_TERMINAL_PAYOUT_STATUSES), + }, + }); + return count > 0; + } + + private async lockAccount( + manager: EntityManager, + accountId: string, + ): Promise { + const account = await manager + .getRepository(LedgerAccount) + .createQueryBuilder('account') + .setLock('pessimistic_write') + .where('account.id = :accountId', { accountId }) + .getOne(); + if (!account) { + throw new NotFoundException(`Ledger account ${accountId} not found`); + } + return account; + } + + private async acquireCreateLock(manager: EntityManager): Promise { + await manager.query('SELECT pg_advisory_xact_lock($1)', [ + SETTLEMENT_CREATE_LOCK_KEY, + ]); + } + + /** + * Derived from the singleton system accounts (a handful per currency) + * rather than by scanning every account. Any currency that has ever had + * movement necessarily has one: every balanced transaction in this + * module has a system account on one side — a charge credits REVENUE, a + * top-up debits TREASURY — so nothing can be missed by looking here. + */ + private async currenciesWithAccounts(): Promise { + const systemAccounts = await this.accountRepository.find({ + where: { ownerId: IsNull() }, + }); + return [...new Set(systemAccounts.map((account) => account.currency))]; + } + + private minPayout(): number { + return this.config.get('CREDITS_SETTLEMENT_MIN_PAYOUT', 1); + } + + private maxAttempts(): number { + return this.config.get('CREDITS_SETTLEMENT_MAX_PAYOUT_ATTEMPTS', 5); + } + + private note(summary: SettlementRunSummary | undefined, message: string) { + this.logger.warn(message); + summary?.notes.push(message); + } + + private emptySummary(): SettlementRunSummary { + return { + batchesCreated: 0, + batchesExecuted: 0, + payoutsSubmitted: 0, + payoutsConfirmed: 0, + payoutsFailed: 0, + payoutsAwaitingRail: 0, + entriesSettled: 0, + notes: [], + }; + } +} diff --git a/backend/src/credits/split-allocation.spec.ts b/backend/src/credits/split-allocation.spec.ts new file mode 100644 index 00000000..7ba2b54b --- /dev/null +++ b/backend/src/credits/split-allocation.spec.ts @@ -0,0 +1,174 @@ +import { + allocateByBasisPoints, + assertBasisPointsSumToTotal, + BasisPointShare, + SplitAllocationError, + TOTAL_BASIS_POINTS, +} from './split-allocation'; + +const shares = (...basisPoints: number[]): BasisPointShare[] => + basisPoints.map((bp, index) => ({ key: `r${index}`, basisPoints: bp })); + +const total = (allocations: Array<{ amount: number }>) => + allocations.reduce((sum, allocation) => sum + allocation.amount, 0); + +describe('assertBasisPointsSumToTotal', () => { + it('accepts a set that sums to exactly 100%', () => { + expect(() => + assertBasisPointsSumToTotal(shares(1500, 8000, 500)), + ).not.toThrow(); + }); + + it('rejects a set that sums to less than 100%', () => { + expect(() => assertBasisPointsSumToTotal(shares(1500, 8000))).toThrow( + SplitAllocationError, + ); + }); + + it('rejects a set that sums to more than 100%', () => { + expect(() => assertBasisPointsSumToTotal(shares(5000, 6000))).toThrow( + /must sum to 10000/, + ); + }); + + it('rejects an empty recipient list', () => { + expect(() => assertBasisPointsSumToTotal([])).toThrow( + /at least one recipient/, + ); + }); + + it('rejects a zero or negative share', () => { + expect(() => assertBasisPointsSumToTotal(shares(0, 10000))).toThrow( + /positive integer share/, + ); + expect(() => assertBasisPointsSumToTotal(shares(-100, 10100))).toThrow( + /positive integer share/, + ); + }); + + it('rejects a fractional share', () => { + expect(() => assertBasisPointsSumToTotal(shares(1500.5, 8499.5))).toThrow( + /positive integer share/, + ); + }); +}); + +describe('allocateByBasisPoints', () => { + it('splits an exactly-divisible amount with no remainder at all', () => { + const allocations = allocateByBasisPoints(10000, shares(1500, 8000, 500)); + expect(allocations.map((a) => a.amount)).toEqual([1500, 8000, 500]); + expect(allocations.every((a) => a.remainderUnits === 0)).toBe(true); + }); + + /** + * The case that motivates the whole rounding policy: three equal-ish + * shares of 1000 floor to 333/333/333 and lose a unit. It has to land + * somewhere, exactly once. + */ + it('allocates a leftover unit rather than dropping it', () => { + const allocations = allocateByBasisPoints(1000, shares(3333, 3333, 3334)); + expect(total(allocations)).toBe(1000); + expect(allocations.map((a) => a.amount)).toEqual([333, 333, 334]); + expect(allocations.map((a) => a.remainderUnits)).toEqual([0, 0, 1]); + }); + + it('never creates value: the allocated total equals the input exactly', () => { + const configs = [ + shares(3333, 3333, 3334), + shares(1, 9999), + shares(2500, 2500, 2500, 2500), + shares(1667, 1667, 1666, 1667, 1667, 1666), + shares(1000, 2000, 3000, 4000), + shares(9998, 1, 1), + ]; + const amounts = [ + 0, 1, 2, 3, 7, 9, 10, 11, 99, 100, 101, 999, 1000, 1001, 12345, 99999, + 1_000_003, 7_777_777, + ]; + + for (const config of configs) { + for (const amount of amounts) { + const allocations = allocateByBasisPoints(amount, config); + expect(total(allocations)).toBe(amount); + expect(allocations.every((a) => a.amount >= 0)).toBe(true); + // The remainder pass hands out strictly fewer units than there are + // recipients, so nobody can be topped up twice in one allocation. + const remainderUnits = allocations.reduce( + (sum, a) => sum + a.remainderUnits, + 0, + ); + expect(remainderUnits).toBeLessThan(config.length); + } + } + }); + + it('stays within one minor unit of the exact proportional share', () => { + const config = shares(1667, 1667, 1666, 1667, 1667, 1666); + const allocations = allocateByBasisPoints(99999, config); + for (const allocation of allocations) { + const exact = (99999 * allocation.share.basisPoints) / TOTAL_BASIS_POINTS; + expect(Math.abs(allocation.amount - exact)).toBeLessThan(1); + } + }); + + it('is deterministic: the same inputs always allocate identically', () => { + const config = shares(3333, 3333, 3334); + const first = allocateByBasisPoints(1_000_001, config); + const second = allocateByBasisPoints(1_000_001, config); + expect(first.map((a) => a.amount)).toEqual(second.map((a) => a.amount)); + }); + + /** + * With identical remainders the tie has to break somewhere, and it must + * break the same way every run — otherwise two auditors reconciling the + * same batch get different answers. + */ + it('breaks a remainder tie by sortOrder, then by input position', () => { + const config: BasisPointShare[] = [ + { key: 'late', basisPoints: 3333, sortOrder: 9 }, + { key: 'early', basisPoints: 3333, sortOrder: 1 }, + { key: 'middle', basisPoints: 3334, sortOrder: 5 }, + ]; + // 3334 has the largest remainder and takes the first unit; 'early' + // then wins the tie against 'late' on sortOrder. + const allocations = allocateByBasisPoints(2000, config); + const byKey = new Map(allocations.map((a) => [a.share.key, a.amount])); + expect(total(allocations)).toBe(2000); + expect(byKey.get('middle')).toBe(667); + expect(byKey.get('early')).toBe(667); + expect(byKey.get('late')).toBe(666); + }); + + it('gives everyone zero for a zero amount, and still balances', () => { + const allocations = allocateByBasisPoints(0, shares(3333, 3333, 3334)); + expect(total(allocations)).toBe(0); + expect(allocations.map((a) => a.amount)).toEqual([0, 0, 0]); + }); + + it('hands a single minor unit to exactly one recipient', () => { + const allocations = allocateByBasisPoints(1, shares(3333, 3333, 3334)); + expect(total(allocations)).toBe(1); + expect(allocations.filter((a) => a.amount === 1)).toHaveLength(1); + }); + + it('rejects a negative or fractional amount', () => { + expect(() => allocateByBasisPoints(-1, shares(10000))).toThrow( + SplitAllocationError, + ); + expect(() => allocateByBasisPoints(1.5, shares(10000))).toThrow( + /non-negative integer/, + ); + }); + + it('rejects an amount too large to apportion exactly', () => { + expect(() => + allocateByBasisPoints(Number.MAX_SAFE_INTEGER, shares(10000)), + ).toThrow(/too large to apportion/); + }); + + it('rejects a config that does not sum to 100%', () => { + expect(() => allocateByBasisPoints(1000, shares(5000, 4000))).toThrow( + SplitAllocationError, + ); + }); +}); diff --git a/backend/src/credits/split-allocation.ts b/backend/src/credits/split-allocation.ts new file mode 100644 index 00000000..60abe15f --- /dev/null +++ b/backend/src/credits/split-allocation.ts @@ -0,0 +1,137 @@ +/** + * Basis-point allocation with an explicit, documented remainder rule + * (issue #1575). + * + * ## The rounding policy + * + * Any percentage split of an integer amount leaves a remainder: 1000 + * minor units across 3333/3333/3334 basis points floors to 333/333/333 + * and loses 1. Silently dropping it makes the ledger stop balancing; + * rounding each share independently can *create* value. So: + * + * 1. Every recipient first gets `floor(amount * basisPoints / 10000)`. + * 2. The leftover (always strictly less than the recipient count) is + * handed out one minor unit at a time to the recipients with the + * largest fractional remainder — the **largest-remainder method**. + * 3. Ties are broken deterministically: lower `sortOrder` first, then + * the recipient's position in the input. Identical inputs therefore + * always produce an identical allocation — there is no run-to-run + * drift for an auditor to chase. + * + * The result is guaranteed to sum to exactly `amount`, which is what lets + * a split be posted as balanced double-entry ledger legs. + */ + +export interface BasisPointShare { + /** Caller's identifier — echoed back on the allocation. */ + key: string; + basisPoints: number; + /** Deterministic tie-breaker for the remainder; lower wins. */ + sortOrder?: number; +} + +export interface AllocatedShare { + share: T; + /** Minor units. Sum over all allocations === the input amount. */ + amount: number; + /** How many minor units this share received from the remainder pass. */ + remainderUnits: number; +} + +export const TOTAL_BASIS_POINTS = 10000; + +/** Thrown for a config that could never allocate correctly. */ +export class SplitAllocationError extends Error {} + +/** + * Validates that a set of shares is a usable split: at least one + * recipient, each with positive basis points, summing to exactly 100%. + * Called at configuration time so a broken split is rejected there, and + * again at computation time as a defence against a config mutated by + * anything that bypassed the service. + */ +export function assertBasisPointsSumToTotal( + shares: readonly BasisPointShare[], +): void { + if (shares.length === 0) { + throw new SplitAllocationError( + 'A revenue split needs at least one recipient', + ); + } + for (const share of shares) { + if (!Number.isInteger(share.basisPoints) || share.basisPoints <= 0) { + throw new SplitAllocationError( + `Recipient "${share.key}" has basis points ${share.basisPoints}; ` + + 'each recipient needs a positive integer share', + ); + } + } + const total = shares.reduce((sum, share) => sum + share.basisPoints, 0); + if (total !== TOTAL_BASIS_POINTS) { + throw new SplitAllocationError( + `Revenue split basis points must sum to ${TOTAL_BASIS_POINTS} ` + + `(100%), got ${total}`, + ); + } +} + +/** + * Allocates `amount` (minor units, >= 0) across `shares`. See the module + * doc for the rounding rule. The returned array is in input order, not + * allocation order, so callers can zip it against their own recipients. + */ +export function allocateByBasisPoints( + amount: number, + shares: readonly T[], +): AllocatedShare[] { + if (!Number.isInteger(amount) || amount < 0) { + throw new SplitAllocationError( + `Split amount must be a non-negative integer (minor units), got ${amount}`, + ); + } + // Keeps `amount * basisPoints` exact in IEEE-754 integer arithmetic. + if (amount > Number.MAX_SAFE_INTEGER / TOTAL_BASIS_POINTS) { + throw new SplitAllocationError( + `Split amount ${amount} is too large to apportion exactly`, + ); + } + assertBasisPointsSumToTotal(shares); + + const allocations = shares.map((share, index) => { + const scaled = amount * share.basisPoints; + return { + share, + index, + amount: Math.floor(scaled / TOTAL_BASIS_POINTS), + remainder: scaled % TOTAL_BASIS_POINTS, + remainderUnits: 0, + }; + }); + + let leftover = + amount - allocations.reduce((sum, entry) => sum + entry.amount, 0); + + // Strictly less than allocations.length, so a single ordered pass is + // always enough — no wrap-around, no recipient getting two units while + // another with the same remainder gets none. + const byRemainder = [...allocations].sort( + (a, b) => + b.remainder - a.remainder || + (a.share.sortOrder ?? 0) - (b.share.sortOrder ?? 0) || + a.index - b.index, + ); + for (const entry of byRemainder) { + if (leftover <= 0) { + break; + } + entry.amount += 1; + entry.remainderUnits += 1; + leftover -= 1; + } + + return allocations.map((entry) => ({ + share: entry.share, + amount: entry.amount, + remainderUnits: entry.remainderUnits, + })); +} diff --git a/backend/src/credits/testing/in-memory-ledger.ts b/backend/src/credits/testing/in-memory-ledger.ts new file mode 100644 index 00000000..64552752 --- /dev/null +++ b/backend/src/credits/testing/in-memory-ledger.ts @@ -0,0 +1,467 @@ +import { FindOperator } from 'typeorm'; +import { LedgerAccount } from '../entities/ledger-account.entity'; +import { LedgerEntry } from '../entities/ledger-entry.entity'; +import { LedgerTransaction } from '../entities/ledger-transaction.entity'; +import { MeteredUsageEvent } from '../entities/metered-usage-event.entity'; +import { PaymentCreditApplication } from '../entities/payment-credit-application.entity'; +import { RevenueSplitConfig } from '../entities/revenue-split-config.entity'; +import { RevenueSplitRecipient } from '../entities/revenue-split-recipient.entity'; +import { SettlementBatch } from '../entities/settlement-batch.entity'; +import { SettlementPayout } from '../entities/settlement-payout.entity'; +import { LedgerEntryDirection } from '../enums/ledger-entry-direction.enum'; + +/** + * Test-only in-memory stand-in for the credit ledger's tables (issue + * #1575). Not a general TypeORM emulator — just enough of the repository + * API for these services, with two behaviours that matter for what the + * specs need to prove: + * + * - **`manager.transaction` serializes.** Callbacks run strictly one + * after another, which is exactly the effect the `FOR UPDATE` row locks + * have on transactions contending for the same account. That is what + * lets a single-threaded jest run demonstrate the overdraft race: the + * second charge reads the first one's committed balance. + * - **Unique indexes are enforced,** raising a Postgres-shaped + * `{ code: '23505', constraint }` error — the real idempotency guard the + * services recover from. + */ + +// ── where-clause matching ──────────────────────────────────────────────── + +function toComparable(value: unknown): any { + return value instanceof Date ? value.getTime() : value; +} + +function valueMatches(actual: unknown, expected: unknown): boolean { + if (expected instanceof FindOperator) { + switch (expected.type) { + case 'isNull': + return actual === null || actual === undefined; + case 'not': + // TypeORM's `value` getter unwraps a nested operator, so + // `Not(IsNull()).value` is `undefined`, not the IsNull operator — + // `child` is what preserves the nesting. + return !valueMatches(actual, expected.child ?? expected.value); + case 'in': + return (expected.value as unknown[]).some((candidate) => + valueMatches(actual, candidate), + ); + case 'equal': + return toComparable(actual) === toComparable(expected.value); + case 'lessThanOrEqual': + return toComparable(actual) <= toComparable(expected.value); + case 'lessThan': + return toComparable(actual) < toComparable(expected.value); + case 'moreThanOrEqual': + return toComparable(actual) >= toComparable(expected.value); + case 'moreThan': + return toComparable(actual) > toComparable(expected.value); + default: + throw new Error( + `in-memory-ledger: unsupported find operator "${expected.type}"`, + ); + } + } + if (expected === undefined) { + return true; + } + return toComparable(actual) === toComparable(expected); +} + +function whereMatches(row: any, where: any): boolean { + if (!where) { + return true; + } + if (Array.isArray(where)) { + return where.some((clause) => whereMatches(row, clause)); + } + return Object.entries(where).every(([key, expected]) => + valueMatches(row[key], expected), + ); +} + +function applyOrder(rows: T[], order?: Record): T[] { + if (!order) { + return rows; + } + const keys = Object.entries(order); + return [...rows].sort((a: any, b: any) => { + for (const [key, direction] of keys) { + const left = toComparable(a[key]); + const right = toComparable(b[key]); + if (left === right) { + continue; + } + const comparison = left > right ? 1 : -1; + return direction === 'DESC' ? -comparison : comparison; + } + return 0; + }); +} + +// ── repository fake ────────────────────────────────────────────────────── + +export interface UniqueIndexSpec { + constraint: string; + keys: string[]; +} + +export interface FakeRepositoryOptions { + idPrefix: string; + unique?: UniqueIndexSpec[]; + /** Populate relations for a `find`/`findOne` that asks for them. */ + hydrate?: (row: any, relations: any) => any; +} + +export class FakeRepository { + private sequence = 0; + + constructor( + readonly rows: any[], + private readonly options: FakeRepositoryOptions, + ) {} + + create(data: any = {}): any { + return Array.isArray(data) + ? data.map((item) => ({ ...item })) + : { ...data }; + } + + async save(input: any): Promise { + if (Array.isArray(input)) { + const saved = []; + for (const item of input) { + saved.push(await this.save(item)); + } + return saved; + } + const row = { ...input }; + if (!row.id) { + row.id = `${this.options.idPrefix}-${++this.sequence}`; + } + row.createdAt = row.createdAt ?? new Date(); + row.updatedAt = new Date(); + + this.assertUnique(row); + + const index = this.rows.findIndex((existing) => existing.id === row.id); + if (index >= 0) { + this.rows[index] = { ...this.rows[index], ...row }; + } else { + this.rows.push(row); + } + // Mutate the caller's object the way TypeORM does (it assigns the + // generated id back onto the entity it was handed). + Object.assign(input, row); + return { ...row }; + } + + async findOne(options: any = {}): Promise { + const found = applyOrder( + this.rows.filter((row) => whereMatches(row, options.where)), + options.order, + )[0]; + if (!found) { + return null; + } + return this.hydrate({ ...found }, options.relations); + } + + async find(options: any = {}): Promise { + let found = this.rows.filter((row) => whereMatches(row, options.where)); + found = applyOrder(found, options.order); + if (options.take) { + found = found.slice(0, options.take); + } + return found.map((row) => this.hydrate({ ...row }, options.relations)); + } + + async count(options: any = {}): Promise { + return this.rows.filter((row) => whereMatches(row, options.where)).length; + } + + async update(criteria: any, partial: any): Promise<{ affected: number }> { + const where = + typeof criteria === 'string' ? { id: criteria } : (criteria ?? {}); + let affected = 0; + for (const row of this.rows) { + if (whereMatches(row, where)) { + Object.assign(row, partial, { updatedAt: new Date() }); + affected++; + } + } + return { affected }; + } + + async delete(criteria: any): Promise<{ affected: number }> { + const where = + typeof criteria === 'string' ? { id: criteria } : (criteria ?? {}); + const keep = this.rows.filter((row) => !whereMatches(row, where)); + const affected = this.rows.length - keep.length; + this.rows.splice(0, this.rows.length, ...keep); + return { affected }; + } + + async remove(entity: any): Promise { + return this.delete({ id: entity.id }).then(() => entity); + } + + /** + * Supports only the two chains these services actually build: the + * `FOR UPDATE` account lock, and a select-with-params read. + */ + createQueryBuilder(_alias?: string) { + const params: Record = {}; + const builder: any = { + setLock: () => builder, + orderBy: () => builder, + addOrderBy: () => builder, + where: (_sql: string, args?: Record) => { + Object.assign(params, args ?? {}); + return builder; + }, + andWhere: (_sql: string, args?: Record) => { + Object.assign(params, args ?? {}); + return builder; + }, + getOne: async () => { + const row = this.rows.find((candidate) => + params.accountId + ? candidate.id === params.accountId + : candidate.id === params.id, + ); + return row ? { ...row } : null; + }, + getMany: async () => { + const ids: string[] = params.ids ?? []; + return this.rows + .filter((row) => ids.includes(row.id)) + .sort((a, b) => (a.id > b.id ? 1 : -1)) + .map((row) => ({ ...row })); + }, + }; + return builder; + } + + private hydrate(row: any, relations: any): any { + if (!relations || !this.options.hydrate) { + return row; + } + return this.options.hydrate(row, relations); + } + + private assertUnique(row: any): void { + for (const index of this.options.unique ?? []) { + const clash = this.rows.find( + (existing) => + existing.id !== row.id && + index.keys.every( + (key) => toComparable(existing[key]) === toComparable(row[key]), + ), + ); + if (clash) { + const error: any = new Error( + `duplicate key value violates unique constraint "${index.constraint}"`, + ); + error.code = '23505'; + error.constraint = index.constraint; + throw error; + } + } + } +} + +// ── the harness ────────────────────────────────────────────────────────── + +export interface LedgerHarness { + accounts: FakeRepository; + transactions: FakeRepository; + entries: FakeRepository; + splitConfigs: FakeRepository; + splitRecipients: FakeRepository; + batches: FakeRepository; + payouts: FakeRepository; + usageEvents: FakeRepository; + paymentApplications: FakeRepository; + /** Number of transaction callbacks that have run — proves serialization. */ + transactionCount: () => number; + balanceOf: (accountId: string) => number; + /** Balance re-derived from the append-only entries, for the audit check. */ + derivedBalanceOf: (accountId: string) => number; +} + +export function createLedgerHarness(): LedgerHarness { + const accountRows: any[] = []; + const transactionRows: any[] = []; + const entryRows: any[] = []; + const splitConfigRows: any[] = []; + const splitRecipientRows: any[] = []; + const batchRows: any[] = []; + const payoutRows: any[] = []; + const usageRows: any[] = []; + const applicationRows: any[] = []; + + const accounts = new FakeRepository(accountRows, { + idPrefix: 'account', + unique: [ + { + constraint: 'uq_ledger_accounts_owned', + keys: ['kind', 'ownerId', 'currency'], + }, + ], + }); + const transactions = new FakeRepository(transactionRows, { + idPrefix: 'transaction', + unique: [ + { + constraint: 'uq_ledger_transactions_reference', + keys: ['reference'], + }, + ], + }); + const entries = new FakeRepository(entryRows, { + idPrefix: 'entry', + }); + const splitRecipients = new FakeRepository( + splitRecipientRows, + { idPrefix: 'recipient' }, + ); + const splitConfigs = new FakeRepository(splitConfigRows, { + idPrefix: 'split-config', + unique: [{ constraint: 'uq_revenue_split_configs_name', keys: ['name'] }], + hydrate: (row, relations) => + relations?.recipients + ? { + ...row, + recipients: splitRecipientRows + .filter((recipient) => recipient.configId === row.id) + .map((recipient) => ({ ...recipient })), + } + : row, + }); + const batches = new FakeRepository(batchRows, { + idPrefix: 'batch', + }); + const payouts = new FakeRepository(payoutRows, { + idPrefix: 'payout', + unique: [ + { + constraint: 'uq_settlement_payouts_idempotency_key', + keys: ['idempotencyKey'], + }, + ], + }); + const usageEvents = new FakeRepository(usageRows, { + idPrefix: 'usage', + unique: [ + { + constraint: 'uq_metered_usage_events_usage_reference', + keys: ['usageReference'], + }, + ], + }); + const paymentApplications = new FakeRepository( + applicationRows, + { + idPrefix: 'application', + unique: [ + { + constraint: 'uq_payment_credit_applications_payment_id', + keys: ['paymentId'], + }, + ], + }, + ); + + const byEntity = new Map>([ + [LedgerAccount, accounts], + [LedgerTransaction, transactions], + [LedgerEntry, entries], + [RevenueSplitConfig, splitConfigs], + [RevenueSplitRecipient, splitRecipients], + [SettlementBatch, batches], + [SettlementPayout, payouts], + [MeteredUsageEvent, usageEvents], + [PaymentCreditApplication, paymentApplications], + ]); + + let transactionCount = 0; + let queue: Promise = Promise.resolve(); + + const manager: any = { + getRepository: (entity: unknown) => { + const repository = byEntity.get(entity); + if (!repository) { + throw new Error( + `in-memory-ledger: no fake repository for ${String(entity)}`, + ); + } + return repository; + }, + increment: async ( + _entity: unknown, + criteria: any, + property: string, + value: number, + ) => { + for (const row of accountRows) { + if (whereMatches(row, criteria)) { + row[property] = (row[property] ?? 0) + value; + } + } + return { affected: 1 }; + }, + // The advisory lock settlement takes before creating a batch. + query: async () => [], + // Serializes callbacks, standing in for the row locks that force real + // concurrent transactions to take turns. + transaction: async (callback: (m: any) => Promise) => { + const run = queue.then(() => { + transactionCount++; + return callback(manager); + }); + queue = run.then( + () => undefined, + () => undefined, + ); + return run; + }, + }; + + for (const repository of byEntity.values()) { + (repository as any).manager = manager; + } + + return { + accounts, + transactions, + entries, + splitConfigs, + splitRecipients, + batches, + payouts, + usageEvents, + paymentApplications, + transactionCount: () => transactionCount, + balanceOf: (accountId: string) => + accountRows.find((row) => row.id === accountId)?.balance ?? 0, + derivedBalanceOf: (accountId: string) => + entryRows + .filter((entry) => entry.accountId === accountId) + .reduce( + (sum, entry) => + entry.direction === LedgerEntryDirection.CREDIT + ? sum + entry.amount + : sum - entry.amount, + 0, + ), + }; +} + +/** Minimal ConfigService stand-in: a map with `get(key, default)`. */ +export function fakeConfigService(values: Record = {}) { + return { + get: (key: string, fallback?: unknown) => + values[key] !== undefined ? values[key] : fallback, + } as any; +} diff --git a/backend/src/database/migrations/1787408900000-CreateCreditLedgerTables.ts b/backend/src/database/migrations/1787408900000-CreateCreditLedgerTables.ts new file mode 100644 index 00000000..84722d93 --- /dev/null +++ b/backend/src/database/migrations/1787408900000-CreateCreditLedgerTables.ts @@ -0,0 +1,419 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Micropayment credit ledger, revenue splits and batch settlement + * (issue #1575). + * + * The CHECK constraints below deliberately duplicate validation the + * services already do. Ledger correctness is the kind of thing that must + * hold even for a row inserted by a migration, a script or a future + * caller that forgot the service — so "amounts are positive" and "a split + * recipient is internal xor external" are enforced by the database too. + */ +export class CreateCreditLedgerTables1787408900000 implements MigrationInterface { + name = 'CreateCreditLedgerTables1787408900000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE "ledger_accounts_kind_enum" AS ENUM ( + 'USER', 'TREASURY', 'REVENUE', 'PLATFORM_FEE', 'HUB_OPERATOR', + 'REFERRAL' + ) + `); + await queryRunner.query(` + CREATE TYPE "ledger_transactions_kind_enum" AS ENUM ( + 'TOP_UP', 'CHARGE', 'REVENUE_SPLIT', 'SETTLEMENT', 'REVERSAL', + 'ADJUSTMENT' + ) + `); + await queryRunner.query(` + CREATE TYPE "ledger_entries_direction_enum" AS ENUM ('DEBIT', 'CREDIT') + `); + await queryRunner.query(` + CREATE TYPE "settlement_batches_status_enum" AS ENUM ( + 'PENDING', 'IN_PROGRESS', 'SETTLED', 'PARTIALLY_SETTLED', 'FAILED', + 'ABANDONED' + ) + `); + await queryRunner.query(` + CREATE TYPE "settlement_batches_mode_enum" AS ENUM ( + 'DISTRIBUTION', 'NET_PAYABLE' + ) + `); + await queryRunner.query(` + CREATE TYPE "settlement_payouts_status_enum" AS ENUM ( + 'PENDING', 'SUBMITTED', 'CONFIRMED', 'FAILED' + ) + `); + await queryRunner.query(` + CREATE TYPE "metered_usage_events_resource_enum" AS ENUM ( + 'RESOURCE_MINUTES', 'PRINTING', 'MEETING_ROOM_OVERAGE' + ) + `); + await queryRunner.query(` + CREATE TYPE "payment_credit_applications_kind_enum" AS ENUM ( + 'TOP_UP', 'REVENUE_SPLIT' + ) + `); + + // One account per user, plus the singleton system accounts. `balance` + // is a materialized cache of the append-only entries — it exists so an + // overdraft check can be O(1) and so a charge has a single row to lock. + await queryRunner.query(` + CREATE TABLE "ledger_accounts" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "kind" "ledger_accounts_kind_enum" NOT NULL, + "owner_id" uuid, + "currency" varchar(3) NOT NULL, + "balance" bigint NOT NULL DEFAULT 0, + "overdraft_limit" bigint NOT NULL DEFAULT 0, + "external_payout_address" varchar, + "frozen" boolean NOT NULL DEFAULT false, + "label" varchar, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "pk_ledger_accounts" PRIMARY KEY ("id"), + CONSTRAINT "ck_ledger_accounts_overdraft_limit" + CHECK ("overdraft_limit" >= 0) + ) + `); + // An owned account (user / hub operator / referrer) is unique per + // owner and currency... + await queryRunner.query(` + CREATE UNIQUE INDEX "uq_ledger_accounts_owned" + ON "ledger_accounts" ("kind", "owner_id", "currency") + WHERE "owner_id" IS NOT NULL + `); + // ...and a system account is a singleton per kind and currency. Two + // partial indexes rather than one, because NULL owner_id would not + // collide under a plain unique index. + await queryRunner.query(` + CREATE UNIQUE INDEX "uq_ledger_accounts_system" + ON "ledger_accounts" ("kind", "currency") + WHERE "owner_id" IS NULL + `); + await queryRunner.query(` + CREATE INDEX "idx_ledger_accounts_owner_id" + ON "ledger_accounts" ("owner_id") + `); + + // `reference` is the transaction-level idempotency guard: a replayed + // charge, a re-run settlement pass or a resumed batch job all collide + // on it and get the original transaction back. + await queryRunner.query(` + CREATE TABLE "ledger_transactions" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "kind" "ledger_transactions_kind_enum" NOT NULL, + "reference" varchar NOT NULL, + "currency" varchar(3) NOT NULL, + "amount" bigint NOT NULL, + "description" text, + "metadata" jsonb, + "actor_id" uuid, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "pk_ledger_transactions" PRIMARY KEY ("id"), + CONSTRAINT "ck_ledger_transactions_amount" CHECK ("amount" > 0) + ) + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "uq_ledger_transactions_reference" + ON "ledger_transactions" ("reference") + `); + + await queryRunner.query(` + CREATE TABLE "revenue_split_configs" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "name" varchar NOT NULL, + "description" text, + "active" boolean NOT NULL DEFAULT true, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "pk_revenue_split_configs" PRIMARY KEY ("id") + ) + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "uq_revenue_split_configs_name" + ON "revenue_split_configs" ("name") + `); + + // "Basis points sum to 10000" is a property of the whole set and so + // is validated in RevenueSplitService; what a row-level CHECK *can* + // guarantee is that no single share is nonsensical, and that a + // recipient is internal xor external. + await queryRunner.query(` + CREATE TABLE "revenue_split_recipients" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "config_id" uuid NOT NULL, + "label" varchar NOT NULL, + "basis_points" int NOT NULL, + "account_id" uuid, + "external_address" varchar, + "sort_order" int NOT NULL DEFAULT 0, + CONSTRAINT "pk_revenue_split_recipients" PRIMARY KEY ("id"), + CONSTRAINT "ck_revenue_split_recipients_basis_points" + CHECK ("basis_points" > 0 AND "basis_points" <= 10000), + CONSTRAINT "ck_revenue_split_recipients_target" + CHECK (("account_id" IS NULL) <> ("external_address" IS NULL)), + CONSTRAINT "fk_revenue_split_recipients_config" + FOREIGN KEY ("config_id") REFERENCES "revenue_split_configs"("id") + ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "fk_revenue_split_recipients_account" + FOREIGN KEY ("account_id") REFERENCES "ledger_accounts"("id") + ON DELETE NO ACTION ON UPDATE NO ACTION + ) + `); + await queryRunner.query(` + CREATE INDEX "idx_revenue_split_recipients_config_id" + ON "revenue_split_recipients" ("config_id") + `); + + await queryRunner.query(` + CREATE TABLE "settlement_batches" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "status" "settlement_batches_status_enum" NOT NULL DEFAULT 'PENDING', + "currency" varchar(3) NOT NULL, + "mode" "settlement_batches_mode_enum" NOT NULL, + "split_config_id" uuid, + "period_end" timestamptz NOT NULL, + "total_amount" bigint NOT NULL DEFAULT 0, + "claimed_entry_count" int NOT NULL DEFAULT 0, + "notes" text, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "pk_settlement_batches" PRIMARY KEY ("id"), + CONSTRAINT "fk_settlement_batches_split_config" + FOREIGN KEY ("split_config_id") + REFERENCES "revenue_split_configs"("id") + ON DELETE SET NULL ON UPDATE NO ACTION + ) + `); + await queryRunner.query(` + CREATE INDEX "idx_settlement_batches_status" + ON "settlement_batches" ("status") + `); + + // Append-only. The two settlement markers are separate on purpose: + // `settlement_batch_id` is the CLAIM (this entry belongs to one batch + // and no other), `settled_at` is the SETTLED marker, written only once + // the payout has actually been confirmed by the rail. + await queryRunner.query(` + CREATE TABLE "ledger_entries" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "transaction_id" uuid NOT NULL, + "account_id" uuid NOT NULL, + "direction" "ledger_entries_direction_enum" NOT NULL, + "amount" bigint NOT NULL, + "currency" varchar(3) NOT NULL, + "settlement_batch_id" uuid, + "settled_at" timestamptz, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "pk_ledger_entries" PRIMARY KEY ("id"), + CONSTRAINT "ck_ledger_entries_amount" CHECK ("amount" > 0), + CONSTRAINT "fk_ledger_entries_transaction" + FOREIGN KEY ("transaction_id") + REFERENCES "ledger_transactions"("id") + ON DELETE NO ACTION ON UPDATE NO ACTION, + CONSTRAINT "fk_ledger_entries_account" + FOREIGN KEY ("account_id") REFERENCES "ledger_accounts"("id") + ON DELETE NO ACTION ON UPDATE NO ACTION, + CONSTRAINT "fk_ledger_entries_settlement_batch" + FOREIGN KEY ("settlement_batch_id") + REFERENCES "settlement_batches"("id") + ON DELETE SET NULL ON UPDATE NO ACTION + ) + `); + await queryRunner.query(` + CREATE INDEX "idx_ledger_entries_transaction_id" + ON "ledger_entries" ("transaction_id") + `); + await queryRunner.query(` + CREATE INDEX "idx_ledger_entries_account_id" + ON "ledger_entries" ("account_id") + `); + await queryRunner.query(` + CREATE INDEX "idx_ledger_entries_settlement_batch_id" + ON "ledger_entries" ("settlement_batch_id") + `); + // The settlement claim scan only ever looks at unclaimed entries, so + // the index it uses excludes everything already accounted for — which + // is the vast majority of the table on a busy ledger. + await queryRunner.query(` + CREATE INDEX "idx_ledger_entries_unclaimed" + ON "ledger_entries" ("account_id", "created_at") + WHERE "settlement_batch_id" IS NULL + `); + + // `idempotency_key` is what the payout rail dedupes on, which is what + // makes re-executing a crashed batch safe. + await queryRunner.query(` + CREATE TABLE "settlement_payouts" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "batch_id" uuid NOT NULL, + "label" varchar NOT NULL, + "account_id" uuid, + "external_address" varchar, + "basis_points" int, + "amount" bigint NOT NULL, + "currency" varchar(3) NOT NULL, + "status" "settlement_payouts_status_enum" NOT NULL DEFAULT 'PENDING', + "idempotency_key" varchar NOT NULL, + "on_chain_reference" varchar, + "ledger_transaction_id" uuid, + "attempts" int NOT NULL DEFAULT 0, + "last_error" text, + "confirmed_at" timestamptz, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "pk_settlement_payouts" PRIMARY KEY ("id"), + CONSTRAINT "ck_settlement_payouts_amount" CHECK ("amount" > 0), + CONSTRAINT "fk_settlement_payouts_batch" + FOREIGN KEY ("batch_id") REFERENCES "settlement_batches"("id") + ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "fk_settlement_payouts_account" + FOREIGN KEY ("account_id") REFERENCES "ledger_accounts"("id") + ON DELETE NO ACTION ON UPDATE NO ACTION, + CONSTRAINT "fk_settlement_payouts_ledger_transaction" + FOREIGN KEY ("ledger_transaction_id") + REFERENCES "ledger_transactions"("id") + ON DELETE NO ACTION ON UPDATE NO ACTION + ) + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "uq_settlement_payouts_idempotency_key" + ON "settlement_payouts" ("idempotency_key") + `); + await queryRunner.query(` + CREATE INDEX "idx_settlement_payouts_batch_id" + ON "settlement_payouts" ("batch_id") + `); + // Powers the "does this account already have an in-flight payout?" + // guard that stops the same balance being committed to two batches. + await queryRunner.query(` + CREATE INDEX "idx_settlement_payouts_account_in_flight" + ON "settlement_payouts" ("account_id") + WHERE "status" IN ('PENDING', 'SUBMITTED') + `); + + await queryRunner.query(` + CREATE TABLE "metered_usage_events" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "user_id" uuid NOT NULL, + "resource" "metered_usage_events_resource_enum" NOT NULL, + "units" int NOT NULL, + "unit_price" bigint NOT NULL, + "amount" bigint NOT NULL, + "currency" varchar(3) NOT NULL, + "usage_reference" varchar NOT NULL, + "ledger_transaction_id" uuid NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "pk_metered_usage_events" PRIMARY KEY ("id"), + CONSTRAINT "ck_metered_usage_events_positive" + CHECK ("units" > 0 AND "unit_price" > 0 AND "amount" > 0), + CONSTRAINT "fk_metered_usage_events_ledger_transaction" + FOREIGN KEY ("ledger_transaction_id") + REFERENCES "ledger_transactions"("id") + ON DELETE NO ACTION ON UPDATE NO ACTION + ) + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "uq_metered_usage_events_usage_reference" + ON "metered_usage_events" ("usage_reference") + `); + await queryRunner.query(` + CREATE INDEX "idx_metered_usage_events_user_id" + ON "metered_usage_events" ("user_id") + `); + + // Lives on the credits side so a split config can be attached to a + // Payment without the payments module knowing this module exists. + await queryRunner.query(` + CREATE TABLE "payment_credit_applications" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "payment_id" uuid NOT NULL, + "kind" "payment_credit_applications_kind_enum" NOT NULL, + "split_config_id" uuid, + "ledger_transaction_id" uuid, + "applied_at" timestamptz, + "last_error" text, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "pk_payment_credit_applications" PRIMARY KEY ("id"), + CONSTRAINT "fk_payment_credit_applications_payment" + FOREIGN KEY ("payment_id") REFERENCES "payments"("id") + ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "fk_payment_credit_applications_split_config" + FOREIGN KEY ("split_config_id") + REFERENCES "revenue_split_configs"("id") + ON DELETE SET NULL ON UPDATE NO ACTION, + CONSTRAINT "fk_payment_credit_applications_ledger_transaction" + FOREIGN KEY ("ledger_transaction_id") + REFERENCES "ledger_transactions"("id") + ON DELETE NO ACTION ON UPDATE NO ACTION + ) + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "uq_payment_credit_applications_payment_id" + ON "payment_credit_applications" ("payment_id") + `); + // The sweep looks for confirmed-but-unapplied rows. + await queryRunner.query(` + CREATE INDEX "idx_payment_credit_applications_unapplied" + ON "payment_credit_applications" ("payment_id") + WHERE "applied_at" IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "idx_payment_credit_applications_unapplied"`, + ); + await queryRunner.query( + `DROP INDEX "uq_payment_credit_applications_payment_id"`, + ); + await queryRunner.query(`DROP TABLE "payment_credit_applications"`); + await queryRunner.query(`DROP INDEX "idx_metered_usage_events_user_id"`); + await queryRunner.query( + `DROP INDEX "uq_metered_usage_events_usage_reference"`, + ); + await queryRunner.query(`DROP TABLE "metered_usage_events"`); + await queryRunner.query( + `DROP INDEX "idx_settlement_payouts_account_in_flight"`, + ); + await queryRunner.query(`DROP INDEX "idx_settlement_payouts_batch_id"`); + await queryRunner.query( + `DROP INDEX "uq_settlement_payouts_idempotency_key"`, + ); + await queryRunner.query(`DROP TABLE "settlement_payouts"`); + await queryRunner.query(`DROP INDEX "idx_ledger_entries_unclaimed"`); + await queryRunner.query( + `DROP INDEX "idx_ledger_entries_settlement_batch_id"`, + ); + await queryRunner.query(`DROP INDEX "idx_ledger_entries_account_id"`); + await queryRunner.query(`DROP INDEX "idx_ledger_entries_transaction_id"`); + await queryRunner.query(`DROP TABLE "ledger_entries"`); + await queryRunner.query(`DROP INDEX "idx_settlement_batches_status"`); + await queryRunner.query(`DROP TABLE "settlement_batches"`); + await queryRunner.query( + `DROP INDEX "idx_revenue_split_recipients_config_id"`, + ); + await queryRunner.query(`DROP TABLE "revenue_split_recipients"`); + await queryRunner.query(`DROP INDEX "uq_revenue_split_configs_name"`); + await queryRunner.query(`DROP TABLE "revenue_split_configs"`); + await queryRunner.query(`DROP INDEX "uq_ledger_transactions_reference"`); + await queryRunner.query(`DROP TABLE "ledger_transactions"`); + await queryRunner.query(`DROP INDEX "idx_ledger_accounts_owner_id"`); + await queryRunner.query(`DROP INDEX "uq_ledger_accounts_system"`); + await queryRunner.query(`DROP INDEX "uq_ledger_accounts_owned"`); + await queryRunner.query(`DROP TABLE "ledger_accounts"`); + await queryRunner.query( + `DROP TYPE "payment_credit_applications_kind_enum"`, + ); + await queryRunner.query(`DROP TYPE "metered_usage_events_resource_enum"`); + await queryRunner.query(`DROP TYPE "settlement_payouts_status_enum"`); + await queryRunner.query(`DROP TYPE "settlement_batches_mode_enum"`); + await queryRunner.query(`DROP TYPE "settlement_batches_status_enum"`); + await queryRunner.query(`DROP TYPE "ledger_entries_direction_enum"`); + await queryRunner.query(`DROP TYPE "ledger_transactions_kind_enum"`); + await queryRunner.query(`DROP TYPE "ledger_accounts_kind_enum"`); + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts index a56b08eb..cf659261 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -24,7 +24,23 @@ async function bootstrap() { '## Payment state machine\n' + 'INITIATED -> AWAITING_CONFIRMATION -> CONFIRMED | FAILED | EXPIRED\n' + 'CONFIRMED -> REFUNDED | PARTIALLY_REFUNDED\n' + - 'All transitions are enforced by a single guarded service method.', + 'All transitions are enforced by a single guarded service method.\n\n' + + '## Credit ledger\n' + + 'Charges too small to settle on-chain per event move value inside a ' + + 'double-entry ledger instead: a charge debits the member and credits ' + + 'platform revenue, and value only crosses the platform boundary ' + + 'later, in one netted settlement batch per recipient.\n' + + '- Every ledger transaction balances (debits == credits) and its ' + + '`reference` is unique, so any retry is a no-op rather than a ' + + 'duplicate.\n' + + '- A charge is refused if it would breach the account overdraft ' + + 'ceiling (0 by default), checked under the account row lock so ' + + 'concurrent charges cannot overdraw together.\n' + + '- Revenue splits are basis points summing to exactly 10000, ' + + 'allocated by the largest-remainder method so rounding never loses ' + + 'or duplicates a minor unit.\n' + + '- Settlement never marks a ledger entry settled until the payout ' + + 'rail confirms the transfer from fresh state.', ) .setVersion('1.0') .addBearerAuth() diff --git a/backend/src/payments/payments.module.ts b/backend/src/payments/payments.module.ts index 511310a6..1899d798 100644 --- a/backend/src/payments/payments.module.ts +++ b/backend/src/payments/payments.module.ts @@ -18,6 +18,8 @@ import { SandboxRailAdapter } from './adapters/sandbox-rail.adapter'; import { WalletsModule } from '../wallets/wallets.module'; import { loadSorobanConfig } from './soroban/soroban-config'; import { SorobanRailAdapter } from './soroban/soroban-rail.adapter'; +import { SorobanPayoutAdapter } from './soroban/soroban-payout.adapter'; +import { EXTERNAL_PAYOUT_RAIL } from '../credits/credits.tokens'; import { EscrowSubmissionProcessor } from './soroban/escrow-submission.processor'; import { EscrowContractClient } from './soroban/escrow-contract.client'; import { @@ -94,9 +96,29 @@ import { adapter: SorobanRailAdapter, ) => (sorobanConfig ? adapter : null), }, + // The credit ledger's off-platform payout port (issue #1575), + // implemented over the escrow rail above. Same conditional shape: + // null unless the Soroban rail is actually configured, which + // SettlementService treats as "no external payouts are possible" — + // batches keep their payouts PENDING and say so, rather than silently + // marking a balance as paid. + { + provide: EXTERNAL_PAYOUT_RAIL, + inject: [SOROBAN_CONFIG, SorobanPayoutAdapter], + useFactory: ( + sorobanConfig: ReturnType, + adapter: SorobanPayoutAdapter, + ) => (sorobanConfig ? adapter : null), + }, SorobanRailAdapter, + SorobanPayoutAdapter, EscrowSubmissionProcessor, ], - exports: [PaymentsService, PaymentConfirmationService, ReconciliationService], + exports: [ + PaymentsService, + PaymentConfirmationService, + ReconciliationService, + EXTERNAL_PAYOUT_RAIL, + ], }) export class PaymentsModule {} diff --git a/backend/src/payments/soroban/escrow-submission.processor.spec.ts b/backend/src/payments/soroban/escrow-submission.processor.spec.ts index e961ee3c..4c29e303 100644 --- a/backend/src/payments/soroban/escrow-submission.processor.spec.ts +++ b/backend/src/payments/soroban/escrow-submission.processor.spec.ts @@ -201,7 +201,9 @@ describe('EscrowSubmissionProcessor', () => { }); it("treats a retried create against the contract's already-done guard as success via a fresh read", async () => { - contractClient.submit.mockRejectedValue(new Error('escrow already released')); + contractClient.submit.mockRejectedValue( + new Error('escrow already released'), + ); const payment = makePayment(); paymentRepository.findOne.mockResolvedValue(payment); @@ -278,4 +280,85 @@ describe('EscrowSubmissionProcessor', () => { ).resolves.toBeUndefined(); }); }); + + /** + * The off-platform leg of a credit-ledger settlement batch (issue + * #1575): a treasury-funded escrow created for, and released to, the + * recipient. Every step re-reads contract state first, which is what + * makes a retried settlement pass safe. + */ + describe('payout', () => { + const payoutJob = { + kind: 'payout' as const, + escrowIdHex: 'ef'.repeat(32), + beneficiaryAddress: Keypair.random().publicKey(), + amount: 25_000, + }; + + it('creates the escrow from the treasury, then releases it', async () => { + contractClient.getEscrowStatus + .mockResolvedValueOnce(EscrowStatus.NOT_FOUND) + .mockResolvedValueOnce(EscrowStatus.LOCKED); + + await submitJob(payoutJob); + + expect(contractClient.buildCreateTx).toHaveBeenCalledWith( + TREASURY.publicKey(), + Buffer.from(payoutJob.escrowIdHex, 'hex'), + TREASURY.publicKey(), + payoutJob.beneficiaryAddress, + BigInt(payoutJob.amount), + ); + expect(contractClient.buildReleaseTx).toHaveBeenCalledWith( + TREASURY.publicKey(), + Buffer.from(payoutJob.escrowIdHex, 'hex'), + ); + }); + + it('does not re-create an escrow that already exists', async () => { + contractClient.getEscrowStatus.mockResolvedValue(EscrowStatus.LOCKED); + + await submitJob(payoutJob); + + expect(contractClient.buildCreateTx).not.toHaveBeenCalled(); + expect(contractClient.buildReleaseTx).toHaveBeenCalledTimes(1); + }); + + it('is a no-op for an escrow already released — no double payout', async () => { + contractClient.getEscrowStatus.mockResolvedValue(EscrowStatus.RELEASED); + + await submitJob(payoutJob); + + expect(contractClient.buildCreateTx).not.toHaveBeenCalled(); + expect(contractClient.buildReleaseTx).not.toHaveBeenCalled(); + }); + + it('does not release when the create never reached finality', async () => { + contractClient.getEscrowStatus.mockResolvedValue(EscrowStatus.NOT_FOUND); + contractClient.pollFinality.mockResolvedValue('NOT_FOUND'); + + await submitJob(payoutJob); + + expect(contractClient.buildCreateTx).toHaveBeenCalledTimes(1); + expect(contractClient.buildReleaseTx).not.toHaveBeenCalled(); + }); + + it('does not release an escrow that came back REFUNDED', async () => { + contractClient.getEscrowStatus + .mockResolvedValueOnce(EscrowStatus.NOT_FOUND) + .mockResolvedValueOnce(EscrowStatus.REFUNDED); + + await submitJob(payoutJob); + + expect(contractClient.buildReleaseTx).not.toHaveBeenCalled(); + }); + + it('logs rather than throws on a genuine on-chain failure', async () => { + contractClient.getEscrowStatus.mockResolvedValue(EscrowStatus.NOT_FOUND); + contractClient.submit.mockRejectedValue(new Error('txBAD_SEQ')); + + await expect(submitJob(payoutJob)).resolves.toBeUndefined(); + expect(contractClient.buildReleaseTx).not.toHaveBeenCalled(); + }); + }); }); diff --git a/backend/src/payments/soroban/escrow-submission.processor.ts b/backend/src/payments/soroban/escrow-submission.processor.ts index 286b6fb5..53eda906 100644 --- a/backend/src/payments/soroban/escrow-submission.processor.ts +++ b/backend/src/payments/soroban/escrow-submission.processor.ts @@ -11,7 +11,10 @@ import { PaymentFailureReason } from '../enums/payment-failure-reason.enum'; import { ConfirmationSource } from '../enums/confirmation-source.enum'; import { PaymentConfirmationService } from '../payment-confirmation.service'; import { WalletsService } from '../../wallets/wallets.service'; -import { attachSignature, EscrowContractClient } from './escrow-contract.client'; +import { + attachSignature, + EscrowContractClient, +} from './escrow-contract.client'; import { EscrowStatus } from './escrow-status.enum'; import { deriveEscrowId } from './escrow-id'; import { mapSorobanError } from './soroban-error-mapping'; @@ -25,7 +28,17 @@ import { type SubmitJobData = | { kind: 'create'; paymentId: string } | { kind: 'release'; escrowIdHex: string; paymentId: string } - | { kind: 'refund'; escrowIdHex: string }; + | { kind: 'refund'; escrowIdHex: string } + // Treasury -> recipient transfer for a settlement batch's off-platform + // leg (issue #1575). Reuses this rail rather than adding a second one: + // an escrow created and released to the beneficiary in one job is a + // transfer, and it inherits the failure/retry semantics above for free. + | { + kind: 'payout'; + escrowIdHex: string; + beneficiaryAddress: string; + amount: number; + }; /** * Runs the actual on-chain submission pipeline off the request thread @@ -65,6 +78,8 @@ export class EscrowSubmissionProcessor { return this.handleRelease(job.data.escrowIdHex); case 'refund': return this.handleRefund(job.data.escrowIdHex); + case 'payout': + return this.handlePayout(job.data); } } @@ -218,6 +233,99 @@ export class EscrowSubmissionProcessor { await this.submitTreasuryAction(escrowIdHex, 'release'); } + /** + * The off-platform leg of a credit-ledger settlement batch (issue + * #1575): a treasury-funded escrow created for, and immediately + * released to, the recipient. + * + * Every step re-reads contract state first, so the job is idempotent + * against its own retries and against a duplicate delivery of the same + * escrow id: an escrow that already exists is not created again, and one + * already RELEASED is left alone. That, plus the deterministic escrow id + * the adapter derives from the settlement payout's idempotency key, is + * what lets settlement re-run a batch without paying anyone twice. + * + * This never reports success back to settlement. SettlementService only + * ever believes a fresh `getEscrowStatus` read (see + * SorobanPayoutAdapter.getPayoutStatus), so a job that dies here leaves + * the payout SUBMITTED and the ledger still showing the balance as owed. + */ + private async handlePayout(data: { + escrowIdHex: string; + beneficiaryAddress: string; + amount: number; + }): Promise { + const escrowId = Buffer.from(data.escrowIdHex, 'hex'); + const treasuryKeypair = Keypair.fromSecret( + this.sorobanConfig.treasurySecretKey, + ); + + try { + const existing = await this.contractClient.getEscrowStatus( + this.sorobanConfig.treasuryPublicKey, + escrowId, + ); + + if (existing === EscrowStatus.RELEASED) { + this.logger.log( + `Payout escrow ${data.escrowIdHex} is already released — nothing to do`, + ); + return; + } + + if (existing === EscrowStatus.NOT_FOUND) { + const tx = await this.contractClient.buildCreateTx( + treasuryKeypair.publicKey(), + escrowId, + treasuryKeypair.publicKey(), + data.beneficiaryAddress, + BigInt(data.amount), + ); + tx.sign(treasuryKeypair); + const { hash } = await this.contractClient.submit(tx); + const finality = await this.contractClient.pollFinality( + hash, + this.pollOptions(), + ); + if (finality !== 'SUCCESS') { + this.logger.warn( + `Payout escrow ${data.escrowIdHex}: create tx ${hash} is ` + + `${finality} within the bounded poll — leaving it for the next ` + + 'settlement pass to re-read', + ); + return; + } + } + + // Fresh read again: the create above only proves the call did not + // revert, not that the escrow is in the state a release needs. + const beforeRelease = await this.contractClient.getEscrowStatus( + this.sorobanConfig.treasuryPublicKey, + escrowId, + ); + if (beforeRelease !== EscrowStatus.LOCKED) { + this.logger.warn( + `Payout escrow ${data.escrowIdHex} is ${beforeRelease} after ` + + 'create — not releasing', + ); + return; + } + await this.submitTreasuryAction(data.escrowIdHex, 'release'); + } catch (error) { + const mapping = mapSorobanError(error); + if (mapping.alreadySucceeded) { + this.logger.log( + `Payout escrow ${data.escrowIdHex}: contract reports already done`, + ); + return; + } + this.logger.error( + `Payout escrow ${data.escrowIdHex} failed: ` + + (error instanceof Error ? error.message : String(error)), + ); + } + } + private async handleRefund(escrowIdHex: string): Promise { await this.submitTreasuryAction(escrowIdHex, 'refund'); } diff --git a/backend/src/payments/soroban/soroban-payout.adapter.spec.ts b/backend/src/payments/soroban/soroban-payout.adapter.spec.ts new file mode 100644 index 00000000..3474c2e0 --- /dev/null +++ b/backend/src/payments/soroban/soroban-payout.adapter.spec.ts @@ -0,0 +1,133 @@ +import { createHash } from 'crypto'; +import { SorobanPayoutAdapter } from './soroban-payout.adapter'; +import { EscrowStatus } from './escrow-status.enum'; +import { deriveEscrowId } from './escrow-id'; + +describe('SorobanPayoutAdapter', () => { + let queue: { add: jest.Mock }; + let contractClient: { getEscrowStatus: jest.Mock }; + let sorobanConfig: { treasuryPublicKey: string }; + let adapter: SorobanPayoutAdapter; + + const payout = { + destinationAddress: 'GOPERATORADDRESS', + amount: 25_000, + currency: 'USD', + idempotencyKey: 'settlement:batch-1:account:account-7', + }; + + beforeEach(() => { + queue = { add: jest.fn().mockResolvedValue(undefined) }; + contractClient = { getEscrowStatus: jest.fn() }; + sorobanConfig = { treasuryPublicKey: 'GTREASURY' }; + adapter = new SorobanPayoutAdapter( + queue as any, + contractClient as any, + sorobanConfig as any, + ); + }); + + describe('submitPayout', () => { + it('never calls the chain — it derives the escrow id and enqueues', async () => { + const result = await adapter.submitPayout(payout); + + expect(contractClient.getEscrowStatus).not.toHaveBeenCalled(); + const escrowIdHex = createHash('sha256') + .update(`payout:${payout.idempotencyKey}`, 'utf8') + .digest('hex'); + expect(result.reference).toBe(escrowIdHex); + expect(queue.add).toHaveBeenCalledWith( + 'submit', + { + kind: 'payout', + escrowIdHex, + beneficiaryAddress: payout.destinationAddress, + amount: payout.amount, + }, + { jobId: `payout:${escrowIdHex}`, attempts: 1 }, + ); + }); + + /** + * Settlement's whole retry story rests on this: the same idempotency + * key must always address the same on-chain record, so re-submitting + * cannot create a second transfer. + */ + it('derives the same escrow id for the same idempotency key', async () => { + const first = await adapter.submitPayout(payout); + const second = await adapter.submitPayout(payout); + + expect(second.reference).toBe(first.reference); + expect(queue.add.mock.calls[0][2].jobId).toBe( + queue.add.mock.calls[1][2].jobId, + ); + }); + + it('derives different escrow ids for different payouts', async () => { + const first = await adapter.submitPayout(payout); + const second = await adapter.submitPayout({ + ...payout, + idempotencyKey: 'settlement:batch-1:account:account-8', + }); + expect(second.reference).not.toBe(first.reference); + }); + + /** + * A payout escrow and a payment escrow must never be able to collide, + * or a settlement could address a member's payment escrow. + */ + it('cannot collide with a payment’s escrow id', () => { + const paymentId = 'e2f1c0d4-0000-0000-0000-000000000001'; + expect( + SorobanPayoutAdapter.deriveEscrowId(paymentId).toString('hex'), + ).not.toBe(deriveEscrowId(paymentId).toString('hex')); + }); + }); + + describe('getPayoutStatus', () => { + async function statusFor(escrowStatus: EscrowStatus) { + contractClient.getEscrowStatus.mockResolvedValue(escrowStatus); + return adapter.getPayoutStatus('a1b2c3'); + } + + it('confirms only a RELEASED escrow — the funds are with the recipient', async () => { + await expect(statusFor(EscrowStatus.RELEASED)).resolves.toBe('confirmed'); + }); + + it('treats LOCKED as pending: created, but not yet released', async () => { + await expect(statusFor(EscrowStatus.LOCKED)).resolves.toBe('pending'); + }); + + /** + * NOT_FOUND covers "the submission is still queued" as much as + * "nothing was created", so calling it a failure would race settlement + * against its own queue. + */ + it('treats NOT_FOUND as pending, never as failed', async () => { + await expect(statusFor(EscrowStatus.NOT_FOUND)).resolves.toBe('pending'); + }); + + it('reports REFUNDED as failed — the escrow came back', async () => { + await expect(statusFor(EscrowStatus.REFUNDED)).resolves.toBe('failed'); + }); + + it('reads fresh contract state, from the treasury’s point of view', async () => { + contractClient.getEscrowStatus.mockResolvedValue(EscrowStatus.RELEASED); + await adapter.getPayoutStatus('a1b2c3'); + + expect(contractClient.getEscrowStatus).toHaveBeenCalledWith( + 'GTREASURY', + Buffer.from('a1b2c3', 'hex'), + ); + }); + + it('propagates a rail error instead of inventing a verdict', async () => { + contractClient.getEscrowStatus.mockRejectedValue( + new Error('rpc unreachable'), + ); + await expect(adapter.getPayoutStatus('a1b2c3')).rejects.toThrow( + 'rpc unreachable', + ); + }); + }); +}); diff --git a/backend/src/payments/soroban/soroban-payout.adapter.ts b/backend/src/payments/soroban/soroban-payout.adapter.ts new file mode 100644 index 00000000..00f17a18 --- /dev/null +++ b/backend/src/payments/soroban/soroban-payout.adapter.ts @@ -0,0 +1,102 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bull'; +import { Queue } from 'bull'; +import { createHash } from 'crypto'; +import { + ExternalPayoutRail, + PayoutStatus, + PayoutSubmission, + SubmitPayoutInput, +} from '../../credits/interfaces/external-payout-rail.interface'; +import { EscrowContractClient } from './escrow-contract.client'; +import { EscrowStatus } from './escrow-status.enum'; +import { SorobanConfig } from './soroban-config'; +import { + ESCROW_CONTRACT_CLIENT, + SOROBAN_CONFIG, + SOROBAN_ESCROW_QUEUE, +} from './soroban.tokens'; + +/** + * Implements the credit ledger's off-platform payout port (issue #1575) + * over the #1574 Soroban escrow rail: a treasury-funded escrow created + * for, and released to, the recipient — i.e. a transfer, expressed with + * the contract this codebase already talks to. + * + * Two properties settlement depends on, and how they are provided: + * + * - **Idempotent on `idempotencyKey`.** The escrow id is derived from the + * key by hash, so the same key always addresses the same on-chain + * record; the queue job id is derived from it too, so Bull collapses a + * duplicate enqueue. Re-submitting a payout is therefore a no-op rather + * than a second transfer. + * - **Submission is never treated as success.** `submitPayout` only + * enqueues, exactly as the escrow rail's own `initiate` does; + * `getPayoutStatus` is a fresh contract-state read, which is the only + * thing that ever moves a payout to CONFIRMED. + */ +@Injectable() +export class SorobanPayoutAdapter implements ExternalPayoutRail { + constructor( + @InjectQueue(SOROBAN_ESCROW_QUEUE) private readonly queue: Queue, + @Inject(ESCROW_CONTRACT_CLIENT) + private readonly contractClient: EscrowContractClient, + @Inject(SOROBAN_CONFIG) private readonly sorobanConfig: SorobanConfig, + ) {} + + /** + * Deterministic escrow id for a payout — the same role + * `deriveEscrowId(paymentId)` plays for a Payment, keyed on the + * settlement payout's idempotency key instead. Kept distinct from the + * payment derivation by a domain prefix so a payout can never collide + * with a payment's escrow. + */ + static deriveEscrowId(idempotencyKey: string): Buffer { + return createHash('sha256') + .update(`payout:${idempotencyKey}`, 'utf8') + .digest(); + } + + async submitPayout(input: SubmitPayoutInput): Promise { + const escrowId = SorobanPayoutAdapter.deriveEscrowId(input.idempotencyKey); + const escrowIdHex = escrowId.toString('hex'); + + await this.queue.add( + 'submit', + { + kind: 'payout', + escrowIdHex, + beneficiaryAddress: input.destinationAddress, + amount: input.amount, + }, + { jobId: `payout:${escrowIdHex}`, attempts: 1 }, + ); + + return { reference: escrowIdHex }; + } + + /** + * RELEASED is the only confirmation: the funds are with the recipient. + * LOCKED means the escrow exists but the release has not landed yet, and + * NOT_FOUND covers "the submission is still in flight" as much as + * "nothing was created" — both are `pending`, never `failed`, so + * settlement never marks entries settled (or gives up) on the strength + * of a race against its own queue. Only REFUNDED — the escrow returned + * to the treasury — is a definite failure. + */ + async getPayoutStatus(reference: string): Promise { + const status = await this.contractClient.getEscrowStatus( + this.sorobanConfig.treasuryPublicKey, + Buffer.from(reference, 'hex'), + ); + switch (status) { + case EscrowStatus.RELEASED: + return 'confirmed'; + case EscrowStatus.REFUNDED: + return 'failed'; + case EscrowStatus.LOCKED: + case EscrowStatus.NOT_FOUND: + return 'pending'; + } + } +}