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) per tier. Stored on users.credits as sfw_processor and nsfw_processor columns. Once assigned, never auto-changed.

How it works

  • Checkout (CIT): ResolveProcessor(ctx, userID, isNSFW) reads the stored processor. If empty, assigns one via decideProcessorForNewUser and persists it (write-once, race-safe via WHERE IS NULL OR = ''). For NSFW, the assignment is geo-gated by the client’s access country, read from gRPC metadata x-dw-client-country (forwarded by brisket from Cloudflare’s cf-ipcountry header).
  • Renewal (MIT): overrideProcessorFromCredits does a read-only lookup. Never creates assignments — falls back to domain.ResolvePSPRouting default if missing.
  • Vaulting: Same as checkout — resolves processor and passes it in Primer metadata.
  • Primer routing: All sessions include metadata["psp"] so Primer workflows can distinguish between processors (e.g., EMP vs NMI for NSFW).

Processors

TierProcessorConstant
SFWCybersource (primary), Stripe (fallback)domain.PSPCybersource, domain.PSPStripe
NSFWEMP (default), NMI (US-only split)domain.PSPEMP, domain.PSPNMI

NSFW geo-gated split (NMI/EMP)

New NSFW users are routed by access country at first checkout:

  • US users (cf-ipcountry == "US"): 50/50 deterministic hash split (domain.HashSplit(userID, 2)) between NMI and EMP.
  • Non-US users (EU + rest of world): always EMP.
  • Unknown/invalid geo (missing header, XX, T1, etc.): always EMP (fail-safe — EMP is the universal, fraud-velocity-protected processor).

The assignment is write-once and hard-locked: vaulted-card renewals are processor-specific (an EMP-vaulted card cannot renew on NMI and vice versa), so existing assignments are never auto-changed regardless of where the user logs in later. Geo comes from the access location at first checkout, not card BIN or billing address. New NSFW assignments are logged with client_country for split observability.

A future US→100% NMI ramp is a separate follow-up and intentionally not flag-controlled here.

  • 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