Skip to content

Merchant of Record (PIQUE-1005) — QA & Test Script

Status: Post-deploy verification Shipped in: 46387719 — "Make the producer the merchant of record on every Stripe charge, with readiness guards and contract-validated Stripe tests" (PR #1167) Branch at time of writing: stage Owner: Platform / Payments

Looking to actually run the tests against Stripe? See merchant-of-record-e2e-stripe-testmode.md — the executable test-mode runbook, with exact expected cents for every scenario and a paste-able verifier. It goes deeper than §7 here on the two money movements: the fee pullback from the producer and the custom fee/surcharge split. This document is the analysis and risk register; read it to understand why a step matters.


Table of Contents

  1. What changed
  2. Money-flow model (read this first)
  3. Charge call-site map
  4. Pre-flight: production readiness checks
  5. Automated regression suite
  6. Automated coverage matrix
  7. Manual test script (staging, Stripe test mode)
  8. A. Normal fee flow — hosted Checkout
  9. B. Embedded checkout & iframe embeds
  10. C. Custom fee / surcharge flow
  11. D. Bundles / series
  12. E. SDK checkout
  13. F. At-door Terminal / Tap to Pay
  14. G. Readiness guard & error UX
  15. H. Refunds, cancellations, disputes
  16. I. Webhooks & post-payment reconciliation
  17. J. Foreign currency (MXN)
  18. K. Free / $0 orders
  19. Edge-case matrix
  20. Known risks & open questions
  21. Production monitoring & post-deploy verification
  22. Rollback plan
  23. Appendix A — test data setup
  24. Appendix B — Stripe test cards & triggers
  25. Appendix C — sign-off sheet
  26. Appendix D — live test-mode run, 2026-08-21
  27. Appendix E — pre-deployment fixes, 2026-08-21

1. What changed

Every Stripe charge the platform creates is now a destination charge with the producer as the settlement merchant. Concretely, five charge call sites gained the same two parameters:

{"transfer_data": {"destination": acct_id}, "on_behalf_of": acct_id}

built by a single helper, build_destination_charge_kwargs (apps/api/producers/services.py:292), so the two values can never drift apart. Alongside them, an application_fee_amount — the platform's entire cut — computed by one shared formula, CheckoutService._compute_application_fee_cents (apps/api/tickets/services/checkout_service.py:2228).

Three supporting mechanisms shipped with it:

Mechanism Where Purpose
Readiness guard producer_can_accept_card_payments (apps/api/producers/services.py:217) Refuse the sale unless the connected account's card_payments capability is ACTIVE and charges_enabled is true. Self-heals by refreshing once from Stripe on a cache miss.
Platform-self-charge skip is_platform_account (apps/api/producers/services.py:279) Stripe rejects transfer_data[destination] / on_behalf_of pointing at the caller's own account. When the producer is the platform account (single-account dev/test setups) all MoR kwargs and application_fee_amount are omitted.
4xx error mapping ProducerNotReadyError (apps/api/tickets/services/checkout_service.py:276) A not-yet-sellable producer is a client error (400 PRODUCER_NOT_CONFIGURED), not a 500. Handlers also cancel the order to release the seat reservation.

There is no runtime kill switch. A ProducerFeatureFlags.merchant_of_record_enabled field was added in migration 0053 and removed again in 0056. MoR is unconditional for every producer. See Rollback plan.

Also in this commit (in scope for regression, out of scope for the money-flow tests): a Stripe contract test harness (apps/api/tickets/tests/stripe_contract.py, 841 lines) that validates mocked Stripe request params against stripe-python's generated CreateParams TypedDicts and returns real typed StripeObject responses; plus a FIELD_ENCRYPTION_KEY validation change in apps/api/utils/field_encryption.py.


2. Money-flow model (read this first)

Testers need to be able to predict the exact cents before opening the Stripe dashboard. Two flows, one invariant.

The invariant

The producer nets exactly the post-discount ticket subtotal. Everything above it — platform fee, processing gross-up, custom fee, surcharge, FX conversion fee — rides on application_fee_amount and lands in the platform balance.

Normal (default) fee flow

Constants: PLATFORM_FEE_PERCENTAGE = 0.05, PLATFORM_FEE_PER_TICKET = $1.50, Stripe 2.9% + $0.30 grossed up as (subtotal * 0.029 + 0.30) / 0.971 (_gross_up_processing, checkout_service.py:148).

Worked example — 2 × $25.00 tickets, no promo, USD:

Line Formula Value
Ticket subtotal 2 × 25.00 $50.00
Platform fee roundup(50.00 × 0.05 + 2 × 1.50) $5.50
Processing fee roundup((55.50 × 0.029 + 0.30) / 0.971) $1.97
Buyer charged 50.00 + 5.50 + 1.97 $57.47 → amount = 5747
application_fee_amount 550 + 197 747
Producer receives 5747 − 747 5000 = the face value

Stripe's own processing fee ($57.47 × 2.9% + $0.30 = $1.97) is deducted from the platform's balance, so the platform's true net is ≈ $5.50 — the platform fee, exactly as designed.

Custom fee flow

When a producer has a custom_platform_fee_model and/or custom_surcharge_model (FKs on Producer, apps/api/producers/models.py:214), the default platform fee is replaced entirely (calculate_fees, checkout_service.py:868), and platform_fees is None on the order.

Worked example — 2 × $25.00, 2% custom fee + 8.75% surcharge:

Line Formula Value
Ticket subtotal $50.00
Custom fee roundup(50.00 × 0.02) $1.00
Custom surcharge roundup(50.00 × 0.0875) $4.38
Processing fee roundup((55.38 × 0.029 + 0.30) / 0.971) $1.97
Buyer charged $57.35 → amount = 5735
application_fee_amount 100 + 438 + 197 735
Producer receives 5735 − 735 5000

Then a second money movement happens. The custom fee and surcharge are collected into the platform balance as part of the application fee, and are paid out to their designated recipients by separate stripe.Transfer.create calls (apps/api/tickets/services/transfer_utils.py:249) to BaseFeeModel.destination_stripe_account_id, fired from the payment_intent.succeeded webhook and retried by the daily retry_pending_transfers sweep (apps/api/tickets/task.py:1881).

Buyer  ──charge $57.35──▶  Platform (charge object lives here)
                            ├── auto transfer  $50.00 ──▶ Producer connected acct
                            ├── Transfer.create $1.00 ──▶ custom fee destination
                            ├── Transfer.create $4.38 ──▶ surcharge destination
                            └── retains ≈ $0.00 + processing to cover Stripe fee

This is the highest-risk interaction in the whole change: with MoR, the platform balance is much thinner than it used to be, and those two outbound transfers now compete with pending settlement. balance_insufficient is handled as a deferral, not a failure (transfer_utils.py:264), and escalates to an alert after 3 days. See Risk R6.


3. Charge call-site map

Every path a tester must exercise, and what it should send.

# Flow Code Stripe call MoR kwargs App fee
1 Hosted Checkout (single tickets) checkout_service.py:2289 checkout.Session.create → payment_intent_data ✅ ✅
2 Embedded / Payment Element (incl. iframe embeds) checkout_service.py:2444 PaymentIntent.create ✅ ✅
3 Bundle / series Checkout checkout_service.py:3575 checkout.Session.create → payment_intent_data ✅ ✅
4 SDK checkout (third-party sites) sdk/views.py:842 PaymentIntent.create ✅ ✅
5 At-door Terminal (card_present) checkin/views.py:808 PaymentIntent.create ✅ ✅ (platform + processing only)

Readiness is enforced at four places:

Guard Code Result on failure
validate_producer_financial(show) tickets/views/order_validation.py:172, called from order_views.py:663 400 PRODUCER_NOT_CONFIGURED, and releases the reservation (R1 — fixed)
_get_ready_producer_financial(producer) checkout_service.py:98 raises ProducerNotReadyError → 400 + order cancelled (order_views.py:506, bundle_checkout_views.py:218)
SDK inline guard sdk/views.py:951 400 {"error": …, "error_code": "PRODUCER_NOT_CONFIGURED"} (R2 — fixed)
Terminal guard checkin/views.py:776 (require_card_present=True) 400, transaction marked failed, reader lock released

4. Pre-flight: production readiness checks

Run these before working through the manual script. Anything that fails here invalidates the results below.

4.1 Platform account ID is configured

# On the API host / container
python manage.py shell -c "from django.conf import settings; print(repr(settings.STRIPE_PLATFORM_ACCOUNT_ID))"
  • Expected: a non-empty acct_… matching the live platform account.
  • If empty: is_platform_account always returns False (producers/services.py:288). Harmless for normal producers, but any producer whose stripe_account_id equals the platform account will hard-fail with "The 'on_behalf_of' param cannot be set to your own account."

4.2 No producer is pointed at the platform account

SELECT p.id, p.name, pf.stripe_account_id
FROM producers_producerfinancial pf
JOIN producers_producer p ON p.id = pf.producer_id
WHERE pf.stripe_account_id = '<STRIPE_PLATFORM_ACCOUNT_ID>';
  • Expected: 0 rows in production.

4.3 Connected-account capability audit

python manage.py audit_connected_accounts --dry-run

Read-only; never calls Account.modify. It reports, per account, whether card_payments and transfers are active and reconciles the cached ProducerFinancial flags.

Then find every producer with a published, future, on-sale show that cannot currently sell:

SELECT DISTINCT p.id, p.name, pf.stripe_account_id,
       pf.charges_enabled, pf.card_payments_active, pf.payouts_enabled
FROM tickets_show s
JOIN producers_producer p          ON p.id = s.producer_id
LEFT JOIN producers_producerfinancial pf ON pf.producer_id = p.id
WHERE s.published = true
  AND s.start_time > now()
  AND (pf.id IS NULL
       OR pf.stripe_account_id IS NULL OR pf.stripe_account_id = ''
       OR pf.charges_enabled = false
       OR pf.card_payments_active = false)
ORDER BY p.name;
  • Expected: empty, or a known list of producers being onboarded.
  • Every row here is a live show that will show buyers "Event Not Ready". This is the single most important query in this document.

4.4 Request missing capabilities (remediation, not verification)

python manage.py enable_stripe_capabilities --dry-run                 # preview
python manage.py enable_stripe_capabilities --account-id acct_XXX     # one account
python manage.py enable_stripe_capabilities --allow-livemode          # all, live mode

Only requests card_payments + transfers; Stripe activates them once the account clears its requirements. It will not force active, and it reports remaining currently_due / past_due fields truthfully.

4.5 Custom fee destinations are reachable

SELECT 'fee' AS kind, id, name, percentage, destination_stripe_account_id
FROM producers_customplatformfeemodel
UNION ALL
SELECT 'surcharge', id, name, percentage, destination_stripe_account_id
FROM producers_customsurchargemodel;

For each destination_stripe_account_id, confirm in Stripe that it is a connected account onboarded under this platform with transfers active, and that it is not the platform account itself (a self-transfer is rejected).

Also confirm that a producer's fee model and surcharge model do not share a destination. Both values are well-formed acct_... ids, so nothing used to catch it, and the symptom only appears after the money moves: two Transfers land in the same connected account with only metadata.type to tell them apart. This happened on order 0O42AU73 — a custom_surcharge transfer was delivered to the platform-fee destination. Producer.clean() now rejects the assignment, but rows created before that guard are unaffected:

SELECT p.id, p.name, f.destination_stripe_account_id AS shared_destination
FROM producers_producer p
JOIN producers_customplatformfeemodel f ON f.id = p.custom_platform_fee_model_id
JOIN producers_customsurchargemodel  s ON s.id = p.custom_surcharge_model_id
WHERE f.destination_stripe_account_id = s.destination_stripe_account_id;

Expect zero rows. Fixing the model does not repair orders already placed — Order.custom_surcharge_destination is snapshotted at checkout and is deliberately never re-read (see transfer_utils.attempt_stripe_transfer).


5. Automated regression suite

All backend tests run inside the API container, from the worktree root.

Use --noinput, not --keepdb. A retained test database silently drifts after a migration and produces dozens of phantom NotNullViolation / UndefinedColumn failures with run-to-run-varying counts. If you see a large or unstable failure count, rebuild before triaging anything.

5.1 Core MoR guard suite (fast — run this first)

docker compose exec -T api python manage.py test \
  tickets.tests.test_checkout_mor_guard \
  tickets.tests.test_producer_financial_gate \
  producers.tests.test_card_payment_guard \
  producers.tests.test_destination_charge_kwargs \
  tickets.tests.test_stripe_contract_platform_guard \
  --noinput

Verified on 2026-08-18 against stage @ 46387719: Ran 28 tests … OK.

5.2 Fee-boundary suite (what Stripe actually receives)

docker compose exec -T api python manage.py test \
  tickets.tests.test_custom_fee_stripe_boundary \
  tickets.tests.test_bundle_stripe_app_fee \
  tickets.tests.test_payment_intent \
  tickets.tests.test_embed_checkout \
  sdk.tests.test_checkout_custom_fees \
  --noinput

5.3 Surface suites

# SDK
docker compose exec -T api python manage.py test sdk --noinput

# At-door terminal
docker compose exec -T api python manage.py test checkin --noinput

# Producers (capability caching, audit/enable commands, terminal provisioning)
docker compose exec -T api python manage.py test producers --noinput

# Checkout + webhook + refund
docker compose exec -T api python manage.py test \
  tickets.tests.test_checkout_core \
  tickets.tests.test_checkout_edge_cases \
  tickets.tests.test_checkout_failures \
  tickets.tests.test_checkout_security \
  tickets.tests.test_bundle_checkout \
  tickets.tests.test_refund_service \
  tickets.tests.test_refund_for_cancellation \
  tickets.tests.test_custom_transfer_escalation \
  tickets.tests.test_webhook_payment_intent \
  tickets.tests.test_webhook_checkout \
  tickets.tests.test_webhook_charge_refunded \
  tickets.tests.test_webhook_refund_created \
  --noinput

5.4 Frontend

cd apps/frontend && npm test -- --testPathPattern="checkout|Checkout"

Covers PaymentErrorAlert and lib/checkout/errors.ts, which render the PRODUCER_NOT_CONFIGURED → "Event Not Ready" state.

Environment note. On a machine running several worktree Docker stacks concurrently, the Docker VM can run out of memory and SIGKILL (exit 137) the test runner mid-migration. If you see exit 137 with no test summary, that is the environment, not a test failure — free memory (docker compose down in your own worktree's stacks only) and re-run smaller batches. Suites 5.2–5.3 were not executed during authoring of this document for exactly that reason; 5.1 was, and passed.


6. Automated coverage matrix

What already has a regression test, and what only a human can catch.

Behaviour Covered by Status
on_behalf_of == transfer_data.destination, always producers/tests/test_destination_charge_kwargs.py ✅
Capability cached-active → no Stripe call producers/tests/test_card_payment_guard.py ✅
charges_enabled=True but card_payments inactive → refused same ✅
Stale cache self-heals via one refresh same ✅
Refresh raises → fail closed same ✅
card_present guard delegates to card_payments same ✅
Not-ready producer → 400 PRODUCER_NOT_CONFIGURED tickets/tests/test_checkout_mor_guard.py ✅
Cancel-cleanup failure does not escalate 400 → 500 same ✅
Bundle not-ready → 400 + reservation released test_checkout_mor_guard.py, test_bundle_checkout.py ✅
Stripe rejects self-charge (contract-enforced in mocks) tickets/tests/test_stripe_contract_platform_guard.py ✅
Platform-account skip omits all three kwargs — hosted test_custom_fee_stripe_boundary.py ✅
… bundle test_bundle_stripe_app_fee.py ✅
… SDK / terminal sdk/tests/, checkin/tests/__init__.py:1144 ✅
Default-path application_fee_amount cents test_bundle_stripe_app_fee.py, test_payment_intent.py ✅
Custom-fee application_fee_amount cents (hosted / PI / bundle / SDK) test_custom_fee_stripe_boundary.py, test_bundle_stripe_app_fee.py, sdk/tests/test_checkout_custom_fees.py ✅
Fee-model-only and surcharge-only variants same ✅
FX fee folded into app fee (default + custom) test_bundle_stripe_app_fee.py, test_custom_fee_stripe_boundary.py ✅
Embedded PI carries on_behalf_of test_embed_checkout.py:96 ✅
Terminal PI carries on_behalf_of checkin/tests/__init__.py:1129 ✅
Terminal readiness failure releases reader lock checkin/tests/test_terminal_payment.py:274 ✅
Charge→webhook→snapshotted custom transfers test_custom_fee_stripe_boundary.py:373 ✅
Reservation released on the validate_producer_financial path test_checkout_mor_guard.py ✅ R1
SDK not-ready response carries a machine-readable code sdk/tests/test_checkout_views.py ✅ R2
Readiness-refresh negative cooldown producers/tests/test_card_payment_guard.py ✅ R3
automatic_tax.liability pinned to the platform test_automatic_tax_liability.py ✅ R4
Buyer-facing statement descriptor audit producers/tests/test_audit_statement_descriptors.py ✅ R5
Custom-fee transfer backlog report test_pending_transfer_backlog_report.py, test_beat_schedule_transfers.py ✅ R6
Daily capability drift reconciliation producers/tests/test_capability_reconciliation.py, test_beat_schedule.py ✅ R8
Terminal reader account scoping — ❌ needs production facts R7
Dispute routing / evidence request — ❌ (untestable in unit tests) R5
charge.dispute.* handling: record, clawback, notify tickets/tests/test_dispute_webhook.py ✅ (pt-2h8)
Terminal applies the producer's custom fee model and surcharge checkin/tests/test_terminal_custom_fees.py ✅ (pt-4pxu)
Fee/surcharge destination collision audit producers/tests/test_audit_fee_destinations.py ✅ (pt-4zs2)

7. Manual test script (staging, Stripe test mode)

Environment: staging, Stripe test mode, with at least the fixtures from Appendix A.

For every case: record the PaymentIntent id, the charge id, and paste the amount, application_fee_amount, on_behalf_of, transfer_data.destination, and transfer.amount you see in the Stripe dashboard into the sign-off sheet.

A. Normal fee flow — hosted Checkout

ID Steps Expected
A1 Buy 2 × $25 tickets on PRODUCER_READY, hosted checkout, card 4242… Stripe: amount=5747, application_fee_amount=747, on_behalf_of and transfer_data.destination both = PRODUCER_READY.stripe_account_id; automatic transfer of 5000 to that account. Order COMPLETED, tickets emailed.
A2 Same, 1 ticket platform_fee = roundup(25×0.05 + 1.50) = 2.75; processing = roundup((27.75×0.029+0.30)/0.971) = 1.14; amount=2889, app_fee=389, producer nets 2500.
A3 Apply a 20% promo code to A1 Fees computed on the post-discount subtotal ($40.00); producer nets 4000. Confirms the fee base did not regress.
A4 Apply a 100% promo code Routed to the free order path — no Stripe call at all (see K).
A5 Ticket with a donation add-on Donation is part of the ticket subtotal → flows to the producer, not the platform.
A6 Buy on a show whose owner is PRODUCER_READY but which lists PRODUCER_OTHER as an additional producer MoR is the owning producer only. on_behalf_of = PRODUCER_READY. Additional producers are display-only.
A7 Two browsers buy the last ticket simultaneously One succeeds with correct MoR kwargs; the loser gets a sold-out error, not a Stripe error.
A8 Buy on PRODUCER_PLATFORM (a producer whose stripe_account_id == STRIPE_PLATFORM_ACCOUNT_ID) Charge succeeds with no transfer_data, no on_behalf_of, no application_fee_amount. Platform keeps 100%.

B. Embedded checkout & iframe embeds

ID Steps Expected
B1 Checkout with ?ui_mode=embedded (Payment Element inline) PaymentIntent.create carries MoR kwargs + application_fee_amount; response has clientSecret; metadata.checkout_flow = "payment_element".
B2 Same with ?ui_mode=embedded&embedContext=true (iframe embed) Identical money params, plus payment_method_types=["card"] only (no Cash App / Amazon Pay — they need top-level navigation). Payment completes inside the iframe.
B3 Embedded flow on the partner iframe host (third-party domain) No CSP/CORS regressions; Stripe 3DS challenge renders in-frame.
B4 Embedded checkout, then abandon and retry the same cart Idempotency key is sha256(checkout_session_token) (checkout_service.py:2455) — Stripe returns the same PaymentIntent, not a duplicate. Verify only one PI exists for that session token.
B5 Embedded checkout against a not-ready producer 400 with error_code: PRODUCER_NOT_CONFIGURED; frontend shows the "Event Not Ready" alert with isRetryable: false and a contact organizer action, not a retry button.
B6 Embedded, non-USD (MXN) payment_method_types=["card"], currency=mxn, application_fee_amount in centavos (see J).

C. Custom fee / surcharge flow

Use PRODUCER_CUSTOM (2% fee model + 8.75% surcharge model, distinct destination accounts).

ID Steps Expected
C1 Buy 2 × $25 hosted amount=5735, application_fee_amount=735, producer transfer 5000. Order row: platform_fees IS NULL, custom_platform_fee_amount=1.00, custom_surcharge_amount=4.38, both *_destination snapshotted.
C2 After payment_intent.succeeded fires Two additional Transfer objects in Stripe: $1.00 → fee destination, $4.38 → surcharge destination, both with transfer_group = order.uuid and metadata.type of custom_platform_fee / custom_surcharge. Order flags platform_fee_transfer_pending / surcharge_transfer_pending → false.
C3 Producer with only a fee model (no surcharge) custom_surcharge = 0.00; one outbound transfer; app fee = custom_fee + processing.
C4 Producer with only a surcharge model Mirror of C3.
C5 Producer with a 0% fee model is not None (not truthiness) semantics: the custom path still applies, platform_fees stays NULL, custom amount 0.00, no transfer attempted (absence is not a failure).
C6 Force the platform balance below the transfer amount (test mode: spend down, or use a fresh account) Transfer defers with balance_insufficient → warning log, not a Sentry event; order stays COMPLETED with *_transfer_pending = true and transfer_pending_since set.
C7 Run retry_pending_transfers after C6 with funds available Transfer completes; pending flags clear; transfer_pending_since NULLed; no duplicate transfer (the sweep reconciles against Stripe first).
C8 Leave C6 pending > 3 days (or backdate transfer_pending_since) Escalation alert fires once (transfer_alert_sent = true), not on every sweep.
C9 Rename the show, then retry a pending transfer Retry uses the snapshotted custom_*_transfer_description, not a recomputed one — no Stripe IdempotencyError (regression guard for PIQUE-API-7V).
C10 Custom fee + promo code Custom percentages apply to the post-discount subtotal.
C11 Custom fee + FX (MXN) application_fee_amount = custom_fee + surcharge + processing + fx_fee, all in centavos; the outbound custom transfers are converted to USD cents using settlement_exchange_rate when present, else the Frankfurter snapshot.
C12 Custom fee destination == the producer's own account Money round-trips: producer receives 5000 from the charge and the custom fee back as a transfer. Confirm this is intended for the configured producer.

D. Bundles / series

ID Steps Expected
D1 Buy a 3-show bundle on PRODUCER_READY payment_intent_data carries MoR kwargs + application_fee_amount; metadata.order_type = "bundle".
D2 Bundle on PRODUCER_CUSTOM Custom fee formula applies identically; downstream transfers fire.
D3 Bundle on PRODUCER_NOT_READY 400 PRODUCER_NOT_CONFIGURED and the order is CANCELLED with reserve_until cleared — bundles have no earlier guard, so this is the path that actually exercises the reservation-release code. Verify bundle inventory is immediately re-purchasable.
D4 Bundle on PRODUCER_PLATFORM All MoR kwargs omitted.

E. SDK checkout

Third-party sites using a ProducerAPIKey against /api/sdk/….

ID Steps Expected
E1 Full SDK flow: create cart → add tickets → checkout PaymentIntent.create carries MoR kwargs + application_fee_amount; metadata.sdk = "true", checkout_flow = "payment_element".
E2 SDK checkout with a promo code Discount applied per-ticket before fees, matching CheckoutService.
E3 SDK checkout on PRODUCER_CUSTOM Custom formula matches the first-party flow to the cent.
E4 SDK checkout on PRODUCER_NOT_READY 400 carrying both error and error_code: "PRODUCER_NOT_CONFIGURED" (R2 — fixed). Confirm the embedder's UI degrades sanely.
E5 Producer A's API key requesting a checkout for producer B's show (surfaced via additional-producer linking) Readiness and MoR are evaluated against B (the show owner), not the key holder. If B is not ready, A's site shows the failure.
E6 SDK checkout on PRODUCER_PLATFORM MoR kwargs omitted.
E7 Stripe error mid-PaymentIntent.create (force with a Stripe test-mode failure) Order flips to PAYMENT_FAILED, reserve_until cleared, 500 returned, seats released.
E8 SDK cart already checked_out Re-checkout is rejected; no second PaymentIntent.
E9 SDK donation-only cart Donation-inclusive subtotal settles to the producer.

F. At-door Terminal / Tap to Pay

ID Steps Expected
F1 Server-driven reader sale on PRODUCER_READY PaymentIntent.create with payment_method_types=["card_present"] plus MoR kwargs and application_fee_amount = platform_fee + processing_fee (no FX term on this path).
F2 Same on PRODUCER_PLATFORM MoR kwargs and app fee omitted.
F3 Sale on PRODUCER_NOT_READY 400; TerminalTransaction.status = "failed"; error_message set; reader lock released immediately (not held for the 300 s TTL). Confirm a second operator can claim the reader right away.
F4 Retry the failed transaction from F3 Blocked by the status gate — the stale retry cannot dispatch to a reader someone else has claimed.
F5 Terminal donation flow Same MoR params.
F6 Multi-admission ("table") ticket sale Headcount materialization unchanged; MoR params present.
F7 Oversell attempt Fails and refunds; the refund reverses the transfer (see H).
F8 Mobile Tap to Pay: mint a connection token, connect a reader, take a tap ⚠️ Cross-account check. terminal_connection_token mints on the connected account (producers/views.py:1061, stripe_account=…), while every server-driven reader call in checkin/views.py (retrieve, collect_inputs, set_reader_display, process_payment_intent) is platform-scoped. Confirm which account your physical readers and Locations actually live on — a reader registered under a connected account will 404 on the platform-scoped calls. See R7.
F9 provision_terminal_location for a producer with card_payments_active = false Command refuses / warns rather than provisioning a location that cannot take a tap.

G. Readiness guard & error UX

ID Steps Expected
G1 ProducerFinancial row missing entirely 400 PRODUCER_NOT_CONFIGURED.
G2 stripe_account_id = '' 400 PRODUCER_NOT_CONFIGURED.
G3 charges_enabled = true, card_payments_active = false Refused. This is the specific case charges_enabled-only gating used to let through, and it hard-declines at Stripe.
G4 Cache stale: DB says card_payments_active = false, Stripe says active The guard refreshes once, updates the cache, and allows the sale. Verify exactly one Account.retrieve in the Stripe request log for that checkout.
G5 Cache stale in the other direction: DB true, Stripe inactive The guard trusts the cache and lets the charge through — Stripe then declines. Verify the resulting error surfaces as a payment failure, not a silent success. Run audit_connected_accounts to reconcile.
G6 Stripe API down/erroring during the refresh Fails closed → 400, plus logger.exception with a full traceback.
G7 Frontend rendering of PRODUCER_NOT_CONFIGURED Title "Event Not Ready", no retry button, "contact the event organizer" copy, role="alert" / aria-live="assertive" announced by a screen reader.
G8 Not-ready producer, single-ticket hosted checkout — check inventory The order is created before the guard runs, but the reservation is now released on this path too. Confirm the seat frees immediately rather than at expiry. See R1.
G9 Producer becomes not-ready between order creation and session creation The deeper ProducerNotReadyError path fires → 400 and order cancelled.
G10 50 concurrent checkouts against one not-ready producer Measure Stripe Account.retrieve volume and p95 latency. Expect one refresh per 60 s, not one per attempt, now that the negative cooldown is in. See R3.

H. Refunds, cancellations, disputes

ID Steps Expected
H1 Full refund of an A1 order Fees are never refunded — "full" means order.ticket_subtotal, so the buyer gets $50.00, not the $56.44 charged (portal/views.py:4470; cancellation_refund_target). Refund.create with reverse_transfer=True and refund_application_fee=False (refund_service.py:229); the platform keeps its $6.44 fee. Because no explicit reversal amount is passed, record transfer_reversal.amount — a charge-proportional reversal recovers only $44.30 of the $50.00 paid out. See pt-3664.
H2 Partial refund (1 of 2 tickets) Half the subtotal refunded; proportional reversal; application fee still not refunded.
H3 Refund when the producer's connected balance is $0 (already paid out) Reversal drives the connected account negative; Stripe recovers from future volume. Confirm this is acceptable to Finance and that the producer-facing balance UI does not break on a negative value.
H4 Refund of a PRODUCER_PLATFORM order (no transfer exists) reverse_transfer=True on a charge with no transfer — verify Stripe does not error. This combination has no automated coverage.
H5 Refund of a custom-fee order after the outbound custom transfers landed The custom fee/surcharge already left the platform balance and correctly stay with their destinations — they are earned revenue, and fees are never refunded. Nothing should attempt to reverse them. Confirm the ledger nets out; the only exposure is the reversal size in pt-3664.
H6 Refund of a custom-fee order before the outbound transfers fire Confirm the pending transfers are cancelled or still fire — and which is intended.
H7 Show cancellation → bulk refunds Batch path uses the same reversal semantics; idempotency keys prevent double refunds under at-least-once Celery delivery.
H8 Dispute a test charge (4000000000000259) Per Stripe, the platform is debited the disputed amount and the fee on destination charges with or without on_behalf_of — so confirm the debit lands on the platform, and establish who receives the evidence request and where the dispute surfaces in the Dashboard, which the docs do not settle. See R5.
H9 Read the buyer's card statement descriptor on a test charge With on_behalf_of, the descriptor derives from the connected account's settings, not the platform's. Confirm buyers will recognize it — a mismatch here drives chargebacks.

I. Webhooks & post-payment reconciliation

ID Steps Expected
I1 checkout.session.completed for a hosted order Order completes; update_stripe_transfer_metadata modifies the PaymentIntent and the Charge without stripe_account — the charge lives on the platform (checkout_service.py:2655).
I2 payment_intent.succeeded for an embedded/SDK order Same metadata update; latest_charge normalized through _charge_id_from whether it is a string id or an expanded Charge object.
I3 Duplicate webhook delivery Idempotent — no duplicate attendees, emails, or transfers.
I4 Webhook arrives for an ABANDONED / CANCELLED / PAYMENT_FAILED order whose payment actually succeeded Order is recovered (RECOVERABLE_ORDER_STATUSES). Verify the producer still received the transfer.
I5 payment_intent.payment_failed Seats released; no transfer; order PAYMENT_FAILED.
I6 Verify charge.id is stored on the order order.charge_id populated; required by the refund path when payment_intent_id is absent.

J. Foreign currency (MXN)

ID Steps Expected
J1 Buy 2 × $25-equivalent in MXN, hosted currency=mxn; all fee amounts and application_fee_amount are in centavos, computed from the MXN-denominated fees (_calculate_foreign_currency_fees, checkout_service.py:901). payment_method_types=["card"] only.
J2 Confirm the FX conversion fee is in the app fee fx_fee_cents = order.foreign_currency_conversion_fee × 100 is added on both the default and custom paths.
J3 Post-payment settlement capture _capture_fx_settlement reads latest_charge.balance_transaction.exchange_rate and stores settlement_exchange_rate. Compare against the Frankfurter snapshot and confirm the spread is recorded, not silently absorbed.
J4 MXN + custom fee models Outbound custom transfers are converted MXN→USD at settlement_exchange_rate (multiply) when present, else at the snapshot (divide). Confirm the correct direction was used — the two rates are inverses.
J5 MXN order with a non-US connected account ⚠️ on_behalf_of makes settlement currency follow the connected account's country. Verify Stripe does not reject the charge or apply an unexpected conversion.

K. Free / $0 orders

ID Steps Expected
K1 $0 ticket on PRODUCER_READY Free-order path (order_views.py:395) — no Stripe call, no readiness guard. Tickets issued inline.
K2 $0 ticket on PRODUCER_NOT_READY ✅ Still succeeds. A producer who cannot take cards can still distribute free tickets. Confirm this is the intended product behaviour.
K3 100% promo reducing a paid ticket to $0 Routed to the free path; calculate_fees zero-guard prevents _gross_up_processing($0) = $0.31 from corrupting the order.
K4 Free bundle Same.

8. Edge-case matrix

Beyond the flow scripts — cases worth an explicit pass/fail.

# Edge case Why it matters Expected
1 Producer's stripe_account_id == platform account, and STRIPE_PLATFORM_ACCOUNT_ID unset The skip guard is disabled Stripe rejects: "The 'on_behalf_of' param cannot be set to your own account." Now reproduced in mocked tests by the contract harness.
2 stripe_account_id points at an account on a different Stripe platform Not our connected account Stripe rejects at charge time; the readiness guard's Account.retrieve should 403 first and fail closed.
3 Connected account deleted/rejected in Stripe after caching active Cache trusts itself Charge fails at Stripe. audit_connected_accounts is the reconciliation tool.
4 Capability flips active → inactive mid-checkout Race between guard and charge Charge fails; buyer sees a payment error, seats released by the failure path.
5 application_fee_amount ≥ amount Stripe rejects Not currently validated in code. With the gross-up formula the fee always stays below the total, but verify the smallest sellable ticket (e.g. $0.50) explicitly.
6 application_fee_amount = 0 (0% custom models, $0 processing) Stripe accepts 0 Verify no amount_too_small error and the producer receives the full charge.
7 Very large order (near MAX_ORDER_AMOUNT) Integer cents overflow / Stripe limits Amounts computed correctly, no rounding drift.
8 Rounding: subtotal producing a half-cent _round_up (ROUND_UP) vs Stripe's integer cents Producer nets exactly the subtotal; the platform never eats a cent. Check $33.33 × 3.
9 Idempotent retry of the embedded PaymentIntent with a changed cart Idempotency key is derived from the session token, not the amount Stripe returns the original PI with the old amount. Confirm the cart is re-validated / the token rotated.
10 Producer changes their custom fee model between order creation and webhook Transfers use snapshots Order's snapshotted amounts/destinations/descriptions are used — not the live model.
11 Custom fee destination account is closed/rejected Transfer fails Sentry event with order_uuid / transfer_type / destination tags; pending flags stay set; escalates at 3 days.
12 Two custom transfers, one succeeds one fails Partial state Only the failing one stays pending; the successful one is not retried (idempotency + reconcile).
13 Order with checkout_session_token missing Idempotency key cannot be derived StripeError raised before any Stripe call — verify it is not a silent 500 loop.
14 Show with a producer whose ProducerFinancial exists but charges_enabled=false and card_payments_active=true Both flags are required Refused.
15 Cash App / Amazon Pay on a hosted USD session with on_behalf_of Method availability can depend on the settlement account Verify all three methods still render for a US connected account.
16 automatic_tax on a session with on_behalf_of Tax is computed from the platform's registrations/origin — on_behalf_of does not affect it; only automatic_tax.liability does, and it is now pinned to {"type": "self"} See R4 — the original "connected account" expectation was wrong. Still spot-check the tax line in one taxable jurisdiction.
17 3DS / SCA challenge Regulatory treatment follows the settlement account's country Challenge renders and completes in hosted, embedded, and iframe modes.
18 Radar fraud rules Rules now evaluate against the connected account Verify platform-level Radar rules still apply as expected.
19 Terminal reader registered on the connected account Cross-account API scoping See R7 / test F8.
20 Reservation expiry after a not-ready 400 Inventory availability Seats free at expiry at worst; immediately on the bundle path. See R1.

9. Known risks & open questions

Findings from reading the shipped code, and what happened to each after investigation. Two of the eight (R4, R5) rested on a premise that turned out to be wrong — worth reading before acting on either.

Each is tracked in beads (local per-machine tracker; run bd show <id>):

Risk Issue Priority Status
R1 — reservation leak on the single-ticket not-ready path pt-dqk (bug) P1 ✅ Fixed — 074a00ac
R2 — SDK not-ready response has no error code pt-1eg P2 ✅ Fixed — adf37fdb
R3 — uncached readiness refresh on the hot path pt-5gw P2 ✅ Fixed — 719b598f
R4 — automatic_tax with on_behalf_of pt-ohc ~~P0~~ → P2 ⚠️ Premise disproved; liability pinned explicitly — 8f5fec1f
R5 — dispute liability / statement descriptor shift pt-uio P1 ⚠️ Half disproved; descriptor audit added — e5d62c00. Platform liability now measured live — Appendix D
R6 — thinner platform balance vs. custom transfers pt-ezw P1 ✅ Daily backlog report — 8c49f302
R7 — terminal reader account scoping pt-5uc P1 🔍 Confirmed real; needs production facts before any code change
R8 — optimistically-stale capability cache pt-l55 P2 ✅ Fixed — d3670db8

A ninth item was opened during this work: no charge.dispute.created webhook handler existed anywhere in the codebase (pt-2h8). Stripe explicitly recommends one, and because the producer's funds have already left the platform by the time a dispute lands, recovering them requires a deliberate transfer reversal. Pre-existing, not a regression — but MoR raises its cost. Now fixed; see Appendix E.

R1 — The single-ticket not-ready path leaks a seat reservation

CheckoutSessionView runs validate_producer_financial(show) at order_views.py:663, which is after process_checkout has already created the order and reserved inventory. That handler returns _producer_not_configured_response() and returns — it never cancels the order.

The commit added careful reservation-release logic to the other handler (ProducerNotReadyError, order_views.py:506), but because the outer guard fires first for the same condition, that cleanup is effectively unreachable on the single-ticket path. Bundles have no outer guard, so they do release correctly.

Impact: every buyer who hits "Event Not Ready" holds a seat until reservation expiry. On a not-ready producer that is every buyer.

✅ Fixed in 074a00ac. _release_reservation_after_readiness_failure now runs on both paths — the view-level validate_producer_financial failure and the ProducerNotReadyError handler. The cancel is best-effort inside a try/except so a failure to release never escalates the buyer's 400 into a 500.

Verify: test G8 — watch tickets_order for the CANCELLED/reserve_until state after a not-ready 400.

R2 — SDK not-ready response has no machine-readable error code

sdk/views.py:951 returns {"error": "This show is not available for purchase."} with 400. The first-party API returns {"status", "message", "error_code": "PRODUCER_NOT_CONFIGURED", "details": {"is_retryable": false}}. Third-party SDK embedders cannot distinguish a not-ready producer from any other 400, so they cannot render the equivalent "contact the organizer" state.

✅ Fixed in adf37fdb. All three SDK readiness branches now return error_code: "PRODUCER_NOT_CONFIGURED" alongside the human-readable error, so existing embedders keep working while new ones can branch on the code. The literal was also promoted to a shared PRODUCER_NOT_CONFIGURED constant in producers/services.py and imported by sdk/views.py, tickets/views/order_validation.py, and tickets/services/checkout_service.py — previously the same string was typed out in three places and could drift.

Verify: test E4.

R3 — Readiness refresh is an uncached Stripe call on the hot path

producer_can_accept_card_payments calls StripeConnectService().update_producer_status() — a live Account.retrieve — on every checkout attempt where the cached flags are not both true, with no negative cache and no backoff. A producer stuck in a not-ready state under load generates one Stripe round-trip per attempt: added latency inside the checkout request, plus rate-limit exposure.

✅ Fixed in 719b598f. A 60-second negative cooldown (READINESS_REFRESH_COOLDOWN_SECONDS) now suppresses the refresh after a not-ready answer, so a stuck producer under load costs one Account.retrieve per minute rather than one per checkout attempt. The cooldown is keyed by connected account, not by ProducerFinancial row, because it describes what Stripe will say about that account. It is also started when the refresh call itself raises, so a Stripe outage cannot turn into a retry storm. Cache failures are logged and treated as "no cooldown" — the guard still fails closed.

Note for anyone extending this: the test suite swaps CACHES to DummyCache, so tests covering cooldown behaviour need an explicit @override_settings(CACHES=...LocMemCache...) or they will silently pass regardless of the code.

Verify: test G10.

R4 — automatic_tax with on_behalf_of — ⚠️ premise disproved

Originally filed P0 on the belief that on_behalf_of silently moved tax calculation to the connected account. It does not.

Stripe's Checkout Session API reference is explicit that one parameter, and only that parameter, selects the taxpayer — automatic_tax.liability:

The account that's liable for tax. If set, the business address and tax registrations required to perform the tax calculation are loaded from this account.

on_behalf_of appears in Stripe's tax-for-platforms guide as an independent "(Optional)" parameter alongside it, not as an input to it. With automatic_tax.liability unset, the liable account is the one making the request — the platform. So tax behaviour did not change when MoR shipped.

The remediation was therefore not a behaviour fix but the removal of an implicit assumption: both hosted Checkout Session sites now pass automatic_tax={"enabled": True, "liability": {"type": "self"}} so the platform's liability is stated rather than inherited from which API key made the call. Covered by tickets/tests/test_automatic_tax_liability.py.

Still worth doing: edge case 16 — a staging spot-check of the tax line in one taxable jurisdiction. The documentation is unambiguous, but this is tax.

R5 — Dispute liability and statement descriptor — ⚠️ half disproved

The bead asserted that disputes, dispute fees, and the buyer-facing statement descriptor all moved to the producer. Only the descriptor did.

Dispute liability did not move. Stripe, on destination charges, without qualification:

For destination charges, with or without on_behalf_of, Stripe debits dispute amounts and fees from your platform account.

on_behalf_of is irrelevant to it. The platform was liable before MoR and is liable after. Finance and Support need no change on this point.

The descriptor did move. From Stripe's Connect statement-descriptor guide, the customer's statement uses the connected account's static component for "Direct charges, Destination charges with on_behalf_of, Separate charges and transfers with on_behalf_of". Since PIQUE-1005 that is us.

Three details make this sharper than it first looks:

  1. The documented fallback rarely fires. Stripe falls back to the platform's descriptor only "If that information isn't set" — but create_express_account sets business_profile.name to the producer name, and Stripe auto-generates a descriptor from it during onboarding. Most producers therefore have one.
  2. PIQTIX is the wrong field to reason from. The descriptor sync_stripe_payout_settings maintains lives on settings.payouts.statement_descriptor — the producer's own bank statement for payouts. It has never been what a buyer sees, before or after MoR.
  3. The two findings compound. An unrecognizable producer descriptor produces "I don't recognize this charge" chargebacks, and per the quote above the platform pays for those disputes and their fees.

Nothing in the codebase could report the cardholder-facing string, so the acceptance question ("is it recognizable to buyers?") was unanswerable. It now is:

docker compose exec api python manage.py audit_statement_descriptors
docker compose exec api python manage.py audit_statement_descriptors --flagged-only

Read-only by design — what a descriptor should say is a business and compliance decision, since it is meant to identify the merchant of record, who is now the producer.

Verify: run the audit against production; test H8 remains useful for confirming the dispute evidence request routing and Dashboard visibility, which the docs do not settle.

R6 — Thinner platform balance vs. outbound custom-fee transfers

The platform used to hold the whole charge and pay the producer out. Now the producer's share leaves immediately and the platform retains only the application fee — out of which Stripe's processing fee is also deducted. The custom fee and surcharge transfers are drawn from that much thinner balance.

Expect balance_insufficient deferrals to become more common. The retry sweep and 3-day escalation exist and are tested, but the rate is a new operational signal — and nothing emitted a number you could watch, so "deferrals are up" stayed invisible until individual orders hit the 3-day alarm: three days late, one order at a time.

✅ Addressed in 8c49f302. report_pending_transfer_backlog runs daily at 05:30 UTC (after the retry sweep, so it never counts a backlog the sweep is about to clear). It counts the two pending flags separately — they draw on the same balance but fail independently, so a combined number would hide which one is starving — plus the subset already aged past the escalation window. It always logs the structured line so the baseline series exists even on clean days, and alerts Slack only at or above TRANSFER_BACKLOG_ALERT_THRESHOLD.

⚠️ That threshold is currently 10, which is a guess. Retune it once two weeks of the daily series exist — that is the first real task this report enables.

Verify: tests C6–C8; then watch the daily transfer_backlog log line.

R7 — Terminal reader account scoping is inconsistent

terminal_connection_token (producers/views.py:1061) mints against the connected account. Every server-driven reader call in checkin/views.py (Reader.retrieve, collect_inputs, set_reader_display, process_payment_intent) is platform-scoped — no stripe_account. provision_terminal_location creates Locations on the connected account.

These are plausibly two different products (mobile Tap to Pay SDK vs. server- driven smart readers), but if any physical reader is registered under a connected account, the platform-scoped calls will 404.

🔍 Confirmed real, deliberately not changed. The split is exactly as described — verified line by line:

Call Location Scope
terminal.ConnectionToken.create producers/views.py:1060 connected (stripe_account=)
terminal.Location.create provision_terminal_location.py:181 connected (stripe_account=)
terminal.Reader.set_reader_display checkin/views.py:644 platform
terminal.Reader.collect_inputs checkin/views.py:672 platform
terminal.Reader.process_payment_intent checkin/views.py:835 platform
terminal.Reader.retrieve checkin/views.py:911 platform
terminal.Reader.cancel_action checkin/views.py:992 platform

What MoR adds to the analysis: the at-door PaymentIntent is created platform-side at checkin/views.py:816 with build_destination_charge_kwargs — which is correct, because a destination charge with on_behalf_of must be created on the platform. Reader.process_payment_intent has to be called on the account that owns that PaymentIntent. So the platform-scoped reader calls are consistent with the charge model, and it is the connection-token/Location side that looks wrong for smart readers.

Why no code change here: the two flows may both be correct if they really are separate products. Tap to Pay on mobile uses the connection token and confirms the PaymentIntent through the client SDK — it never makes a server-side Reader.process_payment_intent call, so connected-account scoping is right for it. That is only true if no smart reader is registered on a connected account. Flipping the scoping blind would break a currently-working at-door flow and could orphan already-provisioned Locations. This needs production facts first.

Diagnostics that settle it (read-only, run against production keys):

# 1. Readers on the PLATFORM account. Server-driven flow works only for these.
stripe terminal readers list

# 2. Readers and Locations on each CONNECTED account. Anything here will 404
#    on the platform-scoped calls above.
stripe terminal readers list   --stripe-account acct_XXXX
stripe terminal locations list --stripe-account acct_XXXX

Then the verdict is mechanical:

  • All readers on the platform → the server-driven flow is fine; the connection token/Location scoping serves only mobile Tap to Pay. Document both as intentionally separate and close.
  • Any smart reader on a connected account → that reader's retrieve / process_payment_intent / cancel_action calls are already failing. Fix forward by registering it on the platform, since the destination charge cannot move.

Verify: test F8.

R8 — Stale-cache-in-the-optimistic-direction is not self-healing

The guard only refreshes when the cache says not ready. A cache that says ready while Stripe says inactive is trusted and the charge hard-declines at Stripe. audit_connected_accounts is the only reconciliation path, and it is manual.

✅ Fixed in d3670db8. reconcile_connected_account_capabilities runs daily at 08:00 UTC, refreshing every connected account and alerting on any that transitioned from ready to not-ready — the direction the hot path can never detect on its own.

One trap found while building it, worth knowing if you touch this code: update_producer_status swallows exceptions and returns False, leaving the flags untouched. A naive implementation therefore read an unreachable account as "no change" and counted it as healthy. The task now uses that boolean return to track unreachable separately from checked, so a Stripe outage reports as an outage rather than as a clean bill of health.

Verify: test G5; watch for the daily task's drifted/unreachable counts.


10. Production monitoring & post-deploy verification

10.1 Sentry

Watch for, in the first 48 h:

Signal Meaning
"cannot be set to your own account" A producer is pointed at the platform account with the skip guard disabled (pre-flight §4.1/§4.2 missed it).
"without the card_payments capability" The readiness guard was bypassed or a cache was optimistically stale (R8).
"Failed to refresh Stripe status for producer_financial" Guard fail-closed events — each one is a refused sale.
"Error cancelling order … after readiness failure" Cleanup failures; the 400 is still correct but a reservation leaked.
Transfer failures tagged transfer_type / destination Custom fee/surcharge payout problems (R6).
InvalidRequestError on Transfer.create Bad destination_stripe_account_id.

10.2 Stripe dashboard spot checks

For the first 20 live charges after deploy:

  • on_behalf_of is set and equals transfer_data.destination
  • application_fee_amount matches the order's fee snapshot to the cent
  • the automatic transfer equals the post-discount ticket subtotal
  • the charge object lives on the platform account (not the connected one)
  • connected-account balance reflects the transfers
  • statement descriptor is what buyers will recognize

10.3 SQL health queries

Orders that never got their custom transfers:

SELECT uuid, created_at, status,
       custom_platform_fee_amount, custom_surcharge_amount,
       platform_fee_transfer_pending, surcharge_transfer_pending,
       transfer_pending_since, transfer_alert_sent
FROM tickets_order
WHERE (platform_fee_transfer_pending OR surcharge_transfer_pending)
ORDER BY transfer_pending_since NULLS LAST;

Baseline this before deploy and compare weekly — a rising count is R6.

Orders stuck reserved after a readiness failure (R1 detector):

SELECT id, uuid, status, reserve_until, created_at
FROM tickets_order
WHERE status = 'AWAITING_PAYMENT'
  AND payment_intent_id IS NULL
  AND session_id IS NULL
  AND created_at > now() - interval '1 day'
ORDER BY created_at DESC;

Capability drift (run alongside audit_connected_accounts):

SELECT producer_id, stripe_account_id, charges_enabled,
       card_payments_active, payouts_enabled, updated_at
FROM producers_producerfinancial
WHERE charges_enabled <> card_payments_active
ORDER BY updated_at DESC;

Any row here is an account where the old charges_enabled-only gate and the new card_payments gate disagree — i.e. a producer who could sell before and cannot now (or vice versa).

10.4 Business metrics to watch for regression

  • Checkout conversion rate, by producer — a producer-specific cliff means a readiness failure, not a UX problem.
  • Count of PRODUCER_NOT_CONFIGURED responses per hour (should be ~0 after pre-flight §4.3 is clean).
  • Producer payout timing and amounts vs. the pre-MoR baseline.
  • Tax collected per order, before vs. after (R4).

11. Rollback plan

There is no feature flag — merchant_of_record_enabled was added in migration 0053 and removed in 0056. Options, in increasing order of blast radius:

  1. Per-producer mitigation (no deploy). For a producer whose account cannot support MoR, the only lever is fixing the Stripe account: run enable_stripe_capabilities --account-id acct_XXX, then have the producer clear their currently_due requirements. There is no way to sell for them without MoR.
  2. Revert the charge-site kwargs only. Make build_destination_charge_kwargs return {"transfer_data": {"destination": id}} without on_behalf_of, and relax producer_can_accept_card_payments to charges_enabled only. This restores the pre-MoR destination-charge model (platform as merchant of record) in one small, reviewable diff and leaves the contract-test harness and guards in place.
  3. Full revert of 46387719. Note this also reverts the Stripe contract test harness, the CMS Collections work squashed into the same commit, and the FIELD_ENCRYPTION_KEY validation change. Migration 0056 (RemoveField) would need a forward migration to restore the column. Prefer option 2.

In-flight orders during a rollback: charges already created keep their on_behalf_of; refunds still use reverse_transfer=True, which stays correct for both models. No data migration is needed.


Appendix A — test data setup

Run in the API shell on staging. Adjust account ids to real Stripe test-mode connected accounts.

from decimal import Decimal
from producers.models import (
    Producer, ProducerFinancial, CustomPlatformFeeModel, CustomSurchargeModel,
)

def mk(name, acct, *, charges=True, cards=True, fee=None, surcharge=None):
    p = Producer.objects.create(name=name)
    ProducerFinancial.objects.create(
        producer=p,
        stripe_account_id=acct,
        charges_enabled=charges,
        card_payments_active=cards,
        payouts_enabled=charges,
    )
    if fee or surcharge:
        p.custom_platform_fee_model = fee
        p.custom_surcharge_model = surcharge
        p.save()
    return p

fee_model = CustomPlatformFeeModel.objects.create(
    name="QA 2% fee", percentage=Decimal("0.0200"),
    destination_stripe_account_id="acct_qa_fee_dest",
)
surcharge_model = CustomSurchargeModel.objects.create(
    name="QA 8.75% surcharge", percentage=Decimal("0.0875"),
    destination_stripe_account_id="acct_qa_surcharge_dest",
)

PRODUCER_READY      = mk("QA Ready",       "acct_qa_ready")
PRODUCER_NOT_READY  = mk("QA Not Ready",   "acct_qa_notready", cards=False)
PRODUCER_NO_ACCT    = mk("QA No Account",  "")
PRODUCER_CUSTOM     = mk("QA Custom Fees", "acct_qa_custom",
                         fee=fee_model, surcharge=surcharge_model)
PRODUCER_FEE_ONLY   = mk("QA Fee Only",    "acct_qa_feeonly", fee=fee_model)
PRODUCER_SUR_ONLY   = mk("QA Surcharge",   "acct_qa_suronly", surcharge=surcharge_model)
PRODUCER_ZERO_PCT   = mk("QA Zero Pct",    "acct_qa_zero",
                         fee=CustomPlatformFeeModel.objects.create(
                             name="QA 0%", percentage=Decimal("0.0000"),
                             destination_stripe_account_id="acct_qa_fee_dest"))
# Set this one's account to the real platform account id:
from django.conf import settings
PRODUCER_PLATFORM   = mk("QA Platform Acct", settings.STRIPE_PLATFORM_ACCOUNT_ID)

Then give PRODUCER_READY and PRODUCER_CUSTOM each a published future show with a $25.00 ticket type, plus a series/bundle for section D, and issue a ProducerAPIKey for section E.

Producer with additional_producers (test A6/E5): create a show owned by PRODUCER_READY and add PRODUCER_CUSTOM to show.additional_producers. The link is display-only — money must never follow it.


Appendix B — Stripe test cards & triggers

Purpose Card
Success 4242 4242 4242 4242
Requires 3DS authentication 4000 0025 0000 3155
Declined (generic) 4000 0000 0000 0002
Insufficient funds 4000 0000 0000 9995
Creates a dispute (test H8) 4000 0000 0000 0259
Mexico-issued (test J) 4000 0048 4000 8001

Terminal: use the Stripe simulated reader (process_payment_intent against a SimulatedWisePosE) for F1–F7; only F8 needs physical hardware.

Useful CLI:

# The route is registered as `webhooks/stripe/` (tickets/urls.py) and included
# at the root (brktickets/urls.py), and the API port is per-worktree — take it
# from compose rather than assuming 8000.
stripe listen --forward-to "localhost:$(docker compose port api 8080 | cut -d: -f2)/webhooks/stripe/"
stripe listen --print-secret   # the whsec_ for STRIPE_WEBHOOK_SECRET
stripe trigger payment_intent.succeeded
stripe payment_intents retrieve pi_XXX --expand latest_charge.balance_transaction
stripe transfers list --transfer-group <order-uuid>

Appendix C — sign-off sheet

Section Cases Owner Run date Pass Fail Notes
Pre-flight §4 4.1–4.5
Automated §5.1 28 tests 2026-08-18 ✅ — Verified OK at 46387719
Automated §5.2–5.4
A. Normal fee flow A1–A8
B. Embedded / embeds B1–B6 Claude 2026-08-21 Payment Element embedContext not run All live orders used ui_mode=embedded; the iframe wrapper differs by one dict and does not touch MOR kwargs. Appendix D
C. Custom fees C1–C12 Claude 2026-08-21 C1, C2 — Live test mode; promo interaction too. Appendix D
D. Bundles D1–D4 Claude 2026-08-21 fees + routing — Session inspected, not paid. Appendix D
E. SDK E1–E9 Claude 2026-08-21 charge + both transfer legs — Live test mode, order KJK3O0LH. Appendix D
F. Terminal F1–F9 Claude 2026-08-21 custom fee schedule + both transfer legs (unit) not charged through a reader pt-4pxu fixed. Appendix E
G. Readiness / UX G1–G10
H. Refunds / disputes H1–H9 Claude 2026-08-21 H5, H8 — Both measured live (Appendix D); dispute handling built since (Appendix E)
I. Webhooks I1–I6
J. FX / MXN J1–J5 Claude 2026-08-21 routing settlement rate pt-c0pc, pt-kirm
K. Free orders K1–K4
Edge cases §8 1–20
Risks §9 verdicts R1–R8

Release is not signed off until every R1–R8 risk has an explicit verdict.


Appendix D — live test-mode run, 2026-08-21

Everything below was executed against real Stripe test mode from a local Docker stack (piquetickets-mor-qa-2, API on localhost:8057) with stripe listen forwarding to /webhooks/stripe/. Producer connected account acct_1U6MmpFkOdT0Y8ar; fee destination acct_1U6MzgFkvwOhSerV; surcharge destination acct_1U6MzjC7bMT5t7KK. Every id below is a real object you can retrieve.

The fee models mirror production but with the surcharge destination corrected — production still has both models pointing at the fee destination, which is the misconfiguration that started this (pt-4zs2).

What passed

Case Order Evidence
C1/C2 — custom fee + surcharge routing, USD 8ZF8D6AT amount=616, application_fee_amount=116, on_behalf_of and transfer_data.destination both acct_1U6Mmp…; tr_1U6rhQ… \$0.50 → fee dest, tr_1U6rhR… \$0.18 → surcharge dest; both pending flags cleared
Platform nets exactly zero 8ZF8D6AT +616 −48 (Stripe) −616 (transfer) +116 (app fee) −50 −18 = 0
H5 — refund after transfers landed 8ZF8D6AT re_3U6rgT…; reversal trr_1U6rs5… for the full 616; application fee refunded=False; both custom transfers untouched. Producer-side ledger: +500 then −616 = −116 — the producer absorbs the \$1.16 of fees, the platform stays at 0
Promo code × custom fees MXUQ918U 50% off \$5.00 → fee \$0.25, surcharge \$0.09, processing \$0.40, total \$3.24; application_fee_amount=74; tr_1U6s4x… 25 → fee dest, tr_1U6s4y… 9 → surcharge dest. Custom fees are charged on the discounted base, per calculate_fees' documented contract
Retry sweep + no double-pay Y1CC07JG Surcharge destination pointed at a nonexistent account before confirm → fee leg landed, surcharge leg deferred, surcharge_transfer_pending=True, transfer_pending_since stamped. Destination repaired, platform_fee_transfer_pending re-armed, retry_pending_transfers() run: surcharge tr_1U6s8R… created, and the fee leg reconciled to the existing tr_1U6s7o… instead of paying twice. Both flags cleared, transfer_pending_since=None
Beat registration — PeriodicTask rows present and enabled: retry_pending_transfers 0 5 * * *, report_pending_transfer_backlog 30 5 * * *; both registered in the worker
D — bundle fee math and routing EGH3Q072 \$20.00 bundle → fee \$2.00, surcharge \$0.70, processing \$0.99, total \$23.69. Correct per-destination snapshot and both transfer descriptions. Hosted page renders "Pay MOR QA Producer" with the fee lines itemised
R4 — automatic_tax on the bundle path EGH3Q072 Session carries automatic_tax.liability = {"type": "self"}, matching the single-ticket path. Tax Calculation at the real bundle amounts (Tucson AZ) returns tax_amount_inclusive = 0, taxability_reason: product_exempt, percentage_decimal: "0.0" on all four lines
E — SDK checkout, end to end KJK3O0LH Real cart → /api/v1/sdk/cart/{id}/checkout/ → confirm. amount=616, application_fee_amount=116, on_behalf_of and transfer_data.destination both acct_1U6Mmp…; tr_1U6sO0… 50 → fee dest, tr_1U6sO1… 18 → surcharge dest, both with the right metadata.type and description; both pending flags cleared. Byte-identical money to the Payment Element run — the SDK's unit tests all stop at PaymentIntent.create, so this is the first time the outbound legs have been observed on this path

What it found

Finding Issue Where
MXN settlement_exchange_rate is never captured on the Payment Element path — charge.balance_transaction is null for the first 2–5s and payment_intent.succeeded lands inside that window. Every MXN order silently uses the Frankfurter fallback, and fx_spread — the field meant to measure the drift — is always NULL pt-c0pc (P1) _capture_fx_settlement
At-door Terminal charges apply the default fee schedule and never the producer's custom fee model or surcharge; both fee-destination accounts are bypassed at the door pt-4pxu (P1) — ✅ fixed, Appendix E checkin/views.py:545
feeBreakdown.foreignCurrencyConversionFeeCents is always null even though the fee is charged (5.95 MXN of a 110.52 MXN order) — build_fee_breakdown_response never sets the fx_conversion_fee key the serializer reads pt-kirm (P2) order_views.py:123
Dispute liability measured: raised to P1 pt-2h8 — ✅ fixed, Appendix E see below

H8 / R5 — disputes, measured

Order 6OBQD70D (\$5.00 ticket, custom fee model), charged with pm_card_createDispute. Dispute du_1U6s61… produced one balance transaction, on the platform account:

txn_1U6s62CNvFiW6S5reNd8c4T0  type=adjustment  amount=-616  fee=1500  net=-2116

Nothing else moved — producer transfer tr_3U6s5y… reversed=False, and both custom transfers reversed=False. This confirms R5's quote empirically: the platform is liable, on_behalf_of is irrelevant to it.

The sharp edge is the interaction with custom fees. The platform's net on such an order is exactly \$0, so a single chargeback takes it to −\$21.16 — ticket price plus the \$15 fee, uncapped and unrecovered. Meanwhile the app never notices: the webhook forwarded charge.dispute.created (evt_1U6s62…), the handler returned 200 while doing nothing, and the order stayed COMPLETED with valid tickets.

That silent 200 is fixed, and the clawback-vs-absorb question has been decided — see Appendix E.

Not covered by this run

At-door Terminal was reviewed in code but not charged through a physical reader. Hosted Checkout was opened and inspected but not paid — completing it needs a card entered by hand.

The embed path (embedContext=true) was not driven separately. It diverges from the run above by exactly one dict: is_embed forces payment_method_types: ["card"] instead of automatic_payment_methods (checkout_service.py:2470). The destination-charge kwargs — on_behalf_of, transfer_data.destination, application_fee_amount — are built below that branch and are not conditioned on it, and the custom-fee legs run in the webhook off the order snapshot, which the branch never touches. Every live order in this run used ui_mode=embedded, so the Payment Element itself is the most-exercised surface here; what is untested is the iframe wrapper around it, not the money.


Appendix E — pre-deployment fixes, 2026-08-21

Four things were named as required before production. Three are code and are done; the fourth needs production credentials and is a runbook step, not a change.

1. Terminal now applies the producer's custom fee schedule (pt-4pxu)

At-door charges used a hardcoded copy of the default schedule (PLATFORM_FEE_PERCENTAGE, PLATFORM_FEE_PER_TICKET in checkin/views.py) and never looked at producer.custom_platform_fee_model or custom_surcharge_model. Pre-existing, but MoR turns it into a money-routing bug: a producer on a custom schedule who sold at the door was short, and both fee-destination accounts were bypassed entirely.

The two constants are gone — the door must not carry its own copy of the schedule, or it drifts from the web again. _calculate_terminal_fees now delegates to CheckoutService.calculate_fees, the same function the web and SDK paths use, and TerminalTransaction carries the same ten snapshot fields as Order so the amounts and destinations are fixed at start_transaction and never recomputed.

One structural difference from the online path is worth knowing: the terminal order is created in checkin's own reader webhook, after payment succeeds. The payment_intent.succeeded handler that routes custom transfers online can fire before that order exists, so it cannot see it. The transfers are therefore routed explicitly at order creation, outside the atomic block, with the same _flag_custom_fee_transfers_pending fallback so a failure defers to the nightly retry sweep instead of taking down fulfillment.

_route_custom_fee_transfers and _flag_custom_fee_transfers_pending moved from tickets/views/webhook_views.py to tickets/services/transfer_utils.py so both callers share one implementation.

Tests: checkin/tests/test_terminal_custom_fees.py — 8 cases covering the charged amount, the snapshot, the reader display names, the default schedule still showing a "Platform Fee" line, the application_fee_amount, the order snapshot, both transfer legs at fulfillment, and a failed transfer deferring to the sweep.

2. Disputes: record, claw back, and tell someone (pt-2h8)

Policy decided: clawback. On charge.dispute.created the producer's destination transfer is reversed, recovering the ticket revenue from the party that received it. The custom fee and surcharge destinations are not touched — those are third parties (a venue collecting a surcharge, say) and clawing back from them is a different and more contestable decision.

Two deliberate limits, both recorded in tickets/services/dispute_service.py:

  • The reversal happens when the dispute opens, not when it is lost. Waiting would be more precise but far less collectable — by the time a dispute closes the producer has usually been paid out and the reversal fails against an empty balance. If the dispute is later won, repay_won_dispute sends the money back. Stripe's \$15 dispute fee is not returned by Stripe and is not passed on.
  • A balance_insufficient failure is expected, not exceptional. It is recorded as reversal_pending with reversal_pending_since stamped rather than raised, so it can be retried while the producer's balance refills from later sales.

That deferral is only defensible if something picks it back up, so retry_pending_dispute_reversals runs daily at 06:00 UTC (after the existing transfer sweep and backlog report). It retries only disputes still open — once a dispute closes the outcome is settled, and continuing to debit the producer would be taking money for a decided question. A clawback still unrecovered after three days escalates to Sentry once; unlike a stuck transfer it cannot be completed by hand from the dashboard, because the producer's balance genuinely does not hold the money. What it needs is a decision — net against future payouts, invoice, or write off — so the alert reports the facts rather than proposing one.

The producer transfer is located by reading charge.transfer off the expanded PaymentIntent, not by searching transfer_group — that group also matches the custom fee legs, which must not be reversed.

New OrderDispute model (migration 0139_orderdispute) records the Stripe dispute id, amount, reason, status, evidence_due_by, and both the reversal and repayment transfer ids. Keyed on the dispute id, so a redelivered webhook updates the row instead of double-debiting; the reversal itself is additionally keyed {order.uuid}-dispute-{dispute_id} at Stripe.

Notifications. A dispute has a hard deadline — miss evidence_due_by and it is forfeited automatically — so send_dispute_notification fires on created:

  • Slack (send_to_api_update_channel): the money picture. Order, amount, reason, deadline, producer, whether the clawback actually landed, and the platform's cost if lost (amount + \$15).
  • Producer email: the customer-service picture. What was disputed, the deadline, what happened to their payout, and what evidence wins a chargeback (door scan, attendee record, correspondence, the refund policy as shown at purchase). They hold that evidence; the platform does not.

Slack is dispatched inside its own try/except — a Slack outage must never take down the producer email, which is the half with a deadline attached.

An unattributable dispute (no order matches the charge or PI) pages Sentry rather than logging quietly: money leaving with no owner is the worst case, not the quiet one.

Tests: tickets/tests/test_dispute_webhook.py — 17 cases covering recording and redelivery, the reversal and its idempotency key, balance_insufficient deferring, a platform-account charge having nothing to claw back, won-repayment and its non-duplication, the created/updated/closed routing, the unattributable case, and both notifications including a Slack outage not suppressing the email. tickets/tests/test_dispute_reversal_sweep.py — 8 more covering the sweep's selection, the closed-dispute exclusion, the escalation threshold, per-dispute error isolation, and the beat registration.

3. Fee-destination collision audit (pt-4zs2)

Producer.clean() (committed at 3881f962) refuses a fee model and surcharge model that share a destination_stripe_account_id. But a validator only stops new collisions — it never runs on rows that predate it, on a bulk update, or on a raw SQL fix, so the known-bad production row is unaffected by it.

audit_fee_destinations is the retrospective half:

docker compose exec api python manage.py audit_fee_destinations
docker compose exec api python manage.py audit_fee_destinations --check-stripe
docker compose exec api python manage.py audit_fee_destinations --producer-id 42

It reports, per producer: the collision itself; how many orders have already been snapshotted to the colliding destination — those snapshots are immutable by design (Stripe validates every parameter against the idempotency key), so fixing the fee model does not repair them and they need manual reconciliation; malformed or missing destinations; and a fee routed back to the producer's own connected account, which is a round trip rather than a fee. Destinations shared across several producers are listed but not flagged — one venue collecting a surcharge for several producers looks exactly like that.

Strictly read-only. Without --check-stripe it never calls Stripe at all, so it is safe to point at production. Exits non-zero on any error-level finding, so it can be scheduled.

Still needs a human: correcting the Arizona Surcharge destination in the production admin (acct_1U6MzgFkvwOhSerV → acct_1U6MzjC7bMT5t7KK) requires production access.

4. Readiness audit against production — runbook step, no code

audit_connected_accounts already reports the "Healthy (on_behalf_of)" count and the needs-attention breakdown, is read-only against Stripe, and supports --dry-run. Nothing needed building; it needs running, against production, before deploy:

python manage.py audit_connected_accounts --dry-run

producer_can_accept_card_payments is correctly wired into checkout, SDK, and validation, so a producer whose account lacks card_payments_active is blocked at checkout rather than hard-declining at Stripe. That is the right failure mode, but it is still a producer who cannot sell — count them before deploy day, not after.

Not fixed by this pass

There is still no feature flag. The only gate is is_platform_account(), so deploying flips every connected producer to merchant-of-record at once. That is a deliberate scope call, not an oversight — but it is what makes item 4 above a hard gate rather than a nice-to-have.