Skip to content

Billing

Billing

Purpose

Document subscription, payment, credit-application, dispute, dunning, and failed-operation recovery behavior.

Participants

  • Sirloin owns all billing state and APIs.
  • Chargebee owns subscription and invoice state.
  • Primer processes payments and disputes.
  • Postgres stores billing, fraud, failed-operation, and subscription-sync records.
  • PostHog and Klaviyo receive analytics and lifecycle events.

Sequence

Checkout (CIT)

sequenceDiagram
participant Brisket
participant Sirloin
participant Primer
participant Chargebee
participant DB as Postgres
Brisket->>Sirloin: ReservePaymentAttempt (count quota check)
Sirloin->>DB: Advisory lock + count + insert authorization_attempt
Sirloin-->>Brisket: Allowed / blocked + remaining attempts
Brisket->>Sirloin: Create checkout
Sirloin->>Sirloin: Enforce velocity (amount + count windows)
Sirloin->>Primer: Create payment session
Sirloin-->>Brisket: Client token/order
Brisket->>Primer: User pays
Brisket->>Sirloin: Submit payment id
Primer-->>Sirloin: Payment webhook fast path
Sirloin->>Chargebee: Record payment and activate
Chargebee-->>Sirloin: Invoice webhook fast path
Sirloin->>Chargebee: Poll invoice/subscription state fallback
Sirloin->>DB: Apply credits if idempotency checks pass
Sirloin->>DB: Record payment_successful velocity event
Primer-->>Sirloin: Dispute webhook when dispute changes

Renewal Dunning (MIT)

sequenceDiagram
participant Worker
participant Sirloin
participant Chargebee
participant Primer
participant DB as Postgres
Worker->>Chargebee: List unpaid renewal invoices
Worker->>DB: Acquire invoice lock
Worker->>DB: Check DunningAttemptExists
Worker->>Sirloin: Resolve PSP routing (fail-closed)
Sirloin->>Chargebee: Retrieve subscription
Sirloin->>DB: Enforce velocity (amount + count windows)
Sirloin->>DB: MIT count-quota gate (5/20/30 limits)
Sirloin->>DB: Record authorization_attempt
Sirloin->>Primer: Create renewal payment
Primer-->>Sirloin: Payment result
Sirloin->>Chargebee: Record payment on invoice
Sirloin->>DB: Record dunning_attempt + payment_successful

NSFW Payment Method Enforcement

EMP (NSFW payment processor) only supports card payments — no Apple Pay or Google Pay. NMI supports cards and wallets but only accepts payment methods vaulted through its own gateway — a card vaulted via Stripe won’t work for NMI renewals. Three enforcement points prevent hard failures:

1. Frontend: Hide Vaulted Methods on SFW→NSFW Switch

When a user upgrades from SFW to NSFW tier, showVaultedMethods={false} forces fresh card entry. This does not apply to NSFW→NSFW plan changes (user already has a compatible method on file).

  • brisket/.../single-plan-upgrade/index.tsx
  • brisket/.../upgrade-selection/upgrade-button.tsx

2. Backend: Auto-Set Primary After NSFW Checkout

After a successful NSFW checkout payment, maybeSetNSFWCheckoutPrimary fetches the payment details from Primer, extracts the vaulted token, and sets it as the customer’s default payment instrument. This ensures the next renewal uses the EMP-compatible card.

  • Fires only on subscription purchase (not renewals)
  • Guards on access: "full" metadata (NSFW only)
  • Non-fatal: all errors logged and swallowed

3. Backend: Card Preference for Scheduled NSFW Renewals

During scheduled dunning retries, resolveNSFWRenewalPaymentToken checks if the primary payment method is a wallet (APAY/GPAY). If so, it searches saved instruments for a card via SelectCardForNSFWRenewal and substitutes it. Falls back to the primary if no card is found (EMP rejects, enters dunning).

  • Only activates for: scheduled retry (no client IP) + NSFW plan + wallet primary
  • Manual retries skip substitution (user explicitly chose the method)
  • instrumentLister interface enables testing without full Primer handler

Per-User Processor Assignment

Each user is locked to a specific payment processor (PSP), and NMI merchant account (MID), per tier. Stored on users.credits as sfw_processor, nsfw_processor, and nsfw_processor_mid. Once locked, never auto-changed (first write wins).

How it works — both lanes are settle-then-lock (NSFW: FOXY-596, SFW: FOXY-877)

