Skip to content
All posts

Migrating fee models without breaking historical reconciliation

Version your fee structure, stamp charges with it at creation, and let the fee object — not today's schedule — define historical expectations.

FeeGuard14 min read

Migrating fee models without breaking historical reconciliation

Changing an application-fee structure mid-life is routine; corrupting every historical audit while doing it is optional. This article shows how to version fee structures, cut over without ambiguity, and keep refund expectations comparable across eras for anyone who runs reconciliation on a Connect platform.

Why migrations corrupt historical audits

Every expectation formula your audit runs needs the fee as charged. The expected application-fee refund on any refund is a ratio between two recorded amounts:

Expected fee refund = round((refund.amount ÷ charge.amount) × fee-as-charged)

The moment someone recomputes that expectation from the current fee schedule instead of the charged one, every historical judgment flips. The schedule is a living configuration value; the fee on an old charge is frozen history. Auditing history with a living value manufactures findings out of thin air.

A concrete misjudgment, with both computations shown. Assumptions:

  • Era v1: the platform charges sellers a 10% application fee.
  • Era v2, adopted later: the platform charges a flat $2.00 application fee per transaction.
  • A charge of $100.00 = 10000 cents was created under v1, so its application fee is $10.00 = 1000 cents.
  • Weeks later — possibly after the v2 cutover — a 40% refund of $40.00 = 4000 cents is issued.

The correct expectation, using the fee as charged:

  • Ratio: 4000 ÷ 10000 = 0.40
  • Expected fee refund: 0.40 × 1000 = 400 cents = $4.00

The corrupted expectation, using today's flat schedule:

  • Ratio: 4000 ÷ 10000 = 0.40
  • Expected fee refund: 0.40 × 200 = 80 cents = $0.80

An auditor holding the wrong formula reports a $3.20 variance on a refund where none exists. The error also runs in reverse: migrate upward from a flat $2.00 fee to 10%, audit old flat-era charges under the 10% schedule, and the auditor now "discovers" $3.20 of missing fee refund per refund that was never owed. Multiply either direction across thousands of historical refunds and the migration has quietly invalidated the entire audit trail — not because the ledger changed, but because the lens did.

The corruption is also silent in code. Expectation formulas live in queries and scripts that rarely change, while fee schedules live in configuration that changes exactly once per migration. Nothing fails at deploy time: every query still runs, every row still computes, every dashboard still fills. The wrongness surfaces only in review, as a wave of findings dated before the cutover. Version stamping exists precisely so a query can tell which lens a charge belongs to, instead of leaving the discrimination to whoever happens to be reading the report.

Version everything forward

The fix starts before the price changes. Stamp the governing schedule onto every payment at creation time, in PaymentIntent metadata:

import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string);

const paymentIntent = await stripe.paymentIntents.create({
  amount: 10000,
  currency: "usd",
  application_fee_amount: 1000,
  transfer_data: { destination: "acct_SELLER" },
  metadata: {
    fee_version: "2026-h2-schedule",
    policy_version: "clawback-policy-3",
  },
});

Two properties make this stamp valuable. First, it travels with the money: the resulting charge carries the metadata forward, so any reader months later knows which schedule produced the fee without consulting deployment history. Second, it freezes intent at creation time, which is the only moment the pairing of charge and schedule is guaranteed to be known.

You cannot create this provenance after the fact. Some objects accept metadata updates later, but a retroactive stamp records nothing true about which schedule governed the charge when money moved — it records only what someone now claims. Treat pre-stamping charges as versionless and rely on cutover timestamps plus the recorded fee amounts instead.

This ordering matters: the migration begins when the first stamped PaymentIntent is created, not when prices change. Deploy stamping, run it long enough to cover a full range of charge states (including partial refunds and disputes on stamped charges), then move the prices. The engineering work of owning flag and version policy sits with the payments team; the payments engineer role guide sketches that ownership directly.

Three cutover patterns and their tradeoffs

With versioning deployed, the remaining decision is how charges cross from v1 pricing to v2 pricing. The table below compares the three workable patterns.

PatternHow it worksComparabilityBlast radiusMain cost
Clean-date boundaryOne timestamp; charges created after it use v2High — a single filter splits erasEntire catalog at onceLaunch coordination; support must know the date cold
Dual-run windowBoth schedules computed side by side for a fixed periodHighest — overlapping evidence validates the new schemeStaged by definitionTwo expectation sets maintained simultaneously
Per-product rolloutProducts migrate one at a timeLowest — boundaries differ per product lineSmallest per stepMany boundaries; audits must join product to version

