Skip to content

Merchant of Record — End-to-End Stripe Test-Mode Runbook

Scope: PIQUE-1005 (producer as merchant of record). Every charge path, both fee models, and — the reason this document exists — the money movements after the charge: the fee pullback from the producer's account, and the split of custom fees and surcharges out to their destinations.

Companion document: merchant-of-record-qa.md is the analysis and risk register. This one is the executable runbook: exact inputs, exact expected cents, exact commands. Run this one against Stripe test keys; read that one to understand why a step matters.

Environments: written for a local Docker environment pointed at Stripe test keys, and replicable verbatim on stage. §8 lists the only differences.


Table of Contents

  1. Read this first: where the money actually sits
  2. Environment setup
  3. Verification toolkit
  4. Expected-value matrix
  5. Test scenarios
  6. Open questions to settle empirically
  7. Sign-off sheet
  8. Replicating on stage

1. Read this first: where the money actually sits

1.1 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 first.

1.2 Two money movements, not one

A custom-fee order moves money twice, and only the first is atomic with the charge:

        ┌─ Movement 1 (atomic with the charge, done by Stripe) ─────────────┐
Buyer ──┤  charge $57.35 on the PLATFORM                                    │
        │    ├── auto transfer $50.00 ─────────▶ producer connected account │
        │    └── application_fee_amount $7.35 ─▶ retained by platform       │
        └───────────────────────────────────────────────────────────────────┘

        ┌─ Movement 2 (separate API calls, fired from the webhook) ─────────┐
        │  Transfer.create $1.00 ──▶ custom fee destination                 │
        │  Transfer.create $4.38 ──▶ surcharge destination                  │
        └───────────────────────────────────────────────────────────────────┘

Movement 1 is Stripe's; it either happens with the charge or not at all. Movement 2 is ours — two stripe.Transfer.create calls in _route_custom_fee_transfers (tickets/views/webhook_views.py:60) via attempt_stripe_transfer (tickets/services/transfer_utils.py:198), fired from the payment_intent.succeeded / checkout.session.completed webhook, retried by retry_pending_transfers (tickets/task.py:1890).

Almost everything that can go wrong with fee splitting lives in Movement 2.

1.3 The finding that shapes this whole runbook

The processing fee is grossed up to cover Stripe's cut exactly. So once Movement 2 completes, here is what the platform actually keeps:

Scenario Buyer charged Platform retains Stripe's fee Platform net
Normal, 2 × $25 $56.44 $6.44 $1.94 +$4.50 ✅ the platform fee
Custom 2% + 8.75%, 2 × $25 $57.35 $1.97 $1.96 +$0.01
Custom 10% + 5%, 4 × $75 $355.62 $10.62 $10.61 +$0.01

On the custom-fee path the platform nets about one cent per order. The custom fee and the surcharge pass straight through to third parties.

That is by design — but it means the platform balance has essentially no buffer from which to fund Movement 2, while Movement 2 is attempted seconds after the charge, when the charge's own funds are still in the platform's pending balance. balance_insufficient is therefore an ordinary, expected outcome, handled as a deferral rather than a failure (transfer_utils.py:263).

Consequence for testing: if your platform test balance is healthy, Movement 2 will succeed instantly and you will have tested only the happy path. Group E exists to make you test the other one.


2. Environment setup

2.1 Keys

apps/api/.env must hold test-mode keys:

STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...        # from `stripe listen`, see §2.5
STRIPE_PLATFORM_ACCOUNT_ID=acct_...    # the PLATFORM's own account id

Confirm you are not pointed at live keys before doing anything else:

docker compose exec api python -c "
from django.conf import settings
k = settings.STRIPE_SECRET_KEY
assert k.startswith('sk_test_'), 'NOT A TEST KEY — STOP'
print('test mode OK', k[:12] + '...')
"

2.2 The check that invalidates everything else

build_destination_charge_kwargs is skipped entirely when a producer's Stripe account equals the platform account (is_platform_account, producers/services.py:332). This exists so a single-account dev setup doesn't get rejected by Stripe — but it means a producer pointed at the platform account is not exercising merchant-of-record at all. Every assertion in this runbook would silently pass while testing nothing.

docker compose exec api python manage.py shell -c "
from django.conf import settings
from producers.models import ProducerFinancial
plat = settings.STRIPE_PLATFORM_ACCOUNT_ID
print('platform:', plat or '(UNSET — MoR guard disabled!)')
bad = ProducerFinancial.objects.filter(stripe_account_id=plat) if plat else []
for f in bad:
    print('  ✗ PRODUCER ON PLATFORM ACCOUNT:', f.producer.name, f.stripe_account_id)
print('OK — no producer aliases the platform' if plat and not bad else 'FIX BEFORE PROCEEDING')
"

2.3 Test accounts you need

Create three distinct Stripe test connected accounts. Distinctness is the point: if the fee destination is the same account as the producer, a misrouted transfer is invisible.

Role Purpose Needs card_payments active?
acct_PRODUCER The producer / merchant of record Yes — on_behalf_of requires it
acct_FEEDEST Custom platform fee destination No — only transfers
acct_SURDEST Custom surcharge destination No — only transfers

Express test accounts can be driven straight to fully-onboarded in test mode. Verify each is actually ready — charges_enabled alone is not sufficient for the producer; card_payments must be active:

docker compose exec api python manage.py shell -c "
import stripe; from django.conf import settings
stripe.api_key = settings.STRIPE_SECRET_KEY
for aid in ['acct_PRODUCER','acct_FEEDEST','acct_SURDEST']:
    a = stripe.Account.retrieve(aid)
    print(f'{aid}: charges_enabled={a.charges_enabled} '
          f'card_payments={a.capabilities.get(\"card_payments\")} '
          f'transfers={a.capabilities.get(\"transfers\")} '
          f'payouts={a.payouts_enabled}')
