Skip to content

Settle-Then-Lock NSFW Processor Assignment

Context

NSFW (full-access) charges route through high-risk processors (NMI, eMerchantpay/EMP). A vaulted NSFW payment credential is processor-homed: a token that settled on NMI does not clear on EMP and vice versa. So the processor a user is charged on must stay stable across their subscription lifetime, or every renewal cross-homes and hard-declines.

The prior model assigned that processor eagerly, before any payment, using a deterministic hash bucket (domain.HashSplit(user_id, n)). This has two structural problems:

  1. Blind allocation. The hash sees nothing about the card — not the BIN, not the issuer country, not whether the instrument is a wallet. It cannot know which processor the card will actually clear on, so a meaningful fraction of first payments land on the wrong processor and decline with no second attempt.
  2. No recovery. Once bucketed, a user whose card only works on the other processor churns; nothing re-homes them.

Primer, in contrast, sees the full instrument at authorization time and can route on BIN / issuer country / wallet type, and can fall back to a second processor within one payment. The goal of FOXY-596 is to let Primer route the first payment intelligently, observe which processor actually took the money, and lock that pair for all future charges — replacing a blind up-front guess with an evidence-based assignment.

This ADR supersedes hash-split allocation for the NSFW lane and extends the wallet-gating decision (see NSFW Processor-Aware Wallet Payment Gating).

Decision

Adopt settle-then-lock for the NSFW lane: do not assign a processor up front; let the first payment discover it, then lock the observed processor + merchant account first-wins.

Unassigned emission. ResolveProcessor(isNSFW=true) returns an empty PSP for an unassigned user (processorassignment.go). Checkout, renewal, and top-up all emit Primer metadata access=full and omit the psp key when empty (never psp=""). SFW keeps eager assignment (Cybersource/Stripe). No longer true as of 2026-08-06: the SFW lane omits psp the same way, and eager assignment is gone from both lanes — see the superseding ADR.

Discovery. With access=full and psp absent, Primer’s “unset-psp” high-risk Workflow routes the payment (wallet → NMI, US card → 50/50 NMI/EMP, rest → EMP with NMI fallback; renewals → EMP with NMI fallback). Two shots per payment, card-aware.

Lock on settle. On a successful, subscription-linked NSFW settle, LockProcessorFromPayment (processorlock.go) reads which processor + merchant account actually took the money (payment.processor.{name, processorMerchantId}, from the Primer GET /payments/{id} the caller already holds) and writes it first-wins to credits.nsfw_processor (+ credits.nsfw_processor_mid, NMI-only). First writer wins; later settles no-op.

Replay. A locked user’s later checkouts/renewals emit psp (and psp_mid for NMI) so Primer routes deterministically to the same processor + MID.

Repair. An unlocked user’s renewal or top-up calls RepairProcessorLockFromHistory, which recovers the lock from the user’s most recent subscription-linked settled Primer payment before falling back to psp-absent discovery — so a lock that failed to persist self-heals instead of gambling on a blind probe.

domain.HashSplit is deleted.

Metadata contract (sirloin → Primer)

The only routing surface is four metadata keys plus Primer’s native paymentType. Verified emission per payment class:

Payment classpaymentTypepayment_typepsp (unlocked)psp (locked)psp_midchargebee_subscription_id
Subscription CIT checkoutFIRST_PAYMENTomittednmi/empNMI-onlypresent
Renewal MITSUBSCRIPTION"renewal"omittednmi/empNMI-onlypresent
Top-up (customer-present)ECOMMERCEomittednmi/empNMI-onlyabsent
Top-up direct saved-cardUNSCHEDULED(NSFW blocked)
Vaulting session (no charge)FIRST_PAYMENTemp (concrete) — historical; since 2026-08-06 vaulting omits psp when the lane is unlockednmi/empNMI-onlyabsent
  • access is always present (ensureAccessMetadata defaults to regular), so a workflow trigger never silently misses.
  • psp is omitted, never empty — the workflow trigger is “psp is empty” and must match an absent key.
  • psp_mid carries the raw opaque Primer processorMerchantId, NMI-only (nsfwMidForPSP), replayed only when the persisted lock is NMI (lockedNSFWProcessorMid).
  • payment_type == "renewal" is the sole positive MIT marker sirloin sets; combined with Primer’s native paymentType, it distinguishes renewals from customer-present payments.

Primer Workflows (external, dashboard-hosted)

Three high-risk Workflows consume the contract. They live in the Primer dashboard, not this repo.

  1. Unset-psp — trigger access=full AND psp empty. Split on Primer’s customer-initiated flag:
    • CIT (FIRST_PAYMENT / ECOMMERCE): wallet → NMI (% split across NMI MIDs); US-issued card → 50/50 NMI/EMP (NMI leg % split across MIDs; EMP leg with NMI fallback); rest → EMP with NMI fallback.
    • Else / MIT (not customer-initiated = SUBSCRIPTION / UNSCHEDULED): EMP with NMI fallback.
  2. NMI — trigger access=full AND psp == "nmi". Branch on psp_mid: SSB MID → SSB (fallback Esquire); otherwise Esquire (fallback SSB).
  3. EMP / rest — trigger access=full AND psp non-empty AND psp != "nmi". EMP home, no cross-processor fallback (an EMP-homed token cannot clear elsewhere).

