Skip to main content

Guarantees

The Money Service provides a set of architectural guarantees that modules can rely on unconditionally. These are not configuration options — they are structural invariants enforced at the database and application layer.


1. ACID Transactions

Every balance-modifying operation (credit, debit, transfer, hold, confirm, cancel, reversal) executes inside a single PostgreSQL ACID transaction.

PropertyGuarantee
AtomicityAll steps of an operation commit together or none do. Partial failure is impossible.
ConsistencyDatabase constraints (CHECK (balance >= 0), foreign keys) are enforced on every commit.
IsolationSELECT ... FOR UPDATE provides row-level locking. Concurrent operations on the same wallet are serialized.
DurabilityCommitted transactions survive crashes. PostgreSQL WAL ensures no data loss.

The Saga Pattern is not needed for internal Money operations because all wallets are in the same PostgreSQL database. Saga applies only when coordinating Money with external systems — handled by the hold/confirm/cancel pattern.


2. Idempotency

Every balance-modifying SDK method and REST endpoint requires an Idempotency-Key (UUID v4 or v7):

Client sends:
POST /wallets/:id/credit
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
{ "amount": 5000 }

→ Money Service: execute credit, store (key → result) for 24h
→ Response: 201 Created, { transactionId, status: completed, ... }

Client resends (e.g. after network timeout):
POST /wallets/:id/credit
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 ← same key
{ "amount": 5000 }

→ Money Service: key found, return original result
→ Response: 201 Created (same body — no second credit)
ParameterValue
Key formatUUID v4 or v7
Key storage TTL24 hours after operation
Enforcement levelDatabase-level unique constraint per (walletId, idempotencyKey)
Missing key400 Bad Requestvalidation-error
Key from different wallet409 ConflictIDEMPOTENCY_KEY_CONFLICT

3. No Float — Integer Cents

All amounts are stored as BIGINT (integer cents). Float arithmetic is prohibited throughout the service:

IEEE 754 float error:
0.1 + 0.2 = 0.30000000000000004

Integer cents:
10 + 20 = 30 (exact, always)

shopspring/decimal handles intermediate calculations (e.g. fee percentages). The result is always rounded to an integer before any database write. No float value ever enters the transactions or wallets tables.

Display formatting ($12.50 from 1250) is the responsibility of the UI layer. The Money Service returns integers only.


4. Audit Trail

Every operation is recorded in the Audit Service asynchronously. Per-operation audit events:

OperationAudit event
creditmoney.transaction.credited
debitmoney.transaction.debited
transfermoney.transaction.transferred
holdmoney.hold.created
confirmmoney.hold.confirmed
cancelmoney.hold.canceled
cancel (TTL expiry)money.hold.expired
reversalmoney.transaction.reversed

The audit call is non-blocking — the Money Service does not wait for the Audit Service to confirm the record. If the Audit call fails, the financial operation is not rolled back. The Audit Service's dual-write guarantee (Kafka primary + PostgreSQL WAL fallback) ensures the audit record is eventually persisted.


5. Double-Entry Bookkeeping

Every transaction produces two ledger entries: one debit and one credit. This maintains conservation of value across the system:

Credit $50 to wallet-1:
Ledger entry 1 (debit): external_source -5000
Ledger entry 2 (credit): wallet-1 +5000
Net: 0

Transfer $30 from wallet-1 to wallet-2:
Ledger entry 1 (debit): wallet-1 -3000
Ledger entry 2 (credit): wallet-2 +3000
Net: 0

The sum of all ledger entries in the system is always zero. This is the basis for financial reconciliation and fraud detection.


6. Concurrent Debit Protection

Simultaneous debits on the same wallet are serialized using PostgreSQL pessimistic row-level locking:

BEGIN;
SELECT id, available, frozen FROM wallets
WHERE id = $walletId
FOR UPDATE; -- acquires row-level lock (~ms)

-- Check constraint:
IF available < amount THEN
ROLLBACK;
RETURN 400 Insufficient Funds;
END IF;

UPDATE wallets
SET available = available - amount
WHERE id = $walletId;

INSERT INTO transactions ...;
COMMIT; -- releases lock

The CHECK (available >= 0) constraint at the PostgreSQL level provides a second barrier — negative balance is impossible even if application-level logic has a bug.


7. Transfer Deadlock Prevention

Concurrent inverse transfers (wallet-A → wallet-B and wallet-B → wallet-A simultaneously) would deadlock under naive locking. The Money Service prevents this by always acquiring locks in ascending wallet_id (UUID) order:

SELECT id, available FROM wallets
WHERE id IN ($fromWalletId, $toWalletId)
ORDER BY id ASC -- deterministic lock order
FOR UPDATE;
Transfer X: wallet-abc → wallet-xyz
Lock order: wallet-abc (lower UUID), then wallet-xyz

Transfer Y: wallet-xyz → wallet-abc
Lock order: wallet-abc (lower UUID), then wallet-xyz

Both acquire locks in the same order → deadlock impossible.

If PostgreSQL signals 40P01 (deadlock detected), the Money Service retries automatically:

AttemptDelay before retry
1 (initial)— immediate
2100 ms
3200 ms
4 (final)400 ms — if still fails → 500 Internal Server Error

Maximum 3 retries (4 total attempts). Lock hold time is milliseconds, so real deadlocks at this level are extremely rare in practice.


8. Transaction Retention

PeriodStorageAccess
0 – 7 yearsPostgreSQL (monthly partitions: transactions_2026_04, …)Immediate, O(log n) via composite index (wallet_id, created_at DESC)
After 7 yearsRecords soft-deleted; audit record of deletion preservedNo API access

Retention period of 7 years satisfies AML/KYC compliance requirements. Monthly partitioning ensures queries scan only the relevant partition:

-- Query hits only the April 2026 partition — not all 84 partitions
SELECT * FROM transactions
WHERE wallet_id = $walletId
AND created_at >= '2026-04-01'
AND created_at < '2026-05-01'
ORDER BY created_at DESC
LIMIT 20;

Guarantee Summary

#GuaranteeEnforcement mechanism
1ACIDPostgreSQL transactions
2IdempotencyUUID key, 24h DB-level constraint
3No floatBIGINT cents, shopspring/decimal
4Audit trailAsync, non-blocking, dual-write
5Double-entryTwo ledger entries per operation
6No negative balanceSELECT FOR UPDATE + CHECK (balance >= 0)
7No deadlockUUID ASC lock order + 3-retry backoff
87-year retentionMonthly partitioned PostgreSQL table