"

Expected: producer shows card_payments=active; all three show transfers=active.

2.4 Seed the fixtures

One producer with no custom models (normal path) and one with both (custom path), so you can run both flows without mutating a producer mid-run — mutating fee models between orders is a common source of confusing results, because the order snapshots the fee at creation time.

docker compose exec api python manage.py shell
from decimal import Decimal
from django.utils import timezone
from datetime import timedelta
from producers.models import (
    Producer, ProducerFinancial, CustomPlatformFeeModel, CustomSurchargeModel,
)
from tickets.models import Show, Ticket

PRODUCER_ACCT = "acct_PRODUCER"
FEE_ACCT      = "acct_FEEDEST"
SUR_ACCT      = "acct_SURDEST"

def mkshow(producer, title, price, qty=200):
    t0 = timezone.now() + timedelta(days=30)
    s = Show.objects.create(
        title=title, description="MoR E2E", producer=producer, published=True,
        door_time=t0, start_time=t0 + timedelta(hours=1), end_time=t0 + timedelta(hours=3),
    )
    Ticket.objects.create(show=s, name="GA", price=Decimal(price), quantity=qty)
    return s

# --- Normal fee flow producer -------------------------------------------
p_norm = Producer.objects.create(name="E2E Normal Co", bio="normal", is_active=True)
ProducerFinancial.objects.create(producer=p_norm, stripe_account_id=PRODUCER_ACCT)

# --- Custom fee flow producer -------------------------------------------
fee = CustomPlatformFeeModel.objects.create(
    name="E2E 2pct", percentage=Decimal("0.0200"), destination_stripe_account_id=FEE_ACCT)
sur = CustomSurchargeModel.objects.create(
    name="E2E 8.75pct", percentage=Decimal("0.0875"), destination_stripe_account_id=SUR_ACCT)
p_cust = Producer.objects.create(
    name="E2E Custom Co", bio="custom", is_active=True,
    custom_platform_fee_model=fee, custom_surcharge_model=sur)
ProducerFinancial.objects.create(producer=p_cust, stripe_account_id=PRODUCER_ACCT)

for label, p, price in [("N", p_norm, "25.00"), ("C", p_cust, "25.00")]:
    s = mkshow(p, f"E2E {label} $25", price)
    print(label, "show", s.id, s.title)
print("normal producer", p_norm.id, "| custom producer", p_cust.id)

Both producers deliberately share acct_PRODUCER. The producer leg is identical between flows; what differs is Movement 2. Reusing one account keeps its balance in one place and makes the comparison cleaner.

2.5 Webhook forwarding

Movement 2 only ever fires from a webhook. Without forwarding, custom fee transfers never happen and every Group B/D/E test silently fails to run.

stripe listen --forward-to localhost:8080/webhooks/stripe/
# copy the printed whsec_... into apps/api/.env, then:
docker compose restart api

Leave this running for the whole session. Confirm delivery is live before starting:

stripe trigger payment_intent.succeeded
docker compose logs --tail=20 api | grep "Processing Stripe webhook event"

2.6 Platform test balance

Test-mode charges land in pending like live ones. To exercise the happy path in Group B/D you need available funds; to exercise Group E you need to not have them. Check where you are:

docker compose exec api python manage.py shell -c "
import stripe; from django.conf import settings
stripe.api_key = settings.STRIPE_SECRET_KEY
b = stripe.Balance.retrieve()
print('platform available:', [(x.amount, x.currency) for x in b.available])
print('platform pending:  ', [(x.amount, x.currency) for x in b.pending])
"

To add available funds directly (bypassing pending), charge Stripe's dedicated test card:

Card Effect
4000 0000 0000 0077 Succeeds, funds go straight to available balance
4242 4242 4242 4242 Ordinary success (lands in pending)
4000 0000 0000 0259 Succeeds, then disputes as fraudulent
4000 0000 0000 9995 Declines with insufficient_funds

Run one 4000...0077 charge for ~$200 before Group B so Movement 2 can settle immediately; leave the balance alone before Group E.


3. Verification toolkit

3.1 One-shot order verifier

The single most useful thing in this document. Paste into docker compose exec api python manage.py shell, set UUID, run. It checks both movements and both accounts, and prints ✓/✗ against expectations rather than making you eyeball raw JSON.

from decimal import Decimal
import stripe
from django.conf import settings
from producers.models import ProducerFinancial
from tickets.models import Order

stripe.api_key = settings.STRIPE_SECRET_KEY
UUID = "PASTE-ORDER-UUID-HERE"

o   = Order.objects.select_related("show__producer").get(uuid=UUID)
fin = ProducerFinancial.objects.get(producer=o.show.producer)
ok  = lambda c: "✓" if c else "✗ FAIL"

print(f"\n=== ORDER {o.uuid}  status={o.status}  currency={o.currency}")
print(f"  platform_fees           {o.platform_fees}")
print(f"  processing_fees         {o.payment_processing_fees}")
print(f"  custom_platform_fee     {o.custom_platform_fee_amount} -> {o.custom_platform_fee_destination}")
print(f"  custom_surcharge        {o.custom_surcharge_amount} -> {o.custom_surcharge_destination}")
print(f"  pending flags           fee={o.platform_fee_transfer_pending} "
      f"surcharge={o.surcharge_transfer_pending} since={o.transfer_pending_since}")

# ---- Movement 1: the charge itself -------------------------------------
pi = stripe.PaymentIntent.retrieve(o.payment_intent_id, expand=["latest_charge"])
ch = pi.latest_charge
dest = ch.transfer_data.destination if ch.transfer_data else None

