Skip to content
All posts

Partial refunds and proportional reversals: the exact math

Partial refunds split money three ways; transfer reversals and fee refunds follow proportionality only when flags demand it. Formulas and worked cents inside.

FeeGuard15 min read

Partial refunds and proportional reversals: the exact math

If you issue partial refunds on Stripe Connect, you need the exact arithmetic: who gives back what, when a reversal follows proportionally, and when it does not happen at all. This article works the cents-level math for platform engineers and finance ops, with formulas you can drop straight into reconciliation code.

A partial refund forces an allocation decision

A full refund has one correct unwind. Give the buyer everything back, pull the transferred funds home, return the application fee. Every position returns to zero except one: Stripe keeps its processing fee for having run the original payment (Stripe refunds). There is nothing to negotiate.

A partial refund has no single correct unwind. The buyer receives some amount, say $40.00 of a $100.00 charge, and that $40.00 has to come from somewhere. On a Connect transaction there are exactly three candidate sources:

  • the platform's balance, which holds the application fee and absorbs Stripe's processing fee
  • the seller's connected account, which received a transfer at sale time
  • some mix of the two

Nothing in stripe.refunds.create states which source you chose. You state it indirectly, through two optional booleans: refund_application_fee and reverse_transfer, both documented on the create refund endpoint. Omit them and you have made a decision anyway: the platform funds the entire refund out of its own balance while the seller keeps every cent. Set them to true and you have chosen proportionality instead. Either way, real money moves among three parties according to a policy most codebases never wrote down. The response hides the choice too: a Refund object with status=succeeded looks identical under both policies. The allocation becomes visible only later, when you join the refund to its transfer and application fee and compare positions. The general allocation problem is laid out in the partial-refund transfer math overview; this article goes deeper on the arithmetic itself.

First the rule Stripe applies when you ask for proportionality, then three worked examples with every column re-added, then the allocations that are not proportional, and finally a check you can run over your own data.

The proportional rule, in Stripe's own words

Two sentences in the API reference define the entire behavior. On reverse_transfer, from https://docs.stripe.com/api/refunds/create:

Boolean indicating whether the transfer should be reversed when refunding this charge. The transfer will be reversed proportionally to the amount being refunded (either the entire or partial amount).

On refund_application_fee, same page:

Otherwise, the application fee will be refunded in an amount proportional to the amount of the charge refunded.

So proportionality is not derived from fairness, contracts, or merchant configuration. It is a fixed ratio applied to the original movements. The convention used throughout this article, working in integer cents:

Expected reversal = round((amount_refunded ÷ charge.amount) × transfer.amount)

Missing = Expected − Σ(existing reversal amounts on that transfer)

The same shape covers the fee: expected fee refund = round((amount_refunded ÷ charge.amount) × application_fee_amount). For what the reversal objects look like once created, see the reverse_transfer explainer.

Two implementation conventions matter. First, work in integer cents end to end; dollar floats produce phantom fractions that compound. Second, round once per computation and reconcile cumulatively across multiple refunds rather than per event. Worked example B shows why a per-event check can pass while the books drift by a cent.

Three boundary notes from the same reference page. A transfer can be reversed only by the application that created the charge, so a connected account cannot pull either lever on its own. Proportionality applies per refund call: two sequential partials produce two separate reversals, which is exactly why the cumulative view in example B matters more than any single event. And on destination charges there is an ordering constraint: if you refund the application fee you must also reverse the transfer, so treat refund_application_fee=true and reverse_transfer=true as one decision on that charge type (destination charges).

Worked example A: a destination charge, refunded 40 percent

Inputs and assumptions:

  • US platform, USD. Destination charge created with transfer_data.destination and application_fee_amount of 1000.
  • Stripe processing fee assumed at standard US card pricing of 2.9% + $0.30, per Stripe's published pricing: 2.9% of $100.00 is $2.90, plus $0.30, giving $3.20.
  • Charge: 10,000¢ ($100.00). Application fee retained by the platform: 1,000¢ ($10.00). Transfer to the seller: 9,000¢ ($90.00). Processing fee kept by Stripe: 320¢ ($3.20).
  • The buyer requests a $40.00 partial refund. The platform calls refunds create with amount=4000, reverse_transfer=true, refund_application_fee=true.

The first table shows each party's net position after the sale, before any refund.

