Skip to content

Primer Payment Gateway

ADR 003: Primer as Payment Gateway

Date: 2026-04
Status: Accepted
Context: Billing system requires PCI-compliant payment processing with vaulting support.

Problem

Payment processing requires:

  • PCI compliance (no card data in our systems)
  • Payment vaulting (save card for renewals)
  • 3D Secure support (SCA compliance)
  • Multiple payment method support
  • Webhook + polling for payment confirmation

Which payment gateway should be the primary collector?

Decision

Use Primer as the primary payment processor for all Primer checkout flows:

  • Primer handles payment processing & vaulting
  • Chargebee handles subscription & invoice management
  • Payments are recorded in Chargebee after Primer confirms

Implementation

// 1. Chargebee subscription created in "future" state
subscription := createPendingSubscription(customerID, itemPriceID, couponCode)
// status = "future", start_date = 10 years from now
// auto_collection = OFF (Primer handles collection)
// 2. Get Primer client token for frontend
clientToken := primer.GetClientToken(apiKey)
primerSession := primer.CreateClientSession(clientToken, invoice.ID)
return CheckoutResponse{
ClientToken: primerSession.ClientToken,
OrderID: invoice.ID,
}
// 3. User completes payment in Primer UI
// (happens in frontend, not our code)
// 4. Primer webhook or polling detects payment confirmed
// (submitpaidinvoice.go or events/poller.go)
// 5. Record payment in Chargebee
processor.RecordPayment(ctx, &PaymentRequest{
InvoiceID: invoiceID,
TransactionID: primerTransactionID,
Amount: invoiceAmount,
Source: domain.PaymentSourcePrimerWebhook, // or PaymentSourcePrimerPolling
})
// → Activates subscription
// → Chargebee records payment
// → EventPoller then syncs credits

Rationale

Why Primer?

  1. PCI compliance: Primer handles card tokenization; we never touch raw card data
  2. Vaulting built-in: Cards saved automatically; renewal payments use vaulted tokens
  3. 3D Secure native: Automatic SCA/3DS handling; compatible with strong customer auth
  4. Fast checkout: Client-side integration; minimal latency
  5. Multiple sources: Supports cards, ACH, local payment methods

Why Chargebee for Subscriptions?

  1. Subscription state machine: Chargebee’s subscription model matches our needs
  2. Invoice management: Automatic invoice generation for renewals
  3. Tax calculations: Chargebee handles VAT/sales tax
  4. Audit trail: All billing actions logged in Chargebee
  5. Analytics: Revenue recognition, MRR calculations

Separation of Concerns

ResponsibilityOwner
Payment processing (card → money)Primer
Subscription stateChargebee
Invoice generationChargebee
Payment recordingUs (payments/processor.go)
Credit calculationUs (events/credits.go)
Analytics notificationUs (events/analytics.go)

Consequences

Positive

  • No PCI burden: Primer handles compliance
  • Flexible payment methods: Supports anything Primer supports
  • Renewal automation: Chargebee generates invoices; Primer charges vaulted cards
  • Audit trail: Chargebee records everything

Negative

  • Two-system complexity: Primer ↔ Chargebee reconciliation needed
  • Idempotency required: Must handle retried payment confirmations
  • Webhook + polling: Need both mechanisms for reliability
  • Coordination: Payment → Chargebee → Credit allocation requires orchestration

Testing

// Test: Primer checkout creates pending subscription
response := handler.CreatePrimerCheckout(userID, itemPriceID)
require.NotEmpty(t, response.ClientToken)
require.NotEmpty(t, response.OrderID)
// Verify subscription is pending
sub := subscriptions.GetActiveSubscription(userID)
require.Nil(t, sub) // Not active yet
pendingSub := subscriptions.FindPendingSubscription(userID)
require.Equal(t, domain.SubscriptionStatusFuture, pendingSub.Status)
// Test: Payment recording activates subscription
processor.RecordPayment(ctx, &PaymentRequest{
InvoiceID: response.OrderID,
Amount: expectedAmount,
Source: domain.PaymentSourcePrimerWebhook,
})
// Verify subscription is now active
active := subscriptions.GetActiveSubscription(userID)
require.Equal(t, domain.SubscriptionStatusActive, active.Status)