expected_app_fee = int(
    sum(x for x in (o.platform_fees, o.custom_platform_fee_amount,
                    o.custom_surcharge_amount, o.payment_processing_fees,
                    getattr(o, "foreign_currency_conversion_fee", None) or 0)
        if x is not None) * 100
)

print(f"\n=== CHARGE {ch.id}")
print(f"  amount                  {ch.amount}")
print(f"  application_fee_amount  {ch.application_fee_amount}  "
      f"expected {expected_app_fee}  {ok(ch.application_fee_amount == expected_app_fee)}")
print(f"  on_behalf_of            {ch.on_behalf_of}  {ok(ch.on_behalf_of == fin.stripe_account_id)}")
print(f"  transfer_data.dest      {dest}  {ok(dest == fin.stripe_account_id)}")
print(f"  MoR pair matches        {ok(ch.on_behalf_of == dest)}")

# ---- Movement 1b: what the producer actually received -------------------
# CORRECTED 2026-08-20 after measuring it. Stripe transfers the GROSS charge to
# the connected account and pulls the application fee back as a `fee` on the
# CONNECTED account's own balance transaction. So tr.amount == ch.amount, and
# the producer's actual take is that balance transaction's `net`. Asserting
# tr.amount == ch.amount - application_fee_amount fails on every scenario.
if ch.transfer:
    tr = stripe.Transfer.retrieve(ch.transfer)
    expected_producer = ch.amount - (ch.application_fee_amount or 0)
    print(f"\n=== PRODUCER LEG {tr.id}")
    print(f"  destination             {tr.destination}  {ok(tr.destination == fin.stripe_account_id)}")
    print(f"  amount (gross)          {tr.amount}  expected {ch.amount}  {ok(tr.amount == ch.amount)}")
    print(f"  reversed                {tr.amount_reversed}")
    for rev in stripe.Transfer.list_reversals(tr.id, limit=10).data:
        print(f"  reversal                {rev.id} amount={rev.amount}")

    # C-1/C-2: the pullback, on the producer's ledger.
    py = stripe.Charge.retrieve(tr.destination_payment,
                                stripe_account=fin.stripe_account_id,
                                expand=["balance_transaction"])
    pbt = py.balance_transaction
    print(f"\n=== C-1/C-2 PULLBACK (connected account ledger)")
    print(f"  fee (app fee pulled)    {pbt.fee}  expected {ch.application_fee_amount}  "
          f"{ok(pbt.fee == (ch.application_fee_amount or 0))}")
    print(f"  net to producer         {pbt.net}  expected {expected_producer}  "
          f"{ok(pbt.net == expected_producer)}")
else:
    print("\n=== PRODUCER LEG: none (no transfer_data — MoR was SKIPPED)  ✗ FAIL")

# ---- Movement 2: the custom fee split -----------------------------------
# NB: our custom transfers use transfer_group = order.uuid. Stripe's own
# destination-charge transfer uses its own group, so it will NOT appear here.
print(f"\n=== CUSTOM FEE SPLIT (transfer_group={o.uuid})")
found = {}
for t in stripe.Transfer.list(transfer_group=str(o.uuid), limit=100).auto_paging_iter():
    found[t.metadata.get("type")] = t
    print(f"  {t.metadata.get('type'):22} {t.amount:>8} -> {t.destination}  ({t.id})")
if not found:
    print("  (none)")

for kind, amt, dst in [
    ("custom_platform_fee", o.custom_platform_fee_amount, o.custom_platform_fee_destination),
    ("custom_surcharge",    o.custom_surcharge_amount,    o.custom_surcharge_destination),
]:
    if amt and dst:
        t = found.get(kind)
        print(f"  {kind}: expect {int(amt*100)} -> {dst}  "
              f"{ok(t and t.amount == int(amt*100) and t.destination == dst)}")
    else:
        print(f"  {kind}: expect NO transfer  {ok(kind not in found)}")

# ---- Balances -----------------------------------------------------------
pb = stripe.Balance.retrieve()
ab = stripe.Balance.retrieve(stripe_account=fin.stripe_account_id)
print(f"\n=== BALANCES")
print(f"  platform  available {[(x.amount, x.currency) for x in pb.available]} "
      f"pending {[(x.amount, x.currency) for x in pb.pending]}")
print(f"  producer  available {[(x.amount, x.currency) for x in ab.available]} "
      f"pending {[(x.amount, x.currency) for x in ab.pending]}")

3.2 Fleet reconciliation

Run after each group to catch anything stuck:

docker compose exec api python manage.py shell -c "
from tickets.models import Order
from django.db.models import Q
qs = Order.objects.filter(Q(platform_fee_transfer_pending=True)|Q(surcharge_transfer_pending=True))
print('orders with pending transfers:', qs.count())
for o in qs.select_related('show__producer')[:25]:
    print(' ', o.uuid, o.status, 'fee=', o.platform_fee_transfer_pending,
          'sur=', o.surcharge_transfer_pending, 'since=', o.transfer_pending_since)
"

And the same signal the daily beat task reports:

docker compose exec api python manage.py shell -c "
from tickets.task import report_pending_transfer_backlog
print(report_pending_transfer_backlog())
"

3.3 Driving the retry sweep by hand

The sweep is scheduled daily at 05:00 UTC, so waiting for it mid-session is not practical. Call it directly:

docker compose exec api python manage.py shell -c "
from tickets.task import retry_pending_transfers
retry_pending_transfers()
"

Calling it this way runs the identical code path the schedule invokes; it just does not prove the schedule fires. That is a separate, one-line check — see §8.4.


4. Expected-value matrix

