Skip to main content

@platform/sdk-money

@platform/sdk-money is the module interface to the Platform Money Service — the ACID-safe, double-entry wallet primitive. All amounts are integers in cents — floats are prohibited to prevent rounding errors in financial calculations.


Installation

pnpm add @platform/sdk-money

Core Rules

RuleValue
Amount typeInteger cents only. $12.501250. Never use floats.
Zero or negative amountsRejected → 400 Bad Request (invalid-amount)
CurrencyISO 4217 three-letter code (USD, EUR, GBP)
IdempotencyEvery mutating call requires idempotencyKey (UUID). The SDK auto-generates a UUID v7 if omitted, but callers should supply their own for replay-safe operations.
ACIDAll operations execute in a single PostgreSQL transaction
Double-entryEvery operation creates a debit and a credit record
Negative balanceImpossible — CHECK (balance >= 0) constraint enforced at database level

credit()

Add funds to a wallet:

import { kernel } from '@platform/sdk-core';

const tx = await kernel.money().credit({
walletId: '01j9pwal0000000000000001',
amountCents: 5000, // $50.00
currency: 'USD',
idempotencyKey: crypto.randomUUID(),
description: 'Welcome bonus',
metadata: { promoCode: 'WELCOME50' },
});
// tx: { id, type: 'credit', status: 'completed', amountCents: 5000,
// balanceAfterCents: 15000, createdAt }

debit()

Withdraw funds from a wallet. Returns 400 Insufficient Funds if available < amountCents:

const tx = await kernel.money().debit({
walletId: '01j9pwal0000000000000001',
amountCents: 1250, // $12.50
currency: 'USD',
idempotencyKey: crypto.randomUUID(),
description: 'Subscription fee',
});
// tx: { id, type: 'debit', status: 'completed', amountCents: 1250,
// balanceAfterCents: 13750, createdAt }

Concurrent debit protection: the Money Service uses SELECT ... FOR UPDATE (pessimistic row-level lock) on the wallet row. Two simultaneous debits on the same wallet are serialized — the second debit waits (milliseconds), then re-checks the available balance. Negative balance is structurally impossible.


transfer()

Move funds between two wallets atomically. Both wallets must use the same currency:

const result = await kernel.money().transfer({
fromWalletId: '01j9pwal0000000000000001',
toWalletId: '01j9pwal0000000000000002',
amountCents: 2500,
currency: 'USD',
idempotencyKey: crypto.randomUUID(),
description: 'Payout to creator',
});
// result: { debitTxId, creditTxId, status: 'completed' }

Deadlock prevention: When locking two wallets, the Money Service always acquires locks in ascending UUID order (SELECT ... FOR UPDATE ... ORDER BY id ASC). Both concurrent transfers lock in the same order → deadlock is structurally impossible. On the rare PostgreSQL deadlock error (40P01) → automatic retry (max 3 attempts, 100ms / 200ms / 400ms backoff).


hold() / confirm() / cancel()

Two-phase debit for integration with external payment systems (Stripe, PayPal). Freeze funds while waiting for external confirmation:

// Phase 1: Freeze — funds reserved but not yet debited
const holdTx = await kernel.money().hold({
walletId: '01j9pwal0000000000000001',
amountCents: 10000, // $100.00
currency: 'USD',
idempotencyKey: crypto.randomUUID(),
ttlHours: 72, // Default 72h, max 168h (7 days)
description: 'Order #ORD-1234 authorization',
});
// holdTx: { id, type: 'hold', status: 'held',
// available: 90000→, frozen: 0→10000 }

// Phase 2a: Confirm — debit finalized
const confirmed = await kernel.money().confirm({
holdTxId: holdTx.id,
idempotencyKey: crypto.randomUUID(),
});
// confirmed: { id, type: 'debit', status: 'confirmed', reference_tx_id: holdTx.id }

// Phase 2b: Cancel — funds returned to available
const canceled = await kernel.money().cancel({
holdTxId: holdTx.id,
idempotencyKey: crypto.randomUUID(),
});
// Balance restored: frozen → 0, available += 10000

Balance Fields

FieldMeaning
availableFunds ready for immediate use
frozenFunds reserved by active holds (not spendable)
pendingIncoming credits not yet fully settled

