Billing

How groudon bills a tenant: credit grants on a Stripe customer, metered usage invoiced against them, and the webhooks that connect the two. This page is the reference; the Stripe-side procedures — lookups, granting credits, the invoice-reissue script, coupon release — live in Stripe debugging, and the people/tenant side in Support triage.

Sources: groudon/stripe/ and gateway/webhooks.py, read 2026-09-01/02.


The model

Every tenant has one Stripe customer (tenants.stripe_customer_id, created idempotently per tenant: create_customer_{tenant_id}). Money in is a credit grant on that customer; usage is metered against a rate card and settled by invoices that draw the grants down.

Grants come in two categories, and the difference is load-bearing:

  • paid — purchases and top-ups. Raises only the total.
  • promotional — the welcome/unlock credits and promo-code voucher redemptions. promotional_cents is the free-tier cap: the sum of promotional grants is how far a tenant can go without a card. Granting promotional credit extends the free tier; granting paid credit does not.

Welcome and unlock credits

Two one-shots (groudon/stripe/credits.py):

  • $20 welcome at org creation, no card. claim_welcome_credit stamps users.welcome_credit_tenant_id + welcome_credit_granted_at once per user, ever. Only the stamped tenant is eligible; additional orgs get nothing, by design.
  • $80 billing unlock when a card is added — only on the designated welcome tenant, and once per physical card (card_fingerprint_redemptions, keyed on card fingerprint).

The duplicate guard is the grant list itself, scanned before granting — not the ~24h Stripe idempotency key — so create_initial_credit_grant is safe to re-run; a re-run also repairs the customer’s credit metadata. A grant whose metadata carries type: welcome_bonus counts as the welcome grant for this guard, which is why no other grant may ever use that type.

Invoices

Three shapes, distinguishable by metadata:

ShapeCreated byMarker
usage invoiceStripe metered billingcredits applied at finalization (subtotal − total > 0)
auto top-upgroudon/stripe/topup.pyinvoice metadata source: auto_topup; line metadata type: auto_topup, product: groudon
manual credit purchasecheckout (gateway/dashboard/routes/checkout.py)invoice metadata type: credit_purchase

Numbering is per customer (invoice_prefix), and a paid invoice is immutable — corrections are a credit note plus a replacement invoice (procedure).

The granting webhooks

Credits are minted by webhook handlers in gateway/webhooks.py, and anything that creates invoices or payments by hand must know which handler will fire:

  • payment_intent.succeeded grants a checkout purchase. The amount comes from the credit_amount in the payment-intent metadata, not the amount paid — a discounted purchase still grants the full selected credits.
  • checkout.session.completed grants only zero-total sessions — a 100%-off code or an amount-off voucher redemption, where Stripe creates no payment intent. It reads the same credit_amount metadata from the session and dedupes per (customer, promotion code).
  • invoice.paid grants an auto top-up when a line item carries type=auto_topup, product=groudon. Its idempotency key is invoice_{invoice_id}, so a new invoice is a new key: the line metadata is the only guard against a hand-made invoice minting credits.
  • Credit notes have no handler; issuing one moves nothing in groudon.

Balance thresholds, pause, emails

  • Derivers pause at zero balance and resume when credits land (gateway/billing_controls.py). Pause state is a Redis key (billing_paused_key(app_name), 30-day TTL); both transitions email the user. The API keeps serving while paused — only the queue stops draining.
  • Threshold emails at the 0 crossings, deduplicated by customer-metadata flags (low_balance_email_sent, zero_balance_email_sent), re-armed when the balance recovers.
  • Welcome-only customers (exactly 80 unlock instead of top-up emails.
  • Auto top-up has a $10 minimum threshold.

Customer metadata

Operational flags on the Stripe customer. Several look like evidence of a code path and are not:

KeyWhat it isCaveat
tenant_idjoin key back to groudon
conversion_tracking_enabledanalytics gatealso stamped by the payment webhook; proves nothing about the welcome path
total_credits_granted_cents, promotional_credits_granted_centscached totalsrecomputed from the grant list by backfills; a 0 reflects the grant list, not a failed write
low_balance_email_sent, zero_balance_email_sent, free_tier_exceeded_event_sentdedup flagscleared on recovery/refresh

Coupons: creating a code

Decide two things first:

  1. What the code gives. A fixed dollar amount (“$25 of credits, free”) makes a voucher: the holder redeems it in the dashboard under Billing → redeem code, pays nothing, and their org receives exactly that amount of credits. A percentage (“50% off”) makes a purchase discount: the holder enters it while buying credits and pays less for the full amount they picked. The choice is made by the coupon’s Type — fixed amount = voucher, percentage = discount. Nothing else switches the behavior.
  2. How it is limited. Total number of uses, an expiry date, first-purchase-only, a minimum purchase, or locked to one customer. All of these live on the promotion code, not the coupon.

You then create two objects in Stripe: a coupon (the discount itself) and a promotion code (the string people type, carrying the limits). One coupon can back many codes.

In the Dashboard

  1. Open Products → Coupons+New.
  2. Fill in the coupon: Name (shows on receipts), TypeFixed amount discount with 25.00 USD for a voucher, or Percentage discount for a discount — and Durationonce (our purchases are one-time payments, so the other durations do nothing useful).
  3. In the same dialog, click Use customer-facing promotion codes and enter the code string (e.g. LAUNCH25; leave blank and Stripe invents one). The code is case-insensitive.
  4. Set the limits on the code: Limit the number of times this code can be redeemed → 100, Add an expiration date, and optionally Eligible for first-time order only, Require minimum order value, or Limit to a specific customer.
  5. Create coupon.

Only the coupon’s name is editable afterward — get amount and limits right the first time, or archive and recreate.

Via the CLI

# 1. the coupon: what the code gives
stripe post /v1/coupons --live -i coupon_launch25 \
  -d amount_off=2500 -d currency=usd -d duration=once -d "name=Launch $25 credit"
#    (for a percent discount: -d percent_off=50 instead of amount_off/currency)
 
# 2. the promotion code: the string people type, with the limits
stripe post /v1/promotion_codes --live -i promo_launch25 \
  -d coupon=<id from step 1> -d code=LAUNCH25 \
  -d max_redemptions=100 -d expires_at=<unix epoch>

The -i idempotency keys make a retried release return the existing objects instead of duplicates. The lasting guard is the code string: only one active LAUNCH25 can exist at a time (customer-restricted codes are the exception — several may share a spelling, and the customer’s own wins).

Retiring a code

Archive it in the Dashboard (Coupons → the coupon → ⋯ on the code row → Archive promotion code) or stripe post /v1/promotion_codes/<id> --live -d active=false. Check uptake anytime: times_redeemed on the promotion code. A code that hits its limit or expiry deactivates itself permanently; the freed-up string can be reused on a new code.

What groudon does with it

Both flows land in gateway/dashboard/routes/checkout.py. A percent code discounts the payment while the grant still reads the full credit_amount from metadata — 100% off included, via the zero-total checkout.session.completed path. A voucher goes through a 1,000 max, USD only. Operating procedures: Stripe debugging.