---
title: "PaymentConductor architecture"
description: "CheckoutRedirect DTO, normalizeCheckoutResult, webhook dispatcher, processors, wallet top-up, and API routes"
locale: "en"
---
# PaymentConductor architecture

PaymentConductor is Ring Platform's config-driven payment layer. One PostgreSQL ledger (`payment_transactions`) and one webhook dispatcher serve store checkout, membership upgrades, news promotion, wallet credit top-up, and **public pool (DAO jar) card/PayPal chip-ins**.

**Operator setup:** [Payment integration](/docs/customization/payment-integration.md) · [WayForPay](/docs/features/wayforpay-integration.md) · [Public Pools](/docs/features/public-pools.md)

> **Info**
> Use **Founder** / **Developer** tabs in the docs sidebar to filter this page.

  
- **[Operator guide](/docs/customization/payment-integration.md)** — PSP dashboards and env vars

  
- **[Feature overview](/docs/features/payment-conductor.md)** — Purposes and rails

  
- **[Public Pools & DAO Jars](/docs/features/public-pools.md)** — `public_pool_contribution` + desk-oracle FX (not 1:1)

  
- **[Wallet top-up](/docs/features/wallet.md)** — Live `wallet_topup` via Card / PayPal

  
- **[WayForPay HPP](/docs/features/wayforpay-integration.md)** — form_post vs navigate; returnUrl vs serviceUrl

### For founders

## Why this architecture matters

- **Single ledger** — every card redirect and credit settlement writes `payment_transactions` before the PSP page opens.
- **Idempotent callbacks** — duplicate WayForPay / Stripe / PayPal webhooks do not double-fulfill; `order_reference` is unique.
- **Processor-agnostic UI** — the browser only follows a Conductor `redirect` object. Switching a clone to Stripe is an env flip (`PAYMENT_*_PROCESSOR=stripe`), not a new top-up screen.
- **Webhook = money** — returning to the site after pay is not confirmation; Approved (or Stripe/PayPal capture) webhooks are.
- **Admin audit** — Payments tab loads membership + wallet top-up rows for a user without scraping PSP dashboards.

### For developers

## Request flow

```mermaid
sequenceDiagram
    participant UI as Checkout_or_topup_UI
    participant API as App_API_or_server_action
    participant PC as PaymentConductor
    participant PSP as WayForPay_Stripe_PayPal
    participant WH as webhook_dispatcher
    participant DB as payment_transactions

    UI->>API: create checkout purpose amount
    API->>PC: createCheckout ctx
    PC->>DB: createPending orderReference
    PC->>PC: normalizeCheckoutResult
    PC-->>UI: redirect navigate_or_form_post
    UI->>UI: followCheckoutResult
    UI->>PSP: form_post or location navigate
    Note over UI,PSP: returnUrl is browser UX only
    PSP->>WH: POST webhook Approved_or_capture
    WH->>WH: verify signature parse orderReference
    WH->>DB: markPaid
    WH->>WH: purpose handler store membership news wallet
```

## Browser handoff (`CheckoutRedirect`)

Conductor-owned DTO on `CreateCheckoutResult` — clients must not import WayForPay-branded helpers.

{`type CheckoutRedirectMode = 'navigate' | 'form_post'

interface CheckoutRedirect {
  mode: CheckoutRedirectMode
  url: string
  fields?: Record<string, string | string[]> // required for form_post
}

// CreateCheckoutResult.redirect preferred;
// paymentUrl / paymentFields mirrored for legacy APIs (deprecated)`}

| Mode | When | Client |
|------|------|--------|
| `form_post` | WayForPay HPP (`secure.wayforpay.com/pay`) | Hidden form POST via `followCheckoutRedirect` |
| `navigate` | Stripe Checkout Session, PayPal approve URL, WFP invoiceUrl | `window.location.href` |

Helpers:

- `navigateCheckoutRedirect(url)` / `formPostCheckoutRedirect(url, fields)`
- `normalizeCheckoutResult(result)` — always applied at the end of `PaymentConductor.createCheckout`
- Browser: `lib/payments/checkout-redirect.ts` → `followCheckoutRedirect` / `followCheckoutResult`

