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 haveSearch
tenant idmetadata:tenant_id=OyVxPoRDadL-aPseFB50l (pasting the bare id also works)
user’s emailemail:user@example.com is:customer
invoice numbernumber:ZGAKFKLN-0004 is:invoice
org namename:"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'" --live

Checking 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 --live

Grant 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_… --live

The 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:

  1. 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.

  2. 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=promotional raises the org’s free-tier cap (promotional_cents is the cap) — use paid unless extending the free tier is the point; and never set type: welcome_bonus in 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.

  3. The welcome-credit re-run — when the org was actually owed the $20 and the grant call failed: create_initial_credit_grant is 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:

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 purchasecheckoutinvoice 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_off coupon = a voucher (redeemed for free credits, once per org); percent_off = a purchase discount (pays less, full credits — 100% off included).
  • Release idempotently: -i keys on both creates, and Stripe refuses a duplicate active code string.
  • Check uptake: times_redeemed on stripe 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.