---
title: "PaymentConductor"
description: "Unified payment orchestration for store, membership, news, wallet top-up, and DAO jar chip-ins — WayForPay, Stripe, PayPal, credit, and native token. Config-driven processors, one ledger, canonical /api/payments/*/webhook ingress."
locale: "en"
---
# PaymentConductor

PaymentConductor is Ring's config-driven payment layer. One ledger (`payment_transactions`) and one webhook dispatcher serve **store**, **membership**, **news promotion**, **wallet credit top-up**, and **public pool (DAO jar) card/PayPal chip-ins**. Browser UIs never open a PSP by brand — they follow Conductor **`CheckoutRedirect`** via `followCheckoutResult` (`lib/payments/checkout-redirect.ts`). Deep types and sequences: [PaymentConductor architecture](/docs/architecture/payment-conductor.md).

> **Info**
> Use **Founder** / **Developer** tabs in the docs sidebar to filter this page. `audience` frontmatter controls in-page blocks; sidebar article visibility is curated separately.

> **Warning**
> **Provider status (Conductor):** WayForPay, Stripe, and PayPal are live external PSPs on `/api/payments/{wayforpay|stripe|paypal}/webhook`. Credit balance and native token settle **in-process** inside `createCheckout` (no PSP webhook). `nft_gate` may appear in `ring-config.json` fee tables — it is **not** a `PaymentProcessorId` on Conductor. Stripe Connect marketplace payouts remain backlog (see below). Prefer [Payment integration](/docs/customization/payment-integration.md) for clone env wiring.

## Rail vs processor (comparison)

Buyers pick a **rail**. Conductor persists a **processor** on the ledger row. Card rail resolves to WayForPay or Stripe via `getPaymentProvider(purpose)` — never from UI brand strings.

| Buyer rail (`PaymentRail`) | Typical processor (`PaymentProcessorId`) | Settlement |
|----------------------------|------------------------------------------|-------------|
| `card` | `wayforpay` or `stripe` | External webhook after HPP / Checkout |
| `paypal` | `paypal` | Orders v2 capture webhook (+ Subscriptions lifecycle for recurring membership) |
| `credit_balance` | `credit_balance` | Synchronous in `createCheckout` |
| `native_token` | `native_token` | Synchronous on-chain transfer → treasury |

| Previous / UI habit | Ring equivalent |
|---------------------|-----------------|
| Hard-coded WayForPay SDK in a page | `PaymentConductor.createCheckout` + `followCheckoutResult` |
| Opening WFP HPP as a GET query URL | `CheckoutRedirect.mode = form_post` (POST fields) |
| Clone-local `stripe-promotion-service` | Platform `stripe.processor.ts` + purpose handler |
| Legacy `/api/store/payments/wayforpay/webhook` or `/api/news/promotion/wayforpay-webhook` | Still may delegate — **canonical** is `/api/payments/wayforpay/webhook` |
| “Top up buys RING” | `wallet_topup` credits **fiat points** 1:1 via `creditBalanceService.addFiatUsd` — not on-chain RING |

## Payment purposes

| Purpose | Handler family | Typical processors | Entry |
|---------|----------------|-------------------|-------|
| `store_order` | `handlers/store-order*.ts` | WayForPay, Stripe, credit, native token, PayPal | `POST /api/store/payments/{wayforpay\|stripe\|token\|credit\|paypal\|card}` |
| `membership_upgrade` | `handlers/membership-upgrade*.ts` (+ PayPal subscription handler) | WayForPay, Stripe, PayPal, **native token** | Membership payment actions / `POST /api/membership/payment/*` |
| `news_promotion` | `handlers/news-promotion*.ts` | WayForPay, Stripe, PayPal | News promotion submit |
| `wallet_topup` | `handlers/wallet-topup*.ts` | WayForPay, Stripe, PayPal | [WalletConductor](/docs/features/wallet-conductor.md) `initiateTopUp` |
| `native_token_onramp` | `handlers/native-token-onramp*.ts` | WayForPay / Stripe (PayPal unsupported) | WalletConductor `initiateNativeOnramp` (confidential+) |
| `public_pool_contribution` | `handlers/public-pool-contribution.ts` | WayForPay, Stripe, PayPal | `POST /api/public-pools/[slug]/card-checkout` |

Additional typed purposes (`project_order`, `task_escrow`, `collective_order_slot`, `scheduled_service_slot`) share the same Conductor + dispatcher pattern with matching handlers under `lib/payments/conductor/handlers/`.