WayForPay HPP **rejects GET** query-string “payment URLs” (`Bad Request` / *This page requires only POST data*). Build fields in `lib/payments/wayforpay-hpp.ts`.

## Core modules

| Module | Path | Responsibility |
|--------|------|----------------|
| Conductor | `lib/payments/conductor/payment-conductor.ts` | `createCheckout` (+ normalize), `handleWebhook`, transaction lookup |
| Types | `lib/payments/conductor/types.ts` | Purposes, rails, `CheckoutRedirect`, `normalizeCheckoutResult` |
| Client handoff | `lib/payments/checkout-redirect.ts` | Unbranded `followCheckoutResult` |
| WFP HPP | `lib/payments/wayforpay-hpp.ts` | Sign + `form_post` fields |
| Dispatcher | `lib/payments/conductor/webhook-dispatcher.ts` | Verify PSP payloads; route to purpose handlers |
| Order references | `lib/payments/order-reference.ts` | Build / parse idempotent `orderReference` strings |
| Ledger service | `lib/payments/payment-transaction-service.ts` | CRUD + `listByUserId` on `payment_transactions` |
| Config | `lib/payments/payment.config.ts` | `getPaymentProvider`, `isRailEnabled`, webhook URL helpers |

### Purpose handlers

| Purpose | Handler file |
|---------|----------------|
| `store_order` | `conductor/handlers/store-order.ts` (+ Stripe: `store-order-stripe.ts`) |
| `membership_upgrade` | `conductor/handlers/membership-upgrade.ts` (+ Stripe: `membership-upgrade-stripe.ts`) |
| `news_promotion` | `conductor/handlers/news-promotion.ts` |
| `wallet_topup` | `conductor/handlers/wallet-topup.ts` (+ Stripe / PayPal capture handlers) |
| `native_token_onramp` | `conductor/handlers/native-token-onramp.ts` (+ Stripe: `native-token-onramp-stripe.ts`) |
| `public_pool_contribution` | `conductor/handlers/public-pool-contribution.ts` → `settlePublicPoolCardContribution` |

### Processors

| Processor | File | Browser mode |
|-----------|------|----------------|
| WayForPay | `processors/wayforpay.processor.ts` | HPP `form_post` (or invoice `navigate`) |
| Stripe | `processors/stripe.processor.ts` | `navigate` (Checkout Session URL) |
| PayPal | `processors/paypal.processor.ts` | `navigate` (Orders v2 approve URL) |
| Internal credit | `processors/credit-balance.processor.ts` | Sync settle (no redirect) |
| Native token | `processors/native-token.processor.ts` | Sync settle (no redirect) |

Processor resolution in `createCheckout`:

1. `rail === 'credit_balance'` / `'native_token'` → that processor  
2. Else `metadata.processor` if `paypal` \| `stripe` \| `wayforpay`  
3. Else `getPaymentProvider(purpose)` (`PAYMENT_*_PROCESSOR` / `PAYMENT_DEFAULT_PROCESSOR`)

Legacy helpers (`wayforpay-service.ts`, `wayforpay-store-service.ts`) remain for specialized form building; **new route entry points** go through the conductor.

## Types

{`type PaymentPurpose =
  | 'store_order' | 'membership_upgrade' | 'news_promotion'
  | 'wallet_topup' | 'native_token_onramp'
  | 'public_pool_contribution'
  // also: task_escrow, project_order, collective_order_slot, …

type PaymentRail = 'merchant_redirect' | 'credit_balance' | 'native_token'
type PaymentProcessorId =
  | 'wayforpay' | 'stripe' | 'credit_balance' | 'native-token' | 'paypal'

type PaymentTransactionStatus =
  | 'created' | 'redirected' | 'pending' | 'paid'
  | 'failed' | 'cancelled' | 'refunded'`}

`CreateCheckoutContext` carries `purpose`, optional `rail`, `userId`, `amount`, `currency`, `returnUrl`, `metadata`, and purpose-specific fields (`orderId`, `articleId`, `targetRole`, `publicPoolSlug`, `amountRing`, etc.).

