Risk rules
Deposits and transfers are evaluated before posting. The first matching rule wins; thresholds below are configurable defaults, in minor units.
- Decline: Any owner ID begins with
blocked-, ignoring case. - Decline: Amount is at least
1,000,000 minor units. - Approve: None of the above applies.
Review does not post the transaction or mean an operator approved it. Reversals and settlement confirmations do not use this risk check.
Compliance alert rules
Compliance checks committed transaction events after the outbox publisher delivers them through Kafka. An alert is created when any of these conditions matches:
- Single minor-unit transaction: Total debit postings equal exactly
1 minor unit. Applies to any committed transaction event. - Large transaction: Total debit postings are at least
500,000 minor units. Applies to any committed transaction event. - Repeated transfers: At least three outgoing transfers from the same debit account occur within five minutes. Applies to transfers only; the current transfer counts as one of the three.
Compliance does not stop or reverse a transaction. A risk review before commit means there is no committed event for compliance to evaluate.
Risk DB data model
The risk service owns one table. It stores decisions independently from the ledger database; there are no cross-database foreign keys.
risk_decisions
- PK decision_id
- UUID
- client_scope
- TEXT
- idempotency_key
- TEXT
- request_hash
- TEXT
- transaction_type
- TEXT
- amount_minor
- BIGINT
- owner_ids
- JSONB
- state
- TEXT
- reasons
- JSONB
- correlation_id
- TEXT
- created_at
- TIMESTAMPTZ
- expires_at
- TIMESTAMPTZ
Constraints: (client_scope, idempotency_key) is unique; state is approved, declined, or review. An index on (client_scope, created_at DESC) supports the recent-decision velocity check.
Ledger data model
Arrows connect foreign keys to their referenced tables. PK = primary key · FK = foreign key · ? = nullable. Selected fields shown; hover a relationship line for its exact mapping.
Deferred database constraints require at least two postings with equal debit and credit totals per transaction at commit. The application writes its balance updates, postings, idempotency result, and outbox record within the same transaction. Available funds also account for active holds.
Supporting API authentication storage is omitted from this core money-movement model.
Explore the schema migrations ↗
Temporal settlement, step by step
A durable program for a multi-service money movement
Temporal does not move the money and it is not another ledger. It remembers which orchestration step completed, schedules the next one, and recreates workflow state from an append-only event history when a worker restarts.
SettlementBatchWorkflowOne parent workflow per batch ID. Resolves the bank account, prepares the batch, and starts one child per settlement.
→
SettlementWorkflowOne child workflow per settlement ID. Owns the payout → confirm-or-fail decision for that settlement.
→
Ledger + bank APIsActivities perform the non-deterministic HTTP calls. These services remain the owners of their own data and side effects.
The activity chain
1. Resolve account
Find or create the ledger’s external-bank account.
2. Prepare batch
Create settlements and place holds on available funds.
3. Request payout
Call the synthetic bank using the settlement identity.
4. Reconcile ledger
Confirm after payout, or fail and release the hold after rejection.
What Temporal contributes
Durable executionThe worker can disappear. Completed history remains in Temporal and a replacement worker continues from the next command.
Controlled retriesTransient activity failures back off from one second, cap at ten seconds, and stop after five attempts.
Deterministic orchestrationWorkflow code decides what comes next; network and database work stays inside activities so history can be replayed safely.
Child isolationEach settlement receives its own history and result while the batch workflow aggregates confirmed and failed outcomes.
Durable timersA workflow delay is recorded as a timer, not a sleeping worker process. No worker must remain alive while the clock runs.
Trace continuityThe OpenTelemetry interceptor propagates trace context through workflow and activity headers for cross-service investigation.
WorkflowStarted → ActivityScheduled → ActivityStarted → ActivityFailed → TimerFired → ActivityStarted → ActivityCompleted → WorkflowCompleted
The crucial boundary: Temporal knows that an activity reported completion; it cannot make an external bank and PostgreSQL one atomic transaction. Activities that can be retried must therefore use stable business identities. In this demo, the settlement ID acts as the payout identity, and the payout reference is reused when the ledger confirmation is retried.