PartyMovements since the beginningPosition
Platform+$100.00 charge − $3.20 processing fee − $90.00 transfer (fee retention nets inside the charge)+$6.80
Seller+$90.00 transfer+$90.00
Buyer−$100.00 payment−$100.00
Stripe+$3.20 processing fee+$3.20
Check6.80 + 90.00 − 100.00 + 3.20$0.00

The refund arithmetic, in cents:

  • Refunded fraction = 4000 ÷ 10000 = 0.40
  • Expected reversal = round(0.40 × 9000) = 3600¢ = $36.00
  • Expected fee refund = round(0.40 × 1000) = 400¢ = $4.00

The second table shows positions after the flagged $40.00 refund.

PartyMovements this refundPosition after
Platform+$6.80 prior − $40.00 refund + $36.00 reversal − $4.00 fee refund−$1.20
Seller+$90.00 prior − $36.00 reversal + $4.00 fee refund+$58.00
Buyer−$100.00 prior + $40.00 refund−$60.00
Stripeunchanged+$3.20
Check−1.20 + 58.00 − 60.00 + 3.20$0.00

Read the movements as three transfers of value: $40.00 platform-to-buyer, $36.00 seller-to-platform, $4.00 platform-to-seller. State each party's outcome against the moment before the request: the buyer recovers exactly 40 percent of what they paid; the seller gives back 40 percent of the transfer and receives 40 percent of the fee, landing at $58.00; the platform hands back $4.00 of its own fee and still covers $4.00 of the refund beyond the reversal, so its $6.80 margin becomes $6.80 − $4.00 − $4.00 = −$1.20. The buyer is additionally out $60.00 in value kept, since they retained the goods. Stripe's $3.20 never moves: per Stripe, "Stripe's processing fees from the original transaction aren't returned" (refunds). That single sentence explains why the platform's best achievable position after any refund is negative. Somebody always absorbs the processing fee, and unless your policy moves it elsewhere, it is whichever balance Stripe debited.

Drop both flags and the seller row flattens: no reversal, no fee refund. The platform lands at $6.80 − $40.00 = −$33.20 and the seller keeps $90.00. Same refund request, $32.00 of extra platform cost, decided entirely by two omitted booleans.

Worked example B: sequential refunds and the one-cent residue

Round numbers behave. Ugly ones do not, and production is full of ugly ones. If you want to spot-check other ratios by hand, the partial-refund calculator follows the same rounding convention as this article.

The clean case first. Same canonical inputs as example A: 10,000¢ charge, 1,000¢ fee, 9,000¢ transfer. Refund $40.00 today and $60.00 next week, both flagged:

  • Refund 1: expected reversal = round((4000 ÷ 10000) × 9000) = round(3600) = 3600¢
  • Refund 2: expected reversal = round((6000 ÷ 10000) × 9000) = round(5400) = 5400¢
  • Cumulative: 3600 + 5400 = 9000¢, the entire transfer, exactly

Now the ugly case. Assume a destination charge of 9,995¢ ($99.95), an application fee of 675¢, a processing fee of 320¢ (same pricing assumption, rounded), and a transfer of 9,000¢. Check the sum: 675 + 320 + 9000 = 9995, so the ledger closes. The buyer returns items four times, with refunds of 3000¢, 3000¢, 3000¢, and 995¢.

The table below shows each refund's per-event expectation.

RefundPer-event mathExpected reversal
1round((3000 ÷ 9995) × 9000) = round(2701.35…)2701¢
2round(2701.35…)2701¢
3round(2701.35…)2701¢
4round((995 ÷ 9995) × 9000) = round(896.15…)896¢
Σ per-event2701 + 2701 + 2701 + 8968999¢

The cumulative expectation for the now fully refunded charge is round((9995 ÷ 9995) × 9000) = 9000¢. The four per-event expectations sum to 8999¢. One cent has evaporated between the event view and the position view, even though every individual refund paid out exactly what the formula promised.

That is the trap of per-event reconciliation: each row verifies, and the total is still wrong. The fix is structural. Reconcile cumulatively:

  • track Σ(refunded) and Σ(actual reversal amounts) per charge
  • compare against the single cumulative expectation, round((Σrefunded ÷ charge.amount) × transfer.amount)
  • treat gaps within one cent as rounding noise and systematic gaps as missing reversals