## Configuration surface

`lib/payments/payment.config.ts`:

- `getPaymentProvider(purpose)` — WayForPay vs Stripe from env overrides
- `isRailEnabled(purpose, rail)` — credit / token gates
- `getDefaultStoreCurrencySymbol()` — `PAYMENT_FIAT_CURRENCY` (default `USD`)
- `getWebhookUrl(provider)` — `${site}/api/payments/${provider}/webhook`

Env keys per purpose: `PAYMENT_STORE_PROCESSOR`, `PAYMENT_MEMBERSHIP_PROCESSOR`, `PAYMENT_NEWS_PROCESSOR`, `PAYMENT_WALLET_TOPUP_PROCESSOR`, `PAYMENT_NATIVE_TOKEN_ONRAMP_PROCESSOR`, `PAYMENT_PUBLIC_POOL_CONTRIBUTION_PROCESSOR`. Default: `PAYMENT_DEFAULT_PROCESSOR` (`wayforpay` | `stripe` | `paypal`).

WayForPay env SSOT (no `WAYFORPAY_MERCHANT_ID`):

```bash
WAYFORPAY_MERCHANT_ACCOUNT=...
WAYFORPAY_SECRET_KEY=...
WAYFORPAY_MERCHANT_PASSWORD=...   # regularApi / recurring
WAYFORPAY_DOMAIN=ring-platform.org
WAYFORPAY_API_URL=https://api.wayforpay.com/api
```

## Order reference prefixes

| Purpose | Pattern | Example |
|---------|---------|---------|
| `store_order` | `store_{orderId}_{timestamp}` | `store_ord_abc_1717000000000` |
| `membership_upgrade` | `membership_{userId}_{timestamp}` | `membership_user123_1717000000000` |
| `membership_upgrade` (legacy) | `ring_{userId}_{timestamp}` | still accepted |
| `news_promotion` | `news-promo-{base64url(articleId)}-{timestamp}` | `news-promo-YWJj-1717000000000` |
| `wallet_topup` | `wallettopup_{userId}_{timestamp}` | `wallettopup_user123_1717000000000` |
| `public_pool_contribution` | `poolcontrib_{userId}_{ts}_{base64url(poolSlug)}` | `poolcontrib_user123_1717000000000_…` |

`buildOrderReference` / `parseOrderReference` in `order-reference.ts`. Duplicate webhook delivery is safe: ledger enforces unique `order_reference`.

## Ledger (`payment_transactions`)

Migration: `data/migrations/004_payment_transactions.sql`

JSON `data` fields (via `payment-transaction-service.ts`): `purpose`, `processor`, `rail`, `order_reference`, `entity_type`, `entity_id`, `user_id`, `amount_minor`, `currency`, `status`, `status_history`, `processor_payload`, `paid_at`.

`createPending` returns existing row if `order_reference` already exists. Admin list: `paymentTransactionService.listByUserId` (default purposes: `membership_upgrade`, `wallet_topup`).

## Webhook dispatcher

**WayForPay** — `dispatchWayForPayWebhook`: parse `orderReference` → verify signature → purpose handler. Wallet: `transactionStatus === 'Approved'` before `addFiatUsd`.

**Stripe** — `dispatchStripeWebhook`: verify `stripe-signature` → read `metadata.purpose` → Stripe purpose handlers.

**PayPal** — `dispatchPayPalWebhook` with transmission headers → Orders capture by purpose **or** Subscriptions lifecycle (`BILLING.SUBSCRIPTION.*`, `PAYMENT.SALE.COMPLETED`) via `membership-paypal-subscription.ts`.

**Canonical routes:** `app/api/payments/wayforpay/webhook/route.ts`, `app/api/payments/stripe/webhook/route.ts`, `app/api/payments/paypal/webhook/route.ts` (when enabled).

`serviceUrl` / Stripe endpoint must be **public HTTPS**. Localhost cannot receive PSP callbacks — use a tunnel or staging for settlement tests.

