Skip to content

NSFW Processor-Aware Wallet Payment Gating

Update (FOXY-596, 2026-07): the visibility rule below was extended for settle-then-lock. Wallets are now shown to NSFW users who are NMI-locked or still unassigned (i.e. psp unset OR psp === "nmi"), including outside the US — an unassigned user’s first wallet payment routes to NMI via the high-risk workflow, so hiding wallets from them would block the better route. Apple Pay is additionally gated to @foxy.ai emails on public high-risk processors. The cardOnly → isNSFW + processor refactor documented below still stands; only the walletsAllowed expression gained the unassigned case.

Update (FOXY-1165 / FOXY-1177, 2026-09): walletsAllowed also accepts a cybersource session processor — Cybersource carries Apple Pay on the full-access lane until NMI Apple Pay is fixed (FOXY-105); a subscriber locked to it renews Apple Pay there, while the locked workflow branches that account’s card and Google Pay payments back to EMP/NMI (FOXY-1179). The @foxy.ai gate on Apple Pay is replaced by the PostHog flag full-access-apple-pay (FULL_ACCESS_APPLE_PAY_FLAG): off keeps Apple Pay hidden on 18+ checkouts, on opens it once Primer routes APPLE_PAY on the full-access discovery workflow to Cybersource (FOXY-1179), and off again is the rollback. That flag opens Apple Pay only together with full-access-plans, read for the same user in the browser: the wallet belongs to the new-plans cohort, so a legacy 18+ subscriber — and everyone, after a plans rollback — keeps a cards-only checkout. Even with both flags on, Apple Pay on 18+ is offered only to an unlocked or Cybersource-locked session (allowsFullAccessApplePay): an NMI- or EMP-locked subscriber renews on a branch that cannot carry it. The wallet rule lives in areWalletsAllowed in apps/brisket/src/features/checkout/components/helper-functions.ts.

Update (TRUCOM-96, 2026-10): PrimerComponentsCheckout takes an optional hideWallets prop; PrimerPayments forwards it and only the billing-page payment-methods dialog (ManagePaymentsDialog, which serves both fix-payment and change-default) sets it. For a full-access customer in dunning whose primary payment method is Google Pay (isGooglePayDunningCase in PrimerPayments), walletsVisible turns Apple Pay, Google Pay and the “Or add a card” divider off on an nmi vaulting session, because NMI cannot renew an Android Google Pay device token and a re-added one fails the same way; on a cybersource session the wallets stay. The banner, the billing strip and the dialog ask such a customer to add a card.

Replace the blanket cardOnly flag (NSFW = hide Apple Pay/Google Pay) with processor-aware logic: EMP = card only, NMI = wallets allowed. Backend exposes processor in checkout/vaulting responses; frontend derives wallet visibility from it.

Add processor string field to checkout and vaulting proto responses. Backend populates it from the existing ResolveProcessor result. Frontend replaces cardOnly with isNSFW prop and derives wallet visibility from the processor. The original rule was !isNSFW || processor === 'nmi' with every edge case card-only; the current contract (areWalletsAllowed, see the update above) allows wallets on an SFW checkout, an unrouted full-access session and an NMI- or Cybersource-locked one, and keeps EMP-locked and unknown processors card-only.

  • NMI-locked users see Google Pay on NSFW checkout but not Apple Pay, which needs Cybersource; Cybersource-locked and not-yet-locked users see both wallets, Apple Pay behind full-access-plans and full-access-apple-pay (FOXY-1165); EMP users stay card-only.
  • cardOnly prop removed from frontend — all NSFW gating uses isNSFW + processor.
  • Rollback: revert frontend commits; proto field is additive and harmless if unused.
  • No monitoring changes needed — Primer SDK only offers wallets when the PSP supports them.
  • Backfill is unaffected — existing EMP users continue to see card-only.
  • wallets_allowed boolean in response: simpler frontend but less flexible — every new processor behavior needs a new proto field.
  • Keep cardOnly and override from response: hybrid approach, messy naming when NMI allows wallets.
message CreatePrimerCheckoutResponse {
// ... existing fields 1-14 ...
string processor = 15; // PSP identifier (e.g. "emp", "nmi", "cybersource")
}
message CreateVaultingSessionResponse {
string client_token = 1;
string processor = 2; // PSP identifier
}
  • SFW flows return the assigned SFW processor (e.g. "cybersource") — frontend ignores processor when isNSFW is false.
  • No breaking change — new field, additive.

Processor is already resolved in all relevant code paths. Wire existing value into response structs.

Subscription checkout (createprimercheckout.go ~line 491)

Section titled “Subscription checkout (createprimercheckout.go ~line 491)”

processor in scope from ResolveProcessor call at ~line 343. Add Processor: processor to CreatePrimerCheckoutResponse.

Top-up checkout (createprimercheckout.go ~line 1275)

