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.
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
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 uses !isNSFW || processor === 'nmi' for wallet visibility. All edge cases default to card-only (safe).
Consequences
- NMI users see Apple Pay / Google Pay on NSFW checkout; 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
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
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)
Processor is already resolved in all relevant code paths. Wire existing value into response structs.
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)
processor in scope from ~line 861. Add Processor: processor to response.
Vaulting session (vaultingsession.go ~line 158)
processor in scope from ~line 83. Add Processor: processor to CreateVaultingSessionResponse.
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)
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)
| # | 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
useCreateCheckoutSessionhook: readprocessorfrom checkout API response, surface it toPrimerComponentsCheckout- Vaulting flow: read
processorfrom vaulting session response
Wallet visibility logic (primer-components-checkout/index.tsx)
// Before:const hasApplePay = !cardOnly && availableCheckoutPaymentMethods?.some( (method) => method.type === "APPLE_PAY") ?? false
// After:const walletsAllowed = !isNSFW || processor === 'nmi'const hasApplePay = walletsAllowed && availableCheckoutPaymentMethods?.some( (method) => method.type === "APPLE_PAY") ?? falseconst hasGooglePay = walletsAllowed && availableCheckoutPaymentMethods?.some( (method) => method.type === "GOOGLE_PAY") ?? falseQuota reservation
Replace cardOnly with isNSFW in the reservation gate (~line 195). Same behavior — all NSFW purchases reserve regardless of processor.
Edge Cases
| Case | Behavior | Reason |
|---|---|---|
| Processor empty string | Card only | Safe default, EMP behavior |
| Unknown processor value | Card only | Only explicit nmi unlocks wallets |
| 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
- 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)