Choose by failure tolerance rather than preference. A clean-date boundary is simplest to reason about and simplest to explain to sellers, and it concentrates risk into one moment. The dual-run window buys confidence: during the overlap you compute expectations under both schemes, watch them diverge on real refunds, and confirm the divergence matches projections before the old scheme retires. Per-product rollout suits platforms whose product lines have independent release cadences; its price is that "which era is this charge in?" becomes a function of product and date rather than date alone, which your audit queries must encode explicitly.

Whichever pattern you pick, the boundary lives in your configuration and your documentation — Stripe has no concept of your fee schedule eras, and nothing on the Stripe side changes meaning at your cutover.

A dual-run window also generates its own validation arithmetic, free of charge. For any refund during the overlap, compute the expectation under both schemes and confirm the difference equals the schedule delta applied to the recorded fee. In the running example: $4.00 under v1 minus $0.80 under v2 equals $3.20, and (1000 − 200) × 0.40 = 320 cents agrees. A mismatch anywhere means one of the two formulas is reading the wrong object, and it is better to learn that during the window than after retirement.

The fee object, not the schedule, is the record

Here is the load-bearing fact for everything historical. On direct charges and destination charges, collecting a fee creates an ApplicationFee object whose fields include amount, amount_refunded, currency, account, balance_transaction, charge, and refunded (ApplicationFee object). That object records what was actually collected on that actual charge.

When you issue a refund, Stripe settles the fee refund against that recorded object, following fixed semantics: with a full charge refund the full application fee is refunded; otherwise the fee refund is proportional to the amount refunded, and only the application that created the charge can refund it (Refunds API). Nothing in that settlement consults your current pricing page or your fee-version configuration. The object wins, always.

That is why the rule can be stated plainly: audits join to the fee object, never to the fee schedule. The object is the historical record; the schedule is merely the generator of future objects.

The same asymmetry explains why expectations must exist at all. On direct charges, application fees are not automatically refunded when issuing a refund — the platform must explicitly pass refund_application_fee=true or refund the fee separately afterward, or the connected account loses that amount (direct charge refunds). Whether each refund carried the right flags is exactly what the audit checks, and it checks by comparing amount_refunded on the object against the proportional expectation computed from recorded amounts.

An audit pass in TypeScript, runnable as written; the for await form drives the SDK's auto-pagination:

import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string);
const since = Math.floor(Date.now() / 1000) - 90 * 24 * 60 * 60;

for await (const fee of stripe.applicationFees.list({ created: { gte: since } })) {
  const charge = await stripe.charges.retrieve(fee.charge as string);
  const refunds = await stripe.refunds.list({ charge: charge.id });
  const refundedCents = refunds.data.reduce((sum, r) => sum + r.amount, 0);
  const expectedFeeRefund = Math.round(
    (refundedCents / charge.amount) * fee.amount
  );
  const missing = expectedFeeRefund - fee.amount_refunded;
  if (missing > 0) {
    console.log(
      `${fee.id}: expected ${expectedFeeRefund}, refunded ${fee.amount_refunded}, missing ${missing}`
    );
  }
}

Notice what the script never reads: a schedule. Every input comes off recorded objects. For mechanics of returning the fee itself, see the application-fee refund explainer. Separate charges and transfers sit outside this machinery entirely — there are no application fee objects in that pattern, so the platform's "fee" is simply the gap between charge and transfer, and recovery means manual transfer reversals (separate charges and transfers).

Refunds that straddle the boundary

The most common migration panic is a charge created under v1 and refunded after the v2 cutover. Which era's expectation applies?

The charge's recorded fee, always. Settlement already worked this way — Stripe applied the fee object at refund time regardless of anything else — so the audit merely mirrors reality. Worked row with the canonical numbers:

ItemValueSource of truth
Charge created under v1$100.00 = 10000 centsCharge object
Recorded application fee$10.00 = 1000 centsApplicationFee amount
Refund issued after cutover$40.00 = 4000 centsRefund object
Correct expected fee refund0.40 × 1000 = 400 cents = $4.00Object-to-object ratio
Naive v2-schedule answer0.40 × 200 = 80 cents = $0.80Wrong lens

The four-dollar figure is not an opinion about fairness between eras; it is what the proportional settlement actually did to the recorded thousand-cent fee. Passing refund_application_fee=true produces it automatically, because Stripe reads its own object. Manual reconciliation must read the same object or it will report phantom variances on every straddling refund — precisely the $3.20 gap computed in the opening example.

One more constant across the boundary: the original processing fee. Stripe's processing fees from the original transaction are not returned on refunds (refunds), under any fee version, on any charge type. Migration changes nothing about that irreducible cost.

Shadow-compute the delta before you switch

Everything above protects the past. Before committing to a new schedule, you can also protect the future by replaying recent activity under the proposed scheme — client-side, with no production effect.

