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.
Refund tax/amount split misallocates across stacked partial refunds (
refunded_amountcan exceednet_amount)Summary
In
Order.calculate_refunded_tax_from_totalthe tax side is capped atrefundable_tax_amountbut the amount side is not capped atrefundable_amount. Across stacked partial refunds each refund's tax roundsdown and the shortfall spills into
refunded_amount, so cumulativerefunded_amountcreeps pastnet_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):Minimal reproduction
A sweep of ~500k realistic random orders (3–27% tax, up to 15 stacked partial
refunds each ≤ remaining charge) ends with
refunded_amount > net_amountin~115 cases, overage up to +4 minor units.
Reachability
The API refund path (
RefundService.create) is safe — it bounds the totalto
remaining_balance. The affected path is Stripe-dashboard refunds: theycarry no
refund_idin metadata, so therefund.createdwebhook falls throughto
create_from_stripe, which splits an arbitrarystripe_refund.amountvia
from_totalwith no upstream bound, and Stripe allows multiple partialrefunds per charge.
Impact
payout_amount = net − fee − refunded_amountis skewed against the merchant bythe overage.
under-remitted).
refunded_amount ≥ net_amountthe order flips tostatus = refundedwhile
refundable_tax_amountis still positive, so a laterRefundService.createraisesRefundedAlreadyand the residual tax can neverbe 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 therefunded_amount ≤ net_amountbound but drops the capped minor unit, sorefunded_amount + refunded_tax_amountno longer equals the Stripe refund on theoverflowing 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 taxonly up to
refundable_tax_amount, or (cleaner) special-case the final refundthat closes out the order to
return refundable_amount, refundable_tax_amount(the same pattern the function already uses for the
== remaining_balancecase).Note on discovery
Found by an automated invariant check ("cumulative
refunded_amount ≤ net_amount") over the refund kernel, reproduced with the real function, andconfirmed 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 mirroramount cap.