Billing Pitfalls And Troubleshooting
Billing Pitfalls & Troubleshooting
This document covers common mistakes, subtle bugs, and edge cases in the billing system.
Race Conditions
TOCTOU: Check-Then-Act Without Lock
Pitfall: Check if payment is duplicate, then record it — without locking.
// ❌ WRONGisDuplicate, _ := p.IsDuplicate(ctx, txID) // Check: not duplicateif isDuplicate { return &PaymentResult{Success: true}, nil}// ... context switch; another goroutine also checks and finds not duplicate ...err := p.recordPayment(ctx, req) // Act: record (but another thread also records!)// → DOUBLE-CHARGEFix: Use distributed lock.
// ✓ CORRECTlock, _ := locker.TryLock(ctx, lockKey)defer lock.Release(ctx)
isDuplicate, _ := p.IsDuplicate(ctx, txID) // Check + Act both inside lockif isDuplicate { return &PaymentResult{Success: true}, nil}err := p.recordPayment(ctx, req) // Only one thread executes thisSee: Distributed Locks For Payments
Concurrent Activation
Pitfall: Multiple payment confirmations (webhook + polling) both activate the same subscription.
// Time 1: Webhook arrivespayment1 := &PaymentRequest{InvoiceID: inv123, Source: PaymentSourcePrimerWebhook}processor.RecordPayment(ctx, payment1) // Activates subscription// → Chargebee: subscription.start_date = now, status = active
// Time 1.5: Polling worker also sees same invoicepayment2 := &PaymentRequest{InvoiceID: inv123, Source: PaymentSourcePrimerPolling}processor.RecordPayment(ctx, payment2) // Activates again?// → Without lock: double-activation (idempotency fails)// → Chargebee: start_date updated twice (not terrible, but unsafe)Fix: Distributed lock serializes all payment attempts.
// payments/processor.go - RecordPayment always acquires lock firstlock, _ := locker.TryLock(ctx, locks.InvoiceLock(req.InvoiceID))defer lock.Release(ctx)
// Inside lock: only one thread proceedsisDuplicate, _ := p.IsDuplicate(ctx, req.TransactionID)if isDuplicate { return &PaymentResult{Success: true, AlreadyProcessed: true}, nil}// Safe to record; no other thread can interfereConcurrent Cleanup
Pitfall: Cleanup worker and user action both cancel the same subscription.
// Time 1: Cleanup detects expired checkoutexpiredSub := findExpiredCheckout()cancelSubscription(expiredSub.ID) // Sets status = cancelled
// Time 1.5: User tries to reactivate (didn't know it was expired)reactivateSubscription(expiredSub.ID) // status = non_renewing → active// → User gets access to expired subscription!Fix: Validate subscription state before cancellation; use transactions if supported.
// ✓ Safe approachlock, _ := locker.TryLock(ctx, locks.SubscriptionLock(subID))defer lock.Release(ctx)
sub, _ := getSubscription(subID)if sub.Status != "future" || sub.StartDate > now - 1*year { return // Not a future subscription, or not expired; skip}cancelSubscription(subID) // Now safeTimezone Gotchas
Chargebee Stores UTC; Comparisons Fail in Local Time
Pitfall: Query subscriptions created “today” without considering timezone.
// ❌ WRONGtoday := time.Now() // Local timestartOfDay := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.Local)endOfDay := startOfDay.AddDate(0, 0, 1)
// Query Chargebee (returns UTC times)subs, _ := listSubscriptionsCreatedBetween(startOfDay, endOfDay)// startOfDay is 2026-04-08 09:00:00 PDT = 2026-04-08 16:00:00 UTC// endOfDay is 2026-04-09 09:00:00 PDT = 2026-04-09 16:00:00 UTC// But subscriptions created at 2026-04-08 00:00:00 UTC don't match!Fix: Always convert to UTC and be explicit.
// ✓ CORRECTtoday := time.Now().UTC()startOfDay := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.UTC)endOfDay := startOfDay.AddDate(0, 0, 1)
subs, _ := listSubscriptionsCreatedBetween(startOfDay, endOfDay)// Now timezone-awareRenewal Times Drift Across Timezones
Pitfall: User in PST renews on the “same date” as user in EST, but they’re actually different dates in UTC.
User A (PST): 2026-04-08 23:59:59 PST = 2026-04-09 06:59:59 UTCUser B (EST): 2026-04-08 00:00:01 EST = 2026-04-08 04:00:01 UTC
Both think they renewed on 2026-04-08, but in Chargebee (UTC) they're different dates.Dunning worker runs at 2026-04-08 12:00 UTC: - User A's renewal is in the future (not yet due) - User B's renewal is overdueMitigation: Chargebee stores everything in UTC; don’t try to be timezone-smart. Use renewal date from Chargebee, not local calculations.
Free Checkouts (100% Discount)
Pitfall: Granting Access Without Chargebee Confirmation
Pitfall: Coupon gives 100% discount → amount = 0 → skip Primer → immediately grant access → user cancels on the other side → refund issued → user still has access.
// ❌ WRONGif discountedAmount == 0 { grantAccess(userID) // Grant access immediately // But what if Chargebee payment fails? User has access!}Fix: Record payment in Chargebee even for zero-amount invoices.
// ✓ CORRECT// Create invoice in Chargebee with 0 amountinvoice, _ := createInvoiceForItems(customerID, items, coupon)
// Record payment (even though amount = 0)processor.RecordPayment(ctx, &PaymentRequest{ InvoiceID: invoice.ID, Amount: 0, Source: domain.PaymentSourceFreeCheckout,})
// EventPoller will process the invoice and apply credits// Only then is access grantedPitfall: Saved Payment Method Not Verified for Free Checkouts
Pitfall: 100% discount checkout succeeds without saved payment method → user later expects dunning retry on failed renewal.
User applies 100% coupon, no saved card: ✓ Checkout succeeds (free) ✓ Subscription created, activated ✓ Access granted
Subscription renewal comes due: ✗ No saved card for dunning retry ✗ User unaware renewal failed (expected renewal to work)Mitigation: For free checkouts, require saved payment method (Primer vaulting) OR explicitly document the limitation.
// Optional: Validate that user has saved cardif discountedAmount == 0 && !userHasSavedPaymentMethod(userID) { return errors.New("100% discount requires saved payment method for renewals")}Coupon Issues
Pitfall: Coupon Stacking
Pitfall: User applies multiple coupons; Chargebee allows it → discount is larger than expected.
// Coupon 1: 10% off// Coupon 2: 10% off// Both applied → 19% total discount (10% + 10% of remainder)// Expected: 10% totalMitigation:
- Chargebee’s standard behavior: “Last coupon wins” (only one active at a time)
- Our validation:
validatecoupon.gochecks if coupon is applicable - Never add coupons together; let Chargebee handle the limit
See: validatecoupon.go for coupon validation logic
Pitfall: Expired Coupon Applied After Subscription Creation
Pitfall: Coupon valid when subscription is created, expires before payment is recorded.
Time 1: Validate coupon → Coupon expires 2026-04-10Time 2: Create pending subscription with couponTime 3: User completes payment (2026-04-11) Coupon is now expired! But Chargebee invoice already has coupon applied → What discount does user get?Chargebee behavior: Coupon is applied at invoice creation time (subscription creation). If it expires before payment, the discount is still honored on that invoice. This is correct (honors what was valid at checkout time).
Mitigation: Document that coupons are validated at subscription creation, not payment recording. If user delays payment and coupon expires, the original discount applies anyway.
Currency Issues
Pitfall: Currency Mismatches
Pitfall: User in EUR, selects USD pricing, currency gets confused.
// User is in EU (default EUR)// User clicks "USD plan" button// Frontend sends item_price_id = "plan-Starter-USD-Monthly"// But user's billing address is in EUR country
// Chargebee creates subscription in USD// But user's account says EUR// Revenue report is confused (double-count?)Mitigation:
- Frontend should respect user’s timezone/currency preference
ListProducts()returns prices in all currencies; frontend selects- Server should validate that selected currency matches user’s account
See: products/list.go for product listing with currency handling
Pitfall: Cross-Currency Coupons
Pitfall: Coupon is valid in USD only, user applies it to EUR subscription.
// Coupon: "10% off USD plans only"// User: Tries to apply to EUR subscription// Chargebee: Silently ignores coupon (no error)// User: Unaware coupon didn't applyMitigation: Validate coupon against specific item_price_id (currency-specific).
// validatecoupon.go should check:if !coupon.ApplicableTo(itemPriceID) { return errors.New("coupon not applicable to this plan")}Renewal Credits & Proration
Pitfall: Renewal Credits Not Subtracted From Upgrade
Pitfall: User has 3 months left on annual plan, upgrades to premium. Should get prorated credit for unused time. If not subtracted, over-charges.
Annual Starter: $348/year, 3 months remaining = $87 creditPremium upgrade: $1188/year, prorated to 3 months = $297User should pay: $297 - $87 = $210
If credit not subtracted: User pays $297 + $87 = ❌ WRONGMitigation: Chargebee handles this automatically if invoice_immediately = true during subscription change. The created invoice includes both the credit and the prorated charge.
See: payments/processor.go:ActivateSubscription() - sets InvoiceImmediately: true
Idempotency Key Pitfalls
Pitfall: Transaction ID Not Globally Unique
Pitfall: Same transaction ID used for different payments.
// Primer transaction: "12345"// User applies to invoice_A// Later, user applies same "12345" to invoice_B (copy/paste error?)// Both invoices marked as paid with same transaction ID// Idempotency check: "Transaction 12345 already processed" → skips invoice_B// → invoice_B not recorded; user gets no creditsMitigation: Transaction ID must be globally unique across all payments. Use:
- Primer transaction ID (already unique)
- If manual: generate UUID and require
// PaymentRequest validationfunc (r *PaymentRequest) Validate() error { if r.TransactionID == "" { return errors.New("transaction_id required") } // Assume it's already globally unique (from Primer) return nil}Pitfall: Idempotency Key Lost on Retry
Pitfall: Webhook arrives, we record payment with transaction ID. Webhook retried by Primer, but our request handler doesn’t preserve the original transaction ID.
// Primer Webhook 1: transaction_id = "tx_abc123"// We record payment, store transaction_id in DB
// Primer Webhook 1 Retry (Primer auto-retry): transaction_id = "tx_abc123"// If we generate NEW transaction ID on each call: "tx_xyz789"// → Duplicate payment! (Different transaction ID, same underlying Primer tx)Mitigation: Always use Primer’s transaction ID, never generate a new one.
// submitpaidinvoice.go or primer webhook handlerfunc handlePrimerWebhook(w http.ResponseWriter, r *http.Request) { var event PrimerEvent json.Unmarshal(r.Body, &event)
// Use Primer's ID directly; don't generate new one result, _ := processor.RecordPayment(ctx, &PaymentRequest{ TransactionID: event.TransactionID, // ← From Primer, globally unique InvoiceID: event.OrderID, Amount: event.Amount, Source: domain.PaymentSourcePrimerWebhook, })}Pitfall: SDK Global State
Pitfall: Chargebee SDK has global state (API key) that gets confused if multiple environments run in the same process.
// Process starts with API key for productionchargebee.SetAPIKey(prodKey)
// Test suite runs, temporarily sets test API keychargebee.SetAPIKey(testKey)
// Main code runs again... which API key is active?// → API calls may go to wrong environmentMitigation:
- Don’t use SDK global state; wrap in a client struct (already done:
chargebee/client.go) - Each handler gets its own client instance
- Tests use mock clients, not real SDK
See: chargebee/client.go - wraps SDK in a Client struct
Database Transaction Pitfalls
Pitfall: Chargebee Change + DB Transaction Mismatch
Pitfall: Chargebee update succeeds, but DB transaction fails. Now they’re out of sync.
// Step 1: Update Chargebeechargebee.UpdateSubscription(subID, params) // Status updated
// Step 2: Start DB transactiontx := db.BeginTx(ctx)tx.UpdateLocalSubscription(subID, ...)tx.Commit() // ❌ FAILS (network error)
// Result: Chargebee has new state, DB has old state// Next query: DB shows old state; user sees inconsistencyMitigation: Use saga pattern. Update Chargebee first, then DB. If DB fails, saga is incomplete but safe to retry.
// ✓ Correct order// Step 1: Update Chargebee (external)chargebee.UpdateSubscription(subID, params)
// Step 2: Record in DB (internal)// If this fails, Chargebee has committed but DB hasn't// Next retry will see the Chargebee update and catch up
db.UpdateLocalSubscription(subID, ...)See: Saga Pattern For Distributed Payments
New Type Boundaries
Pitfall: Money.Multiply() Isn’t Currency-Aware
Pitfall: Multiply USD amount by 12 (yearly multiplier), forget that different currencies have different precision.
// USD: $100.00 = 10000 cents// JPY: ¥10000 = 10000 (no cents; 1 yen is smallest unit)
// Code assumes cents everywhere:amount := 10000amount = amount * 12 // = 120000
// For USD: Correct (= $1200.00)// For JPY: ❌ WRONG ($1200 instead of ¥120000)Mitigation: Chargebee handles this. All amounts are in the smallest currency unit. We just multiply; don’t overthink.
type Money struct { Amount int64 Currency string}
func (m *Money) Multiply(factor int) *Money { // Chargebee handles precision; we just multiply return &Money{ Amount: m.Amount * int64(factor), Currency: m.Currency, }}Pitfall: Credit Amount Overflow
Pitfall: Large credit amounts overflow int64.
// User's credit: 9223372036854775807 (max int64)// Add 1 more credit → wraps to -9223372036854775808// User suddenly has negative credits; exploit!Mitigation: This is unlikely in practice (would require 9 exabytes of credits), but validate on large transfers.
func (c *CreditBundle) Add(other *CreditBundle) *CreditBundle { images := c.Images + other.Images if images > MaxCredits { log.Warn().Msg("credit overflow prevented") images = MaxCredits } // ...}API & Chargebee Quirks
Pitfall: Chargebee Returns nil for “Not Found”
Pitfall: Chargebee API returns nil object for 404 (not found), not an error.
result, err := chargebee.RetrieveSubscription(subID)// If subscription doesn't exist:// result.Subscription = nil// err = nil// ❌ Code assumes `err != nil` means not found
if err != nil { return errors.New("subscription not found") // Never executed!}
// Code continues with result.Subscription = nil// → Panic on nil dereferenceFix: Always check for nil object.
result, err := chargebee.RetrieveSubscription(subID)if err != nil { return fmt.Errorf("failed to retrieve: %w", err)}if result.Subscription == nil { // ← Always check return domain.ErrSubscriptionNotFound}
// Safe to use result.SubscriptionPitfall: Chargebee Timestamps Are Seconds, Not Milliseconds
Pitfall: Chargebee returns Unix timestamps in seconds; code assumes milliseconds.
// Chargebee: 1712592000 (April 8, 2026 noon UTC)// Code: time.Unix(0, 1712592000) // April 1, 1970 + 1.7s// ❌ Off by 50+ years!
// Correct: time.Unix(1712592000, 0)Mitigation: Always use time.Unix(secondsSinceEpoch, 0) when parsing Chargebee timestamps.
Pitfall: Chargebee Enum Values Are Strings, Not Constants
Pitfall: Comparing Chargebee status strings without using enums.
// ❌ WRONG (easy to typo)if sub.Status == "actve" { // Typo: "actve" instead of "active" // This block never executes}
// ✓ CORRECTif sub.Status == subscriptionEnum.StatusActive { // Using SDK enum constant}
// ✓ ALSO CORRECT (in domain layer)if domain.SubscriptionStatus(sub.Status) == domain.SubscriptionStatusActive { // Using domain enum}Testing & Monitoring Pitfalls
Pitfall: Tests Pass Locally, Fail in CI (Timezone)
Pitfall: Test assumes local timezone, but CI runs in UTC.
// ❌ WRONGfunc TestRenewalDate(t *testing.T) { today := time.Now() expectedRenewal := today.AddDate(0, 1, 0) // 1 month from now // Passes locally (local tz); fails in CI (UTC tz)}
// ✓ CORRECTfunc TestRenewalDate(t *testing.T) { today := time.Now().UTC() expectedRenewal := today.AddDate(0, 1, 0) // Works everywhere}Pitfall: Mock Doesn’t Match Real API
Pitfall: Mock Chargebee client doesn’t enforce constraints that real API does.
// Mock allows:mock.CreateSubscription(customerID="", itemPriceID="") // ✓ Mock doesn't care
// Real API rejects:chargebee.CreateSubscription(customerID="", itemPriceID="") // ❌ Error: required field
// Test passes with mock, fails with real APIMitigation: Test with real Chargebee (sandbox) for integration tests. Mocks only for unit tests.
Pitfall: Monitoring Doesn’t Catch Silent Failures
Pitfall: Event polling succeeds (returns 0 events) but Chargebee is actually down.
// Poller runs every 15 secondsresult, err := poller.Poll(ctx, opts)if err != nil { log.Error("poll failed") // Logged // But if no error (e.g., Chargebee returns 500 silently as empty result set) // Poller thinks: "No new events" (correct) // But actually: "API down, all events missed" (not detected)}Mitigation: Add healthcheck endpoint; monitor API latency; alert if poll returns empty for >5 minutes.
Summary: Testing Checklist
When modifying billing code, verify:
- No race conditions: Concurrent calls are serialized by locks
- Idempotency: Same request processed twice → same result
- Timezone-aware: All time comparisons in UTC
- Chargebee nil-safe: Check for nil objects, not just errors
- Currency-safe: Currency precision handled correctly
- Coupon-safe: Coupons don’t stack unexpectedly
- Free checkout tested: 100% discount works end-to-end
- Renewal tested: Dunning retry succeeds/fails as expected
- Monitoring: Metrics and logs sufficient to diagnose
- Backward compatible: Old subscriptions still work