@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
| Rule | Value |
|---|---|
| Amount type | Integer cents only. $12.50 → 1250. Never use floats. |
| Zero or negative amounts | Rejected → 400 Bad Request (invalid-amount) |
| Currency | ISO 4217 three-letter code (USD, EUR, GBP) |
| Idempotency | Every mutating call requires idempotencyKey (UUID). The SDK auto-generates a UUID v7 if omitted, but callers should supply their own for replay-safe operations. |
| ACID | All operations execute in a single PostgreSQL transaction |
| Double-entry | Every operation creates a debit and a credit record |
| Negative balance | Impossible — 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
| Field | Meaning |
|---|---|
available | Funds ready for immediate use |
frozen | Funds reserved by active holds (not spendable) |
pending | Incoming credits not yet fully settled |
Hold Rules
| Parameter | Value |
|---|---|
| Default TTL | 72 hours (MONEY_HOLD_TTL_HOURS) |
| Max TTL | 168 hours (7 days) |
| Max concurrent holds per wallet | 100 (MONEY_MAX_HOLDS_PER_WALLET). Exceeded → 429 Too Many Requests |
| Hold on already-frozen funds | Prohibited — hold() checks available >= amountCents |
| Partial confirm | Not supported. Confirm applies the full hold amount. For partial: cancel() + new hold(). |
| Auto-expiry | Background 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
| Parameter | Value |
|---|---|
| Eligible statuses | completed only (held, canceled, reversed → 400 Bad Request) |
| One reversal per transaction | Attempting a second reversal → 400 Bad Request |
| Max age | 365 days (MONEY_REVERSAL_MAX_AGE_DAYS). Older → 400 Reversal window expired. For older transactions: Platform Owner CLI intervention. |
| Idempotency | Duplicate reversal with same originalTxId → returns existing reversal |
| Audit | money.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
| Parameter | Default | Configured 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 type | BIGINT | Fixed (~$92 quadrillion max) |
| Min amount | 1 cent (amount > 0) | Fixed |
| Negative balance | Impossible | PostgreSQL CHECK (balance >= 0) |
| Transaction retention | 7 years (AML/KYC compliance) | Fixed |
REST API Reference
For layout brevity, the /api/v1 base path prefix is omitted
from the endpoint table below.
| Method | Endpoint | Description |
|---|---|---|
POST | /wallets | Create wallet |
GET | /wallets | List wallets (paginated, filter by userId, currency) |
GET | /wallets/:id | Wallet details |
GET | /wallets/:id/balance | Current balance (available, frozen, pending) |
POST | /wallets/:id/credit | Credit funds |
POST | /wallets/:id/debit | Debit funds |
POST | /wallets/:id/hold | Freeze funds (hold) |
POST | /wallets/:id/confirm | Confirm hold → debit |
POST | /wallets/:id/cancel | Cancel hold → release |
POST | /wallets/:id/reversal | Reverse completed transaction |
POST | /wallets/transfer | Transfer between wallets |
GET | /wallets/:id/transactions | Transaction history (paginated) |
GET | /transactions/:id | Single transaction detail |