#13770·polar

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

Author: demegireCreated Aug 15, 2026Updated Aug 15, 2026

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):

python
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

python
# 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.