Gitpay supports multiple payment connectors behind a single abstract PaymentProvider API.
Set in .env (see .env.example):
# Platform-wide switch (default: stripe)
PAYMENT_PROVIDER=stripe # or whop
# Stripe
STRIPE_KEY=
STRIPE_PUBKEY=
STRIPE_WEBHOOK_SECRET_PLATFORM=
STRIPE_WEBHOOK_SECRET_CONNECT=
# Whop
WHOP_API_KEY=
WHOP_WEBHOOK_SECRET=
WHOP_COMPANY_ID=biz_...
# Optional sandbox
WHOP_SANDBOX=true
# WHOP_API_BASE_URL=https://sandbox-api.whop.com/api/v1Frontend webpack injects PAYMENT_PROVIDER (and STRIPE_PUBKEY / WHOP_COMPANY_ID). Rebuild the frontend after changing env.
src/providers/
PaymentProvider.ts # abstract connector
registry.ts # getPaymentProvider()
stripe/StripePaymentProvider.ts
whop/WhopPaymentProvider.ts # REST client (nock-friendly)
webhooks/WebhookEventRegistry.ts
Domain code should call getPaymentProvider() (or getPaymentProvider(order.provider)) instead of the Stripe SDK directly for new work.
| Provider | Endpoint | Auth |
|---|---|---|
| Stripe platform | POST /webhooks/stripe-platform |
stripe-signature + STRIPE_WEBHOOK_SECRET_PLATFORM |
| Stripe connect | POST /webhooks/stripe-connect |
Connect secret |
| Whop | POST /webhooks/whop |
Standard Webhooks headers + WHOP_WEBHOOK_SECRET |
In NODE_ENV=test, signature verification is skipped for both providers.
-
Create an Account API key (Developer → Account API Keys) with enough permissions for Gitpay.
Prefer Admin while integrating, then narrow down. Custom keys that callaccount_linksmust include at least:company:balance:read(required by Whop for account onboarding / payout portal links)- Company create / manage (connected accounts)
- Payments / checkout / invoices (pay-in)
- Transfers and withdrawals (pay-out)
- Webhooks as needed
If you see
This API key is not authorized to scope to the following action: company:balance:read
even after selecting “all permissions”, check:- Key type — use a Company / Account API key under the platform business
(Developer → Company API Keys / Account API Keys).
App API keys (Apps section) often fail onaccount_linksfor connected accounts
with this exact error even when every checkbox is selected. - Same company as
WHOP_COMPANY_ID— the key must belong to the platformbiz_…
in env. Confirm with a call that returns your company id (e.g. account “me”). - Sub-merchant parent —
Users.whop_account_idmust be a child company
created withparent_company_id = WHOP_COMPANY_ID. Links only work for
sub-merchants of the key’s company. - Recreate the key — after changing roles/permissions, create a new key and
updateWHOP_API_KEY(some “edit permission” flows do not fully re-scope). - Sandbox vs prod — sandbox keys only work with
WHOP_SANDBOX=true/ sandbox API base URL.
-
Create a webhook pointing to
https://<API_HOST>/webhooks/whop. -
Subscribe at least to:
payment.succeeded,payment.failedmembership.activatedand/ormembership.went_valid
(one-time plan / product checkout often delivers these when access is granted;
Gitpay uses them as a paid signal for payment requests whenpayment.succeededis missing)invoice.paid,invoice.past_duewithdrawal.created,withdrawal.updatedrefund.created,refund.updated(payment-request balance clawback: 8% of the refunded amount)dispute.created,dispute.updated(payment-request balance clawback: amount + 8% + $15)dispute_alert.created(Early Dispute Alerts early warning — notifies the seller and, when Whop bills for the alert, debits the ~$29 alert fee; see "Payment request: refunds and fees" below)
-
Store the webhook secret as
WHOP_WEBHOOK_SECRET.
If you only log membership.activated and never payment.succeeded: that is common for Whop product/plan purchases. Ensure membership.activated is subscribed (and deploy code that handles it). Unhandled events still return HTTP 200 so Whop stops retrying — check logs for [whop] membership.activated/went_valid.
| Flow | Stripe | Whop |
|---|---|---|
| Bounty card / checkout | Elements + Charges | Checkout configuration + embed/link |
| Bounty invoice | Stripe Invoices | Whop Invoices (send_invoice) |
| Payment request | Product + Price + Payment Link | Product + Plan (purchase_url), or Gitpay-hosted pay page for custom amounts (see below) |
| Wallet top-up | Stripe invoice | Whop invoice |
A payment request can be created with custom_amount: true, meaning the payer chooses
how much to pay rather than the creator fixing a price. Stripe and Whop support this very
differently:
- Stripe: the Payment Link's Price is created with
custom_unit_amount.enabled: true(createPaymentRequestStripeResources). Stripe's own hosted checkout page lets the payer type an amount directly — one reusable link, no Gitpay involvement beyond creation time. - Whop: there is no equivalent. Every Whop plan (
initial_price) is fixed at creation, and there's no buyer-entered-amount field anywhere in the Whop API or hosted checkout. So forprovider: whop+custom_amount: true,WhopPaymentProvider .createPaymentRequestResourcesdoes not create a plan — only a Product — andPaymentRequest.payment_urlis set to a Gitpay-hosted page instead of a Whop URL:${FRONTEND_HOST}/#/payment-requests/:id/pay. That page collects the payer's amount, callsPOST /payment-requests-public/:id/checkout, which mints a fresh, single-use Whopcheckout_configurationfor that exact amount (WhopPaymentProvider .createCheckoutForAmount, same inline-plan pattern ascreateBountyCheckout), and renders it inline via the@whop/checkoutembed (WhopCheckoutEmbed) — the payer never navigates to whop.com.PaymentRequest.payment_link_idstores the Whop product id (not a plan id) in this case;updatePaymentRequestPaymentLinkMetadata/updatePaymentRequestPaymentLinkActive/updatePaymentRequestDetailstreat that id as best-effort (there's no persistent plan to update) — active/inactive enforcement instead happens in thePOST /payment-requests-public/:id/checkoutendpoint itself, before a new checkout is ever minted.
| Flow | Stripe | Whop |
|---|---|---|
| Pay assignee (bounty) | Transfer to User.account_id |
Transfer to User.whop_account_id |
| Payment request after pay | Transfer (source_transaction charge) | Transfer (platform balance → connected company) |
| User withdraw | Connect Payout (POST /payouts/request) |
Whop POST /withdrawals via same Gitpay Request payout UI (method: whop) |
| Step | Automatic? | API |
|---|---|---|
| Customer pays PR | Webhook | payment / membership |
| Platform → seller company | Webhook or cron | transfers (ledger) |
| Seller → bank | User request | Gitpay POST /payouts/request → Whop withdrawals |
Gitpay Payouts page for Whop:
- Shows connected company available balance (ledger → cents for UI)
- Request payout creates a Whop withdrawal (on-demand; no Stripe-style schedule)
- Requires
Users.whop_account_idand a payout method configured on Whop (portal / verification)
Money movement on Whop is always:
checkout → platform company balance → transfers.create → connected company → withdrawals
Whop’s Workforce Bounties API is not used for GitHub issue bounties (different product model).
With PAYMENT_PROVIDER=whop:
-
User picks a country →
POST /user/account→companies.create→ storesUsers.whop_account_id. Gitpay sends to Whop:- email (deliverable
Users.email) - country (lowercase ISO, e.g.
us,br) - title (display name)
- metadata:
internal_user_id/gitpay_user_id, email, country,currency/default_currency(from country map), name, username
Bank account numbers are not collected in Gitpay for Whop.
- email (deliverable
-
Verification link →
account_linkswithuse_case: account_onboarding. Return/refresh URLs hit the API, which then redirects into the SPA (same pattern as/orders/authorize):Provider callback API route Frontend destination Success / done GET /user/account/verification/return/#/profile/payout-settings/bank-account/account-verification/return?status=successExpired / resume GET /user/account/verification/refresh/#/profile/payout-settings/bank-account/account-verification/refresh?status=expiredWhop requires https:// callback URLs. For local dev:
- Tunnel the API (
ngrok http 3000) and setWHOP_API_HOST=https://…(e.g.https://hesitant-hardy-foothold.ngrok-free.dev). - Keep
FRONTEND_HOST=http://localhost:8082so the API can bounce the browser back to the local app. - Same
WHOP_API_HOSTis used for bounty checkoutredirect_url→GET /orders/whop/return?taskId=…→ SPA task page.
- Tunnel the API (
-
User completes KYC and bank / payout method on Whop (hosted portal). That is where the bank account is “settled” — not in Stripe-style Gitpay bank fields.
-
Gitpay Account holder and Bank account tabs show connected company summary + currency from the user’s country (
currencyMap), not Stripe Country Specs. -
Platform can transfer funds; the user requests withdrawals from Gitpay (Request payout → Whop
withdrawals).
With Stripe (default), existing Connect custom account flow is unchanged (Users.account_id + external bank accounts in Gitpay).
GET /user/account always returns:
| Field | Meaning |
|---|---|
provider |
Active payment provider name (stripe | whop) |
active |
Whether the connected account can use payouts / payment requests |
active is computed by PaymentProvider.isConnectedAccountActive(account):
| Provider | Active when |
|---|---|
| Stripe | Account has an id, is not rejected, and has no requirements.currently_due |
| Whop | Account has a connected company id, Whop's own GET /accounts/{id} capabilities.standard_payout is active (or, if that capability is unavailable — older API key without company:balance:read, or the lookup failed — falls back to company.verified === true, a Whop trust & safety review flag used only as a last resort), and the account has at least one payout method on file. Both the KYC/capability signal and the payout method are required — capabilities.standard_payout reflects payout-rail eligibility, not whether a payout destination has actually been linked. |
The frontend gates payment-request creation and the “Action required” banner via validAccount(), which prefers this active flag (no provider-specific branches).
PAYMENT_PROVIDER only defaults new resources (and frontend build). Each PaymentRequests.provider row is the source of truth for that request.
| Operation | Routing |
|---|---|
| Create | env default (PAYMENT_PROVIDER) unless override |
| Pay webhook | Endpoint-specific (/webhooks/stripe-platform vs /webhooks/whop) |
| Transfer | getPaymentProvider(paymentRequest.provider) |
| Update title/active | getPaymentProvider(paymentRequest.provider) → Stripe link/product or Whop plan/product |
| Refund | Same, from parent PR provider (pi_… / pay_…) |
| Lists | DB only — Stripe and Whop rows appear together; payment list includes PaymentRequest.provider |
When switching env to whop, keep Stripe keys and webhooks until open Stripe PRs are paid or closed.
Shared path for Stripe and Whop:
- Webhook: Stripe
checkout.session.completedor Whoppayment.succeeded processPaymentRequestPaymentFromCheckoutSession→processCheckoutSessionCompleted- Always creates
PaymentRequestPayment(source= Stripe PI or Whoppay_…) and marks the request paid - Transfer flow is extracted into
executePaymentRequestTransfer(shared by webhook + cron) - Emails: payment made + optional instructions always; transfer initiated only when a transfer is created
| Provider | Transfer timing |
|---|---|
| Stripe | Immediate (uses source_transaction on the charge) |
| Whop | Tried immediately; if platform available balance is still settling, payment is stored with transferStatus=pending_funds and completed later |
Whop card payments often land in pending for 1–4 days before becoming available. Ledger transfers can only debit available balance.
When transfer is deferred:
| Field | Value |
|---|---|
PaymentRequestPayment |
Always created on pay (status = paid; transferStatus = pending_funds) |
PaymentRequestPayment.transferStatus |
pending_funds |
PaymentRequest.transfer_status |
pending_funds |
PaymentRequest.transfer_id |
null until transfer succeeds |
PaymentRequestTransfer (Claims) |
Created immediately with status = pending, transfer_id = null so Claims UI shows the claim while funds settle. Updated to status = created + provider transfer_id when cron/script succeeds. |
Why Claims was empty before: Claims → “payment request transfers” reads PaymentRequestTransfer only. Older code created that row after a successful provider transfer, so deferred Whop pays left no claim and the cron only looked for transferStatus = pending_funds (never set if transfer threw a non-matched error).
Daily cron (midnight, with the other daily jobs) runs processPendingPaymentRequestTransfers:
- Loads payments with
transferStatus = pending_funds(oldest first) - Retries
executePaymentRequestTransfer - On success: sets status to
initiated, createsPaymentRequestTransfer, sends transfer + balance emails - If still insufficient available balance: leaves
pending_fundsfor the next day
Manual / ops script (same logic as the cron):
npm run scripts:payment-request:process_pending_transfersWhop’s sandbox docs note that payouts may not be available. Card payments also often stay in pending balance for days (or forever in sandbox), so ledger transfers fail with insufficient available balance.
Use the same settlement script with explicit flags to complete the Gitpay side of the flow without waiting on Whop:
| Step | Real production | Sandbox / dry-run |
|---|---|---|
| 1. Customer pays | Whop checkout + payment.succeeded webhook |
Same, or --bounty-orders to mark unpaid Whop Orders paid |
| 2. Platform → seller company | Webhook / cron transfers (ledger) |
--mock-settlement writes mock_tr_pr_… and completes claims |
| 3. Platform → bounty assignee | Transfer API after assign | --bounty-transfers --mock-settlement |
| 4. Seller → bank | User Request payout → withdrawals |
--mock-payout --user-id=N --amount=… |
# Retry deferred payment-request transfers (real Whop API — needs available balance)
npm run scripts:payment-request:process_pending_transfers
# Sandbox: complete deferred PR transfers without calling Whop ledger
npm run scripts:payment-request:process_pending_transfers -- --mock-settlement
# Emulate payment.succeeded for open Whop bounty orders
npm run scripts:payment-request:process_pending_transfers -- --bounty-orders
npm run scripts:payment-request:process_pending_transfers -- --bounty-orders --order-id=42
# Bounty assignee transfer (mock ledger when sandbox cannot transfer)
npm run scripts:payment-request:process_pending_transfers -- --bounty-transfers --mock-settlement
npm run scripts:payment-request:process_pending_transfers -- --bounty-transfers --task-id=12 --mock-settlement
# Emulate completed withdrawal (Gitpay Payout row only)
npm run scripts:payment-request:process_pending_transfers -- --mock-payout --user-id=3 --amount=50
# Full sandbox path after a real (or emulated) pay-in
npm run scripts:payment-request:process_pending_transfers -- --mock-settlement --bounty-orders --bounty-transfers --mock-payout --user-id=3 --amount=50Architecture (services used by cron, webhooks, and script):
| Service | Role |
|---|---|
executePaymentRequestTransfer |
Single PR payment → transfer (mockSettlement optional) |
processPendingPaymentRequestTransfers |
Batch deferred PR transfers |
markBountyOrderPaid / processUnpaidWhopBountyOrders |
Bounty pay-in (webhook + script) |
transferBuildsService |
Bounty assignee transfer (mockSettlement optional) |
processPendingBountyWhopTransfers |
Batch bounty transfers for script |
mockPayoutSettlement |
Synthetic paid Whop withdrawal row |
Never enable mockSettlement on the daily cron — only the CLI with an explicit flag.
Mock transfer ids look like mock_tr_pr_<paymentId>_<ts> / mock_tr_bounty_<taskId>_<ts>.
Mock payouts use source_id like mock_wdrl_<userId>_<ts> and method: whop, status: paid.
On production, omit mock flags: run real checkouts, wait for available balance (or fund the platform company), then use the cron/script without --mock-settlement and the normal Request payout UI for withdrawals.
-
Real
payment.succeededpayloads often omitstatus; the event type means paid. -
PR metadata is usually on plan.metadata; the handler merges plan + payment metadata.
-
Whop has no
source_transactionon transfers; correlation is via transfermetadata.source_payment_id. -
Transfers use
POST /transferswithtype: "ledger"(platformorigin_id→ connecteddestination_id). Gitpay never useswallet_send(crypto).Sends are only supported from an Ethereum walletis a misleading Whop API error. In practice it often means:- Available balance is 0 while funds sit in pending (card payments settle over 1–4 days).
Check
GET /ledger_accounts/{WHOP_COMPANY_ID}→balances[].balance(available) vspending_balance. - Sandbox limitation: Whop sandbox docs list
payouts as not available yet; ledger transfers may fail the same way even with correct
type: ledger. - Platform company not fully ready for transfers (less common if
capabilities.transferisactive).
Before transferring, the platform needs available USD ≥ transfer amount. Destination must be a connected company (
Users.whop_account_id=biz_…underWHOP_COMPANY_ID). - Available balance is 0 while funds sit in pending (card payments settle over 1–4 days).
Check
| File | Role |
|---|---|
src/mutations/payment-request/checkout-session/processCheckoutSessionCompleted.ts |
Persist payment, then call transfer |
src/services/paymentRequest/executePaymentRequestTransfer.ts |
Shared transfer + balance/debt logic |
src/services/paymentRequest/processPendingPaymentRequestTransfers.ts |
Cron/script batch for pending_funds |
src/services/orders/markBountyOrderPaid.ts |
Bounty order paid (webhook + script) |
src/services/orders/processPendingBountyWhopTransfers.ts |
Script batch for bounty assignee transfers |
src/services/payouts/mockPayoutSettlement.ts |
Ops mock withdrawal completion |
src/crons/paymentRequests/paymentRequestTransferCron.ts |
Daily job wrapper (no mock flags) |
src/scripts/payment-request/process_pending_transfers.ts |
CLI: PR transfers + bounty + mock payout |
| Stripe | Whop |
|---|---|
charge.dispute.created → notify |
dispute.created → notify |
charge.dispute.funds_withdrawn → DEBIT |
Same on dispute.created (Whop withdraws immediately) |
charge.dispute.closed won → CREDIT |
dispute.updated status won → CREDIT |
Debit formula (cents):
disputed amount
+ 8% Gitpay platform fee
+ provider fee (Stripe balance_tx fee, or WHOP_DISPUTE_FEE_CENTS default 1500 = $15)
Lookup: PaymentRequestPayment.source = Stripe payment_intent or Whop payment.id (pay_…).
Debits/credits are idempotent per sourceId + type so webhook retries are safe.
WHOP_DISPUTE_FEE_CENTS (default 1500 = $15) is the source of the Whop provider fee in the
debit formula above — unlike Stripe, whose fee is read per-event from balance_transactions[0].fee,
Whop's webhook payload has no per-event fee breakdown, so this env var stands in for it. Override it
if Whop's actual chargeback fee changes.
A won dispute's CREDIT is amount + provider fee only — it does not reimburse Gitpay's own 8%
platform fee that was part of the original DEBIT, so the balance does not return fully to zero even
when a dispute is won (same behavior on both providers, since they share disputeService.ts).
Refunds debit the seller's PaymentRequestBalance on both providers, sharing
src/services/payments/refunds/refundBalanceService.ts:
| Stripe | Whop |
|---|---|
charge.refunded → DEBIT |
refund.created / refund.updated → DEBIT |
Debit formula (cents): 8% of the amount actually refunded — reason REFUND, reason_details: 'refund_payment_request_requested_by_customer'. This is a lighter clawback than a formal dispute:
Gitpay only recovers its own platform fee, since the provider (not the seller's transferred payout)
absorbs the refunded principal. Idempotent per the refund's own id (not the payment id — a single
payment can have more than one distinct refund, e.g. a manual partial refund issued directly on the
provider dashboard, so deduping by payment id would wrongly suppress a second, real refund).
Gitpay's own refund action (paymentRequestRefund) always issues a full refund — there is no
partial-refund UI in Gitpay. The only way the refunded amount differs from the original charge is a
manual partial refund issued directly on the Stripe or Whop dashboard, outside Gitpay. The debit
always reads the actually-refunded amount from the webhook (Stripe: the latest refund's own amount,
not the charge's cumulative amount_refunded, which is a running total across every refund the
charge has ever had; Whop: refund.amount), so this stays correct even for that edge case.
Whop's Early Dispute Alerts lets you auto-refund transactions under a configurable threshold ($250 minimum, cannot be set lower) when a card network signals an incoming dispute, preempting a formal chargeback. This is a Whop dashboard setting with no Gitpay-side configuration.
Separately from that threshold, Whop bills per alert (~$29, independent of whether the alert
resolves via auto-refund, a formal dispute, or nothing) whenever charge_for_alert is true on the
dispute_alert.created webhook payload. Gitpay debits this immediately when it fires:
| Event | Fee debited | Reason |
|---|---|---|
dispute_alert.created (charge_for_alert=true) |
WHOP_DISPUTE_ALERT_FEE_CENTS (2900 = $29) |
EXTRA_FEE |
WHOP_DISPUTE_ALERT_FEE_CENTS is a plain code constant in src/services/payments/fees/extraFeeService.ts
(not env-configurable, unlike WHOP_DISPUTE_FEE_CENTS) — Whop doesn't expose a fee amount anywhere in
the dispute or dispute-alert API/webhook payloads (unlike Stripe's balance_transactions[0].fee), only
the charge_for_alert boolean confirming whether one applies. The $29 figure is sourced from
third-party research, not Whop's own pricing docs — verify it against a real Whop invoice and adjust
the constant directly if it differs.
The alert fee is captured once, independent of whatever happens next — no cross-event correlation is
needed. An alert that leads to an auto-refund accumulates both the EXTRA_FEE debit and the REFUND
debit as two separate ledger rows; an alert that escalates into a formal dispute accumulates the
EXTRA_FEE debit plus the full DISPUTE debit; a dispute with no preceding alert only ever gets the
dispute debit. The seller is also notified by email as soon as the alert fires (dispute_alert.created
→ PaymentRequestMail.newDisputeAlertForPaymentRequest), before it's known whether it becomes a refund
or a dispute.
The debt-recovery mechanism (executePaymentRequestTransfer.ts) that applies a user's next paid
PaymentRequest against an existing negative PaymentRequestBalance is provider-agnostic, but on
Whop it interacts with Whop's normal pending-balance settlement lag (card payments often take 1–4
days to become available):
- If the new payment fully covers the debt, no provider transfer is attempted at all — the debt is cleared immediately regardless of Whop's available balance.
- If the new payment only partially covers the debt, a real Whop transfer is still required for the
remainder. The debt-clearing CREDIT is written atomically with that transfer, so if the transfer
is deferred (insufficient available balance), the negative balance stays exactly as it was until
the daily cron /
process_pending_transfersretry succeeds — it is never applied optimistically.
Whop's sandbox cannot generate a real chargeback (disputes are bank/card-network driven — there is no
Stripe-CLI-style stripe trigger charge.dispute.created for Whop), and Early Dispute Alerts can't be
triggered on demand either. To validate the deployed code path (real WHOP_WEBHOOK_SECRET signature
verification, real DB writes, real email sending) without a live chargeback or alert, sign and deliver
a synthetic event yourself with src/scripts/whop/simulate_dispute.ts:
- Run a real Whop sandbox checkout to get a genuine
payment.id(pay_…) tied to an existingPaymentRequestPayment. - Simulate the dispute opening:
Confirm a
npm run scripts:whop:simulate_dispute -- --type=dispute.created --payment-id=pay_xxx --amount=49.95 --url=https://your-host/webhooks/whop
PaymentRequestBalanceTransactionDEBIT appears and the dispute-opened email is sent. - Simulate it resolving (won/lost):
Confirm the CREDIT/recovery path (or, for
npm run scripts:whop:simulate_dispute -- --type=dispute.updated --status=won --payment-id=pay_xxx --url=https://your-host/webhooks/whop
--status=lost, that no CREDIT is created). - Simulate a refund (customer-requested or Whop auto-refund from an alert — same event either way):
Confirm an 8%-of-refund
npm run scripts:whop:simulate_dispute -- --type=refund.created --payment-id=pay_xxx --amount=49.95 --url=https://your-host/webhooks/whop
PaymentRequestBalanceTransactionDEBIT (reason='REFUND') appears and the payment is markedrefunded. - Simulate an early dispute alert:
Confirm the seller notification email sends, and (with
npm run scripts:whop:simulate_dispute -- --type=dispute_alert.created --payment-id=pay_xxx --charge-for-alert=true --url=https://your-host/webhooks/whop
--charge-for-alert=true) aPaymentRequestBalanceTransactionDEBIT (reason='EXTRA_FEE') forWHOP_DISPUTE_ALERT_FEE_CENTSappears. Use--charge-for-alert=falseto confirm the notify-only path with no debit.
--url can point at a local server, an ngrok tunnel, or a deployed sandbox — see
src/scripts/whop/simulate_dispute.ts for the full flag list.
Country pickers and GET /user/account/countries are provider-aware:
| Provider | Source | Approx. count |
|---|---|---|
| Stripe | Gitpay Connect product list (country-codes.js / STRIPE_SUPPORTED_COUNTRIES) |
~63 |
| Whop | Whop payout-supported countries (docs) | ~200+ |
- Frontend:
getSupportedCountryCodes()infrontend/src/components/areas/private/shared/provider-country-codes.ts(usesPAYMENT_PROVIDER). - Backend:
src/providers/shared/supportedCountries.ts+userAccountCountries. - Public Supported Countries page and the payout country picker both follow the active provider.
- Stripe bank currency fields still use Stripe Country Spec fields on the countries response when the user already has a Connect account.
When you switch PAYMENT_PROVIDER, rebuild the frontend so the country list matches the backend.
- Default test bootstrap forces
PAYMENT_PROVIDER=stripe. - Whop suites use
withPaymentProvider('whop', …)and nockhttps://api.whop.com(orWHOP_API_BASE_URL). - Fixtures:
test/data/whop/* - Whop API tests:
test/api/**/*Whop*,test/api/webhooks/whop/*
# Stripe-focused
npx mocha test/api/webhooks/stripe/**/*.test.ts
# Whop-focused (webhooks, orders, payouts, settlement script services)
npm run test:whop
npx mocha test/api/scripts/whop-settlement.test.ts- Implement
PaymentProviderundersrc/providers/<name>/. - Register in
registry.ts. - Add webhook route +
register*Handlers. - Add fixtures and parallel tests under
test/data/<name>andtest/api/webhooks/<name>. - Extend frontend
PAYMENT_PROVIDERswitch (badge, checkout card, payout method).