## API routes

| Method | Path | Role |
|--------|------|------|
| `POST` | `/api/payments/wayforpay/webhook` | Unified WayForPay callback |
| `POST` | `/api/payments/stripe/webhook` | Stripe signed events |
| `POST` | `/api/store/payments/wayforpay` | Store redirect via conductor (`store_order`) |
| `POST` | `/api/store/payments/stripe` | Store Stripe Checkout via conductor |
| `POST` | `/api/store/payments/token` | Store native-token rail (`PAYMENT_STORE_ALLOW_TOKEN=true`) |
| `POST` | `/api/store/payments/paypal` | Store PayPal Orders v2 (`NEXT_PUBLIC_PAYMENT_STORE_ALLOW_PAYPAL`) |
| `POST` | `/api/store/payments/card` | Store card alias → WayForPay |
| `GET` | `/api/store/payments/[orderId]/status` | Poll status |
| `POST` | `/api/store/payments/credit` | Internal credit |
| `POST` | `/api/membership/payment/paypal` | Membership PayPal via SubscriptionConductor (Subscriptions v1 / Orders) |
| `POST` | `/api/payments/paypal/webhook` | PayPal Orders + Subscriptions webhooks |
| `GET` | `/api/admin/users/[id]/payments` | Admin Payments tab |
| `POST` | `/api/membership/payment/token` | RING membership |
| `POST` | `/api/news/promotion/submit` | News promotion checkout |

Deprecated aliases (delegate to canonical webhook):

- `app/api/store/payments/wayforpay/webhook/route.ts`
- `app/api/news/promotion/wayforpay-webhook/route.ts`

### Wallet top-up entry

`WalletConductor.initiateTopUp` / `initiateCreditTopupPayment` → `PaymentConductor.createCheckout({ purpose: 'wallet_topup' })` → processor from env or `metadata.processor` → UI `followCheckoutResult(result.redirect)` → webhook credits via `creditBalanceService.addFiatUsd`. Amount gate: 25–2000. Card tab omits `processor` (env SSOT); PayPal tab sets `processor=paypal`.

### Membership card entry

`initiateMembershipPayment` card path → `PaymentConductor.createCheckout({ purpose: 'membership_upgrade' })`. PaymentModal tabs: native-token, card, PayPal (flag-gated). Recurring PayPal: [SubscriptionConductor](/docs/features/subscriptions.md). Manage UI: `/membership/manage`.

## PaymentConductor API

{`export const PaymentConductor = {
  createCheckout(ctx: CreateCheckoutContext): Promise
  // always returns normalizeCheckoutResult(...)
  handleWebhook(
    provider: 'wayforpay' | 'stripe' | 'paypal',
    request: Request,
  ): Promise
  getTransactionByReference(orderReference: string)
}`}

## Security notes

- **Never** store raw card numbers — hosted PSP checkout only
- WayForPay: HMAC / merchant signature verification per flow; HPP via form POST only
- Stripe: `stripe-signature` + `STRIPE_WEBHOOK_SECRET`
- PayPal: webhook transmission signature verification
- `WAYFORPAY_MERCHANT_PASSWORD` for `regularApi` only — never expose to client
- Internal credit routes require authenticated session + ownership checks
- Do not trust `returnUrl` query params for fulfillment

## Related

  
- [architecture/wallet-conductor](/docs/architecture/wallet-conductor.md) — Depends-on: custodial native + credit facade used after settle and builder payout.

  
- [features/public-pools](/docs/features/public-pools.md) — Same-workflow: public_pool_contribution purpose, desk FX, and jar settle handler.

  
- [features/payment-conductor](/docs/features/payment-conductor.md) — Next-step: operator-facing Conductor purposes and rails.

  
- [customization/payment-integration](/docs/customization/payment-integration.md) — Prerequisite: PSP dashboards and env before enabling card jars.

  
- [features/payments](/docs/features/payments.md) — See-also: user-facing payment flows overview.

  
- [api/admin](/docs/api/admin.md) — See-also: admin user Payments tab endpoint.