Computed from the shipped CheckoutService.calculate_fees — not by hand. Every value is exact; a one-cent deviation is a real finding, not rounding noise.

Constants: PLATFORM_FEE_PERCENTAGE = 0.05, PLATFORM_FEE_PER_TICKET = $1.50, processing = roundup((subtotal × 0.029 + 0.30) / 0.971).

4.1 Normal fee flow

ID Order Subtotal Platform fee Processing Buyer application_fee_amount Producer nets
N1 2 × $25.00 $50.00 $5.50 $1.97 $57.47 747 5000
N2 1 × $10.00 $10.00 $2.00 $0.67 $12.67 267 1000
N3 4 × $75.00 $300.00 $21.00 $9.90 $330.90 3090 30000
N4 2 × $25.00, promo → $40.00 $40.00 $5.00 $1.66 $46.66 666 4000

4.2 Custom fee flow

Custom models replace the default platform fee entirely; platform_fees is NULL on the order (calculate_fees, checkout_service.py:868).

ID Order Fee % / Sur % Custom fee Surcharge Processing Buyer App fee Producer → FEEDEST → SURDEST
C1 2 × $25.00 2% / 8.75% $1.00 $4.38 $1.97 $57.35 735 5000 100 438
C2 2 × $25.00 2% / — $1.00 $0.00 $1.84 $52.84 284 5000 100 none
C3 2 × $25.00 — / 8.75% $0.00 $4.38 $1.94 $56.32 632 5000 none 438
C4 2 × $25.00 0% / 0% $0.00 $0.00 $1.81 $51.81 181 5000 none none
C5 4 × $75.00 10% / 5% $30.00 $15.00 $10.62 $355.62 5562 30000 3000 1500
C6 4 × $75.00, promo → $240 10% / 5% $24.00 $12.00 $8.56 $284.56 4456 24000 2400 1200

C4 is the zero-amount guard. attempt_stripe_transfer returns early when the amount is falsy (transfer_utils.py:237) — a $0 transfer must not be created. A $0 Transfer object appearing in Stripe is a failure.

4.3 Free orders

ID Order Expected
Z1 $0 order, normal producer amount = 0; no PaymentIntent, no charge, no transfers
Z2 $0 order, custom producer same; platform_fees is NULL, custom fee/surcharge 0.00, no transfers

The zero-guard fires before the custom-model branch specifically so _gross_up_processing($0) = $0.31 cannot corrupt a free order (checkout_service.py:857). Confirm the buyer is charged nothing at all.


5. Test scenarios

Each scenario: do, then verify with §3.1, then record in §7.

Group A — Normal fee flow, every path

Producer: E2E Normal Co. Expected values: N1 unless stated.

ID Path Do Must be true
A1 Hosted Checkout Buy 2 × $25 via hosted checkout, card 4242…4242 application_fee_amount=644, on_behalf_of=acct_PRODUCER, transfer_data.destination=acct_PRODUCER, producer leg 5000
A2 Embedded / Payment Element Same order via the embedded flow Identical to A1 — the PI is created directly (checkout_service.py:2444)
A3 Bundle / series Buy a 2-show bundle MoR kwargs present on the bundle session; app fee = sum of legs
A4 SDK checkout Buy via the SDK endpoint from a third-party origin Identical MoR kwargs (sdk/views.py:842)
A5 At-door Terminal Sell 2 × $25 through a reader MoR kwargs present; app fee = platform + processing only (checkin/views.py:811)
A6 Iframe embed Buy through an embedded iframe Same as A2 — confirms embedding doesn't bypass the guard

A7 — invariant sweep. Across A1–A6, assert in every case:

charge.amount − charge.application_fee_amount == ticket subtotal in cents
charge.on_behalf_of == charge.transfer_data.destination == acct_PRODUCER

The second is what build_destination_charge_kwargs exists to guarantee; if the two ever differ, the settlement merchant and the payee have drifted apart.

Group B — Custom fee flow, every path

Producer: E2E Custom Co. Run after funding the platform balance (§2.6) so Movement 2 settles immediately.

ID Path Expected
B1 Hosted Checkout, 2 × $25 C1 — and two transfers appear in group <order-uuid>
B2 Embedded / Payment Element C1
B3 Bundle C1 arithmetic per leg; transfers keyed per order
B4 SDK checkout C1
B5 At-door Terminal ⚠️ See §6.3 — expect the default fee, not C1
B6 Fee model only (C2) One transfer to FEEDEST; none to SURDEST
B7 Surcharge only (C3) One transfer to SURDEST; none to FEEDEST
B8 Both models at 0% (C4) Zero transfers created
B9 Custom + promo code (C6) Fee computed on the discounted subtotal

Group C — Fee pullback from the producer account

This is the application_fee_amount mechanic: Stripe moves the entire charge to the producer, then pulls the application fee back to the platform. Verifying it means looking at the producer's ledger, not just the charge.

ID Check How
C-1 The pullback happened On the producer account, list balance transactions for the charge and confirm both the inbound payment and the application_fee deduction
C-2 Producer nets exactly face value transfer.amount == charge.amount − application_fee_amount == ticket subtotal, for every A/B scenario
C-3 Application Fee object exists stripe.ApplicationFee.list(charge=ch_X) returns exactly one, amount equal to charge.application_fee_amount
C-4 Pullback under promo N4 / C6 — the fee base is the discounted subtotal, so the pullback shrinks with it
C-5 Producer sees the gross The producer's Express dashboard shows the full charge and the fee as separate lines (this is application_fee_amount behaviour, not transfer_data[amount])
docker compose exec api python manage.py shell -c "
import stripe; from django.conf import settings
stripe.api_key = settings.STRIPE_SECRET_KEY
CH='ch_XXX'; ACCT='acct_PRODUCER'
print('--- application fees on the charge')
for f in stripe.ApplicationFee.list(charge=CH).data:
    print(' ', f.id, f.amount, 'refunded=', f.amount_refunded)