> **Tip**
> `wallet_topup` ≠ on-chain RING. **`public_pool_contribution`** converts charged fiat to pledged native via the **Token Desk oracle** (`nativeUi = fiatMajor / nativePerMainCurrency`) — never 1:1. See [Public Pools](/docs/features/public-pools.md).

## Recommended path

Use Cursor skill **`international-payment-systems-specialist`** (or UA WayForPay desk **`commerce-ua-dropshipping-woocommerce-wayforpay-guru`**) with this starter prompt — review the plan before mutating env/secrets:

> “Configure Ring PaymentConductor for this clone: set `PAYMENT_DEFAULT_PROCESSOR` and purpose overrides, enable rails in `ring-config.json` `payment.gateways`, wire WayForPay/Stripe/PayPal credentials from `env.local.template`, register canonical webhooks under `/api/payments/*/webhook`, and smoke-test store + wallet_topup. Do not invent routes or fee contracts.”

**Inputs checklist:** clone domain / public base URL · merchant PSP dashboard access · `ring-config.json` payment block · secrets only in env (never MDX) · pause for approval before writing production secrets.

Operator walkthrough (non-agent): [Payment integration](/docs/customization/payment-integration.md) · [WayForPay](/docs/features/wayforpay-integration.md).

## Manual path

### Pick default card processor

Set `PAYMENT_DEFAULT_PROCESSOR=wayforpay` (UA/UAH-first) or `stripe` (USD-first). Optional purpose overrides: `PAYMENT_STORE_PROCESSOR`, `PAYMENT_MEMBERSHIP_PROCESSOR`, `PAYMENT_NEWS_PROCESSOR`, `PAYMENT_WALLET_TOPUP_PROCESSOR`, `PAYMENT_NATIVE_TOKEN_ONRAMP_PROCESSOR`, `PAYMENT_PUBLIC_POOL_CONTRIBUTION_PROCESSOR` (blank → default).

### Enable gateways and rails in config

In `ring-config.json`, set `payment.cardPaymentProcessor`, `payment.supportedMethods`, and `payment.gateways.*.enabled`. Fee `%` / `feeFixedCents` there are **clone config defaults for display/estimation** — not a guarantee of the PSP’s live schedule.

### Wire credentials

From `env.local.template`: `WAYFORPAY_MERCHANT_ACCOUNT` + `WAYFORPAY_SECRET_KEY` (+ `WAYFORPAY_MERCHANT_PASSWORD` for recurring/`regularApi`); or `STRIPE_SECRET_KEY` + `STRIPE_WEBHOOK_SECRET`; for PayPal `PAYPAL_CLIENT_ID` / `PAYPAL_CLIENT_SECRET` / `PAYPAL_WEBHOOK_ID` (+ membership plan ids when using Subscriptions). There is **no** `WAYFORPAY_MERCHANT_ID`.

### Register canonical webhooks

Point each PSP dashboard to:

| Endpoint | Provider verify |
|----------|-----------------|
| `POST /api/payments/wayforpay/webhook` | WayForPay HMAC → `dispatchWayForPayWebhook` |
| `POST /api/payments/stripe/webhook` | `STRIPE_WEBHOOK_SECRET` → `dispatchStripeWebhook` |
| `POST /api/payments/paypal/webhook` | Transmission signature + `PAYPAL_WEBHOOK_ID` → `dispatchPayPalWebhook` |

Apply `data/migrations/004_payment_transactions.sql` before production ledger use.

### Smoke checkout handoff

Create a checkout through Conductor; UI must call `followCheckoutResult` / `followCheckoutRedirect`. Expect `navigate` (Stripe/PayPal/invoice URL) or `form_post` (WayForPay HPP). Confirm ledger row → handler fulfillment after webhook (or sync mark-paid for credit/token).

### For founders

## Why this matters for your clone

- **One money desk** — store, membership, news, wallet top-up, and DAO jar chip-ins share ledger + webhook path.
- **Swap PSP without rewriting UI** — change `PAYMENT_DEFAULT_PROCESSOR` / purpose env + `gateways.*.enabled`.
- **Zero-fee internal rails** — credit and native token when enabled (`PAYMENT_STORE_ALLOW_CREDIT`, `PAYMENT_STORE_ALLOW_TOKEN` / public flags).
- **Admin visibility** — `GET /api/admin/users/[id]/payments` lists ledger rows (defaults include `membership_upgrade` + `wallet_topup`).
- **Membership + store PayPal** — live when `NEXT_PUBLIC_PAYMENT_STORE_ALLOW_PAYPAL=true`, `PAYPAL_*` set, and `gateways.paypal.enabled`. Recurring membership uses Subscriptions v1 — see [SubscriptionConductor](/docs/features/subscriptions.md).

