
Affiliate Commission on Upgrades, Downgrades, Prorations
Commission on upgrades, downgrades and prorations comes down to one rule: pay on charges the store reports, and treat the plan change itself as bookkeeping until a charge shows up. An App Store upgrade bills immediately and refunds the prorated remainder. An App Store downgrade bills nothing until the next renewal date. A Google Play plan change does either, depending on the replacement mode your app passed. Stripe is the outlier that invoices a visible proration line.
What follows is every plan-change case mapped to a commission decision, a table built for pasting into an affiliate agreement, and a seven month ledger recomputed to the cent.
#Why an affiliate ledger drifts away from revenue
An affiliate ledger drifts because a subscription is not a sequence of identical charges. Between the first purchase and the last renewal, about eight lifecycle cases change the amount billed without arriving as anything shaped like a purchase: upgrades, downgrades, crossgrades, term switches, prorations, billing retries, pauses and transfers.
RevenueCat describes PRODUCT_CHANGE as "A subscriber has changed the product of their subscription", and that sentence is the event's entire contribution to the money question. The amount lives in whatever paid event travels with it. RevenueCat's event flows page says an immediate change dispatches PRODUCT_CHANGE alongside a RENEWAL on the App Store or an INITIAL_PURCHASE on Google Play, while a change at period end sends PRODUCT_CHANGE now and the RENEWAL at the next billing cycle. Checked 2026-09-12.
Rounding is the harmless gap: the ledger at the end of this post pays $42.00 where 20 percent of $209.92 is $41.98. Policy gaps are the expensive kind, and one unclear clause about monthly to annual switching moves the payout on a single subscriber by $80.00.
So our opinion, held for a dull reason: a commission ledger should be reproducible rather than instantaneously equal to net revenue. A creator who can recompute their balance from a list of charges argues with you once. A balance that silently tracks prorations and store adjustments cannot be recomputed by anyone. The event mapping underneath sits in RevenueCat webhooks for affiliate commissions; this post starts where a plan changes.
#What happens to commission when a subscriber upgrades mid-term?
An upgrade earns the affiliate a commission when, and only when, the store reports a charge. On the App Store that charge is immediate, so the commission is immediate and larger. On Google Play it depends entirely on the replacement mode your app passed at purchase time, and three of the five plan-change modes move no money at all on the day of the upgrade.
Apple's auto-renewable subscriptions page defines upgrades through subscription levels inside a group: someone buying a higher level is "immediately upgraded and receive a refund of the prorated amount of their original subscription". Verified 2026-09-12. The new period starts now, the billing date moves, and the old period is partly refunded by a credit that never arrives as an event you can act on.
The replacement mode chosen by the client decides whether a Google Play upgrade is a charge, a credit, or nothing.
| Replacement mode | What Google Play does | Money on the day | Commission decision |
|---|---|---|---|
| `WITH_TIME_PRORATION` | Change is immediate, remaining time is credited by pushing the next billing date forward. The default | None | None now. Commission the next `RENEWAL` at the new price |
| `CHARGE_PRORATED_PRICE` | Change is immediate, billing cycle unchanged, the price difference for the remaining period is charged | Partial charge | Commission the amount charged, not the new plan price |
| `CHARGE_FULL_PRICE` | Change is immediate and the user is charged full price for the new entitlement now | Full charge | Commission it in full. This is the App Store shaped case |
| `WITHOUT_PRORATION` | Change is immediate, the new price is charged at renewal, billing cycle unchanged | None | None now. The next `RENEWAL` carries the new price |
| `DEFERRED` | The change happens only when the subscription renews | None | None now. Wait for the renewal |
| `KEEP_EXISTING` | The payment schedule for the item is unchanged | None | None. Used for add-ons |
Mode names and behaviour from Google's [Play Billing subscriptions documentation](https://developer.android.com/google/play/billing/subscriptions), checked 2026-09-12. The mode is set by the client at purchase, so the same upgrade in two builds of your app can produce two different ledgers.
That table is why we hold the third of the three defensible policies. Commissioning the price difference needs a proration number the stores mostly do not send. Commissioning the new amount from the next renewal underpays whenever the store charged immediately. Commissioning whatever paid event arrives works everywhere.
Our normalizer therefore returns null for PRODUCT_CHANGE and stores the raw payload anyway. Unmapped is the policy, not a missing feature: a handler that priced the change would guess at a proration the payload does not contain, and in a money path a guess is worse than a gap, because the gap shows up in a reconciliation and the guess does not. The cost is that the creator keeps commission on a period Apple partly refunded, $1.42 worth in the ledger below, which we would rather overpay and explain.
#Downgrades and crossgrades: what changes, and what must not
A downgrade changes nothing about commission today and everything about commission from the next renewal date. Apple states the behaviour plainly: on a downgrade "the subscription continues until the next renewal date, then is renewed at the lower level and price". No money moves at the moment of the change, so there is nothing to pay and nothing to reverse.
Crossgrades split on duration. Apple's rule is that a switch to an equivalent level begins immediately when both subscriptions are pay-up-front and the same duration, and "if the durations are different, the new subscription goes into effect at the next renewal date". RevenueCat sorts the same behaviour into a period-end flow where "the customer will retain their entitlement based on the original product". Both checked 2026-09-12.
The asymmetry belongs in your agreement. An upgrade can create a commission on the day it happens. A downgrade can never destroy one.
Our attribution engine could not do that reversal even if a policy asked for it. It has exactly one negative path, the refund one in our clawback policy post, and it is reached only by an event that says the money went back to the customer. A downgrade offers nothing to claw back against, because no money came back from the store.
One thing should still react without the ledger moving: the forecast. A drop from $19.99 to $9.99 halves expected earnings for every remaining month of the window, and a creator who sees only an earned balance notices three renewals later. We show earned and expected separately for that reason.
#Monthly to annual: one big commission or twelve small ones?
A monthly to annual switch produces one commission on one annual charge, not twelve monthly ones. The interesting question is not that charge, it is what the switch does to a recurrence cap. A cap written as "12 payments" and a cap written as "12 months" agree perfectly while the subscriber stays monthly, then diverge by $80.00 on the same subscriber the moment they switch.
Take the ledger from the end of this post: seven monthly charges worth $22.00 in commission, then a $99.99 annual plan whose first charge lands on 10 July 2026 and earns $20.00.
Cap the recurrence in months, not in payments.
| What it does to the ledger | Cap of 12 payments | Cap of 12 months from the first attributed charge |
|---|---|---|
| Charges commissioned to 10 Jul 2026 | 8, worth $42.00 | 8, worth $42.00 |
| Annual renewal, 10 Jul 2027 | Payment 9 of 12, so +$20.00 | Outside the window, nothing |
| Renewals 2028–2030 | Payments 10, 11 and 12, so +$60.00 | Nothing |
| Cap reached | 10 July 2030 | 1 January 2027 |
| Lifetime commission | $122.00 | $42.00 |
| Can the creator verify it themselves? | Only by counting charges they cannot see | Yes, from the click date and the plan price |
Both columns use the same 20 percent rate and the same events. The only difference is the unit the cap is counted in.
A cap of twelve payments turns a term switch into a four year liability on one subscriber, silently, because nothing in the webhook stream announces that a subscriber just multiplied their remaining eligible payments by twelve. We would pay the $122.00 if we had written that clause, and we would have written a bad clause. The elapsed-time cap also has a property worth more than the money: the creator can compute it themselves, from the click date and the price on your paywall.
A third policy prorates the annual charge to the months inside the window, 174 of the term's 365 days, so $20.00 × 174 / 365, or $9.53. That one is a mistake, because it creates a payable row matching no store transaction.
Our own schema implements the elapsed-time cap and nothing else. A CustomerApp carries commissionRateBps, attributionWindowDays, commissionDurationDays and holdPeriodDays, defaulting to a 14 day hold. Set commissionDurationDays to 365 and the twelve month cap is enforced by the engine, measured from the click, so a term switch cannot multiply the liability behind your back. Leave it blank and it is a lifetime deal, deliberately. A cap counted in payments rather than in days is still a clause you enforce by hand when you review a payout.
#What is proration, and does it ever reach your ledger?
Proration is the partial charge or credit a billing system creates when a subscription changes mid-period, so the customer pays for what they actually used. On Stripe it is a visible invoice line you can read and commission. On the App Store and Google Play it is arithmetic the store performs internally, and your webhook usually shows a plan change with no proration attached.
Stripe's prorations documentation works the example everyone meets. A customer halfway through a month on a $10 plan who moves to a $20 plan is billed $5 more: a $5 credit for unused time and a $10 charge for remaining time. Those line items carry "proration": true and descriptions like "Unused time on Silver plan after 01 Sep 2020". Verified 2026-09-12.
Three values of proration_behavior decide when that reaches you. The default, create_prorations, creates the items but bills them only under certain conditions, so the next invoice is what pays. always_invoice invoices immediately, and the commission belongs on the invoice total with the credit already netted. none skips prorations and bills the full new price at the next invoice.
Store billing gives you far less, which is the practical answer for most readers here. A RevenueCat payload exposes price, price_in_purchased_currency and currency, all describing the transaction, with no line item breakdown and nothing naming a credit. Across the store events we have processed, none itemised the prorated portion of a plan change, which is why our normalizer reads a single amount and moves on.
On Stripe, commission the invoice net of prorations. On the App Store and Google Play, commission the paid event and accept that the store either netted it for you or did not, and will not tell you which. If your agreement pays on net revenue, the difference is a month-end reconciliation against store financial reports, and the question underneath it is worked through in commission on net or gross App Store revenue.
#Billing retries and grace periods: one charge, one commission
A failed renewal that later succeeds must produce exactly one commission, on the successful charge. Billing retries are where ledgers double count: the failure arrives as two events, the recovery as a third, and only the third involves money. RevenueCat's event reference is explicit that BILLING_ISSUE "does not indicate the subscription has expired".
RevenueCat's event flows page says BILLING_ISSUE, CANCELLATION and EXPIRATION "are dispatched in order at the same time" when there is no grace period. With grace periods configured, "the customer will retain entitlements as the app store retries", a successful retry sends RENEWAL, and a failed one sends EXPIRATION at the end. Google Play calls the grace period "the maximum duration users will retain subscription entitlements while a declined renewal payment remains unresolved", set per base plan. Both checked 2026-09-12.
Five rules cover it, and only the fifth needs a database.
- Pay nothing on
BILLING_ISSUE. Readgrace_period_expiration_at_ms, documented as present only on this event, and hold the expected renewal until then. - Pay nothing on the
CANCELLATIONcarryingcancel_reasonofBILLING_ERROR. It is the failure, not a refund. - Pay the
RENEWALthat follows a recovery, at the amount on the event. A recovered charge is a charge. - Reverse nothing on
EXPIRATION. It ends future commission and touches no past commission. - Enforce one commission per event id in the schema, not in application code.
Our Commission model carries a @@unique([eventId]) constraint commented "One commission per revenue event (prevents double-counting on re-processing)". Deduplication in application code fails the day two deliveries land in different processes, which is precisely what a retry storm during a store outage produces.
One opinion on grace periods: hold the expected renewal, do not delete it. We have not measured a recovery rate and will not quote somebody else's, so this is an argument about the dashboard rather than the odds. A figure that vanishes on the day of a failed charge and returns nine days later teaches creators that your numbers move for reasons you cannot explain.
#Pause, uncancellation and resubscribe
Pause and uncancellation pay nothing, and saying so precisely is most of the work. A pause stops future billing, an uncancellation restores billing that had not yet stopped, and neither carries a charge. Only a lapsed subscriber coming back involves money, and there attribution rather than eligibility decides who gets paid.
Pause is a Google Play feature. Google's subscription settings help says users "can pause their subscription instead of canceling" and that "pausing a subscription will only take effect once your current billing period has concluded". RevenueCat matches it with SUBSCRIPTION_PAUSED, "the subscription was scheduled to pause at the end of the current period", and warns that access should be revoked on the EXPIRATION carrying an expiration reason of SUBSCRIPTION_PAUSED, not on the pause event. Both checked 2026-09-12. Suspend the expected renewal rather than deleting it: a paused subscriber has not churned.
UNCANCELLATION is quieter still, defined as "a non-expired canceled subscription was re-enabled", in a flow where "the customer never loses entitlements". No money moved when the subscriber cancelled, so none moves when they change their mind.
Resubscribe is the one to think about. RevenueCat sends RENEWAL for both cases, defining it as "an existing subscription was renewed, or a lapsed user resubscribed", so the payload will not say whether this is month four or a return after six months away. The amount is real either way. The question is whether the original click still deserves it.
Our engine answers it with two clocks rather than one. attributionWindowDays, 30 by default, decides whether a click can win a subscriber at all. commissionDurationDays decides how long that subscriber's later events keep earning, and it is the clock a resubscribe is measured against once the creator has been paid at least once. A subscriber who converted in March and resubscribes in month eight still pays the original creator on a 12 month program. A user who only ever started a trial, was never charged, and wanders back in month eight has established nothing, so they are measured against the 30 day window and match nothing.
That split is the whole point. Widening the recurring period used to mean widening acquisition with it, because one number did both jobs, so a 12 month program required a 365 day window and a click from last November could win a brand new first purchase. Those are different risks and they now have different settings.
#Transfers and family sharing move revenue off the attributed user
A transfer moves a paying subscription to a different App User ID, so renewals keep arriving while the identity your attribution was built on stops receiving them. RevenueCat defines TRANSFER as "a transfer of transactions and entitlements was initiated between App User ID(s)", carries transferred_from and transferred_to, and sends the webhook only to the destination user. Checked 2026-09-12.
Family sharing is the gentler cousin. RevenueCat's Apple Family Sharing page describes is_family_share as true when "the user's family made the purchase and this user has access via their family", and says family-shared transactions are not included in charts data. One charge, several people with access, one commission. A ledger paying per entitled user invents revenue Apple never collected.
We do not re-join attribution on transfer, and you should not assume any tool does. Our normalizer returns null for TRANSFER and stores the raw payload. The gap is recorded rather than hidden: the integration matrix carries a test named "KNOWN GAP: a purchase under the post-login id is not linked to the pre-login attribution", with a comment that handling SUBSCRIBER_ALIAS and TRANSFER would close it.
Three things you can do today, none of them a webhook handler.
- Store every
TRANSFERpayload with both id fields, so the question is answerable later even though nothing answers it now. - Call
identify()again with the real user id after login. In our pipeline that triggers the backfill path and recovers commissions the anonymous id missed. - Reconcile transferred subscriptions monthly against store reports and adjust the balance by hand, with a note.
Would we close the gap? Yes, by letting attribution follow the entitlement, because the subscriber who keeps paying is usually the one the creator introduced. It is not shipped and not scheduled here.
#Currency: commission the amount charged, convert once
Commission belongs on the amount the customer was actually charged, in the currency they were charged in, converted exactly once at payout using a rate you write down. RevenueCat gives you both numbers: price is documented as the "USD price of the transaction" and price_in_purchased_currency as the "price in the purchase currency", with currency as an ISO 4217 code that "can be null if unknown".
Converting twice is how a ledger drifts. Convert at ingest to store a tidy USD number, convert again to pay a creator in euros, and the two rates will not match. Rates of 1.09 and 1.05 on the same pair differ by 3.8 percent, which is a figure creators notice and cannot reproduce, because neither rate appears anywhere they can see.
Our own code shows the shape and where we have not finished. The normalizer prefers the purchased-currency amount, price_in_purchased_currency ?? price, and carries the currency code beside it. The field it lands in is called amountUsdCents, honest for a dollar purchase and a lie for a euro one: the value is right and the name is wrong. What saves it is that nothing converts, because the payout CSV emits amount_usd_cents, amount_usd and the event's currency on every row.
Two rules follow, and the second is easy to lose:
- Store the charged amount and its currency together, and never overwrite either with a converted figure.
- Snapshot the rate on the commission row, not the affiliate row. Our
Commissionmodel holdsrateBpsas a "snapshot of the rate at calc time", so raising a creator from 20 to 25 percent cannot reprice last quarter.
Refunds at a moved rate are what make people give up and convert early. A EUR 9.99 charge refunded later is one refund of one charge, so the reversal quotes the amount and currency of the original row. Reversing at today's rate leaves a fully refunded subscriber with a balance that does not net to zero.
#A policy table you can paste into an affiliate agreement
Every lifecycle case below resolves to one of four decisions: commission the charge, commission nothing, suspend an expectation, or reverse. It is written so a creator can read it without knowing what a webhook is, which is the test a commission clause has to pass.
Pay on charges the store reports, reverse only on refunds, and treat every other event as a change to what you expect rather than to what you owe.
| Case | What the billing system does | Money moved | Commission decision |
|---|---|---|---|
| Upgrade, App Store | Immediate, with a prorated refund of the old subscription | Full charge at the new price | Commission the charge in full |
| Upgrade, Google Play, `CHARGE_FULL_PRICE` | Immediate, user charged full price now | Full charge | Commission the charge in full |
| Upgrade, Google Play, `CHARGE_PRORATED_PRICE` | Immediate, price difference for the remaining period charged | Partial charge | Commission the amount charged, not the plan price |
| Upgrade, Google Play, `WITH_TIME_PRORATION`, `WITHOUT_PRORATION` or `DEFERRED` | Immediate or deferred access change, no charge today | None | Nothing now. Commission the next renewal |
| Upgrade, Stripe, `always_invoice` | Proration calculated and invoiced immediately | Net invoice, credit included | Commission the invoice total |
| Downgrade, any store | Continues to the renewal date, then renews lower | None | Nothing now, and never a clawback |
| Crossgrade, same duration | New subscription begins immediately | Charge at the new price | Commission the charge |
| Crossgrade, different duration | Takes effect at the next renewal date | None | Nothing now. Commission the next renewal |
| Monthly to annual | One annual charge replaces twelve monthly ones | One large charge | Commission it once. Cap recurrence in months |
| Annual to monthly | Deferred to the annual renewal date | None for up to a year | Nothing. Expect a smaller renewal later |
| Proration credit, Stripe | Negative invoice item for unused time | Credit, not cash back | Net it inside the invoice. Never a standalone reversal |
| Failed renewal | `BILLING_ISSUE` and `CANCELLATION` with `BILLING_ERROR` | None | Nothing. Mark the expected renewal at risk |
| Grace period recovery | `RENEWAL` after a successful retry | Full charge | Commission once, deduplicated on event id |
| Grace period exhausted | `EXPIRATION` | None | Nothing. End the recurrence, keep past commissions |
| Pause scheduled | `SUBSCRIPTION_PAUSED`, effective at period end | None | Nothing. Suspend the expected renewal |
| Pause ends | Billing resumes, `RENEWAL` | Full charge | Commission normally |
| Uncancellation | A non-expired cancelled subscription is re-enabled | None | Nothing. The renewal is the money |
| Resubscribe after expiry | `RENEWAL` for a lapsed user | Full charge | Commission only if the attribution window still covers it |
| Transfer between App User IDs | `TRANSFER`, sent to the destination user | None at the moment of transfer | Nothing automatic. Log it and reconcile out of band |
| Family shared access | A family member gains access, `is_family_share` true | None | Nothing. One charge is one commission |
| Refund of an upgraded period | `CANCELLATION` with `cancel_reason` of `CUSTOMER_SUPPORT` | Money returns to the customer | Reverse the commission for that charge, at the original amount |
| Price increase accepted | Larger amount on the next renewal | Full charge, new amount | Commission the new amount. Do not reuse a cached price |
| Purchase in another currency | Amount arrives in the purchase currency | Full charge | Commission in that currency, convert once at payout |
Store behaviour verified against Apple, Google and RevenueCat documentation on 2026-09-12. The commission column is our recommendation rather than a vendor rule, and the two rows a reasonable person could argue with are the transfer row and the resubscribe row.
#The seven month ledger, recomputed
One subscriber, a 20 percent rate, a recurrence capped in months, and this plan history: $9.99 monthly, an upgrade to $19.99 in month three, a switch to a $99.99 annual plan in month six. Each commission is the charge times 2000 basis points divided by 10,000 and rounded, which is what computeCommissionCents does.
| Date | Event | Charged | Commission | Balance |
|---|---|---|---|---|
| 1 Jan | `INITIAL_PURCHASE` | $9.99 | +$2.00 | $2.00 |
| 1 Feb | `RENEWAL`, `renewal_number` 1 | $9.99 | +$2.00 | $4.00 |
| 1 Mar | `RENEWAL`, `renewal_number` 2 | $9.99 | +$2.00 | $6.00 |
| 10 Mar | `PRODUCT_CHANGE` to the $19.99 plan | Nothing | None | $6.00 |
| 10 Mar | `RENEWAL`, `renewal_number` 3, immediate upgrade | $19.99 | +$4.00 | $10.00 |
| 10 Apr | `RENEWAL`, `renewal_number` 4 | $19.99 | +$4.00 | $14.00 |
| 10 May | `RENEWAL`, `renewal_number` 5 | $19.99 | +$4.00 | $18.00 |
| 10 Jun | `RENEWAL`, `renewal_number` 6 | $19.99 | +$4.00 | $22.00 |
| 20 Jun | `PRODUCT_CHANGE` to the $99.99 annual plan | Nothing | None | $22.00 |
| 10 Jul | `RENEWAL`, `renewal_number` 7 | $99.99 | +$20.00 | $42.00 |
Ten events, eight charges, two plan changes worth nothing. Total charged is $209.92 and the creator earns $42.00, against $41.98 for 20 percent taken in one step: each commission rounds up by two tenths of a cent, and eight of them make 1.6 cents.
Read the 10 March row twice. Apple refunded the unused 22 days of a 31 day March period, about $7.09, and the $2.00 booked on 1 March stands because no event reports that refund. The creator ends $1.42 ahead of net revenue: a bounded, explainable overpayment, which we prefer to a proration estimate no store confirms.
#Where MyAppAffiliate fits, and the two things it will not do
MyAppAffiliate is an affiliate and creator attribution platform for subscription mobile apps and SaaS, tracking links and codes from click through subscription, renewal and refund via RevenueCat, Adapty, Superwall, Stripe and Paddle webhooks, on flat pricing with no percentage of revenue. On the subject of this post, it commissions purchase and renewal events, snapshots the rate on each commission row, and enforces one commission per event id in the database, as our webhook docs describe.
Two limits belong in the same breath, because they are the two a plan-change heavy app will meet first. PRODUCT_CHANGE is unmapped on purpose, so an upgrade produces a commission only through the paid event that accompanies it. Transfer re-join is not implemented, and the integration test suite pins the gap in place with a case named for it, asserting that no commission appears, so nobody can close it by accident and nobody can claim it was closed.
Neither limit is a roadmap tease. We think a platform that guesses at money it cannot see is worse than one that leaves a documented hole, and we would rather you find both of these in a blog post than in a payout dispute.
#Decide your plan-change policy before the next upgrade
The next upgrade in your app will be handled under some policy, written or not, and an unwritten one is the version that gets argued about later. Take the decision table above, delete the rows for billing systems you do not use, and paste the rest into your affiliate agreement.
Then do one thing this week. Trigger a plan change in a store sandbox, let the PRODUCT_CHANGE and its paid event land, and confirm your database holds exactly one commission for that subscriber at the charged amount. The payout mechanics on the other side of that row are in affiliate commissions and payouts for subscription apps.
Does an upgrade earn the affiliate a new commission?
Only when the store reports a charge. An App Store upgrade bills immediately, so it earns a commission on the higher amount that day. A Google Play upgrade may bill nothing at all, depending on the replacement mode the app passed, and then the next renewal is the payable event instead.
What happens to commission when a subscriber switches from monthly to annual?
One commission on one annual charge, not twelve monthly ones. The risk sits in the recurrence cap. Twelve payments and twelve months are the same promise for a monthly subscriber and very different after a switch: on the ledger in this post, $122.00 against $42.00 for one customer.
Should you claw back commission on a downgrade?
No. A downgrade changes the next renewal, not the one already billed. Apple continues the current subscription to its renewal date and only then charges the lower price, so no money returns to the customer and nothing exists to reverse. Reversing anyway is a policy error dressed as accounting.
Does a billing retry create two commissions?
It should not. A failed renewal arrives as `BILLING_ISSUE` plus a `CANCELLATION` carrying `cancel_reason` of `BILLING_ERROR`, and neither moves money. The recovery arrives as one `RENEWAL`, which is the single payable event. Enforce that with a unique constraint on event id rather than in application code.
What happens when a subscription transfers to another account?
RevenueCat sends `TRANSFER` with `transferred_from` and `transferred_to`, delivered to the destination user only. No money moves at that instant, and later renewals bill the new App User ID. We do not re-join attribution on transfer, so the working answer today is to store the event and reconcile by hand.