Payouts
The payout product line — beneficiaries, providers, scheduled payouts, the money-out ledger, and the two fulfilment providers (Merit, Ledig). Includes the critical "pipeline wired but not driven" finding.
Payouts are a separate product line — paying money out to beneficiaries — parallel to the voucher/topup/eSIM order engine. The whole subsystem is gated behind the payouts feature flag (utils.FeatureFlags.IsPayoutsEnabled(), checked in main.go:227, routes.go:116, client.go:96, admin.go:234).
Critical — the direct-payout pipeline is wired but not driven
Read this before trusting any happy-path description. In the current build a POST /payouts debits the wallet and stops at status created — it is never submitted to a provider:
- No
created → queuedtransition fires.QueuePayout(payout_service.go:391) is the only path toqueuedand has no production caller (seeders/tests only). - The processor & status-sync crons are not registered.
main.go:227registers only the webhook + scheduled-payout tasks;NewPayoutProcessorTask(submits to provider) andNewPayoutStatusSyncTask(polls provider) are implemented but never added tobaseTasks. - Net effect: no payout — direct or scheduled — ever reaches Merit/Ledig, and no status is ever reconciled. Both dead-end at "created + wallet debited."
- No refund path.
refund_transaction_id,UpdatePayoutParams.RefundTransactionID, and thepayout.refundedevent all exist but nothing writes them — so a failed/cancelled-after-debit payout leaves the client's money debited and never re-credited. This is a fund-loss / reconciliation risk.
Triage this first: register the two crons, wire QueuePayout into CreatePayout, and implement the refund-on-failure branch.
The intended lifecycle (vs. what runs today)
Solid arrows = what actually runs. Dotted = designed but not wired. The whole right side of the diagram is currently dark.
Despite an early guess, payouts are not Runa-backed (Runa fuels the voucher line). Payouts are fulfilled by two providers: Merit Incentives (MERIT) and Ledig (LEDIG).
Data model
Tables under database/migration/2025120800000*, 20260303*, 20260304*. Models in database/models/.
| Model | Table | Notes |
|---|---|---|
Payout (payout.go:95) | payouts | public_id (pay_ prefix), reference_id idempotency key UNIQUE(client_id, reference_id), money NUMERIC(18,6) (amount/fees/total_deducted), FK transaction_id (debit) + refund_transaction_id (never written). Beneficiary data is snapshotted onto the row. |
PayoutBeneficiary (payout_beneficiary.go:12) | payout_beneficiaries | Soft-deletable, denormalized stats, Identifiers JSON (provider-specific). |
BeneficiaryPayoutProvider (beneficiary_payout_provider.go:12) | beneficiary_payout_providers | Admin-only join binding a beneficiary to a provider + the identifiers needed to pay them (revtag / IBAN / account_alias). Drives provider resolution. |
PayoutProvider / PayoutProviderAttribute (payout_provider.go:9,80) | payout_providers / payout_provider_attributes | Provider registry; API creds stored as attributes (is_secret flag). Fee percentage/fixed. Many-to-many currencies/countries. |
PayoutEvent (payout_event.go:9) | payout_events | Audit: from_status→to_status, actor_type (client/system/provider/admin), provider_response JSONB. |
ScheduledPayout (scheduled_payout.go:154) | scheduled_payouts | Statuses active/paused/completed/cancelled/exhausted; once/daily/weekly/monthly; timezone-aware ComputeNextRunAt. Money NUMERIC(15,2) — precision differs from payouts. |
ScheduledPayoutExecution (:376) | scheduled_payout_executions | One row per run, UNIQUE(scheduled_payout_id, execution_number). |
ClientPayoutConfig (client_payout_config.go:10) | client_payout_configs | Per-client payouts_enabled, daily/monthly/min/max limits, allowed currencies/providers. Limits are computed & shown but NOT enforced on create. |
ClientPayoutWebhook + PayoutWebhookDelivery | payout_webhook_deliveries | Outbound client webhooks (separate from voucher webhooks). |
PayoutStatus enum (payout.go:17): created, queued, processing, settling, action_required, completed, failed, cancelled, expired. Postgres native ENUM with sql.Scanner/driver.Valuer. Helpers IsTerminal, IsCancellable.
Services
PayoutService (services/payout_service.go)
CreatePayout (:68) — the money-moving entry point that does run:
- Idempotency:
GetPayoutByReferenceIDreturns the existing payout ifreference_idwas seen (replay, not error). - Loads wallet, checks ownership.
ResolveProviderpicks provider + beneficiary-provider identifiers (see below).- Fees via
provider.CalculateFees;total_deducted = amount + fees; balance check. SERIALIZABLEtx: insert payout (created), debit wallet (UpdateWalletBalance, same ledger path as voucher orders), linktransaction_id, emitpayout.createdevent, commit.- Fires a wallet-debited client webhook async in a goroutine with
context.Background()(this one does work — it's the separate wallet-webhook system).
Transitions (each writes a PayoutEvent): QueuePayout (created→queued, no caller), ProcessPayout (queued→processing, calls provider; on error → failed, no refund), SyncPayoutStatus (polls provider), CancelPayout (best-effort provider cancel; swallows provider-cancel failure → local/provider divergence).
Scheduled payouts (services/scheduled_payout_service.go)
CreateScheduledPayout— heavy validation (schedule type,HH:MM, timezone, per-type day fields, ownership), computesnext_run_at.ExecuteScheduledPayout— builds a request with deterministicreference_id = sch_<publicID>_<n>(idempotent per execution), callsCreatePayout, writes an execution row, advances the schedule.- Driven by cron
scheduled-payout-processor(*/1 * * * *, registeredmain.go:230). The job usesFOR UPDATE SKIP LOCKEDfor safe multi-worker claiming.
Scheduled payouts run and debit wallets on time — but because the created payout is never queued/processed, they too dead-end at created. The schedule engine works; the fulfilment engine behind it is dark.
Outbound client webhooks (services/payout_webhook_service.go)
HMAC-SHA256 signed, MaxAttempts:5, exponential backoff, driven by the payout-webhook-delivery cron (registered). But TriggerPayoutEvent has zero callers — nothing ever enqueues a payout webhook, so the delivery cron runs against a table payouts never populate. (The wallet-debited webhook is a different system and does fire.)
Beneficiaries & the "beneficiary provider" concept
Clients create beneficiaries (email/name/country/phone). A beneficiary provider (BeneficiaryPayoutProvider) is an admin-only join binding one beneficiary to one payout provider plus the provider-specific identifiers needed to actually pay them — e.g. {"revtag": "..."} for Merit/Revolut, {"account_alias": "..."} for Ledig (required, else Ledig hard-fails MISSING_ACCOUNT_ALIAS). Clients never see providers.
Resolution (repo/payout_provider.go:721, ResolveBestProvider): one parameterized SQL query joining beneficiary_payout_providers → payout_providers, filtered by active + currency support + amount-within-min/max + optional allowed-provider list, ordered by computed fee ASC, LIMIT 1 — cheapest eligible linked provider wins.
Gap: the client/API beneficiary-create flow never creates a beneficiary-provider link, so a freshly-created beneficiary has no provider and CreatePayout fails resolution until an admin links one. No error is surfaced at beneficiary-create time.
Provider integration (services/external_vendors/payouts/)
Interface PayoutProvider (contract.go:6): CreatePayout, GetPayoutStatus, CancelPayout, GetProviders, Ping. ProviderFactory.GetProvider switches on provider code. BaseProvider has shared HTTP with DoRequestWithRetry (exp. backoff, retry on 5xx/429, max 3).
| Provider | Auth | Endpoints | Notes |
|---|---|---|---|
Merit (merit_provider.go) | Authorization: Bearer <api_key> | POST /v2/payouts, GET /v2/payouts/{id}, POST /v2/payouts/{id}/cancel | Passes revtag; forwards notify_beneficiary. |
Ledig (ledig_provider.go) | x-api-key: <api_key> | POST /v1/fiat_payout | Requires account_alias. GetPayoutStatus is a no-op (no status endpoint) and CancelPayout is unsupported — Ledig payouts can never be reconciled or cancelled via provider. |
There is no inbound provider→Octopus webhook handler anywhere in http/. All provider status flow was designed to be poll-based via SyncPayoutStatus — which is itself not scheduled. The payout_webhook_* files are strictly outbound platform→client webhooks.
Handlers & routes
Three surfaces:
- Public API v1 (
routes.go:115, handlerpayout.go, DTOsentity/payout.go) — the only surface with declarativevalidate-tag validation.GET/POST /v1/payouts,GET /v1/payouts/:id,POST /v1/payouts/:id/cancel, plus beneficiaries CRUD. See API Reference → Payouts. - Client portal (
routes/client.go:95) — beneficiaries (weaker, ad-hoc validation), payouts, and full scheduled-payout CRUD + pause/resume/cancel. - Admin (
routes/admin.go:233,admin_payout_api.go~1149 lines) — dashboard/stats (Redis-cached), payout list/detail (bypasses client scoping), provider management (where API keys are entered as attributes), client-config CRUD (audit-logged), scheduled-payout admin (calls service withclientID=0to skip ownership), beneficiary-provider links.
Money flow
- Debit at
CreatePayout:total_deducted = amount + feesdebited inside the serializable tx, reusing the generic wallet ledger. Currency derived from the wallet, not the request. No FX in the payout path. - Fees: provider config (
percentage/fixed), stored on the payout. - Refund: schema supports it; nothing implements it → money debited on failed/cancelled payouts is not returned (see the critical callout).
- Limits:
ClientPayoutConfigdaily/monthly/min/max +ClientPayoutUsageare computed and shown in admin but not enforced at create — only provider min/max is enforced (via the resolution SQL).
Notifications
- Beneficiary: the platform sends no email itself — it forwards
notify_beneficiaryto Merit (NotifyRecipient), delegating to the provider. Ledig ignores it. - Client webhooks: designed but never enqueued (
TriggerPayoutEventhas no callers) — see above. See Notifications & Email for the outbound-webhook machinery this reuses.
Findings summary
| Severity | Finding | Where |
|---|---|---|
| Critical | Direct + scheduled payouts never submitted to provider (processor/sync crons unregistered; QueuePayout uncalled). | main.go:227, payout_service.go:391 |
| Critical | No refund path → wallet debited but not re-credited on failure/cancel. | payout_service.go:518; refund_transaction_id unused |
| High | TriggerPayoutEvent (client payout webhooks) has zero callers. | payout_webhook_service.go:41 |
| High | Client-created beneficiaries have no provider link → payouts fail resolution silently. | beneficiary-create paths |
| Medium | Client-config limits computed & shown but not enforced on create. | payout_service.go (no IsAmountValid call) |
| Medium | Ledig can't report status or cancel → would be permanently non-terminal if processing were enabled. | ledig_provider.go:228,246 |
| Medium | CancelPayout swallows provider-cancel failures → local/provider divergence. | payout_service.go:353 |
| Low | Precision mismatch: payouts NUMERIC(18,6) vs scheduled_payouts NUMERIC(15,2). | migrations |
| Low | BuildConfigFromAttributes triplicated (models / base_provider / factory). | 3 sites |
| Low | Insufficient-balance/resolution errors returned to clients verbatim (InternalServerError.WithMessage(err.Error())). | client_payout.go:266, payout.go:61 |
| Low | Dead: payout.refunded event, WISE/REVOLUT provider codes, WebhookDeliveryStatusProcessing. | various |
Key files
- Models:
database/models/{payout,payout_beneficiary,beneficiary_payout_provider,payout_provider,scheduled_payout,payout_event,client_payout_config,client_payout_webhook}.go - Services:
services/{payout_service,scheduled_payout_service,payout_webhook_service}.go - Providers:
services/external_vendors/payouts/{contract,factory,base_provider,types,merit_provider,ledig_provider}.go - Jobs/scheduler:
jobs/{payout_processor_job,scheduled_payout_processor_job,payout_webhook_job}.go;scheduler/{payout_processor_task,scheduled_payout_processor_task,payout_webhook_task}.go; wiringmain.go:227 - Handlers:
http/handler/{payout,client_payout,client_scheduled_payout,client_beneficiary,admin_payout_api,admin_payout_ui,admin_scheduled_payout,admin_beneficiary_provider}.go; entitieshttp/entity/payout.go - Resolution SQL:
database/repo/payout_provider.go:721
Auth & Access Control
Every way Octopus authenticates and authorizes — JWT (API), cookie sessions (admin), cookie-JWT (client portal), WebAuthn passkeys, TOTP 2FA, IP whitelisting, RBAC, and multi-tenant scoping — plus the security gaps to fix.
Notifications & Email
How Octopus tells customers about their orders — the per-family notifiers, the terminal-only rule, the Jet email templates and design system, outbound HMAC-signed client webhooks with retry/backoff, and the event→notification matrix.