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 changesRenewal 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_successfulNSFW 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.tsxbrisket/.../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)
instrumentListerinterface 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 viadecideProcessorForNewUserand persists it (write-once, race-safe viaWHERE IS NULL OR = ''). For NSFW, the assignment is geo-gated by the client’s access country, read from gRPC metadatax-dw-client-country(forwarded by brisket from Cloudflare’scf-ipcountryheader). - Renewal (MIT):
overrideProcessorFromCreditsdoes a read-only lookup. Never creates assignments — falls back todomain.ResolvePSPRoutingdefault 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
| Tier | Processor | Constant |
|---|---|---|
| SFW | Cybersource (primary), Stripe (fallback) | domain.PSPCybersource, domain.PSPStripe |
| NSFW | EMP (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.
Source Links
- 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