Hold Rules

ParameterValue
Default TTL72 hours (MONEY_HOLD_TTL_HOURS)
Max TTL168 hours (7 days)
Max concurrent holds per wallet100 (MONEY_MAX_HOLDS_PER_WALLET). Exceeded → 429 Too Many Requests
Hold on already-frozen fundsProhibited — hold() checks available >= amountCents
Partial confirmNot supported. Confirm applies the full hold amount. For partial: cancel() + new hold().
Auto-expiryBackground worker (60-second ticker) sets expired holds canceled, releases frozen back to available, publishes money.hold.expired event

reversal()

Create a counter-transaction for a completed credit or debit. Reversal is a new transaction — the original is marked reversed: true:

const reversal = await kernel.money().reversal({
originalTxId: '01j9ptx0000000000000001',
idempotencyKey: crypto.randomUUID(),
reason: 'Customer requested refund — Order #ORD-1234 cancelled',
});
// reversal: { id, type: 'reversal', status: 'completed',
// reference_tx_id: '01j9ptx...', amountCents: 10000 }

Reversal Rules

ParameterValue
Eligible statusescompleted only (held, canceled, reversed400 Bad Request)
One reversal per transactionAttempting a second reversal → 400 Bad Request
Max age365 days (MONEY_REVERSAL_MAX_AGE_DAYS). Older → 400 Reversal window expired. For older transactions: Platform Owner CLI intervention.
IdempotencyDuplicate reversal with same originalTxId → returns existing reversal
Auditmoney.transaction.reversed → Audit Service

End-to-End: Hold → Confirm → Reversal

Step 1: hold(wallet, 5000)
available: 10000 → 5000, frozen: 0 → 5000 [tx: held]

Step 2: confirm(holdTxId)
available: 5000, frozen: 5000 → 0 [tx: confirmed = debit]

Step 3: reversal(confirmTxId)
available: 5000 → 10000, frozen: 0 [tx: reversal = credit]

getBalance()

Retrieve the current wallet balance across all three balance fields:

const balance = await kernel.money().getBalance('01j9pwal0000000000000001');
// {
// walletId: '01j9pwal0000000000000001',
// currency: 'USD',
// availableCents: 13750, // $137.50
// frozenCents: 10000, // $100.00 on hold
// pendingCents: 0,
// totalCents: 23750, // available + frozen + pending
// }

Idempotency

Every mutating call is idempotent when the same idempotencyKey is replayed. The Money Service returns the original transaction without re-executing:

const key = crypto.randomUUID(); // Generate once, store it

// First call: executes the debit
const tx1 = await kernel.money().debit({ walletId, amountCents: 5000, currency: 'USD', idempotencyKey: key });

// Second call (same key — e.g., retry after network failure):
// Returns the SAME transaction, no double-debit
const tx2 = await kernel.money().debit({ walletId, amountCents: 5000, currency: 'USD', idempotencyKey: key });

console.log(tx1.id === tx2.id); // true

Limits

ParameterDefaultConfigured by
Max single transaction$100,000 (10,000,000 cents)Billing plan
Max wallet balance$1,000,000 (100,000,000 cents)Billing plan
Database column typeBIGINTFixed (~$92 quadrillion max)
Min amount1 cent (amount > 0)Fixed
Negative balanceImpossiblePostgreSQL CHECK (balance >= 0)
Transaction retention7 years (AML/KYC compliance)Fixed

REST API Reference

Base Path

For layout brevity, the /api/v1 base path prefix is omitted from the endpoint table below.

MethodEndpointDescription
POST/walletsCreate wallet
GET/walletsList wallets (paginated, filter by userId, currency)
GET/wallets/:idWallet details
GET/wallets/:id/balanceCurrent balance (available, frozen, pending)
POST/wallets/:id/creditCredit funds
POST/wallets/:id/debitDebit funds
POST/wallets/:id/holdFreeze funds (hold)
POST/wallets/:id/confirmConfirm hold → debit
POST/wallets/:id/cancelCancel hold → release
POST/wallets/:id/reversalReverse completed transaction
POST/wallets/transferTransfer between wallets
GET/wallets/:id/transactionsTransaction history (paginated)
GET/transactions/:idSingle transaction detail