### Gateway fee rates (`ring-config.json` defaults)

| Gateway | Fee % (config) | Fixed (config) | Currency | Conductor status |
|---------|----------------|----------------|----------|------------------|
| WayForPay | 2.5% | — | UAH | Live webhook |
| Stripe | 2.9% | $0.30 (`feeFixedCents: 30`) | USD | Live webhook |
| Credit balance | 0% | — | main/fiat points | Live sync |
| Native token | 0% | — | token symbol | Live sync |
| PayPal | 2.9% | $0.30 | USD | Live Orders (+ membership Subscriptions) |

### Quick setup sketch

```json
{
  "payment": {
    "cardPaymentProcessor": "wayforpay",
    "supportedMethods": ["wayforpay", "credit_balance", "native_token"],
    "gateways": {
      "wayforpay": { "enabled": true, "feePercent": 2.5, "currency": "UAH" },
      "stripe": { "enabled": false, "feePercent": 2.9, "feeFixedCents": 30, "currency": "USD" },
      "paypal": { "enabled": false, "feePercent": 2.9, "feeFixedCents": 30, "currency": "USD" }
    }
  }
}
```

{`PAYMENT_DEFAULT_PROCESSOR=wayforpay
PAYMENT_FIAT_CURRENCY=USD
PAYMENT_STORE_ALLOW_CREDIT=true
PAYMENT_STORE_ALLOW_TOKEN=false
NEXT_PUBLIC_PAYMENT_STORE_ALLOW_TOKEN=false
NEXT_PUBLIC_PAYMENT_STORE_ALLOW_PAYPAL=false
# PAYMENT_CREDIT_BALANCE_ACCEPTED_ORDER_CURRENCIES=UAH
WAYFORPAY_MERCHANT_ACCOUNT=your_merchant
WAYFORPAY_SECRET_KEY=your_secret
WAYFORPAY_MERCHANT_PASSWORD=your_password
WAYFORPAY_DOMAIN=your.domain
WAYFORPAY_API_URL=https://api.wayforpay.com/api
# STRIPE_SECRET_KEY=
# STRIPE_WEBHOOK_SECRET=
# PAYPAL_MODE=sandbox
# PAYPAL_CLIENT_ID=
# PAYPAL_CLIENT_SECRET=
# PAYPAL_WEBHOOK_ID=
CONFIDENTIAL_TOKEN_ONRAMP=false
NEXT_PUBLIC_CONFIDENTIAL_TOKEN_ONRAMP=false`}

### For developers

## Implementation surface

Root: `lib/payments/conductor/`, `lib/payments/processors/`, `lib/payments/payment.config.ts`.

| Module | Path | Role |
|--------|------|------|
| Conductor | `lib/payments/conductor/payment-conductor.ts` | `createCheckout` (+ `normalizeCheckoutResult`), `handleWebhook` |
| Types / redirect | `lib/payments/conductor/types.ts` | `PaymentPurpose`, `PaymentRail`, `PaymentProcessorId`, `CheckoutRedirect` |
| Client handoff | `lib/payments/checkout-redirect.ts` | `followCheckoutRedirect` / `followCheckoutResult` |
| Config | `lib/payments/payment.config.ts` | `getPaymentProvider`, `isRailEnabled`, credit currency allow-list |
| Processors | `lib/payments/processors/{wayforpay,stripe,paypal,credit-balance,native-token}.processor.ts` | Per-rail checkout |
| Dispatcher | `lib/payments/conductor/webhook-dispatcher.ts` | Purpose routing + PayPal Subscriptions branch |
| Handlers | `lib/payments/conductor/handlers/*.ts` | Fulfillment after paid |

`PaymentProcessorId`: `wayforpay | stripe | paypal | credit_balance | native_token`.

### Webhook ingress (verified)

| Route file | Dispatch |
|------------|----------|
| `app/api/payments/wayforpay/webhook/route.ts` | `dispatchWayForPayWebhook` |
| `app/api/payments/stripe/webhook/route.ts` | `dispatchStripeWebhook` |
| `app/api/payments/paypal/webhook/route.ts` | `dispatchPayPalWebhook` |