print('--- producer ledger')
for bt in stripe.BalanceTransaction.list(limit=10, stripe_account=ACCT).data:
    print(f'  {bt.type:22} {bt.amount:>8} net={bt.net:>8} {bt.description}')
"

Group D — Custom fee & surcharge split

The heart of it: did the right money reach the right third party, exactly once?

ID Check Expected
D-1 Two distinct destinations FEEDEST gets exactly custom_platform_fee_amount; SURDEST gets exactly custom_surcharge_amount; neither goes to the producer
D-2 Amounts to the cent transfer.amount == int(order.custom_*_amount * 100)
D-3 Currency is USD Transfers are created currency="usd" unconditionally (transfer_utils.py:251) — see H-3 for the MXN implication
D-4 Metadata routing Each transfer carries metadata.type ∈ {custom_platform_fee, custom_surcharge} and metadata.order_id
D-5 Grouping Both carry transfer_group == str(order.uuid); Stripe's own producer transfer does not (different group)
D-6 Exactly once Replay the webhook — no second transfer is created (idempotency keys <uuid>-custom-platform-fee / -custom-surcharge)
D-7 Same destination for both Point both models at FEEDEST; expect two separate transfers, not one merged
D-8 Destination == producer Point the fee model at acct_PRODUCER; money should still route as a separate transfer
D-9 Zero amount C4 — no Transfer object at all
D-10 Description is snapshotted See below — the highest-value regression test in this group

D-6, replaying the webhook:

stripe events resend evt_XXXX          # the payment_intent.succeeded event
# then re-run §3.1 — transfer count must be unchanged

D-10 — description snapshot (regression PIQUE-API-7V). Stripe validates every parameter against an idempotency key, including description. The descriptions are snapshotted onto the order (custom_*_transfer_description) precisely so a show rename between the first attempt and a retry can't change them. To prove the snapshot is load-bearing:

  1. Create a custom-fee order and force its transfers to defer (Group E).
  2. Rename the show.
  3. Run the retry sweep (§3.3).
  4. The transfer must succeed. An IdempotencyError means something recomputed the description from live data instead of reading the snapshot.

Group E — Deferral, retry and escalation

The path that matters most post-MoR, because §1.3 shows the platform has no buffer. Do not skip this because Group B passed — Group B passing only means your test balance happened to be funded.

ID Scenario How to produce it Expected
E-1 Natural deferral Drain/ignore the platform balance, then place a C1 order Transfers deferred; platform_fee_transfer_pending / surcharge_transfer_pending set; transfer_pending_since stamped; order still COMPLETED; buyer unaffected
E-2 Deferral is not an error Same Log shows transfer deferred (funds pending) at WARNING; no Sentry event (balance_insufficient is excluded, transfer_utils.py:263)
E-3 Retry clears it Fund the balance (4000…0077), run §3.3 Both flags clear; exactly two transfers exist; transfer_pending_since cleared
E-4 Partial clearance Fund only enough for the smaller transfer Only that flag clears; the other stays pending and is retried independently — no re-attempt of the succeeded one
E-5 Hard failure path Point a fee model at a deleted/invalid account, place an order Flag set, Sentry event is captured (not balance_insufficient), sweep keeps retrying
E-6 Escalation Backdate transfer_pending_since > 3 days, run the sweep One Sentry error via _alert_stuck_transfer; transfer_alert_sent set; not re-alerted on the next run
E-7 Manual completion is detected With a flag pending, create the transfer by hand in the dashboard, then run the sweep reconcile_first=True finds it by transfer_group + metadata.type and clears the flag without duplicating
E-8 Backlog report Run §3.2 with several pending orders Counts fee/surcharge separately; aged_past_threshold counts only the backdated ones
E-9 Sweep isolation Leave one order in the E-5 broken state alongside healthy pending ones The broken order does not abort the sweep; later orders still process

Backdating for E-6:

docker compose exec api python manage.py shell -c "
from django.utils import timezone; from datetime import timedelta
from tickets.models import Order
o = Order.objects.get(uuid='PASTE-UUID')
o.transfer_pending_since = timezone.now() - timedelta(days=4)
o.transfer_alert_sent = False
o.save(update_fields=['transfer_pending_since','transfer_alert_sent'])
print('backdated', o.uuid)
"

Group F — Refunds and transfer reversal

Fees are never refunded. A "full" refund returns order.ticket_subtotal — the platform fee, processing fee, custom fees and surcharges are non-refundable service charges, so refunded_amount never reaches order.total. Every expected value below follows from that. RefundService sends reverse_transfer=True and refund_application_fee=False (refund_service.py:229), passing no explicit reversal amount — read §6.1 before running F-1 and F-4, whose whole point is to measure what Stripe reverses.

ID Scenario Expected
F-1 Full refund, normal order (N1) Buyer refunded $50.00 (subtotal, not the $56.44 charged); app fee retained in full. Record transfer_reversal.amount — $50.00 leaves the platform whole, $44.30 leaves it out $5.70. See §6.1
F-2 Partial refund (1 of 2 tickets) Buyer refunded half the subtotal; transfer reversed proportionally; app fee retained in full
F-3 Producer balance goes negative Reversal still succeeds; producer balance may go negative — confirm debit_negative_balances behaviour matches policy
F-4 Full refund, custom order after transfers settled Buyer refunded $50.00. Fee + surcharge correctly stay with FEEDEST/SURDEST — they are earned. Record transfer_reversal.amount: 5000 → platform holds at +$0.01; 4359 → platform is out $6.40. See §6.1
F-5 Refund while transfers still pending The sweep does later fire the transfer, and that is correct — the fee was earned and is owed. A transfer that never fires is the defect. See §6.2
F-6 Refund idempotency Re-drive the same refund with the same idempotency key — one Refund object, one reversal
F-7 Free order Refunding a $0 order is a no-op, not an error

