Skip to content

Refund tax/amount split misallocates across stacked partial refunds (refunded_amount can exceed net_amount) #13770

Description

@demegire

Refund tax/amount split misallocates across stacked partial refunds (refunded_amount can exceed net_amount)

Summary

In Order.calculate_refunded_tax_from_total the tax side is capped at
refundable_tax_amount but the amount side is not capped at
refundable_amount. Across stacked partial refunds each refund's tax rounds
down and the shortfall spills into refunded_amount, so cumulative
refunded_amount creeps past net_amount. Total cash refunded is conserved
(the tax side is under by the same amount), so this is a ledger
misallocation
, not a customer over-refund — but it skews merchant payout,
under-remits tax, and can strand residual tax.

Location

server/polar/models/order.py (Order.calculate_refunded_tax_from_total):

refunded_tax_amount = abs(round((self.tax_amount * total_refund_amount) / self.total_amount))
refunded_tax_amount = min(refunded_tax_amount, self.refundable_tax_amount)   # tax IS capped
refunded_amount = total_refund_amount - refunded_tax_amount                  # amount NOT capped

Minimal reproduction

# Order: net_amount = 52, tax_amount = 10  (~19% tax)
# Stripe partial refunds (dashboard, arbitrary amounts): [32, 9, 8, 3, 6, 3]  (sum = 61 = full charge)
# Feeding each through calculate_refunded_tax_from_total + update_refunds yields:
#   refunded_amount = 53  >  net_amount = 52     (over by 1)
#   refunded_tax    = 8   <  its proportional share
# amount + tax = 61 is conserved — the overage on the amount side equals the shortfall on tax.

A sweep of ~500k realistic random orders (3–27% tax, up to 15 stacked partial
refunds each ≤ remaining charge) ends with refunded_amount > net_amount in
~115 cases, overage up to +4 minor units.

Reachability

The API refund path (RefundService.create) is safe — it bounds the total
to remaining_balance. The affected path is Stripe-dashboard refunds: they
carry no refund_id in metadata, so the refund.created webhook falls through
to create_from_stripe, which splits an arbitrary stripe_refund.amount
via from_total with no upstream bound, and Stripe allows multiple partial
refunds per charge.

Impact

  • payout_amount = net − fee − refunded_amount is skewed against the merchant by
    the overage.
  • The MoR reverts the same amount less tax to the authority (tax
    under-remitted).
  • Once refunded_amount ≥ net_amount the order flips to status = refunded
    while refundable_tax_amount is still positive, so a later
    RefundService.create raises RefundedAlready and the residual tax can never
    be refunded through the API.

Magnitude is small (1–4 minor units per affected order), but it is a
ledger-integrity/tax-remittance correctness issue.

Suggested fix

Mirror the tax cap on the amount side. Caveat: a naive
refunded_amount = min(refunded_amount, self.refundable_amount) restores the
refunded_amount ≤ net_amount bound but drops the capped minor unit, so
refunded_amount + refunded_tax_amount no longer equals the Stripe refund on the
overflowing step. The correct fix should keep the split summing to
total_refund_amount — e.g. cap the amount and route the freed unit back to tax
only up to refundable_tax_amount, or (cleaner) special-case the final refund
that closes out the order to return refundable_amount, refundable_tax_amount
(the same pattern the function already uses for the == remaining_balance case).

Note on discovery

Found by an automated invariant check ("cumulative refunded_amount ≤ net_amount") over the refund kernel, reproduced with the real function, and
confirmed reachable via the Stripe-dashboard webhook path. Machine-checked in
Lean: the current split violates the bound on the witness above; adding the
amount cap provably restores it for all refund sequences. The asymmetry was
introduced in 63c054858 (PR #11894), which added the tax cap without the mirror
amount cap.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions