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 cardOnlyisNSFW + processor refactor documented below still stands; only the walletsAllowed expression 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.
  • 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.

Alternatives Considered

  • 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.

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 when isNSFW is 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) — 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)

Caller sites (6 total)

#FileChange
1checkout-types/single-plan-upgrade/index.tsxcardOnly={selectedPlanData?.cardOnly}isNSFW={selectedPlanData?.nsfwSupported}
2checkout-types/upgrade-selection/upgrade-button.tsxcardOnly={plan.cardOnly}isNSFW={plan.nsfwSupported}
3checkout-types/credit-purchase/index.tsxcardOnly={isNsfwSubscriber}isNSFW={isNsfwSubscriber}
4checkout-types/plan-upgrade-or-credits-purchase/index.tsxcardOnly={isNsfwSubscriber}isNSFW={isNsfwSubscriber}
5billing-page/primer/primer-payments/index.tsxcardOnly={isNsfwTier(usage?.tier)}isNSFW={isNsfwTier(usage?.tier)}
6usage/components/PlanUpgradeCard.tsxcardOnly={nextPlan.cardOnly}isNSFW={nextPlan.nsfwSupported}

Plus CreditPacksCarousel passthrough.

Processor from response

  • 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)

// 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"
) ?? false
const hasGooglePay = walletsAllowed && availableCheckoutPaymentMethods?.some(
(method) => method.type === "GOOGLE_PAY"
) ?? false

Quota reservation

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

Edge Cases

CaseBehaviorReason
Processor empty stringCard onlySafe default, EMP behavior
Unknown processor valueCard onlyOnly explicit nmi unlocks wallets
Vaulting flowSame logicisNSFW + processor from vaulting response
UI before responseN/ACheckout UI renders after session creation response
SFW purchaseAll methods availableisNSFW 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)
  • nsfwSupported field on plan constants (reused, not modified)
  • useIsNsfwSubscriber hook (reused, not modified)