Verify a reversal directly. The reversed vs refund line is the §6.1 answer:

docker compose exec api python manage.py shell -c "
import stripe; from django.conf import settings
from tickets.models import Order
stripe.api_key = settings.STRIPE_SECRET_KEY

o = Order.objects.get(uuid='ORDER_UUID')
pi = stripe.PaymentIntent.retrieve(o.payment_intent_id, expand=['latest_charge'])
ch = pi.latest_charge
tr = stripe.Transfer.retrieve(ch.transfer)

refunded = ch.amount_refunded
print('charge', ch.amount, 'app fee', ch.application_fee_amount)
print('refunded to buyer', refunded, '(subtotal in cents:', int(o.ticket_subtotal*100), ')')
print('transfer', tr.amount, 'reversed', tr.amount_reversed)
for r in tr.reversals.data: print('  reversal', r.id, r.amount)

# Fees must never be refunded.
assert refunded <= int(o.ticket_subtotal*100), 'REFUND EXCEEDED SUBTOTAL - fees were refunded'
# Did the reversal recover the whole refund, or only a charge-proportional slice?
shortfall = refunded - tr.amount_reversed
print(('OK - reversal covers the refund' if shortfall <= 0
       else 'SHORTFALL of %d cents absorbed by the platform (see 6.1)' % shortfall))
"

Group G — Readiness guards

Break the producer, confirm every path refuses cleanly and releases inventory.

ID Scenario Expected
G-1 card_payments inactive, hosted checkout 400 PRODUCER_NOT_CONFIGURED; reservation released
G-2 Same, bundle 400, order cancelled
G-3 Same, SDK 400 with both error and error_code: PRODUCER_NOT_CONFIGURED
G-4 Same, terminal 400, transaction failed, reader lock released
G-5 Blank stripe_account_id 400, no Stripe call attempted
G-6 Refresh backoff 10 rapid attempts against a not-ready producer → one Account.retrieve per 60 s, not one per attempt
G-7 Recovery Re-activate card_payments; after the cooldown lapses, checkout succeeds without a restart
G-8 Drift reconciliation Deactivate the capability behind the cache, run reconcile_connected_account_capabilities

Group H — Foreign currency (MXN)

ID Check Expected
H-1 MXN charge carries MoR kwargs on_behalf_of + transfer_data.destination present
H-2 FX conversion fee rides the app fee Producer still nets the MXN subtotal exactly
H-3 Custom transfers convert to USD Transfers are created in USD (transfer_utils.py:251), converted via settlement_exchange_rate, falling back to exchange_rate_snapshot (_compute_transfer_amount)
H-4 Missing rate aborts safely An MXN order with neither rate must defer, not transfer a wrong amount, and must raise a Sentry event
H-5 Rate is immutable Confirm settlement_exchange_rate is written once. Never backfill it — a pending pre-existing transfer would change amount under an aged idempotency key and raise IdempotencyError
H-6 MXN refund reverse_transfer=True is present on MXN refunds too

6. Open questions to settle empirically

Found by reading the code while preparing this runbook. Stated as questions, not proven defects — settle each empirically before filing anything. §6.2 is the opposite case: behaviour that looks wrong and is correct, recorded so nobody "fixes" it.

6.1 Does the proportional reversal under-recover on a fee-bearing refund?

Fees are never refunded. Both production refund paths refund order.ticket_subtotal and nothing more:

  • portal/views.py:4470 — "Only ticket value is refundable — platform and processing fees are excluded"; the default is order.ticket_subtotal - order.refunded_amount.
  • cancellation_refund_target (services/cancellation_email.py:27) — a FULL cancelation returns order.ticket_subtotal.

So the custom fee and surcharge are earned revenue and stay with FEEDEST and SURDEST by design. Movement 2 is not something a refund should undo.

One exception, and it is staff-only. tickets/admin.py:2679 defaults a blank-amount admin refund to order.total - order.refunded_amount — the gross charge, fees included — as does the amount is None branch of create_refund (refund_service.py:153), which nothing else reaches. Tracked as pt-cj9g. Leave the amount field blank in Django admin and you will refund fees that have already been transferred out. Don't use that path for these tests.

ANSWERED — 2026-08-20, Stripe test mode. Not proportional; no shortfall.

Measured on order NB2DKPYP (C1: 2 × $25, 2% fee + 8.75% surcharge), charge ch_3U6NYfCNvFiW6S5r0L8l9uSI:

charge.amount              5735
application_fee_amount      735
refund (ticket_subtotal)   5438
transfer.amount            5735   <- GROSS, see the correction below
transfer.amount_reversed   5438
reversal trr_1U6NaECNvFiW6S5rAx6rlspH  amount=5438
SHORTFALL (refund − reversal) = 0 cents

reverse_transfer=True with no explicit amount reverses the exact refund amount, not a charge-proportional share. The platform recovers every cent it refunds and refund_application_fee=False correctly leaves the fee earned. The −$6.40 row above was the wrong hypothesis. pt-3664 is closed.

But measuring it exposed a real defect — the surcharge is in the refund base

ticket_subtotal (models.py:2444) is total − effective_platform_fee − payment_processing_fees, and effective_platform_fee (models.py:2437) sums platform_fees + custom_platform_fee_amount + foreign_currency_conversion_fee. It does not subtract custom_surcharge_amount.