Method:

  1. Extract the trailing 90 days of charges, refunds, and application fees through the API using a restricted read-only key scoped to those resources (keys).
  2. For each charge, compute the counterfactual fee your proposed schedule would have set — a pure function of the charge's amount and product classification, evaluated in your own code.
  3. Aggregate three series: total fee revenue old versus new, expected fee-refund totals under both schemes, and the count of charges whose fee class changes.
  4. Compare the counterfactual fee-refund totals against amount_refunded as actually recorded, to preview how many findings the new scheme will generate on legacy charges.
  5. Attach the outputs to the change ticket and review them with finance before switching.

Multi-currency volumes add one caution to step 2. Amounts live in each currency's smallest unit, and zero-decimal currencies such as JPY express amounts in the unit itself — one yen is amount 1 (currencies). A replay that divides by one hundred across the board miscomputes every JPY charge by two orders of magnitude. Normalize per currency before applying counterfactual rates, and keep intermediate sums in integer minor units throughout.

Label every output a projection. The replay assumes volume, product mix, and refund behavior stay constant across the boundary; none of those assumptions is ever fully true. The projection sizes the delta, it does not promise it.

To make the shape concrete, continue the running example under explicit assumptions: the trailing 90 days contain 500 charges of $100.00 each, all under v1's 10% fee, and each eventually receives the same 40% refund used above. Then:

  • Fee revenue delta per charge: $10.00 − $2.00 = $8.00 lower under v2.
  • Revenue delta over the window: 500 × $8.00 = $4,000.00 lower.
  • Expected fee refund per refunded charge: $4.00 under v1 versus $0.80 under v2, a $3.20 drop.
  • Refund-allocation delta over the window: 500 × $3.20 = $1,600.00 lower.

Those four numbers, derived entirely from labeled assumptions, are the kind of output the shadow run produces from real data. At warehouse scale the same replay runs as a scheduled query over synced Stripe data; the BigQuery sync integration note covers that pattern.

Tell sellers before the numbers change

Fee models are promises made to sellers, and migrations touch two kinds of promises at once. The obvious one is the take-rate change itself: your platform terms almost certainly commit you to advance notice before the platform's fee changes, so the cutover date must leave room for the contractual notice period.

The easily forgotten one is clawback behavior. If the migration changes when you reverse transfers or return application fees — say, moving from "never reverse" defaults to reverse_transfer=true on refunds — seller payouts shift by exactly the amounts this article has been computing. A policy change that silently shrinks seller payouts destroys trust faster than a fee increase announced openly. Give clawback-policy changes the same notice discipline as price changes: state the effective date, show a worked example of a typical refund before and after, and route questions somewhere human.

Align the dates deliberately. The public effective date should equal the clean-date boundary (or the completion of the rollout), and the dual-run window should finish before the announcement lands, so support answers from validated numbers rather than projections. Keep the stamp and the notice consistent too: the fee_version value written into PaymentIntent metadata should be the same label sellers saw in their email, so support can read a version off any disputed charge verbatim. A ready-made message structure for announcing recovery and clawback changes lives in the clawback communication template, and the surrounding defaults belong in a written refund policy such as the platform refund policy design guide.

Handled this way, the migration strengthens the relationship: sellers see the platform version its own economics with the same rigor it applies to theirs.

Frequently asked questions

Does Stripe keep a record of which fee schedule applied historically?

Stripe records the outcome, not your schedule: the ApplicationFee object stores the exact amount collected on each charge at creation time. Fee schedule versions are your configuration alone, so the durable mapping from charge to schedule is yours to build — metadata stamped at creation, backed by cutover timestamps for older charges.

Can we backfill fee_version onto charges that predate stamping?

Technically some objects accept metadata edits later, but a retroactive stamp proves nothing about which schedule governed the money at creation. Treat pre-migration charges as versionless: rely on cutover timestamps joined with the recorded fee amounts, and let the fee object settle any ambiguity.

What happens to a refund initiated just before cutover that settles afterward?

Settlement follows the charge's recorded fee object whenever it settles, so the era boundary does not affect the outcome. The calendar changes only which support articles apply; the refund's own amount and the charge's recorded fee fully determine settlement. A v1-era charge refunded days after the v2 switch still yields a proportional share of the v1 fee — the worked row in the straddling section computes exactly this case.

How long should a dual-run window last?

Long enough that every relevant event class occurs under both schemes: your payout cycle, partial-refund flows, and dispute lifecycles included. Practically, the natural anchor is your close cadence — complete the overlap before the month-end close that will certify the new scheme, so finance signs off on evidence rather than projections.

Run the 90-day audit

FeeGuard exists because this arithmetic runs silently on every refund your 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, missing application-fee refund, and uncovered dispute loss with the amounts attached. You get the answer first; ongoing monitoring is optional afterward.

Run the free 90-day audit.

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