OctoWiki

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 → queued transition fires. QueuePayout (payout_service.go:391) is the only path to queued and has no production caller (seeders/tests only).
  • The processor & status-sync crons are not registered. main.go:227 registers only the webhook + scheduled-payout tasks; NewPayoutProcessorTask (submits to provider) and NewPayoutStatusSyncTask (polls provider) are implemented but never added to baseTasks.
  • 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 the payout.refunded event 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/.

ModelTableNotes
Payout (payout.go:95)payoutspublic_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_beneficiariesSoft-deletable, denormalized stats, Identifiers JSON (provider-specific).
BeneficiaryPayoutProvider (beneficiary_payout_provider.go:12)beneficiary_payout_providersAdmin-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_attributesProvider registry; API creds stored as attributes (is_secret flag). Fee percentage/fixed. Many-to-many currencies/countries.
PayoutEvent (payout_event.go:9)payout_eventsAudit: from_statusto_status, actor_type (client/system/provider/admin), provider_response JSONB.
ScheduledPayout (scheduled_payout.go:154)scheduled_payoutsStatuses active/paused/completed/cancelled/exhausted; once/daily/weekly/monthly; timezone-aware ComputeNextRunAt. Money NUMERIC(15,2)precision differs from payouts.
ScheduledPayoutExecution (:376)scheduled_payout_executionsOne row per run, UNIQUE(scheduled_payout_id, execution_number).
ClientPayoutConfig (client_payout_config.go:10)client_payout_configsPer-client payouts_enabled, daily/monthly/min/max limits, allowed currencies/providers. Limits are computed & shown but NOT enforced on create.
ClientPayoutWebhook + PayoutWebhookDeliverypayout_webhook_deliveriesOutbound 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:

  1. Idempotency: GetPayoutByReferenceID returns the existing payout if reference_id was seen (replay, not error).
  2. Loads wallet, checks ownership.
  3. ResolveProvider picks provider + beneficiary-provider identifiers (see below).
  4. Fees via provider.CalculateFees; total_deducted = amount + fees; balance check.
  5. SERIALIZABLE tx: insert payout (created), debit wallet (UpdateWalletBalance, same ledger path as voucher orders), link transaction_id, emit payout.created event, commit.
  6. 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), computes next_run_at.
  • ExecuteScheduledPayout — builds a request with deterministic reference_id = sch_<publicID>_<n> (idempotent per execution), calls CreatePayout, writes an execution row, advances the schedule.
  • Driven by cron scheduled-payout-processor (*/1 * * * *, registered main.go:230). The job uses FOR UPDATE SKIP LOCKED for 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).

ProviderAuthEndpointsNotes
Merit (merit_provider.go)Authorization: Bearer <api_key>POST /v2/payouts, GET /v2/payouts/{id}, POST /v2/payouts/{id}/cancelPasses revtag; forwards notify_beneficiary.
Ledig (ledig_provider.go)x-api-key: <api_key>POST /v1/fiat_payoutRequires 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, handler payout.go, DTOs entity/payout.go) — the only surface with declarative validate-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 with clientID=0 to skip ownership), beneficiary-provider links.

Money flow

  • Debit at CreatePayout: total_deducted = amount + fees debited 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: ClientPayoutConfig daily/monthly/min/max + ClientPayoutUsage are 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_beneficiary to Merit (NotifyRecipient), delegating to the provider. Ledig ignores it.
  • Client webhooks: designed but never enqueued (TriggerPayoutEvent has no callers) — see above. See Notifications & Email for the outbound-webhook machinery this reuses.

Findings summary

SeverityFindingWhere
CriticalDirect + scheduled payouts never submitted to provider (processor/sync crons unregistered; QueuePayout uncalled).main.go:227, payout_service.go:391
CriticalNo refund path → wallet debited but not re-credited on failure/cancel.payout_service.go:518; refund_transaction_id unused
HighTriggerPayoutEvent (client payout webhooks) has zero callers.payout_webhook_service.go:41
HighClient-created beneficiaries have no provider link → payouts fail resolution silently.beneficiary-create paths
MediumClient-config limits computed & shown but not enforced on create.payout_service.go (no IsAmountValid call)
MediumLedig can't report status or cancel → would be permanently non-terminal if processing were enabled.ledig_provider.go:228,246
MediumCancelPayout swallows provider-cancel failures → local/provider divergence.payout_service.go:353
LowPrecision mismatch: payouts NUMERIC(18,6) vs scheduled_payouts NUMERIC(15,2).migrations
LowBuildConfigFromAttributes triplicated (models / base_provider / factory).3 sites
LowInsufficient-balance/resolution errors returned to clients verbatim (InternalServerError.WithMessage(err.Error())).client_payout.go:266, payout.go:61
LowDead: 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; wiring main.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; entities http/entity/payout.go
  • Resolution SQL: database/repo/payout_provider.go:721

On this page