Section titled “Top-up checkout (createprimercheckout.go ~line 1275)”

processor in scope from ~line 861. Add Processor: processor to response.

Vaulting session (vaultingsession.go ~line 158)

Section titled “Vaulting session (vaultingsession.go ~line 158)”

processor in scope from ~line 83. Add Processor: processor to CreateVaultingSessionResponse.

  • Zero-amount checkout responses — no payment form shown
  • Scheduled downgrade responses — no payment
  • Direct MIT top-up responses — server-side, no frontend

Remove cardOnly from:

  • Plan constants (constants.ts) — delete cardOnly: true from 3 NSFW plans
  • Type definitions (primer-components-checkout/types.ts, checkout/components/types.ts)
  • Helper function (helper-functions.ts)

Add isNSFW prop to PrimerComponentsCheckout:

  • Plan-based callers pass plan.nsfwSupported (already exists on plan objects)
  • Runtime callers pass isNsfwSubscriber (already exists via useIsNsfwSubscriber hook)
# File Change
1 checkout-types/single-plan-upgrade/index.tsx cardOnly={selectedPlanData?.cardOnly} → isNSFW={selectedPlanData?.nsfwSupported}
2 checkout-types/upgrade-selection/upgrade-button.tsx cardOnly={plan.cardOnly} → isNSFW={plan.nsfwSupported}
3 checkout-types/credit-purchase/index.tsx cardOnly={isNsfwSubscriber} → isNSFW={isNsfwSubscriber}
4 checkout-types/plan-upgrade-or-credits-purchase/index.tsx cardOnly={isNsfwSubscriber} → isNSFW={isNsfwSubscriber}
5 billing-page/primer/primer-payments/index.tsx cardOnly={isNsfwTier(usage?.tier)} → isNSFW={isNsfwTier(usage?.tier)}
6 usage/components/PlanUpgradeCard.tsx cardOnly={nextPlan.cardOnly} → isNSFW={nextPlan.nsfwSupported}

Plus CreditPacksCarousel passthrough.

  • useCreateCheckoutSession hook: read processor from checkout API response, surface it to PrimerComponentsCheckout
  • Vaulting flow: read processor from vaulting session response

Wallet visibility logic (primer-components-checkout/index.tsx)

Section titled “Wallet visibility logic (primer-components-checkout/index.tsx)”
// Before:
const hasApplePay = !cardOnly && availableCheckoutPaymentMethods?.some(
(method) => method.type === "APPLE_PAY"
) ?? false
// After: the processor-based rule this ADR decides (the real helpers are
// areWalletsAllowed and allowsFullAccessApplePay in
// apps/brisket/src/features/checkout/components/helper-functions.ts). Current
// code also applies the TRUCOM-96 hideWallets opt-out through walletsVisible
// in the same file; see the update note at the top.
// Since FOXY-596 an unassigned processor also allows wallets, and since
// FOXY-1165 so does 'cybersource' (Apple Pay on the full-access lane).
const psp = processor?.trim().toLowerCase() ?? ""
const walletsAllowed = !isNSFW || psp === "" || psp === "nmi" || psp === "cybersource"
// Apple Pay on the full-access lane has its own gate: both PostHog flags, and a
// session with no lock or a Cybersource lock. An NMI-locked 18+ session keeps
// Google Pay but never sees Apple Pay.
const applePayAllowed = !isNSFW ||
(newPlansFlag && fullAccessApplePayFlag && (psp === "" || psp === "cybersource"))
const hasApplePay = walletsAllowed && applePayAllowed &&
(availableCheckoutPaymentMethods?.some((method) => method.type === "APPLE_PAY") ?? false)
const hasGooglePay = walletsAllowed &&
(availableCheckoutPaymentMethods?.some((method) => method.type === "GOOGLE_PAY") ?? false)

Replace cardOnly with isNSFW in the reservation gate (~line 195). Same behavior — all NSFW purchases reserve regardless of processor.

Case Behavior Reason
Processor unset (no lock yet) Wallets allowed Primer discovery routes the first payment by method: cards down the high-risk lane, Apple Pay on a full-access plan to Cybersource (FOXY-1179); psp stays unset until settlement locks it (FOXY-596 update)
Unknown processor value Card only Only explicit nmi or cybersource unlocks wallets (Cybersource since FOXY-1165)
Vaulting flow Same logic isNSFW + processor from vaulting response
UI before response N/A Checkout UI renders after session creation response
SFW purchase All methods available isNSFW is false, processor ignored
  • Backend processor assignment logic (already shipped)
  • Primer metadata routing (metadata["psp"])
  • Renewal/dunning flows (server-side, no frontend)
  • Payment attempt quota reservation behavior (stays NSFW-gated)
  • nsfwSupported field on plan constants (reused, not modified)
  • useIsNsfwSubscriber hook (reused, not modified)