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. ThecardOnly→isNSFW+ processor refactor documented below still stands; only thewalletsAllowedexpression gained the unassigned case.Update (FOXY-1165 / FOXY-1177, 2026-09):
walletsAllowedalso accepts acybersourcesession 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 flagfull-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 withfull-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 inareWalletsAllowedinapps/brisket/src/features/checkout/components/helper-functions.ts.Update (TRUCOM-96, 2026-10):
PrimerComponentsCheckouttakes an optionalhideWalletsprop;PrimerPaymentsforwards 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 (isGooglePayDunningCaseinPrimerPayments),walletsVisibleturns Apple Pay, Google Pay and the “Or add a card” divider off on annmivaulting session, because NMI cannot renew an Android Google Pay device token and a re-added one fails the same way; on acybersourcesession the wallets stay. The banner, the billing strip and the dialog ask such a customer to add a card.
Context
Section titled “Context”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.
Decision
Section titled “Decision”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.
Consequences
Section titled “Consequences”- 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-plansandfull-access-apple-pay(FOXY-1165); EMP users stay card-only. cardOnlyprop removed from frontend — all NSFW gating usesisNSFW+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.
Alternatives Considered
Section titled “Alternatives Considered”wallets_allowedboolean in response: simpler frontend but less flexible — every new processor behavior needs a new proto field.- Keep
cardOnlyand override from response: hybrid approach, messy naming when NMI allows wallets.
Proto Changes
Section titled “Proto Changes”billing.proto
Section titled “billing.proto”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 whenisNSFWis false. - No breaking change — new field, additive.
Backend Changes (sirloin)
Section titled “Backend Changes (sirloin)”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.
Skipped (no payment UI)
Section titled “Skipped (no payment UI)”- Zero-amount checkout responses — no payment form shown
- Scheduled downgrade responses — no payment
- Direct MIT top-up responses — server-side, no frontend
Frontend Changes (brisket)
Section titled “Frontend Changes (brisket)”Delete cardOnly, introduce isNSFW
Section titled “Delete cardOnly, introduce isNSFW”Remove cardOnly from:
- Plan constants (
constants.ts) — deletecardOnly: truefrom 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 viauseIsNsfwSubscriberhook)
Caller sites (6 total)
Section titled “Caller sites (6 total)”| # | 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.
Processor from response
Section titled “Processor from response”useCreateCheckoutSessionhook: readprocessorfrom checkout API response, surface it toPrimerComponentsCheckout- Vaulting flow: read
processorfrom 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)Quota reservation
Section titled “Quota reservation”Replace cardOnly with isNSFW in the reservation gate (~line 195). Same behavior — all NSFW purchases reserve regardless of processor.
Edge Cases
Section titled “Edge Cases”| 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 |
What This Does NOT Change
Section titled “What This Does NOT Change”- 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)
nsfwSupportedfield on plan constants (reused, not modified)useIsNsfwSubscriberhook (reused, not modified)