### Store routes (verified)

| Route | Rail | Notes |
|-------|------|-------|
| `POST /api/store/payments/wayforpay` | `card` | `createCheckout({ purpose: 'store_order' })` |
| `POST /api/store/payments/stripe` | `card` | Stripe Checkout (`navigate`) |
| `POST /api/store/payments/card` | `card` | Alias of card path |
| `POST /api/store/payments/token` | `native_token` | Requires `PAYMENT_STORE_ALLOW_TOKEN=true` |
| `POST /api/store/payments/credit` | `credit_balance` | Sync debit; currency allow-list via `PAYMENT_CREDIT_BALANCE_ACCEPTED_ORDER_CURRENCIES` / config |
| `POST /api/store/payments/paypal` | `paypal` | Orders v2; credentials + `gateways.paypal.enabled` |

### Native token — checkout, idempotency, refunds (verified)

**Store checkout UI.** The token rail is enabled in `features/store/components/checkout/payment-step.tsx` via `getClientStorePaymentRails()`: `NEXT_PUBLIC_PAYMENT_STORE_ALLOW_TOKEN === 'true'` **and** `payment.gateways.native_token.enabled !== false`. Server-side gate stays `isRailEnabled('store_order','native_token')` (`PAYMENT_STORE_ALLOW_TOKEN=true`). The checkout flow posts `placeAndPayStoreOrder` (server action) or `POST /api/store/payments/token` — both route through `PaymentConductor.createCheckout({ purpose: 'store_order', rail: 'native_token' })` → `createNativeTokenCheckout` (balance check → treasury SPL transfer → `payment_transactions` createPending → markPaid).

**Membership native pay.** `POST /api/membership/payment/token` is the single chokepoint (UI: `components/membership/ring-payment-modal.tsx`). Gate: `isRailEnabled('membership_upgrade','native_token')` reads **`isPaymentMethodEnabled('native_token')`** (`payment.supportedMethods` SSOT) — **not** the store flag, so membership native works with `PAYMENT_STORE_ALLOW_TOKEN` unset. Composition (ledger parity with card):

| Path | Money movement | Ledger |
|------|----------------|--------|
| Soft launch (no Membership program) | `PaymentConductor.createCheckout({ purpose:'membership_upgrade', rail:'native_token', metadata:{ tokenAmount } })` → treasury SPL | `payment_transactions` (paid) then `SubscriptionConductor` ledger-only via `metadata.tx_hash` — no second transfer, role upgrade included |
| Deployed (on-chain `RingMembership`) | `createOnchainSubscription` (program deducts) | `payment_transactions` row written manually with txSignature |
| `membership_fee` one-shot | Treasury SPL (custom `toAddress` allowed) | `payment_transactions` row; **no** `subscription_ledger` |
| `subscription_renewal` | Pricing default amount (custom `amount` is not honored) | `subscription_ledger` + `payment_transactions` row |

**Idempotency contract** (same as desk orders / NFT listings / public-pool contributions). Clients generate one UUID per payment intent (`crypto.randomUUID()` in `checkout-client.tsx` / `ring-payment-modal.tsx`) and reuse it on retry. `payment_transactions` rows carry a top-level `idempotency_key`; before charging, `findByIdempotencyKey(userId, purpose, key)` replays a **paid** row (same txHash, zero treasury movement) or returns `409 IDEMPOTENCY_IN_FLIGHT` for an in-flight row. Old clients without a key keep the previous behavior.

**Native refunds.** `POST /api/admin/payments/[orderReference]/refund` — platform admin (any native payment) **or** vendor who owns the store order (order `vendorSettlements` ∩ `getVendorEntities`). Idempotent by `orderReference` (already-refunded replays `refundTxHash`); full-amount only; treasury→user via `transferTokenFromTreasury`; ledger gets status `refunded` + `refundTxHash` in `processor_payload`. Transfer failure keeps the row `paid` (retryable). Minimal scope — no `subscription_ledger` / order-status flip.

**Ledger precision.** Native-token rows store `amount_minor` in token precision (`tokenAmount × 1e6`, currency = token symbol); the main-currency stamp is skipped for token-denominated rows (`fx_rate` remains valid) — see `stampMainCurrencyFx`.

### Wallet top-up (verified)

`initiateCreditTopupPayment` (`app/_actions/wallet.ts`) → `WalletConductor.initiateTopUp` → purpose `wallet_topup` → webhook `wallet-topup*.ts` → `creditBalanceService.addFiatUsd` (1:1 points). Distinct from `native_token` spend and from confidential `native_token_onramp`.

