Canonical source: docs/claude/stripe-payments.md. This page is generated by docs/scripts/sync-handbook.mjs. Edit the source file in the repo; changes appear here on the next build.
Stripe Integration Patterns + Client Self-Service
How a Charge Actually Works (READ THIS FIRST)
Most "payments are broken again" confusion comes from one fact: a single charge can be minted four different ways, and each attaches tax, names, and metadata differently. Before debugging any payment issue, identify which path created the charge — the fix is almost always path-specific.
The four charge paths
| # | Path | Code | Who mints the PaymentIntent | Has an invoice? | Where the name lives | Where tax is applied |
|---|---|---|---|---|---|---|
| 1 | Invoice (default) | _charge_via_invoice → charge_card | We create the invoice; Stripe's PI is invoice.payment_intent | ✅ Yes | Invoice line-item description | Invoice automatic_tax (needs customer address) |
| 2 | PI fallback | stripe.PaymentIntent.create inside charge_card (fires only when the invoice path throws) | We call PaymentIntent.create directly | ❌ No | PI metadata.skus / membership_name / description | ❌ None — direct PIs are not auto-taxed |
| 3 | Checkout Session / payment link | stripe.checkout.Session.create (book_subscription_session, shop checkout, etc.) | Stripe mints the PI when the customer pays — so there's no creation log in our system | Sometimes | Session payment_intent_data.metadata (often minimal) | Session automatic_tax (needs customer address) |
| 4 | Subscription | Stripe auto-bills on its own schedule from the Subscription | Stripe, on the renewal date | ✅ Yes (Stripe-generated) | The price/product name on the subscription item | Needs customer.address and price tax_behavior |
Diagnostic tell-tales (how to identify the path from a PaymentIntent):
- Has
invoiceset → path 1 or 4. Log line(invoice path)at charge time → path 1. - No
invoice, and there's aPayment intent createdlog in our system withmetadata.skus→ path 2 ((PI fallback)log). - No
invoice, no creation log in our system, minimal metadata like{uid, clinic, source, platform_fee_cents}→ path 3 (Stripe minted it from a session/link we configured). This is why such charges show as "Payment" with no name. metadatahasbasis_membership_id+platform_fee_percent→ the subscription checkout (book_subscription_session); the recurring renewals afterward are path 4.
The customer object (and the duplicate-customer problem)
ensure_customer_for_user(cfg, clinic, uid, email, alias) resolves the Stripe Customer for a (clinic, uid, alias):
- mapped customer in
clinic_stripe/{clinic}/customer_mapping/{uid_alias}→ 2. search by metadata → 3. search by email → 4. create new.
A client can end up with multiple Stripe customers (different aliases, email drift, races) — e.g. Dan Gray has 3 for one clinic, and ensure returns different ones on different calls. This matters because tax and saved cards live on the customer object, so any per-customer fix (like setting an address) must be applied to all of a client's customers, not just the mapped one.
Tax — the complete picture (VERIFIED against xdoz, 2026-07)
Two independent things gate whether a charge collects tax, and they were conflated for a long time:
- Is the product taxable? = the Stripe product's
tax_code(clinic-controlled, in the Stripe Dashboard).txcd_00000000= Non-taxable; the account defaulttxcd_20030000= taxable (9.75% in CA). - Does Stripe know the jurisdiction? = the customer object's
address(NOT the payment method'sbilling_details, NOT an invoice line).
Verified breakdown (xdoz, 111 active products):
| Charge type | Product tax_code | Should collect tax? | Collecting now? | Fix |
|---|---|---|---|---|
| Memberships | txcd_00000000 (clinic set) | No (CA services) | $0 — correct | none — leave alone |
| Services (PT, IV, cancel fee) | txcd_00000000 (clinic set) | No (CA services) | $0 — correct | none — leave alone |
| Products (supplements/goods) | txcd_20030000 (taxable) | Yes | $0 — WRONG | set customer.address |
- The membership $0 is NOT a bug — the clinic deliberately marked memberships/services
txcd_00000000(Non-taxable) in the Stripe Dashboard, which is standard for CA (services aren't sales-taxed). Our code never sets tax codes (verified:txcd_is hard-coded nowhere;functions_clinic.py:5173passes none; notaxCodefield in our models). Do NOT flip membership tax codes — that's the clinic's tax-liability decision. - The REAL bug is taxable PRODUCTS collecting $0 because the customer has no address.
_charge_via_invoice(:1806) creates an Invoice withautomatic_taxenabled and respects per-product tax codes — but with no customer address the tax step fails/computes $0 (and often falls back to a bare tax-free PI →invoice=Noneon the charge). Stripe's tax engine confirms the fix: taxable product + CA address = 9.75%. - The fix =
customer.address. Set it fromclinic_users/{uid}.address(administrativeArea= state,postalCode). Helper_client_stripe_address(clinic, uid)→ Stripe address dict, orNoneif no usable jurisdiction. Applied at customer creation; existing customers need a one-time backfill (a client can have multiple Stripe customers — set it on all). - Do NOT touch
tax_behavior/ price creation.automatic_tax='complete'on membership invoices proves'unspecified'is fine — the account default resolves it. Hard-coding it would override the clinic's per-product taxability. (Wrong earlier theory.) - Money impact: setting addresses makes taxable product purchases start collecting CA sales tax. Memberships unaffected.
Diagnostic order for "tax isn't applying": (1) check the product's tax_code — if txcd_00000000, $0 is correct and intentional; (2) only if it's a taxable code, check the customer address. The state-fallback (manual_state/manual_postal_code) is wired only into the display endpoint calculate_tax — it does NOT reach the actual charge. _charge_via_invoice retries without tax if Stripe rejects automatic_tax, now with a logger.warning (grep automatic_tax rejected).
Payment names — where the name actually lives (VERIFIED against xdoz, 2026-07)
The name is almost always present — this is mostly a display concern, NOT missing data. Key gotcha: metadata lives on the PaymentIntent, not the Charge. charge.metadata is usually {}; payment_intent.metadata is rich. Auditing charge-level metadata will falsely look like "no name everywhere."
- One-time in-app charges (products, late-cancel fees, membership-linked purchases) are direct PaymentIntents (
invoice=None,source:'basis') and carry full info in PI metadata:membership_name,basis_summary(JSON withmembershipName+productNames),description(e.g."Late Cancellation Fee"). The frontendgetPaymentDescription(PaymentTab.tsx) already reads all of these → they display fine. - Subscription renewals carry the name in the invoice line description (
"1 × PLAYA - Silver Membership (Quarterly)").get_user_stripe_profileexpandsdata.invoice, so the frontend can resolve it. - "Payment" fallback only hits the narrow set of invoice-disconnected PIs with minimal metadata (Dan Gray's bare $206.80 renewals:
{uid, clinic, source, platform_fee_cents}, noinvoice, nobasis_summary). These are the exception, not the rule — do NOT assume a broad backfill is needed. If names look broadly missing in a UI, first check the endpoint/view is passing PImetadata+ expandedinvoice, not charge-level fields. expandis capped at 4 levels —data.items.data.price.product= 5 = rejected.
Read-only audit tools (tools/)
check_xdoz_client_addresses.py— % of clients with a tax-usable address.audit_xdoz_invoices.py— charges bucketed by month: tax, address, source, name.dump_xdoz_charge_detail.py— full PI metadata/description per charge (identifies the creator).check_xdoz_subscriptions.py— per-subscription tax +automatic_taxstatus + customer address +tax_behavior.check_xdoz_tax_config.py— account tax settings + per-producttax_code+ a control calc proving the jurisdiction taxes.check_xdoz_product_tax.py— all products grouped bytax_code; which are taxable and would collect tax with an address.tax_backfill_dryrun.py— per active-subscription: proposed address + Stripe tax.Calculation of what tax it WOULD collect (writes nothing).backfill_customer_addresses.py— setscustomer.addressfromclinic_userson all of each client's Stripe customers.--applyto write; default dry-run.- Run with secrets injected:
STRIPE_ENCRYPTION_KEY=$(gcloud secrets versions access latest --secret=STRIPE_ENCRYPTION_KEY --project=basis-hybrid) STRIPE_API_KEY=$(gcloud secrets versions access latest --secret=STRIPE_API_KEY --project=basis-hybrid) python tools/<script>.py
Debugging checklist for any payment issue
- Which charge path (table above)? Identify before theorizing.
- Which Stripe account (default vs alias)? Connected accounts pass only
stripe_account; the platform key must be set for platform-level reads. - Which customer object — and does the client have duplicates?
- Is the field encrypted (
*_encrypted) or JSON-serialized? (see StripeConfig section) - Verify against real data first (
tools/verify_dan_gray_payment_names.pyis the pattern) before writing any fix — payment fixes that look right on paper have been wrong twice.
Function Overview
| Use Case | Function | Permissions | Notes |
|---|---|---|---|
| One-time client payment | create_payment_intent | [] | Client self-service |
| Recurring subscription | book_subscription | [] | Client self-service |
| Staff charging client | charge_card | BILLING | Requires saved payment method |
| List client subscriptions | list_subscriptions | [] | Filter by caller's uid |
| Cancel subscription | cancel_subscription | [] | Verify ownership |
| Add named Stripe account | add_stripe_account | BILLING | OAuth flow for multi-account |
| List all Stripe accounts | list_stripe_accounts | BILLING | Root + subcollection accounts |
| Remove named account | remove_stripe_account | BILLING | Must reassign items first |
- Subscriptions are stored in Stripe, not Firestore - always query via Stripe API
- Orders are stored in
clinicsv2/{clinicId}/ordersfor history/receipts
StripeConfig Encryption (CRITICAL)
Stripe credentials are encrypted at rest in Firestore. You CANNOT read them directly from doc data.
| Firestore field | Actual data | Access method |
|---|---|---|
oauth_token_encrypted | OAuth tokens (stripe_user_id, etc.) | StripeConfig.decrypt(doc.to_dict(), live) |
api_key_encrypted | Stripe API key | StripeConfig.decrypt(...) |
webhook_secret_encrypted | Webhook signing secret | StripeConfig.decrypt(...) |
publishable_key | Publishable key | Plain text — safe to read directly |
connected_account_id | Connected account ID | Plain text — safe to read directly |
Common mistake: Reading doc.get('oauth_token') returns None because the field is stored as oauth_token_encrypted. Always use StripeConfig.decrypt().
# WRONG — field doesn't exist (it's encrypted)
data = doc.to_dict()
account_id = data.get('oauth_token', {}).get('stripe_user_id')
# CORRECT — decrypt first
cfg = StripeConfig.decrypt(doc.to_dict(), live=True)
account_id = cfg.oauth_token.get('stripe_user_id') if cfg.oauth_token else None
# ALSO CORRECT — check encrypted field exists (no decryption needed)
is_connected = bool(data.get('oauth_token_encrypted') or data.get('connected_account_id'))
Multi-Stripe Account Storage
Clinics can have multiple Stripe accounts (one per location):
| Path | Purpose |
|---|---|
clinic_stripe/{clinicId} | Root/default account |
clinic_stripe/{clinicId}/accounts/{alias} | Named accounts (e.g., "downtown") |
clinic_stripe_test/{clinicId} | Test mode root account |
clinic_stripe_test/{clinicId}/accounts/{alias} | Test mode named accounts |
Items (memberships, products, services) reference accounts via stripeAccountAlias field. The backend auto-resolves which account to use via resolve_stripe_config().
Firestore merge: true Gotcha
When using set(data, merge=True), existing fields NOT in data are preserved. This means boolean flags like pending: True survive merges unless explicitly overwritten:
# Step 1: add_stripe_account sets pending
doc.set({'pending': True, 'alias': 'downtown'}, merge=True)
# Step 2: OAuth callback merges credentials but DOESN'T clear pending
doc.set({'oauth_token_encrypted': '...', 'alias': 'downtown'}, merge=True)
# pending is STILL True!
# FIX: explicitly clear the flag
data['pending'] = False
doc.set(data, merge=True)
Rule: When a workflow sets a temporary flag (pending, processing, etc.), the completion step MUST explicitly clear it.
Client Self-Service Cloud Functions
When creating client-facing payment/booking functions:
- Use
[](empty permissions) inREQUEST_MODEL_MAPfor client self-service - MUST filter by calling user's uid - never allow access to other users' data
- Examples:
create_payment_intent,book_subscription,create_setup_intent - For subscriptions: verify subscription's
customer_idmatches caller before modifying
# Good: Client self-service with uid validation
('create_payment_intent', CreatePaymentIntentRequest, [], create_payment_intent),
# Inside the function:
customer = get_customer_for_uid(uid)
if subscription.customer != customer.id:
raise PermissionError("Not your subscription")
Secret Scoping for Payment Functions
Current secret assignments in functions_payment.py:
| Function | Type | Secrets |
|---|---|---|
payment | on_call | STRIPE_ENCRYPTION_KEY, STRIPE_API_KEY, SLACK_WEBHOOK_URL_PARAM, STRIPE_SIGNER_SECRET_KEY |
stripe_oauth_callback | on_request | STRIPE_ENCRYPTION_KEY, STRIPE_API_KEY, STRIPE_SECRET_KEY, STRIPE_SIGNER_SECRET_KEY |
stripe_oauth_callback_test | on_request | STRIPE_ENCRYPTION_KEY, STRIPE_TEST_API_KEY, STRIPE_TEST_SECRET_KEY, STRIPE_SIGNER_SECRET_KEY |
See docs/claude/firebase-backend.md for general secret scoping rules.