Neither lane pre-assigns a processor. The first payment is routed by a Primer workflow — the high-risk one for NSFW, Regular Access Traffic for SFW — and whichever processor actually settles the money is locked and replayed on all later payments. The NMI merchant account is locked with it, on the NSFW lane only.

  • Checkout (CIT): ResolveProcessor(ctx, userID, isNSFW) reads the lane’s stored processor and assigns nothing — processorRepo carries no writer, so the type system forbids it. No lock on either lane resolves to an empty psp: the checkout carries no processor and the lane’s Primer workflow routes it (card issuer, wallets, percentage split across processors and the NMI MIDs on the NSFW lane; Cybersource primary with a Stripe fallback on SFW). A temporary conservative price ceiling (min(NMI, EMP)) applies while an NSFW processor is unknown; an unassigned SFW checkout has none, because neither Cybersource nor Stripe caps a single transaction.
  • On settle: LockProcessorFromPayment resolves the lane from the payment’s access label, reads the settled processor from Primer, and locks it first-wins — AssignNSFWProcessorWithMid with the MID on NSFW, AssignSFWProcessor on SFW. Each lane resolves processor names through its own table, so a name belonging to the other lane resolves to nothing and cannot be stored. Only subscription-linked settles lock — the payment must carry chargebee_subscription_id (a subscription CIT that vaults the token, or a renewal MIT that charges it), the only token-authoritative signal. Runs best-effort where those settles are observed (mainline/poller processFoundPayment, renewal). Failed payments, top-ups, and vault-only settles never lock.
  • Renewal (MIT): both lanes replay the locked psp (and the NMI MID on NSFW) and route processor-absent when there is none, so the workflow discovers and the settle records it. A credits-read error aborts the attempt on both lanes: routing processor-absent for a locked user would send them through discovery and can settle them off their lock, which first-write-wins then makes permanent. The next dunning tick re-reads.
  • Vaulting / reserve: read-only — they never create a lock.
  • Repair: RepairProcessorLockFromHistory recovers a lock from the user’s settled Primer history (ListPaymentsByCustomer) rather than guessing a default. Wired on the unlocked NSFW top-up and on both lanes’ renewals. Not on the SFW top-up: that lane’s tokens are not processor-homed, so the scan’s cost on a user-facing checkout buys nothing.
  • Primer routing: sessions include metadata["psp"] (and metadata["psp_mid"] for NMI) so the workflow honors the locked route; an absent psp signals the discovery/recovery branches. Adding a processor to a lane’s table without the matching Primer branch routes it silently to Stripe, so the order is Discovery workflow, then locked branch, then table entry.
  • Observability: billing_processor_lock_outcome_total carries outcome, lane and psp, one record per gated settle — read it per lane, since SFW is the larger one and would mask an NSFW failure in aggregate. History repair reports separately on billing_processor_repair_outcome_total. See Billing SLOs.
  • Design record: Settle-Then-Lock SFW Processor Assignment explains why the lane tables are split, why neither lane assigns before a settle, and why a credits-read error aborts the attempt on both lanes.

Processors

TierProcessorConstant
SFWCybersource (primary), Stripe (fallback) — either can be the value that settles, so either can be the one lockeddomain.PSPCybersource, domain.PSPStripe
NSFWEMP, NMI (SSB / Esquire MIDs)domain.PSPEMP, domain.PSPNMI

The lock is hard: vaulted-card renewals are processor-homed (an EMP-vaulted token cannot renew on NMI and vice versa), so a locked pair is never auto-changed. The psp_mid (NMI merchant account) is replayed so renewals stay on the merchant account that took the first payment. Operator overrides (StripUpdateUserProcessors) clear a captured MID only when the NSFW processor actually changes.

  • apps/sirloin/internal/app/services/billing/createprimercheckout.go
  • apps/sirloin/internal/app/services/billing/submitpaidinvoice.go
  • apps/sirloin/internal/app/services/billing/events/poller.go
  • apps/sirloin/internal/app/services/billing/events/credits.go
  • apps/sirloin/internal/app/services/billing/disputes/primerwebhook.go
  • apps/sirloin/internal/app/services/billing/webhooks/
  • apps/sirloin/internal/app/services/billing/dunningretry.go
  • apps/sirloin/internal/app/services/billing/processor_assignment.go
  • apps/sirloin/internal/app/services/billing/reserve_payment_attempt.go
  • apps/sirloin/internal/app/services/billing/successful_payment_velocity.go

State Transitions

Checkout creates a Primer session. Payment completion is resolved through Brisket’s submit call, Primer’s payment webhook fast path, and Primer polling fallback. Chargebee invoice/subscription state is accelerated by Chargebee invoice webhooks but still reconciled through polling. Credits are applied only after payment detection and idempotency checks. Failed operations are persisted for retry rather than silently dropped.

Invariants

  • Sirloin is the billing owner.
  • Primer and Chargebee webhooks are fast paths.
  • Primer and Chargebee polling remain durable replay/fallback mechanisms.
  • Primer disputes are webhook-driven and recorded as fraud audit events.
  • Credit application must be idempotent.
  • Failed operations must remain retryable and auditable.

Error Paths

Dunning retries can succeed, remain processing, no-op when already paid or not in dunning, fail retryably, fail on hard decline, or be blocked by fraud velocity rules. Fraud velocity checks (count quotas, amount windows, cooldowns) apply only to EMP-routed NSFW users; NMI-routed NSFW users skip these checks. CIT checkout can be blocked by ReservePaymentAttempt count-quota exhaustion (daily/weekly/monthly windows) — frontend shows differentiated toast messages. MIT renewal can be blocked by velocity amount windows or count-quota gates; blocked attempts are recorded as quota_blocked dunning attempts to prevent retry spam. PSP routing fails closed if subscription data is missing.

Tests And Verification

  • cd apps/sirloin && make run-tests
  • cd apps/sirloin && make run-tests-all
  • cd apps/sirloin && make lint