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.

Allocating a partial refund across the line items of a multi-ticket order using a deterministic policy A single order holds five ticket line items, each bound to one attendee credential: two VIP tickets and three general tickets. A partial refund arrives carrying an amount and, ideally, explicit refunded line-item ids. The allocator branches: if the refund carries line-item ids, it revokes exactly those credentials; if it carries only an amount, it applies a deterministic tie-break policy — highest price first, then latest issued — to select which line items sum to the refunded amount, rather than guessing. The two selected credentials are revoked and the remaining three stay active, and an assertion confirms the revoked count and summed amount match the refund exactly. partial refund → deterministic line-item allocation order ord_9 · $340 VIP · $100li_1 → cred_a (Dana) VIP · $100li_2 → cred_b (Ravi) GEN · $60li_3 → cred_c GEN · $60li_4 → cred_d GEN · $20li_5 → cred_e refund $200 line ids? or amount? allocator explicit ids → exact else: price desc, then issued desc deterministic — no guess revoke cred_a, cred_b 2 VIP = $200 exactly Dana + Ravi keep cred_c/d/e 3 GEN still active still attending assert Σ = refund count & amount match

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 refunded and 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

  1. 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.
  2. Transition only the allocated line items to refunded, leaving the rest confirmed.
  3. 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

  1. At issuance, persist a line_item_id → credential_id → attendee row per ticket, not one row per order.
  2. Store the paid unit amount per line item so allocation can reason about price.
  3. 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

  1. Prefer explicit line-item ids from the refund payload whenever the gateway provides them; allocate to exactly those.
  2. 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.
  3. 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.

PYTHON
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.

  1. 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.
  2. Check the refund carried line ids. Inspect the refund payload: if refunded_line_item_ids is 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.
  3. Verify idempotency granularity. redis-cli EXISTS refund:alloc:{refund_id} — a guard keyed on order_id instead of refund_id is why a second partial refund was dropped or doubled.
  4. 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:

BASH
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.