Store the reversal amounts the API actually returns alongside the computed expectation. The difference between those two columns, accumulated per charge, is your reconciliation report. Operationally: run the comparison after every charge.refunded event settles, keep a tolerance of one cent per charge for rounding, and alert on anything systematic — the same charge repeatedly short, or every sequential-refund charge short in the same direction, points at a per-event assumption baked into upstream code rather than bad luck.

Worked example C: direct charges and refund_application_fee

Destination charges move a transfer; direct charges do not. A direct charge is created on the connected account itself through the Stripe-Account header, and the platform's compensation arrives as an application fee. Stripe is blunt about refunds on this pattern:

Application fees aren't automatically refunded when issuing a refund. Your platform must explicitly refund the application fee or the connected account—the account on which the charge was created—loses that amount.

(Direct charges)

Inputs and assumptions:

  • Direct charge of 10,000¢ ($100.00) on the connected account, application_fee_amount of 1,000¢.
  • Stripe fees billed to the connected account; assume standard US card pricing again, so the processing fee is 320¢ ($3.20).
  • Positions after the sale: seller +$86.80 (= $100.00 − $3.20 − $10.00), platform +$10.00.
  • The buyer receives a $40.00 partial refund.

With refund_application_fee=true: fee refund = round((4000 ÷ 10000) × 1000) = 400¢ = $4.00. The table below compares the two variants of the same refund.

VariantSeller net effectSeller positionPlatform fee position
Flag set−$40.00 + $4.00 = −$36.00$86.80 − $36.00 = +$50.80$10.00 − $4.00 = $6.00
Flag omitted−$40.00$86.80 − $40.00 = +$46.80$10.00, unchanged

Without the flag the seller absorbs the full $40.00 while the platform keeps the entire fee, which is precisely the outcome the quoted documentation warns about. With it, the two parties share in proportion. Two operational notes specific to direct charges: refunds draw on the connected account's available balance, so a shortfall leaves the refund in pending status until the account is funded; and application-fee objects are created asynchronously by default, announced by the application_fee.created event, so a refund arriving moments after the charge may race a fee object that does not exist yet. If the first call already went out without the flag, the fee can still be refunded separately afterwards through the Application Fees Refund API; the mechanics are in the application-fee refund guide.

When proportional is not the policy

Proportionality is a default behavior, not a law. Three other allocations show up constantly in marketplace terms, and each maps to concrete API calls.

Seller absorbs. The buyer is made whole; the seller returns up to the full transferred amount regardless of refund size. Implement by refunding without reverse_transfer, then creating a reversal with an explicit amount: POST /v1/transfers/{id}/reversals with amount=4000 pulls exactly $40.00 from the seller's $90.00 transfer. The Transfers Reversals endpoint accepts an optional amount, per the separate charges and transfers documentation. One constraint: the reversal succeeds only if the connected account's available balance covers it.

Platform absorbs. No flags, no reversal. The platform funds the buyer entirely; the seller keeps the transfer and the platform keeps the fee. Sometimes that is deliberate customer-experience spend. Often it is simply the default happening to someone who never chose it.

Custom splits. Anything expressible in cents: proportional reversal via the flag plus a manual top-up reversal for the remainder, or manual-only reversals sized however you like. Marketplaces splitting one buyer refund across several sellers do this with explicit amounts per transfer (multi-party split refunds).

The table below maps intents to implementations.

IntentRefund flagsExtra calls
Proportional split, seller compensated for the feereverse_transfer=true, refund_application_fee=truenone
Seller absorbs the whole refundnonePOST /v1/transfers/{id}/reversals with amount
Platform absorbs the whole refundnonenone
Fee returned, transfer untouched (direct charges)refund_application_fee=truenone

Stripe enforces none of this for you. It applies defaults when parameters are omitted and records faithfully whatever happened; it never asks which allocation you meant. Translating written policy into flags at every call site is the platform's job. One more pattern belongs in the map because it has no fee objects at all: separate charges and transfers. The platform collects its margin by transferring less upfront, refunding the charge has no impact on any associated transfers, and reconciliation against the seller means reducing later transfers or reversing manually (separate charges and transfers). Same allocation question, different objects, fully manual answer. When the money has already gone out and the reversal has not, the recovery sequence is in the partial-refund reversal playbook.

Building the check

The expectation formula compresses to a few lines of TypeScript:

// All amounts in integer cents.
function expectedReversal(
  refundAmountCents: number,
  chargeAmountCents: number,
  transferAmountCents: number
): number {
  return Math.round((refundAmountCents / chargeAmountCents) * transferAmountCents);
}

function missingReversalCents(
  refundedCents: number,      // cumulative refunded for this charge
  chargeAmountCents: number,
  transferAmountCents: number,
  reversedCents: number[]     // amounts of existing TransferReversals
): number {
  const expected = expectedReversal(refundedCents, chargeAmountCents, transferAmountCents);
  const actual = reversedCents.reduce((sum, n) => sum + n, 0);
  return expected - actual;
}

Sanity checks against the examples: expectedReversal(4000, 10000, 9000) returns 3600, and expectedReversal(3000, 9995, 9000) returns 2701. Wire missingReversalCents into whatever consumes charge.refunded, feed it the reversal amounts from the associated transfer, and every refund gets graded the moment it settles rather than at month end.

In Sigma, the same logic joins each refund to the transfer its destination charge created and compares against the transfer's reversed total:

SELECT *
FROM (
  SELECT
    c.id                                                           AS charge_id,
    c.amount                                                       AS charge_cents,
    SUM(r.amount)                                                  AS refunded_cents,
    t.id                                                           AS transfer_id,
    t.amount                                                       AS transfer_cents,
    t.amount_reversed                                              AS reversed_cents,
    CAST(ROUND(SUM(r.amount) * 1.0 * t.amount / c.amount) AS INT)  AS expected_cents,
    CAST(ROUND(SUM(r.amount) * 1.0 * t.amount / c.amount) AS INT)
      - t.amount_reversed                                          AS missing_cents
  FROM refunds r
  JOIN charges   c ON c.id = r.charge
  JOIN transfers t ON t.source_transaction = c.id
  GROUP BY 1, 2, 4, 5, 6
)
WHERE missing_cents > 0

Verify table and column names against Stripe's Sigma schema before running anything (schema documentation); the query above sticks to the documented charges, refunds, and transfers tables. Sigma tells you what happened. The join plus the expected-value column is what converts that into what is missing. FeeGuard runs this class of join continuously as part of its monitoring of Connect platforms; the manual versions above are complete on their own if you would rather own the pipeline.

Frequently asked questions

Does the buyer ever receive part of the application fee?

No. An application-fee refund moves money from the platform toward the connected account; on a destination charge Stripe describes it as pushing the application fee funds back to the connected account (destination charges). The buyer's refund consists solely of the amount refunded from the charge itself. Fee refunds compensate sellers; they never reach the cardholder.

Do Stripe's processing fees come back pro-rata on a partial refund?

No. "Stripe's processing fees from the original transaction aren't returned" (refunds). In example A, the $3.20 stays with Stripe even though 40 percent of the charge went back to the buyer. Whichever balance Stripe debited for the refund carries that cost unless your policy explicitly shifts it.

What happens if I try to refund more than remains on the charge?

The API rejects it. Cumulative refunds cannot exceed the charge amount, and once a charge is entirely refunded it cannot be refunded again; the create call raises an error in both cases (create refund). Sequence partials against a running total of refunded cents per charge.

Can Stripe's actual reversal differ from my computed expectation?

Occasionally by one cent, when the ratio lands near a half-cent boundary and rounds the other way. Example B shows the subtler failure mode: every per-event amount correct, cumulative total short by a cent. Store the reversal amounts the API returns, compare cumulatively, and investigate anything off by more than a cent; a recurring one-cent pattern usually means a per-event check somewhere upstream is masking drift.

Can the seller be made to absorb more than the proportional share?

Not through the refund flags alone. reverse_transfer is boolean and strictly proportional. Create a reversal on the transfer directly with an explicit amount via POST /v1/transfers/{id}/reversals, either instead of or in addition to the flagged refund, keeping in mind that the connected account's available balance must cover the reversal.

Run the arithmetic on your own last 90 days

You now have the formulas; the open question is what they say about your account. FeeGuard exists because this arithmetic runs silently on every refund a Connect platform issues. The free audit reads your last 90 days of Connect activity through a restricted, read-only API key and reports every unreversed transfer, unreclaimed application fee, and uncovered dispute loss with the amounts attached, together with the underlying Stripe evidence. You get the answer first; ongoing monitoring afterward is optional.

Run the free 90-day audit.

FeeGuard is an independent product and is not affiliated with, endorsed by, or sponsored by Stripe, Inc.