Payment Orchestrator

Routing rules engine. Selects PSP, runs cascade, delegates to the adapter.

Phase 2
Spec
Orchestrator is the only component that knows which PSP to use and in what order. It never talks to a PSP directly -- always through the Adapter Layer. It has no knowledge of the HTTP layer above it. It receives a normalized RequestContext from the Router and returns an OrchestratorResult.
The Orchestrator holds no state between requests. It is stateless -- any state (transaction status, selected PSP, cascade attempts) is written to the DB via the State Machine and available on the next call.
1.

Orchestration Flow

1

Receive normalized request from Router

Context includes user_id, brand_id, geo, direction, method, amount, currency, session_id.

2

Load PSP candidates from psp_configs

Query: WHERE brand_id = ? AND is_active = true AND (geo = ? OR geo IS NULL) AND direction IN (?, "both") ORDER BY priority ASC

SELECT * FROM psp_configs
WHERE brand_id     = :brand_id
  AND is_active    = true
  AND direction    IN (:direction, 'both')
  AND (geo = :geo OR geo IS NULL)
  AND :method = ANY(methods)
ORDER BY priority ASC
3

Apply routing_rules overrides

Evaluate active rules for this brand, ordered by priority. First matching rule pins a specific psp_config_id. If no rule matches -- use the priority-ordered list from step 2.

4

Select first PSP from candidate list

The top candidate (lowest priority number) becomes the active PSP. Its psp_config_id is written to the transaction record immediately.

5

Resolve adapter and call initiateDeposit / initiateWithdrawal

Adapter is resolved by psp_name from the adapter registry. The call is synchronous -- Orchestrator awaits the UnifiedResponse.

const adapter = adapterRegistry.get(pspConfig.psp_name)
const response = await adapter.initiateDeposit({
  transactionId: tx.id,
  orderId:       tx.psp_order_id,
  amount:        tx.amount_requested,
  currency:      tx.currency,
  method:        tx.method,
  credentials:   pspConfig.credentials,
})
6

Handle adapter response

On success -- transition state machine to PROCESSING and return data to Router. On PSP error -- attempt cascade to next candidate (see Cascade Manager).

7

Return UnifiedResponse to Router

Router maps the UnifiedResponse to the HTTP response shape and sends it to the client.

2.

Routing Dimensions

The Orchestrator builds the SQL query dynamically from the request context. All dimensions are applied simultaneously as AND conditions.

Brand

brand_id -- mandatory. Every PSP config is scoped to a brand.

GEO

player's country from request context. NULL in psp_configs matches any geo.

Direction

"deposit" | "withdrawal". Configs with direction="both" match either.

Currency

Filtered implicitly via method slug -- each method maps to a specific currency.

Method

method slug must be present in psp_config.methods[] array.

Priority

Lowest priority number wins within a brand+geo+direction group.

If no candidates are found after filtering -- the Orchestrator returns PSP_NOT_AVAILABLE (503). This means no active PSP is configured for the given brand/geo/method combination.

3.

Cascade Behavior

If the first PSP fails or returns an error, the Orchestrator automatically tries the next candidate. The Cascade Manager handles the iteration logic. Maximum 3 attempts per request.

1

Adapter throws network / timeout error

Remove failed PSP from candidate list. Retry with next candidate. Log error to transaction_events.

Max attempts: 3
2

PSP returns explicit rejection (e.g. currency not supported)

Treat as hard failure for this PSP. Cascade to next candidate. Do NOT retry the same PSP.

Max attempts: 3
3

All candidates exhausted

Transition transaction to FAILED. Return error to Router. No further retries.

Cascade pseudocode

for (const pspConfig of candidates) {
  try {
    const result = await adapter.initiateDeposit(ctx, pspConfig)
    await stateMachine.transition(tx.id, 'PROCESSING', { pspConfigId: pspConfig.id })
    return result
  } catch (err) {
    await transactionEvents.log(tx.id, 'cascade_attempt_failed', { pspName: pspConfig.psp_name, err })
    continue  // try next candidate
  }
}
// all candidates failed
await stateMachine.transition(tx.id, 'FAILED')
throw new OrchestratorError('PSP_ALL_FAILED')
4.

Internal Interface

The Orchestrator is not an HTTP service. It is called directly from the Router as a TypeScript module. The methods below are the module's public contract.

orchestrateDeposit(ctx: RequestContext): Promise<OrchestratorResult>

Main entry point for deposit requests. Runs the full routing + adapter + cascade pipeline.

Returns:OrchestratorResult: { transactionId, status, walletAddress, destinationTag, expiresAt, amountCrypto }
orchestrateWithdrawal(ctx: RequestContext): Promise<OrchestratorResult>

Entry point for withdrawal requests. Phase 3 -- throws NotImplementedError in Phase 2.

Returns:OrchestratorResult: { transactionId, status }
selectPsp(ctx: RequestContext): Promise<PspConfig[]>

Queries psp_configs + routing_rules and returns ordered candidate list. Separated for testability.

Returns:PspConfig[] -- ordered by effective priority after rules applied
5.

Error Codes

CodeHTTPWhen
PSP_NOT_AVAILABLE
503
No active PSP found for brand + geo + method combination
PSP_ALL_FAILED
502
All cascade candidates exhausted without a successful response
ADAPTER_TIMEOUT
504
PSP adapter did not respond within the configured timeout (default 30s)
ADAPTER_ERROR
502
Adapter returned an error response that is not retryable
STATE_WRITE_FAILED
500
Failed to persist transaction or state transition to DB
DEPO44 | ORCHESTRATOR SPEC v1 | PHASE 2