Invariants

  • First-winsAssignNSFWProcessorWithMid updates WHERE nsfw_processor IS NULL OR '' and returns rows > 0; concurrent settles are race-safe.
  • Token-authoritative — only subscription-linked settles lock (isSubscriptionLinkedPayment, keyed on chargebee_subscription_id). Top-ups charge whatever instrument the user picked that day and never lock.
  • Verify-then-lock — the settled-payments poller locks only after subscription verification (verifyProceed), so a settle from a canceled sibling checkout cannot mislock the surviving subscription.
  • Best-effort — the lock hook only reads credits and does one first-wins UPDATE; it never blocks payment recording, crediting, or activation.
  • NMI-only MIDnsfw_processor_mid is written and replayed only for NMI (the dual-MID processor); EMP and any future single-MID processor never populate it.
  • Fail-closed NSFW lock-read — a credits read error aborts the attempt rather than routing a possibly-locked user psp-absent into the discovery probe.
  • Schema — migration 128_nsfw_processor_mid.sql adds users.credits.nsfw_processor_mid TEXT.

Consequences

Positive:

  • First NSFW payment is routed by Primer with card-aware signals and a fallback leg (two attempts) instead of one blind hash bucket — fewer wrong-home declines.
  • Renewals are deterministic and token-correct: the locked pair is exactly the processor + MID that took the money.
  • Self-healing: an unlocked user (lock write failed, or legacy) is repaired from settled Primer history on the next renewal/top-up; genuinely-new users route psp-absent and lock on discovery.
  • Wallets route natively to NMI and lock NMI, so wallet users renew on a processor that supports wallets.

Negative / accepted trade-offs:

  • Routing correctness lives partly in the Primer dashboard and cannot be verified from this repo. See Deploy Gates.
  • Velocity exemption cohort widens. shouldEnforceHighRiskProcessorQuota exempts NMI-locked users from the high-risk count/amount quotas (they carry NMI’s own ceilings). Because discovery sends wallets and ~50% of US card CITs to NMI, more NSFW users end up NMI-locked and app-side-velocity-exempt than under hash-split. Empty-psp (unassigned) and EMP-locked users remain enforced. Accepted as a deliberate posture; enforcement is otherwise applied consistently.
  • Instrument-switch edge accepted. A user card-locked to EMP who later makes a wallet their default (or promotes a cross-home instrument via SetPrimaryPaymentMethod) can renew into an EMP path that cannot charge the wallet. Treated as user action, not a system defect — the same call as the wallet-gating ADR’s manual-promotion case. The unlocked variant self-heals via repair-from-history.
  • Tests are deferred; the model rides on the metadata contract holding exactly as tabled above.

Observability:

  • billing_processor_lock_outcome_total counter (metrics/processor_lock.go), tagged outcome (locked / already_locked / lost_race / not_subscription_linked / unknown_processor / no_processor_info / assign_error / credits_read_error / ensure_row_error). This is the only signal that a lock silently never lands (the hook is best-effort and swallows failures). Alert on a sustained unknown_processor or no_processor_info rate — either indicates a broken deploy gate.
  • Workflow-drift warning logged when a settled processor differs from the locked psp (observe-only; first-wins never re-homes).

Rollback: revert the branch. The nsfw_processor_mid column is additive and harmless if unused. Reverting restores hash-split allocation. External Primer Workflows are versioned in the dashboard and rolled back independently.

Deploy Gates

These must be validated in the Primer sandbox before production; a mismatch looks green in prod but silently breaks routing cohort-wide.

GateRequirementStatus
Unset-psp CIT/MIT splitElse keyed on “not customer-initiated” (MIT). Primer classifies FIRST_PAYMENT/ECOMMERCE as CIT and SUBSCRIPTION/UNSCHEDULED as MIT, so customer-present top-ups correctly hit the CIT branch.Confirmed against Primer docs
psp-absent triggerWorkflow “psp is empty” matches an absent metadata key (code omits, never sends "").Sandbox-verify
Processor name mappingprocessor.name maps to NMI / EMERCHANTPAY exactly (upper-cased).Sandbox-verify
NMI MID routingWorkflow #2 reads key psp_mid and compares the raw processorMerchantId string, not the labels “SSB”/“Esquire” and not the sandbox-only nmi_processor_id.Sandbox-verify
NMI cross-MID fallbackSSB↔Esquire fallback relies on a Primer-held (not NMI-held) vault token being chargeable across both MIDs.Verified working (Primer owns the vault)

Alternatives Considered

Hash-split allocation (status quo, rejected): deterministic FNV bucket per user before any payment. Simple and stateless, but blind to the instrument and unrecoverable — the source problem this ADR fixes.

Eager NSFW assignment via a cheap probe: assign at checkout by guessing from the card BIN before authorizing. Rejected — it duplicates what Primer already does at authorization time, needs a BIN table sirloin does not own, and still guesses instead of observing the actual settle.

Split routing inside sirloin (emit a concrete psp chosen by our own logic): keep the processor decision in Go and tell Primer where to route. Rejected — sirloin cannot see the card at decision time (the token is created in Primer’s client session), and forcing a processor removes Primer’s per-payment fallback, giving one shot instead of two.

wallet/country in metadata instead of relying on Primer-native signals: have sirloin compute and pass wallet type and issuer country. Rejected — Primer derives both from the instrument itself (BIN, payment-method type), so passing our own copy is redundant and can drift from what the processor actually sees.

Re-home on drift (reassign when a settle contradicts the lock): rejected in favor of observe-only logging. Locked workflows can only settle on their own processor (EMP-only #3, NMI-only #2), so cross-processor drift indicates a manual override or misconfiguration, not a routine event; auto-re-homing would risk flapping. First-wins with drift alerting is the safer contract.