Skip to main content
Synced from the repo — do not edit here

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

#PathCodeWho mints the PaymentIntentHas an invoice?Where the name livesWhere tax is applied
1Invoice (default)_charge_via_invoicecharge_cardWe create the invoice; Stripe's PI is invoice.payment_intent✅ YesInvoice line-item descriptionInvoice automatic_tax (needs customer address)
2PI fallbackstripe.PaymentIntent.create inside charge_card (fires only when the invoice path throws)We call PaymentIntent.create directly❌ NoPI metadata.skus / membership_name / description❌ None — direct PIs are not auto-taxed
3Checkout Session / payment linkstripe.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 systemSometimesSession payment_intent_data.metadata (often minimal)Session automatic_tax (needs customer address)
4SubscriptionStripe auto-bills on its own schedule from the SubscriptionStripe, on the renewal date✅ Yes (Stripe-generated)The price/product name on the subscription itemNeeds customer.address and price tax_behavior

Diagnostic tell-tales (how to identify the path from a PaymentIntent):

  • Has invoice set → path 1 or 4. Log line (invoice path) at charge time → path 1.
  • No invoice, and there's a Payment intent created log in our system with metadata.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.
  • metadata has basis_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):

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

  1. Is the product taxable? = the Stripe product's tax_code (clinic-controlled, in the Stripe Dashboard). txcd_00000000 = Non-taxable; the account default txcd_20030000 = taxable (9.75% in CA).
  2. Does Stripe know the jurisdiction? = the customer object's address (NOT the payment method's billing_details, NOT an invoice line).

Verified breakdown (xdoz, 111 active products):

Charge typeProduct tax_codeShould collect tax?Collecting now?Fix
Membershipstxcd_00000000 (clinic set)No (CA services)$0 — correctnone — leave alone
Services (PT, IV, cancel fee)txcd_00000000 (clinic set)No (CA services)$0 — correctnone — leave alone
Products (supplements/goods)txcd_20030000 (taxable)Yes$0 — WRONGset 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:5173 passes none; no taxCode field 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 with automatic_tax enabled 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=None on the charge). Stripe's tax engine confirms the fix: taxable product + CA address = 9.75%.
  • The fix = customer.address. Set it from clinic_users/{uid}.address (administrativeArea = state, postalCode). Helper _client_stripe_address(clinic, uid) → Stripe address dict, or None if 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 with membershipName + productNames), description (e.g. "Late Cancellation Fee"). The frontend getPaymentDescription (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_profile expands data.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}, no invoice, no basis_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 PI metadata + expanded invoice, not charge-level fields.
  • expand is capped at 4 levelsdata.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_tax status + customer address + tax_behavior.
  • check_xdoz_tax_config.py — account tax settings + per-product tax_code + a control calc proving the jurisdiction taxes.
  • check_xdoz_product_tax.py — all products grouped by tax_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 — sets customer.address from clinic_users on all of each client's Stripe customers. --apply to 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

  1. Which charge path (table above)? Identify before theorizing.
  2. Which Stripe account (default vs alias)? Connected accounts pass only stripe_account; the platform key must be set for platform-level reads.
  3. Which customer object — and does the client have duplicates?
  4. Is the field encrypted (*_encrypted) or JSON-serialized? (see StripeConfig section)
  5. Verify against real data first (tools/verify_dan_gray_payment_names.py is the pattern) before writing any fix — payment fixes that look right on paper have been wrong twice.

Function Overview

Use CaseFunctionPermissionsNotes
One-time client paymentcreate_payment_intent[]Client self-service
Recurring subscriptionbook_subscription[]Client self-service
Staff charging clientcharge_cardBILLINGRequires saved payment method
List client subscriptionslist_subscriptions[]Filter by caller's uid
Cancel subscriptioncancel_subscription[]Verify ownership
Add named Stripe accountadd_stripe_accountBILLINGOAuth flow for multi-account
List all Stripe accountslist_stripe_accountsBILLINGRoot + subcollection accounts
Remove named accountremove_stripe_accountBILLINGMust reassign items first
  • Subscriptions are stored in Stripe, not Firestore - always query via Stripe API
  • Orders are stored in clinicsv2/{clinicId}/orders for history/receipts

StripeConfig Encryption (CRITICAL)

Stripe credentials are encrypted at rest in Firestore. You CANNOT read them directly from doc data.

Firestore fieldActual dataAccess method
oauth_token_encryptedOAuth tokens (stripe_user_id, etc.)StripeConfig.decrypt(doc.to_dict(), live)
api_key_encryptedStripe API keyStripeConfig.decrypt(...)
webhook_secret_encryptedWebhook signing secretStripeConfig.decrypt(...)
publishable_keyPublishable keyPlain text — safe to read directly
connected_account_idConnected account IDPlain 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):

PathPurpose
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) in REQUEST_MODEL_MAP for 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_id matches 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:

FunctionTypeSecrets
paymenton_callSTRIPE_ENCRYPTION_KEY, STRIPE_API_KEY, SLACK_WEBHOOK_URL_PARAM, STRIPE_SIGNER_SECRET_KEY
stripe_oauth_callbackon_requestSTRIPE_ENCRYPTION_KEY, STRIPE_API_KEY, STRIPE_SECRET_KEY, STRIPE_SIGNER_SECRET_KEY
stripe_oauth_callback_teston_requestSTRIPE_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.