Reconciling Partial Refunds Against Multi-Ticket Orders: Allocating a Refund to the Right Badges
Symptom Statement Link to this section
A company bought five tickets on one card in one checkout, then refunded two of them a week later — and now the reconciliation has to answer a question it was never designed to answer: which two of the five badges get revoked? A refund that carries only an order id and a dollar amount cannot say. The common failures are all variations on guessing: the whole order is revoked when only two seats were returned, revoking three attendees who are still coming; or an amount-based heuristic picks the two cheapest tickets when the buyer meant the two VIP seats; or the refund fires twice and revokes four badges for a two-ticket refund. This page addresses that exact problem at the multi-ticket boundary — deterministically allocating a partial refund to the specific ticket credentials it actually paid back. It is a focused case of the refund & chargeback reconciliation stage, which owns the reverse-then-revoke contract for the broader Registration Ingestion & Payment Reconciliation pipeline. The observable tell is a mismatch between refunded ticket count and revoked badge count: finance shows two seats returned, the door shows five (or three, or zero) credentials revoked, and nobody can explain which rule produced that set.
Root Cause Analysis Link to this section
Four concrete failures cause the wrong badges — or the wrong number of badges — to be revoked from a partial refund. They are independent; name the one in play before allocating anything.
- Order-level instead of line-item reconciliation. The handler keys state on the order, not the individual ticket. A partial refund flips the whole order to
refundedand every credential under it is revoked, even the seats that were kept. The model has no concept of “two of five”. - No ticket ↔ attendee binding. Five tickets were purchased but the schema never bound each line item to a specific credential and attendee. When a partial refund names line items, there is nothing to revoke by line item, so the code falls back to order-level or to guessing.
- Amount-based guessing. With only a refunded amount and no line ids, the handler picks a subset of tickets whose prices sum to the amount — but $200 against five tickets can be two $100 VIPs or a $100 + two $50s, and a naive cheapest-first or first-N pick silently revokes the wrong attendees.
- Partial-refund idempotency gaps. Idempotency is keyed on the order or the intent, not the individual refund. A second $200 refund (the buyer returned two more seats) is mistaken for a redelivery of the first and dropped — or a redelivery of the first is mistaken for a new refund and revokes two more.
Symptom-to-Resolution Matrix Link to this section
Whole-Order Revocation on a Partial Refund Link to this section
Symptoms
- A refund of 2 of 5 tickets revokes all 5 credentials; three attendees who are still coming are turned away.
- State transitions are keyed on
order_id, never on a ticket id.
Root cause. Reconciliation operates at order granularity, so any refund collapses the whole order.
Fix
- Model each ticket as a line item with its own credential and its own reversible state, so a refund can touch a subset. The canonical multi-ticket order shape is defined by the event taxonomy schema design.
- Transition only the allocated line items to
refunded, leaving the restconfirmed. - Assert post-condition:
revoked_count == refunded_ticket_count, and fail loudly if the whole order flipped.
Missing Ticket-to-Attendee Binding Link to this section
Symptoms
- A refund names line items but the code cannot resolve which credential each maps to.
- Every ticket in the order shares one credential, or credentials are keyed only to the buyer.
Root cause. The purchase flow never bound each line item to a distinct attendee credential at issuance.
Fix
- At issuance, persist a
line_item_id → credential_id → attendeerow per ticket, not one row per order. - Store the paid unit
amountper line item so allocation can reason about price. - Backfill existing orders by splitting order-level credentials into per-line-item rows before the event opens.
Amount-Based Guessing Link to this section
Symptoms
- The refund carries an amount but no line ids; the wrong-priced tickets get revoked.
- Re-running the allocation picks a different subset each time.
Root cause. Selecting a subset by amount is ambiguous, and an unordered pick is non-deterministic.
Fix
- Prefer explicit line-item ids from the refund payload whenever the gateway provides them; allocate to exactly those.
- When only an amount is available, apply a single documented tie-break policy — highest unit price first, then most-recently issued — so the same refund always selects the same tickets.
- Reject an amount that cannot be composed exactly from remaining line items, routing it to manual reconciliation rather than approximating.
Minimal Working Implementation Link to this section
A self-contained allocator: a Pydantic v2 TicketLineItem and PartialRefund, a deterministic allocation that prefers explicit line ids and otherwise applies a documented tie-break policy, a per-refund idempotency guard, and a verification block proving the allocated credentials sum to the refunded amount exactly.
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
class TicketLineItem(BaseModel):
model_config = ConfigDict(strict=True, extra="ignore")
line_item_id: str
credential_id: str
unit_amount: Decimal = Field(..., ge=0)
issued_seq: int # monotonic issue order, for deterministic tie-break
status: str = "confirmed"
class PartialRefund(BaseModel):
model_config = ConfigDict(strict=True, extra="ignore")
refund_id: str
order_id: str
amount_refunded: Decimal = Field(..., ge=0)
# Gateways may or may not itemize; prefer these when present.
refunded_line_item_ids: list[str] = Field(default_factory=list)
class RefundAllocationError(Exception):
"""The refund amount cannot be composed exactly from open line items."""
def allocate_refund(refund: PartialRefund, items: list[TicketLineItem]) -> list[str]:
"""Return the credential_ids to revoke for this partial refund. Deterministic."""
open_items = [i for i in items if i.status == "confirmed"]
# Path 1 — explicit line ids: allocate to exactly those, no guessing.
if refund.refunded_line_item_ids:
selected = [i for i in open_items
if i.line_item_id in set(refund.refunded_line_item_ids)]
if len(selected) != len(refund.refunded_line_item_ids):
raise RefundAllocationError("named line item not open for refund")
return [i.credential_id for i in selected]
# Path 2 — amount only: deterministic policy = price desc, then issued desc.
ordered = sorted(open_items, key=lambda i: (-i.unit_amount, -i.issued_seq))
selected, running = [], Decimal("0")
for item in ordered:
if running + item.unit_amount > refund.amount_refunded:
continue
selected.append(item)
running += item.unit_amount
if running == refund.amount_refunded:
return [i.credential_id for i in selected]
raise RefundAllocationError(
f"{refund.amount_refunded} not exactly composable from open line items"
)
def reconcile_partial_refund(refund: PartialRefund, items: list[TicketLineItem],
seen: set[str]) -> list[str]:
"""Idempotent per-refund: a redelivery of the SAME refund_id is a no-op."""
if refund.refund_id in seen:
return []
seen.add(refund.refund_id)
to_revoke = allocate_refund(refund, items)
revoke_set = set(to_revoke)
for item in items:
if item.credential_id in revoke_set:
item.status = "refunded"
return to_revoke
if __name__ == "__main__":
order = [
TicketLineItem(line_item_id="li_1", credential_id="cred_a",
unit_amount=Decimal("100"), issued_seq=1),
TicketLineItem(line_item_id="li_2", credential_id="cred_b",
unit_amount=Decimal("100"), issued_seq=2),
TicketLineItem(line_item_id="li_3", credential_id="cred_c",
unit_amount=Decimal("60"), issued_seq=3),
TicketLineItem(line_item_id="li_4", credential_id="cred_d",
unit_amount=Decimal("60"), issued_seq=4),
TicketLineItem(line_item_id="li_5", credential_id="cred_e",
unit_amount=Decimal("20"), issued_seq=5),
]
seen: set[str] = set()
# Amount-only $200 refund → deterministic pick of the two $100 VIP seats.
refund = PartialRefund(refund_id="re_1", order_id="ord_9",
amount_refunded=Decimal("200"))
revoked = reconcile_partial_refund(refund, order, seen)
assert revoked == ["cred_a", "cred_b"] # highest price first
assert sum(i.unit_amount for i in order
if i.status == "refunded") == Decimal("200") # sums to the refund
assert sum(1 for i in order if i.status == "confirmed") == 3 # three still valid
# Redelivery of the same refund_id is a clean no-op — no extra revocations.
assert reconcile_partial_refund(refund, order, seen) == []
print("OK: 2 VIP badges revoked, 3 general kept, redelivery suppressed")
The verification block is the fix’s own regression test: it proves a $200 amount-only refund deterministically revokes the two $100 VIP credentials (not a $100 + $60 + $40 combination that does not exist, and not the whole order), that the revoked amounts sum to the refund exactly, and that replaying the same refund_id revokes nothing further.
Memory & Performance Constraints Link to this section
Allocation is CPU-cheap per order but correctness-sensitive; the constraints are around ambiguity and idempotency granularity, not scale.
| Component | Constraint | Mitigation |
|---|---|---|
| Allocation search | Composing an amount from N line items is combinatorial in the worst case | Orders are small (tens of tickets); the price-desc greedy pass is O(N log N) and exact-match short-circuits early |
| Per-refund idempotency key | Must be keyed on refund_id, not order_id, or successive partial refunds collide |
Key the guard on refund_id; a single order can have many independent refunds over the chargeback window |
| Line-item store | One row per ticket inflates the credential table for large group orders | Index on order_id and line_item_id; the row count is bounded by tickets sold, not by refund volume |
| Ambiguity fallback | An amount that maps to multiple valid subsets must not be silently resolved | Raise RefundAllocationError and route to manual reconciliation rather than picking arbitrarily |
Incident Triage & Rollback Link to this section
Fast path when refunded-ticket count and revoked-badge count disagree — target under 15 minutes.
- Compare the counts.
psql -c "SELECT status, COUNT(*) FROM ticket_line_items WHERE order_id='ord_9' GROUP BY status;"against the refunded ticket count in Stripe. A whole-order flip means order-level reconciliation is in play. - Check the refund carried line ids. Inspect the refund payload: if
refunded_line_item_idsis populated but revocation ignored it, the explicit-id path is not wired. If it is empty, the amount-policy path ran — confirm it picked the intended tickets. - Verify idempotency granularity.
redis-cli EXISTS refund:alloc:{refund_id}— a guard keyed onorder_idinstead ofrefund_idis why a second partial refund was dropped or doubled. - Re-allocate to contain. Run the allocator offline against the order and the specific
refund_id, diff the result against the current revoked set, and correct the delta — re-activating wrongly revoked credentials and revoking missed ones.
Rollback. If a bad allocation revoked the wrong attendees, re-activate the over-revoked credentials immediately (a still-attending guest at the door cannot wait), then git revert HEAD~1 --no-edit && docker compose up -d --build. Because allocation is a pure function of the order and the refund_id, re-running it after the revert reproduces the exact same intended set — the fix is deterministic, so replay is safe.
Post-rollback validation. Re-run allocation and confirm the invariant holds:
python -c "from reconcile import *; ...; \
assert sum(i.unit_amount for i in order if i.status=='refunded') == refund.amount_refunded"
# and confirm no order has more refunded line items than the gateway recorded:
psql -c "SELECT order_id FROM ticket_line_items GROUP BY order_id \
HAVING COUNT(*) FILTER (WHERE status='refunded') > 0;"
Frequently Asked Questions Link to this section
If the refund does not name line items, how do I pick which tickets to revoke? Apply one documented, deterministic tie-break policy and stick to it — highest unit price first, then most-recently issued. The policy matters less than its determinism: the same refund must always select the same tickets so the result is auditable and replay-safe. If the amount cannot be composed exactly from open line items, do not approximate — raise an error and route to manual reconciliation.
Why key idempotency on refund_id instead of the order or payment intent?
Because a single multi-ticket order legitimately produces many independent partial refunds over time — the buyer returns two seats now, two more next week. Keying on the order or intent makes the second refund look like a redelivery of the first, so it is dropped and two attendees keep valid badges they no longer paid for. The refund_id is the only key that distinguishes a genuine second refund from a retry of the first.
What binds a ticket to the attendee whose badge gets revoked?
A per-line-item row persisted at issuance: line_item_id → credential_id → attendee, with the paid unit_amount. Without it, a partial refund has nothing to allocate against and collapses to the order. That binding shape is standardized by the event taxonomy schema design, and getting it right at purchase time is what makes partial-refund reconciliation possible at all.
Related Link to this section
- Refund & Chargeback Reconciliation — the parent stage that defines the reverse → revoke contract this partial-refund allocator plugs into.
- Revoking Badges After a Stripe Refund or Chargeback — the sibling guide for the single-ticket case and the idempotent Stripe handler that carries the allocated credential ids.
- Event Taxonomy Schema Design — defines the multi-ticket order and line-item shape the per-ticket binding this allocator depends on is derived from.
- Payment Webhook Handling — the forward stage that provisions per-line-item credentials at purchase, without which no partial refund can be allocated.