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_centsis 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_creditstampsusers.welcome_credit_tenant_id+welcome_credit_granted_atonce 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:
| Shape | Created by | Marker |
|---|---|---|
| usage invoice | Stripe metered billing | credits applied at finalization (subtotal − total > 0) |
| auto top-up | groudon/stripe/topup.py | invoice metadata source: auto_topup; line metadata type: auto_topup, product: groudon |
| manual credit purchase | checkout (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.succeededgrants a checkout purchase. The amount comes from thecredit_amountin the payment-intent metadata, not the amount paid — a discounted purchase still grants the full selected credits.checkout.session.completedgrants only zero-total sessions — a 100%-off code or an amount-off voucher redemption, where Stripe creates no payment intent. It reads the samecredit_amountmetadata from the session and dedupes per (customer, promotion code).invoice.paidgrants an auto top-up when a line item carriestype=auto_topup, product=groudon. Its idempotency key isinvoice_{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:
| Key | What it is | Caveat |
|---|---|---|
tenant_id | join key back to groudon | — |
conversion_tracking_enabled | analytics gate | also stamped by the payment webhook; proves nothing about the welcome path |
total_credits_granted_cents, promotional_credits_granted_cents | cached totals | recomputed 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_sent | dedup flags | cleared on recovery/refresh |
Coupons: creating a code
Decide two things first:
- 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.
- 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
- Open Products → Coupons → +New.
- Fill in the coupon: Name (shows on receipts), Type — Fixed amount discount with 25.00 USD for a voucher, or Percentage discount for a discount — and Duration → once (our purchases are one-time payments, so the other durations do nothing useful).
- 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. - 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.
- 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.