### Public pool jar (verified)

`POST /api/public-pools/[slug]/card-checkout` → `public_pool_contribution` → desk FX → `settlePublicPoolCardContribution`. Native SPL chip-ins stay on `contributeToPool` (not Conductor).

### Membership PayPal (verified)

`POST /api/membership/payment/paypal` → SubscriptionConductor provider `paypal`: recurring → Subscriptions v1 lifecycle webhooks; one-shot → Conductor Orders capture → `recordPaidSubscription`. UI: `components/membership/payment-modal.tsx` when public PayPal flag is on.

### Ledger

`data/migrations/004_payment_transactions.sql`. Rows via `DatabaseService` / payment transaction service — result contract `{ success, data, error }`.

## Frequently asked questions

### Impact

#### Will credit top-up mint RING?

No. `wallet_topup` adds fiat credit points. On-chain RING buy paths are Token Desk / confidential onramp — not this purpose.

#### Will swapping `PAYMENT_DEFAULT_PROCESSOR` rewrite store UI?

No. UI follows rails + `CheckoutRedirect`. Processor is resolved server-side per purpose.

### Migration

#### Do I keep legacy WayForPay webhook URLs?

Prefer canonical `/api/payments/wayforpay/webhook`. Legacy store/news webhook routes may still delegate — point new PSP dashboard entries at the canonical path.

#### What env name accepts UAH store orders when points are USD?

`PAYMENT_CREDIT_BALANCE_ACCEPTED_ORDER_CURRENCIES` (comma-separated), or `store.creditBalanceAcceptedOrderCurrencies` in config — not the older `PAYMENT_CREDIT_ACCEPT_ORDER_CURRENCY` spelling.

### Ops

#### Why did WayForPay checkout “open but fail”?

HPP requires **form POST** (`form_post`). Opening the URL as GET breaks the session.

#### What happens when a client retries with the same idempotency key?

A paid row replays (same `txHash`, no second charge); an in-flight row returns `409 IDEMPOTENCY_IN_FLIGHT`. Never retry with a fresh key after a timeout — reuse the intent key.

#### Who can refund a native payment?

Platform admin for any native row; a vendor can refund `store_order` native payments of their own store (order `vendorSettlements` match). Refunds are full-amount, idempotent, and mark only `payment_transactions` `refunded`.

#### Where do admins see membership/top-up rows?

`GET /api/admin/users/[id]/payments` (platform admin). Defaults focus on `membership_upgrade` and `wallet_topup`.

## Related documentation

  
- [features/payments](/docs/features/payments.md) — Prerequisite: high-level rails before Conductor purpose details.

  
- [customization/payment-integration](/docs/customization/payment-integration.md) — Next-step: operator PSP dashboard and env wiring for a clone.

  
- [features/public-pools](/docs/features/public-pools.md) — Same-workflow: public_pool_contribution card jar, desk FX, and builder payout.

  
- [features/wayforpay-integration](/docs/features/wayforpay-integration.md) — Deep-dive: WayForPay env, HMAC, and orderReference prefixes.

  
- [architecture/payment-conductor](/docs/architecture/payment-conductor.md) — Deep-dive: CheckoutRedirect DTO, dispatcher sequences, and API route map.

  
- [features/subscriptions](/docs/features/subscriptions.md) — Next-step: PayPal Subscriptions v1 for recurring membership — and native_token provider soft-launch vs on-chain paths.

  
- [api/admin](/docs/api/admin.md) — Next-step: admin native refund endpoint and payments tab.

  
- [features/wallet-conductor](/docs/features/wallet-conductor.md) — Same-workflow: initiateTopUp / onramp entry points into Conductor purposes.

  
- [features/wallet](/docs/features/wallet.md) — See-also: credit top-up vs Token Desk oracle (jar FX shares desk rate).

  
- [features/ring-oracle](/docs/features/ring-oracle.md) — Depends-on: Ring Oracle supplies desk, FX, and credit rates Conductor rails consume.

  
- [features/erp](/docs/features/erp.md) — Next-step: ERP hub cockpits after payment lands.

  
- [features/erp/inventory](/docs/features/erp/inventory.md) — Next-step: store paid handlers call commitSaleForOrder for stock invariant.

  
- [features/erp/commissions](/docs/features/erp/commissions.md) — Same-workflow: processSettlements after paid store order.