So on a surcharge-bearing order ticket_subtotal is inflated by the surcharge — $54.38, not $50.00 — and a "full" refund hands the surcharge back to the buyer. Nothing reverses the surcharge Transfer; reverse_transfer=True touches only the producer's destination-charge transfer. Measured outcome:

Party Movement Net
Buyer paid 5735, refunded 5438 −297 (custom fee + processing)
Producer +5000 net of app fee, −5438 reversal −438 ⚠️
FEEDEST +100, kept (correct — fees aren't refundable) +100
SURDEST +438, paid by the sweep after the order reached REFUNDED +438

The producer paid $4.38 out of pocket on an order they earned nothing from, and the surcharge was simultaneously delivered to SURDEST and returned to the buyer. Filed as a P1. Either the surcharge is a fee (subtract it from the refund base) or it is refundable revenue (reverse its transfer on refund) — but the producer must not silently fund it.

FIXED — 2026-08-20 (pt-xqg5). Resolved as the surcharge is a fee. Order.non_refundable_fees (models.py) is now effective_platform_fee + payment_processing_fees + custom_surcharge_amount, and ticket_subtotal is total − non_refundable_fees. On the C1 order the refund base drops from $54.38 to $50.00, so is_fully_refunded, refund_percentage, net_ticket_revenue and cancellation_refund_target all stop at the ticket value and the producer lands at net 0.

The surcharge deliberately did not go into effective_platform_fee: that property is rendered to producers as "Platform Fee" in reports, payout emails and the portal serializers, and the surcharge is not platform revenue. net_revenue adds non_refundable_fees back, so net_revenue == total − refunded_amount still holds. admin.py now reads order.ticket_subtotal instead of re-deriving the formula. Re-verify with group F on a surcharge-bearing order; the staff-only gross default is still open as pt-cj9g.

On the normal path there is no such gap: ticket_subtotal is exactly $50.00 and the producer nets 0 across purchase + refund.

6.2 The sweep completing a transfer for a refunded order is correct

Recorded here because it looks like a bug and is not — a "fix" would create one.

retry_pending_transfers selects purely on the two pending flags, with no order-status filter:

Order.objects.filter(
    Q(platform_fee_transfer_pending=True) | Q(surcharge_transfer_pending=True)
)

So an order refunded while its transfers were deferred still has its custom fee pushed out on a later sweep. Since fees are non-refundable, that fee was earned at purchase and the destination is owed it; the deferral was only a platform balance problem. Adding a status guard here would withhold money owed to a third party, and would make payment of an earned fee depend on the accident of whether the platform happened to have balance before the buyer asked for a refund.

F-5 asserts the transfer completes. Treat a missing transfer as the defect.

Confirmed for the custom fee; NOT true for the surcharge. Verified on 2026-08-20: the sweep did fire the 438 surcharge transfer for NB2DKPYP while the order was already REFUNDED. That is correct reasoning applied to a wrong premise — the custom fee is excluded from ticket_subtotal and so is genuinely earned, but the surcharge is inside it and was refunded to the buyer. See the defect in §6.1. Until that is resolved, "the sweep completing a surcharge transfer for a refunded order" is a symptom, not correct behaviour.

6.3 At-door ignores custom fee models — pt-j7r

_calculate_terminal_fees (checkin/views.py:545) computes only PLATFORM_FEE_PERCENTAGE + PLATFORM_FEE_PER_TICKET, and the terminal PI sets application_fee_amount = platform_fee + processing_fee (checkin/views.py:811). It never consults the producer's custom models.

So a producer on 2% + 8.75% online is charged the standard 5% + $1.50/ticket at the door, and no custom fee or surcharge transfer is created — the money stays with the platform instead of reaching FEEDEST/SURDEST.

Test B5 confirms this. Likely intentional (at-door economics differ), but it should be a stated decision rather than an accident, because it changes who gets paid.

6.4 Online and at-door round the platform fee differently — pt-j7r

Online uses _round_up (ROUND_UP); at-door uses .quantize(Decimal("0.01")), which is banker's rounding. Verified divergence:

Order Online fee At-door fee
1 × $8.34 $1.76 $1.75
3 × $16.67 $6.01 $6.00
1 × $12.34 $1.88 $1.87
2 × $25.00 $4.50 $4.50

One cent, and only on some prices — but it means the same ticket costs a different amount online than at the door. Confirm with A5/B5 and decide whether to unify.


6.5 The retry sweep's first pass can never succeed — measured

_retry_surcharge_transfer (task.py:1841) and _retry_platform_fee_transfer (task.py:1824) reuse the same idempotency key the inline webhook attempt used (f"{order.uuid}-custom-surcharge"). Stripe caches an idempotency key's response for 24 hours, errors included, so a retry inside that window replays the original balance_insufficient instead of re-executing.

Controlled experiment on NB2DKPYP, identical parameters, $388.75 available:

Call Idempotency key Result
Sweep NB2DKPYP-custom-surcharge balance_insufficient
Raw transfer unrelated key succeeded
Same params NB2DKPYP-custom-surcharge-CONTROL succeeded

The funds were there and the request was valid; only the reused key failed. The sweep runs daily at 05:00 UTC, so a transfer that defers at time T is retried inside the cache window and is guaranteed to fail — it can only succeed on the second sweep. Recovery latency is ~2 days, not ~1, and aged_past_threshold is measured against a retry that could never have worked.

The stable key's stated purpose is duplicate prevention, but reconcile_first=True already does that job — verified: after the out-of-band transfer above, the next sweep detected it by transfer_group + metadata.type and cleared surcharge_transfer_pending without creating a duplicate. Filed as P1.

FIXED — 2026-08-20 (pt-898e). Sweep retries no longer reuse the webhook's key. _retry_idempotency_key (task.py) builds f"{order.uuid}-{type}-retry-{YYYY-MM-DD}" from the UTC sweep day, so the first sweep pass presents a key Stripe has never seen and the request is actually executed. The date rotates daily rather than per call, so two sweeps in one day (Beat misfire, operator re-run) still collapse onto one key. Duplicate safety rests on reconcile_first=True, as it already did in practice. The snapshotted description is unchanged — it is still what the producer's transfer should read, and it must stay stable for the life of a key. Re-verify with group E: the transfer should now clear on the first sweep after funds settle, giving ~1 day recovery rather than ~2.


7. Sign-off sheet

First execution: 2026-08-20, Stripe test mode, platform acct_1OaVYVCNvFiW6S5r. Producer acct_1U6MmpFkOdT0Y8ar, FEEDEST acct_1U6MzgFkvwOhSerV, SURDEST acct_1U6MzjC7bMT5t7KK.

Group IDs Date Result Notes
Setup §2.1–2.6 2026-08-20 ✅ Test-mode key confirmed; §2.2 producer-aliasing guard: none
A — Normal, all paths A1, A2, A4, A7 2026-08-20 ✅ N1 exact ($56.44 / app fee 644 / producer 5000) on hosted, embedded and SDK. A3/A5/A6 not run
B — Custom, all paths B2, B4, B6, B7, B8 2026-08-20 ✅ C1/C2/C3/C4 exact. B8 created zero transfers. B1/B3/B5/B9 not run
C — Fee pullback C-1, C-2 2026-08-20 ✅ Pullback is a fee on the connected account's payment balance transaction; net = 5000 every time. §3.1 corrected
D — Fee/surcharge split via B2/B4/B6/B7 2026-08-20 ✅ Each destination paid to the cent, exactly once, own transfer_group
E — Deferral & retry deferral, sweep, reconcile 2026-08-20 ⚠️→fixed Deferral + escalation + reconcile_first all correct. First retry can never succeed — §6.5 (P1), fixed as pt-898e; re-verify
F — Refunds F-1 2026-08-20 ⚠️→fixed Reversal is exact, shortfall 0 (pt-3664 closed). Surcharge is inside the refund base — §6.1 (P1), fixed as pt-xqg5; re-verify
G — Readiness guards G-1, G-3 2026-08-20 ⚠️→fixed Guard refuses correctly with honest flags, and the reservation is released. Stale flags survive an account change (P2), fixed as pt-h9t8; re-verify
H — MXN H-1…H-6 ☐ Not run
Open questions §6.1–6.5 2026-08-20 ◐ §6.1 answered + new P1; §6.2 amended; §6.5 new P1. §6.3/§6.4 still open

Findings filed from this run: two P1s (surcharge refunded but never reversed; retry sweep idempotency replay), one P2 (readiness flags survive a stripe_account_id change), plus pt-3664 closed as answered.

All three are now fixed (pt-xqg5, pt-898e, pt-h9t8) with unit coverage, 2026-08-20. The fixes are unit-tested, not re-measured against Stripe — groups E, F and G should be re-run in test mode before this is treated as verified end to end. Still open from this run: pt-cj9g (staff-only gross refund default, P3) and §6.3/§6.4.

Exit criteria. All of:

  • Every path sets on_behalf_of == transfer_data.destination == the producer.
  • charge.amount − application_fee_amount == ticket subtotal in every scenario.
  • Every custom fee and surcharge reached its own destination, to the cent, exactly once.
  • No $0 transfers exist.
  • No order is left with a pending flag after the retry sweep, other than the deliberately-broken E-5 order.
  • No refund returned more than order.ticket_subtotal, and no application fee was refunded.
  • transfer_reversal.amount was recorded for F-1 and F-4, and §6.1 is settled either way.
  • The remaining §6 open questions are each confirmed or refuted, and anything confirmed is filed.

8. Replicating on stage

Identical apart from:

  1. Keys. Stage's own sk_test_…. Re-run §2.1 — do not assume.
  2. STRIPE_PLATFORM_ACCOUNT_ID. Must be stage's platform account. If it is unset, is_platform_account returns False for everything and the MoR skip guard silently stops protecting you; if it is wrong, real producers may be skipped. §2.2 is mandatory here, not optional.
  3. Webhooks. Stage should have a real endpoint configured rather than stripe listen. Confirm payment_intent.succeeded and checkout.session.completed are both subscribed — Movement 2 fires from these, so a missing subscription looks exactly like "custom fees are broken".
  4. Beat. Stage runs Celery Beat: retry_pending_transfers at 05:00 UTC and report_pending_transfer_backlog at 05:30 UTC — both daily, not hourly. That cadence makes waiting for a real tick impractical mid-session, so drive the sweep by hand (§3.3) for Group E and treat the scheduled run as a separate next-morning check that the schedule itself fires. Confirm all three entries are registered:
    docker compose exec api python manage.py shell -c "
    from django.conf import settings
    for k in ('retry-pending-transfers','report-pending-transfer-backlog',
              'reconcile-connected-account-capabilities'):
        print(k, settings.CELERY_BEAT_SCHEDULE.get(k))
    "
    
  5. Fixtures. Do not reuse production producer records. Create E2E Normal Co / E2E Custom Co as in §2.4 and delete them afterwards — custom fee models attached to a real producer would change that producer's live economics.
  6. Shared balance. Stage's platform balance is shared with anyone else testing, so Group E results are timing-dependent. Note the balance before and after each E scenario, and prefer E-5 (deterministic hard failure) over E-1 (opportunistic) when you need a reproducible result.