Stripe debugging
Everything Stripe-side of a support question: find the tenant’s customer, read their credits, give them credits, deal with invoices and coupon codes. The people/tenant side (email → tenant, welcome-credit verdicts, pause behavior) is Support triage; the billing model itself is the Billing reference.
Before anything: check which account your CLI is on — stripe config --list — and pass --live. A “no such customer” against the wrong account looks identical to a real miss.
Finding a tenant’s customer
Every groudon customer carries its tenant id in metadata, so the Dashboard search bar finds it directly:
| You have | Search |
|---|---|
| tenant id | metadata:tenant_id=OyVxPoRDadL-aPseFB50l (pasting the bare id also works) |
| user’s email | email:user@example.com is:customer |
| invoice number | number:ZGAKFKLN-0004 is:invoice |
| org name | name:"Org Name" is:customer — last resort: names are not unique |
Search terms live in the URL, so a search is bookmarkable. From the DB side, tenants.stripe_customer_id names the customer; from a bare cus_…, the customer’s tenant_id metadata points back.
CLI equivalents:
stripe get /v1/customers -d email=user@example.com --live
stripe get "/v1/customers/search" -d "query=metadata['tenant_id']:'<tenant_id>'" --live
stripe get "/v1/invoices/search" -d "query=number:'ZGAKFKLN-0004'" --liveChecking credits
The customer’s balance is their credit grants minus what invoices have drawn. Read the grant list — not the customer metadata, whose cached totals are recomputed by backfills and prove nothing:
stripe get /v1/billing/credit_grants -d customer=cus_… -d limit=20 --liveGrant states: pending (not yet effective), granted, depleted (fully used), expired, voided. category matters: promotional grants set the free-tier cap and apply to invoices before paid ones; paid grants are purchases and top-ups. Balance summary and per-invoice draws:
stripe get /v1/billing/credit_balance_summary -d customer=cus_… -d "filter[type]=applicability_scope" -d "filter[applicability_scope][price_type]=metered" --live
stripe get /v1/billing/credit_balance_transactions -d customer=cus_… --liveThe customer’s Dashboard page shows the same grants and their invoices; the search row above gets you there.
Giving a customer credits
Three ways, in order of preference:
-
A voucher code — when more than one org should redeem it, or you want Stripe counting uses. Creation is a coupon plus promotion code: Billing reference has the Dashboard and CLI walkthrough. Vouchers redeem once per organization.
-
A direct grant — one org, right now:
stripe post /v1/billing/credit_grants --live \ -d customer=cus_… \ -d "amount[type]=monetary" \ -d "amount[monetary][value]=2500" -d "amount[monetary][currency]=usd" \ -d "applicability_config[scope][price_type]=metered" \ -d category=paid \ -d "name=Support credit - <reason>" \ -d "metadata[tenant_id]=<tenant_id>" -d "metadata[type]=support_credit"Two rules from
groudon/stripe/credits.py:category=promotionalraises the org’s free-tier cap (promotional_centsis the cap) — usepaidunless extending the free tier is the point; and never settype: welcome_bonusin metadata — any welcome-typed grant trips the welcome duplicate guard and blocks the org’s real welcome credit forever. Add-d expires_at=<epoch>if the credit should lapse; grants without it never expire. -
The welcome-credit re-run — when the org was actually owed the $20 and the grant call failed:
create_initial_credit_grantis idempotent; the verdict table in Support triage says when.
Taking credits back: a grant that has not touched an invoice can be voided; one partially used can only have its remainder expired (/v1/billing/credit_grants/<id>/void and /expire). Voiding an invoice reinstates the credits it drew; issuing a credit note does not.
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 | invoice metadata type: credit_purchase |
Numbering is per customer: ZGAKFKLN-0004 is that customer’s fourth invoice.
invoice.paid is a granting webhook. gateway/webhooks.py creates a paid credit grant 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. Any invoice you create by hand must decide whether it wants that grant. Usually it does not.
Never scan /v1/events unfiltered — production emits thousands of events per hour, and the CLI’s stripe get hard-prefixes /v1, so the filterable v2 events API is unreachable through it anyway. Every object you need (payment_intents, charges, checkout/sessions, invoices, setup_intents, billing/credit_balance_transactions) takes -d customer=.
Reissuing a paid invoice (corrected billing details)
The recurring ask: an EU customer needs their legal name, address, and VAT number on an invoice that already exists. A paid invoice cannot be revised — Stripe’s from_invoice revision flow only accepts open or uncollectible, and the Update endpoint cannot change customer fields after finalization. The fix is a credit note plus a replacement invoice. No money moves.
CUS=cus_… # tenants.stripe_customer_id
INV=in_… # the paid invoice being replaced
NUM=ZGAKFKLN-0004 # its number, for the replacement's description
AMOUNT=2500 # invoice total in cents
# 1. Fix the billing profile. Future invoices snapshot it at finalization,
# and tax IDs render in the header of invoice and credit-note PDFs.
stripe post /v1/customers/$CUS --live \
-d "name=LEGAL NAME S.R.L." \
-d "address[line1]=…" -d "address[city]=…" \
-d "address[postal_code]=…" -d "address[state]=…" -d "address[country]=RO"
stripe post /v1/customers/$CUS/tax_ids --live -d type=eu_vat -d value=RO…
# 2. Credit-note the original. out_of_band = the money already changed hands
# and stays where it is; the PDF cancels the old invoice for accounting.
stripe post /v1/credit_notes --live -d invoice=$INV \
-d amount=$AMOUNT -d out_of_band_amount=$AMOUNT \
-d "memo=Reissued with corrected billing details"
# 3. Replacement invoice. Create the draft FIRST, then the item with
# invoice= — a bare pending item gets swept into the next invoice.
# Plain line metadata, or invoice.paid grants the credits a second time.
stripe post /v1/invoices --live -d customer=$CUS -d auto_advance=false \
-d "metadata[source]=support_reissue" -d "metadata[replaces]=$INV"
stripe post /v1/invoiceitems --live -d customer=$CUS -d invoice=<new in_…> \
-d currency=usd -d amount=$AMOUNT \
-d "description=Credit top-up (reissue of $NUM)"
stripe post "/v1/invoices/<new in_…>/finalize" --live
stripe post "/v1/invoices/<new in_…>/pay" --live -d paid_out_of_band=true # no charge is made
# 4. Send the customer the new invoice_pdf and the credit note's pdf —
# the hosted links are customer-facing; note in the message that no
# new charge was made.Every step is a production write: per-command yes, and confirm the customer id against the tenant before starting.
Card fingerprints
Stripe gives every physical card a fingerprint that is stable across customers — the same card produces the same fingerprint no matter which account adds it. The $80 billing unlock redeems once per fingerprint (groudon.card_fingerprint_redemptions), so this is where multi-account credit questions get settled.
Read the fingerprint from a customer’s cards:
stripe get /v1/payment_methods -d customer=cus_… -d type=card --live
# → data[].card.fingerprint (plus brand, last4, exp)Then ask the orchestrator DB who redeemed on it (connection: Where to look, or the gateway-pod pattern in Support triage):
-- has this card unlocked the $80 anywhere?
select * from groudon.card_fingerprint_redemptions where fingerprint = '<fp>';
-- every card this tenant has redeemed with
select * from groudon.card_fingerprint_redemptions where tenant_id = '<tenant_id>';Reading the row: stripe_customer_id/tenant_id say where the card’s one unlock went. grant_confirmed_at NULL long after granted_at marks an ambiguous failure — the row claimed the card but the $80 grant was never confirmed in Stripe; the reconciliation job sweeps these, and it is the one case where “the card is used up but got nothing” is true. Deleting a tenant does not free its cards, by design.
A user with two orgs and one card: the fingerprint row plus users.welcome_credit_tenant_id together answer “why does my new org get no credits” completely — the welcome credit went to the stamped org, the $80 to the org that redeemed the card.
Coupon codes
Creation — what to decide, the Dashboard walkthrough, the CLI commands — lives in the Billing reference. The short operational version:
amount_offcoupon = a voucher (redeemed for free credits, once per org);percent_off= a purchase discount (pays less, full credits — 100% off included).- Release idempotently:
-ikeys on both creates, and Stripe refuses a duplicate active code string. - Check uptake:
times_redeemedonstripe get /v1/promotion_codes/<id> --live. Retire early:-d active=false.
Watch out for
- The customer-metadata totals (
total_credits_granted_cents,promotional_credits_granted_cents,conversion_tracking_enabled) are operational flags, recomputed or stamped by several paths. Read the grant list for truth. - Duplicate Stripe customers should not exist — creation is idempotent per tenant (
create_customer_{tenant_id}). Two customers for one tenant id is a finding, not noise. - Customers can hold up to 100 unused credit grants; a bulk goodwill script that grants per-org can hit this on heavy accounts.