Concepts, value, and typical clone scenarios — less code.
Concepts, value, and typical clone scenarios — less code.
Preparing Ring content
Preparing Ring content
Preparing Ring content
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.
Use Founder / Developer tabs in the docs sidebar to filter this page. audience frontmatter controls in-page blocks; sidebar article visibility is curated separately.
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 for clone env wiring.
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 |
| 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 |
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/.
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.
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_PROCESSORand purpose overrides, enable rails inring-config.jsonpayment.gateways, wire WayForPay/Stripe/PayPal credentials fromenv.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 · WayForPay.
PAYMENT_DEFAULT_PROCESSOR / purpose env + gateways.*.enabled.PAYMENT_STORE_ALLOW_CREDIT, PAYMENT_STORE_ALLOW_TOKEN / public flags).GET /api/admin/users/[id]/payments lists ledger rows (defaults include membership_upgrade + wallet_topup).NEXT_PUBLIC_PAYMENT_STORE_ALLOW_PAYPAL=true, PAYPAL_* set, and gateways.paypal.enabled. Recurring membership uses Subscriptions v1 — see SubscriptionConductor.ring-config.json defaults)| Gateway | Fee % (config) | Fixed (config) | Currency | Conductor status |
|---|---|---|---|---|
| WayForPay | 2.5% | — | UAH | Live webhook |
No. wallet_topup adds fiat credit points. On-chain RING buy paths are Token Desk / confidential onramp — not this purpose.
PAYMENT_DEFAULT_PROCESSOR rewrite store UI?No. UI follows rails + CheckoutRedirect. Processor is resolved server-side per purpose.
Prefer canonical /api/payments/wayforpay/webhook. Legacy store/news webhook routes may still delegate — point new PSP dashboard entries at the canonical path.
PAYMENT_CREDIT_BALANCE_ACCEPTED_ORDER_CURRENCIES (comma-separated), or store.creditBalanceAcceptedOrderCurrencies in config — not the older PAYMENT_CREDIT_ACCEPT_ORDER_CURRENCY spelling.
HPP requires form POST (form_post). Opening the URL as GET breaks the session.
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.
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.
GET /api/admin/users/[id]/payments (platform admin). Defaults focus on membership_upgrade and wallet_topup.
Prerequisite: high-level rails before Conductor purpose details.
Next-step: operator PSP dashboard and env wiring for a clone.
Same-workflow: public_pool_contribution card jar, desk FX, and builder payout.
Deep-dive: WayForPay env, HMAC, and orderReference prefixes.
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.
Use Founder / Developer tabs in the docs sidebar to filter this page. audience frontmatter controls in-page blocks; sidebar article visibility is curated separately.
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 for clone env wiring.
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 |
| 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 |
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/.
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.
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_PROCESSORand purpose overrides, enable rails inring-config.jsonpayment.gateways, wire WayForPay/Stripe/PayPal credentials fromenv.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 · WayForPay.
PAYMENT_DEFAULT_PROCESSOR / purpose env + gateways.*.enabled.PAYMENT_STORE_ALLOW_CREDIT, PAYMENT_STORE_ALLOW_TOKEN / public flags).GET /api/admin/users/[id]/payments lists ledger rows (defaults include membership_upgrade + wallet_topup).NEXT_PUBLIC_PAYMENT_STORE_ALLOW_PAYPAL=true, PAYPAL_* set, and gateways.paypal.enabled. Recurring membership uses Subscriptions v1 — see SubscriptionConductor.ring-config.json defaults)| Gateway | Fee % (config) | Fixed (config) | Currency | Conductor status |
|---|---|---|---|---|
| WayForPay | 2.5% | — | UAH | Live webhook |
No. wallet_topup adds fiat credit points. On-chain RING buy paths are Token Desk / confidential onramp — not this purpose.
PAYMENT_DEFAULT_PROCESSOR rewrite store UI?No. UI follows rails + CheckoutRedirect. Processor is resolved server-side per purpose.
Prefer canonical /api/payments/wayforpay/webhook. Legacy store/news webhook routes may still delegate — point new PSP dashboard entries at the canonical path.
PAYMENT_CREDIT_BALANCE_ACCEPTED_ORDER_CURRENCIES (comma-separated), or store.creditBalanceAcceptedOrderCurrencies in config — not the older PAYMENT_CREDIT_ACCEPT_ORDER_CURRENCY spelling.
HPP requires form POST (form_post). Opening the URL as GET breaks the session.
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.
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.
GET /api/admin/users/[id]/payments (platform admin). Defaults focus on membership_upgrade and wallet_topup.
Prerequisite: high-level rails before Conductor purpose details.
Next-step: operator PSP dashboard and env wiring for a clone.
Same-workflow: public_pool_contribution card jar, desk FX, and builder payout.
Deep-dive: WayForPay env, HMAC, and orderReference prefixes.
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.
Use Founder / Developer tabs in the docs sidebar to filter this page. audience frontmatter controls in-page blocks; sidebar article visibility is curated separately.
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 for clone env wiring.
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 |
| 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 |
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/.
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.
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_PROCESSORand purpose overrides, enable rails inring-config.jsonpayment.gateways, wire WayForPay/Stripe/PayPal credentials fromenv.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 · WayForPay.
PAYMENT_DEFAULT_PROCESSOR / purpose env + gateways.*.enabled.PAYMENT_STORE_ALLOW_CREDIT, PAYMENT_STORE_ALLOW_TOKEN / public flags).GET /api/admin/users/[id]/payments lists ledger rows (defaults include membership_upgrade + wallet_topup).NEXT_PUBLIC_PAYMENT_STORE_ALLOW_PAYPAL=true, PAYPAL_* set, and gateways.paypal.enabled. Recurring membership uses Subscriptions v1 — see SubscriptionConductor.ring-config.json defaults)| Gateway | Fee % (config) | Fixed (config) | Currency | Conductor status |
|---|---|---|---|---|
| WayForPay | 2.5% | — | UAH | Live webhook |
No. wallet_topup adds fiat credit points. On-chain RING buy paths are Token Desk / confidential onramp — not this purpose.
PAYMENT_DEFAULT_PROCESSOR rewrite store UI?No. UI follows rails + CheckoutRedirect. Processor is resolved server-side per purpose.
Prefer canonical /api/payments/wayforpay/webhook. Legacy store/news webhook routes may still delegate — point new PSP dashboard entries at the canonical path.
PAYMENT_CREDIT_BALANCE_ACCEPTED_ORDER_CURRENCIES (comma-separated), or store.creditBalanceAcceptedOrderCurrencies in config — not the older PAYMENT_CREDIT_ACCEPT_ORDER_CURRENCY spelling.
HPP requires form POST (form_post). Opening the URL as GET breaks the session.
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.
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.
GET /api/admin/users/[id]/payments (platform admin). Defaults focus on membership_upgrade and wallet_topup.
Prerequisite: high-level rails before Conductor purpose details.
Next-step: operator PSP dashboard and env wiring for a clone.
Same-workflow: public_pool_contribution card jar, desk FX, and builder payout.
Deep-dive: WayForPay env, HMAC, and orderReference prefixes.
initiateTopUpnative_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 |
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).
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.
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.
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.
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).
| 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) |
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.
| 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 |
| 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 |
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.
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.
POST /api/public-pools/[slug]/card-checkout → public_pool_contribution → desk FX → settlePublicPoolCardContribution. Native SPL chip-ins stay on contributeToPool (not Conductor).
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.
data/migrations/004_payment_transactions.sql. Rows via DatabaseService / payment transaction service — result contract { success, data, error }.
Deep-dive: CheckoutRedirect DTO, dispatcher sequences, and API route map.
{
"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" }
}
}
}initiateTopUpnative_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 |
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).
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.
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.
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.
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).
| 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) |
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.
| 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 |
| 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 |
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.
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.
POST /api/public-pools/[slug]/card-checkout → public_pool_contribution → desk FX → settlePublicPoolCardContribution. Native SPL chip-ins stay on contributeToPool (not Conductor).
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.
data/migrations/004_payment_transactions.sql. Rows via DatabaseService / payment transaction service — result contract { success, data, error }.
Deep-dive: CheckoutRedirect DTO, dispatcher sequences, and API route map.
{
"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" }
}
}
}initiateTopUpnative_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 |
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).
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.
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.
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.
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).
| 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) |
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.
| 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 |
| 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 |
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.
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.
POST /api/public-pools/[slug]/card-checkout → public_pool_contribution → desk FX → settlePublicPoolCardContribution. Native SPL chip-ins stay on contributeToPool (not Conductor).
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.
data/migrations/004_payment_transactions.sql. Rows via DatabaseService / payment transaction service — result contract { success, data, error }.
Deep-dive: CheckoutRedirect DTO, dispatcher sequences, and API route map.
{
"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" }
}
}
}