Reconciliation
Nightly PSP sync. Detects and resolves discrepancies between our records and PSP data.
Why It Is Needed
Webhooks get lost
A PSP webhook can fail to deliver due to network issues, server restarts, or our endpoint being temporarily unavailable. Without reconciliation, a successful payment sits as TIMED_OUT forever.
TTL fires before blockchain confirms
A player sends crypto just as our TTL timer fires. Our system marks the transaction TIMED_OUT while the payment is actually on its way. Reconciliation detects this and credits the balance retroactively.
PSP status diverges from ours
Our state machine shows PROCESSING but the PSP has already settled the transaction as paid or rejected. This can happen after a cascade attempt where the first PSP actually processed the payment despite returning an error.
Schedule & Configuration
Primary run
RECONCILIATION_CRONDaily at 03:00 UTC -- low-traffic window. Processes all candidates from the previous 48 hours.
Lookback window
RECONCILIATION_LOOKBACK_HOURSTransactions older than 48 hours are considered final and excluded. Prevents re-processing settled transactions.
Alert threshold
RECONCILIATION_ALERT_THRESHOLDIf more than 10 credits are applied in a single run, ops team is notified. Signals a systemic webhook delivery problem.
Algorithm
Select candidate transactions
Query transactions table for rows in states that may need resolution: PROCESSING older than 30 min, TIMED_OUT within the last 48 hours, PENDING_CONFIRMATION older than 2 hours.
SELECT * FROM transactions
WHERE status IN ('PROCESSING', 'TIMED_OUT', 'PENDING_CONFIRMATION')
AND updated_at < NOW() - INTERVAL '30 minutes'
AND created_at > NOW() - INTERVAL '48 hours'Call getTransactionStatus() for each
For each candidate, call the adapter's getTransactionStatus(psp_payment_id) to get the current status directly from the PSP. Adapter returns a UnifiedStatus.
Compare PSP status with our status
If statuses match -- no action, log a reconciliation_check event. If they differ -- discrepancy detected, proceed to resolution.
PSP = COMPLETED, ours = TIMED_OUT / PROCESSING
Payment was successful but we missed it. Transition transaction to COMPLETED via state machine, credit player balance via Wallet Engine. Log reconciliation_credit event.
PSP = FAILED / EXPIRED, ours = PROCESSING / TIMED_OUT
Payment definitively failed. Transition transaction to FAILED. No balance change. Log reconciliation_failed event. If a withdrawal hold was active -- release it back to available.
PSP status still pending
PSP also shows PROCESSING or equivalent. Leave our status unchanged. Schedule for re-check in the next reconciliation run.
Generate reconciliation report
After processing all candidates, write a summary to reconciliation_reports: total checked, credits applied, failures resolved, errors encountered. Alert ops team if credit count exceeds threshold.
Discrepancy Types
| Our status | PSP status | Action | Risk |
|---|---|---|---|
TIMED_OUT | COMPLETED | Credit balance, transition to COMPLETED | High |
PROCESSING | COMPLETED | Credit balance, transition to COMPLETED | High |
PROCESSING | FAILED | Transition to FAILED, no balance change | Medium |
TIMED_OUT | FAILED | Confirm FAILED state, log only | Low |
COMPLETED | FAILED | Critical alert to ops -- do not auto-resolve, manual review required | High |
PROCESSING | Not found | Alert ops -- psp_payment_id mismatch or PSP data loss | High |
The COMPLETED → FAILED scenario is never auto-resolved. The balance was already credited -- automatic debit means a direct financial loss to the player without their knowledge. Manual ops review only.
Events & Reporting
| event_type | When | payload |
|---|---|---|
reconciliation_check | Each transaction checked, statuses match | { psp_status, our_status, checked_at } |
reconciliation_credit | Missed payment found, balance credited | { psp_status, amount_credited, wallet_tx_id } |
reconciliation_failed | Transaction resolved as FAILED | { psp_status, previous_status } |
reconciliation_alert | Critical discrepancy, manual review needed | { our_status, psp_status, reason } |
reconciliation_run | End of each reconciliation job run | { total_checked, credits, failures, alerts, duration_ms } |
reconciliation_reports table (separate from transaction_events)
iduuidPrimary keyrun_attimestamptzWhen the job rantotal_checkedintTotal transactions inspectedcredits_appliedintTransactions where balance was creditedfailures_closedintTransactions resolved as FAILEDalerts_raisedintCritical discrepancies requiring manual reviewduration_msintTotal run time in millisecondsFee & Revenue Model
PSP Fee Structure
Percentage fee
Applied as % of settlement_amount. Typical range: 1.2%--3.5% for cards, 0.5%--1.5% for crypto. Varies by PSP contract and monthly volume tier.
Flat fee per transaction
Fixed amount regardless of transaction size. Typical: $0.10--$0.30 per transaction. Often combined with percentage fee (e.g. 1.5% + $0.20).
Decline & chargeback fees
PSPs charge per declined transaction ($0.05--$0.15) and per chargeback dispute ($15--$100). These appear as separate line items on the PSP invoice and must be tracked separately.
Platform Revenue Models
Markup on PSP fee
Platform charges operator PSP fee + markup percentage. Example: PSP charges 1.8%, platform charges operator 2.3%, pocket 0.5%. Simplest model -- one number per transaction. Stored as platform_fee_pct in psp_configs.
Flat fee per transaction
Platform charges operator a fixed amount per transaction regardless of PSP cost. Example: $0.50 per deposit processed. Predictable for operator, less upside for platform on large transactions. Stored as platform_fee_flat in psp_configs.
Revenue share
Platform takes a percentage of the operator's GGR (Gross Gaming Revenue) attributable to payment processing. More complex to calculate -- requires game revenue data. Typically used for strategic large-volume operators. Defined in operator contract, not in payment config.
FX spread revenue
When operators support multiple display currencies, the platform applies a configurable spread (e.g. 1.5%) on top of the mid-market rate (see FX / Multi-currency). This spread is pure platform revenue -- no PSP cost involved. Tracked as fx_spread_revenue in the fee ledger.
Table transaction_fees -- fee ledger
transaction_fees table stores the fee breakdown for every transaction. This allows the reconciliation job to compare the actual PSP invoice against what the platform expected -- and detect discrepancies before the invoice arrives.| Column | Type | Description |
|---|---|---|
id | uuid PK | Fee record ID |
transaction_id | uuid FK | Reference to the transaction in transactions table |
operator_id | uuid FK | Operator being charged |
psp_id | uuid FK | PSP that processed the transaction |
psp_fee_pct | numeric(6,4) | PSP percentage fee applied (e.g. 0.018 = 1.8%) |
psp_fee_flat | numeric(10,4) | PSP flat fee per transaction in settlement currency |
psp_fee_total | numeric(14,4) | Total PSP cost: (settlement_amount * psp_fee_pct) + psp_fee_flat |
platform_fee_pct | numeric(6,4) | Platform markup percentage charged to operator |
platform_fee_flat | numeric(10,4) | Platform flat fee per transaction charged to operator |
platform_fee_total | numeric(14,4) | Total operator charge: (settlement_amount * platform_fee_pct) + platform_fee_flat |
fx_spread_revenue | numeric(14,4) | Revenue from FX spread on this transaction (0 if no currency conversion) |
platform_revenue | numeric(14,4) | Computed: platform_fee_total - psp_fee_total + fx_spread_revenue |
psp_invoiced_amount | numeric(14,4) | Actual fee from PSP invoice -- populated by reconciliation job, null until invoice arrives |
fee_discrepancy | numeric(14,4) | psp_invoiced_amount - psp_fee_total -- non-zero means unexpected fee from PSP |
currency | char(3) | All amounts in this row are in this currency (settlement currency) |
created_at | timestamptz | When this fee record was created (at transaction completion) |
Settlement Report
Per-operator monthly report
Reconciliation job generates a monthly settlement report per operator: total transaction volume, total PSP fees paid, total platform fees charged to operator, net revenue, FX spread revenue, and any chargeback costs attributed to the operator. Stored in operator_settlement_reports table.
PSP invoice matching
Each PSP sends a monthly invoice. The reconciliation job ingests the invoice line items and matches them against transaction_fees.psp_invoiced_amount. Any line item that differs from the expected fee by more than $0.01 is flagged as fee_discrepancy and sent to ops for review.
Revenue dashboard inputs
Admin Panel revenue dashboard reads from transaction_fees -- aggregated by operator, PSP, method, and time period. Metrics: gross payment volume, PSP cost ratio, platform revenue, effective take rate (platform_revenue / settlement_amount). All in settlement currency.