<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mihir kanzariya</title>
    <description>The latest articles on DEV Community by Mihir kanzariya (@mihirkanzariya).</description>
    <link>https://dev.to/mihirkanzariya</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F271724%2F04df8c1f-7f8f-49c7-8800-4fd47418c12d.png</url>
      <title>DEV Community: Mihir kanzariya</title>
      <link>https://dev.to/mihirkanzariya</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mihirkanzariya"/>
    <language>en</language>
    <item>
      <title>Give a conversion a status, not a boolean</title>
      <dc:creator>Mihir kanzariya</dc:creator>
      <pubDate>Mon, 10 Aug 2026 06:03:37 +0000</pubDate>
      <link>https://dev.to/mihirkanzariya/give-a-conversion-a-status-not-a-boolean-584i</link>
      <guid>https://dev.to/mihirkanzariya/give-a-conversion-a-status-not-a-boolean-584i</guid>
      <description>&lt;p&gt;The ad platform reports more conversions than the payment processor settled. Nobody wrote a bug. The two systems are counting different things, and they will keep disagreeing for as long as both exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  The disagreement is by construction
&lt;/h2&gt;

&lt;p&gt;An ad platform counts a conversion when someone it showed an ad to does the thing you told it to watch for, inside a window the platform defines, attributed by rules the platform owns. It is measuring the effect of its own inventory, using its own view of who saw what.&lt;/p&gt;

&lt;p&gt;A payment processor counts money that cleared. It has no opinion about which click caused the charge, and it does not care that an ad was involved.&lt;/p&gt;

&lt;p&gt;Pull from the ad platform's API and you inherit its window and its attribution model along with the number. That is not a caveat you can strip out. The count is defined by those rules, so importing the count imports the rules. The processor's number carries a different definition: settled funds, minus nothing you have not accounted for yet.&lt;/p&gt;

&lt;p&gt;Both are honest answers. They answer different questions, and the moment you write one of them into a column labelled "revenue" you have picked a side without saying so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick the authoritative event, then say which one it is
&lt;/h2&gt;

&lt;p&gt;The engineering decision is small and specific: which event transitions a conversion into revenue?&lt;/p&gt;

&lt;p&gt;If the answer is "a webhook from the processor," then the ad platform's API is a signal, not a source. If the answer is "the ad platform's postback," you have built a system that reports money nobody has received.&lt;/p&gt;

&lt;p&gt;Most teams never make this decision explicitly. They write a conversion row when the checkout page fires, then join in ad spend later, and the mismatch surfaces months on when someone reconciles the dashboard against a bank statement and cannot explain the delta.&lt;/p&gt;

&lt;p&gt;Make the choice in the schema, not in a comment. Every revenue figure your system emits should be traceable to one processor event with an ID you can go look up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reversals are the case people skip
&lt;/h2&gt;

&lt;p&gt;Payments settle and then stop being settled. Refunds, chargebacks, a subscription renewal that fails on the second cycle. A dashboard that stamps a conversion as revenue at checkout is recording a fact that can still change, and it does not usually have anywhere to put the change.&lt;/p&gt;

&lt;p&gt;The timing is the ugly part. Reversals arrive late. Disputes in particular can land well after the charge, so the most recent period is always the one with the fewest reversals recorded against it. Your last seven days look better than your last seven days will eventually have looked. That number is at its most flattering exactly when someone is reading it to decide whether to raise the budget.&lt;/p&gt;

&lt;p&gt;You cannot fix this by waiting, because someone always wants today's number. You fix it by making the uncertainty part of the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Subscriptions break conversion value outright
&lt;/h2&gt;

&lt;p&gt;For a one-off purchase, the value of a conversion is knowable shortly after it happens. For a subscription, it is not knowable at conversion time at all. The first payment is a first payment. It is not the customer's value, and it is not a share of the customer's value that you can compute yet.&lt;/p&gt;

&lt;p&gt;Any ROAS you calculate on that first charge is a forecast wearing the clothes of a measurement. That is fine if you label it as a forecast. It is not fine when it sits in the same table, in the same font, as spend, which is an amount that really did leave a bank account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give a conversion a status, not a boolean
&lt;/h2&gt;

&lt;p&gt;Here is the shape most systems start with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"click_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"c_9f21"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"campaign_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cmp_4"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"converted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"revenue_cents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4900&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;converted: true&lt;/code&gt; has no room for "settled, then refunded" or "the processor has not confirmed this yet." Anything not final gets rounded to a win, because &lt;code&gt;true&lt;/code&gt; is the only value that means anything happened.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"click_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"c_9f21"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"campaign_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cmp_4"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"processor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pending"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"processor_event_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"amount_cents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"observed_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-08-03T11:02:14Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"settled_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reversed_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reversal_reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;status&lt;/code&gt; moves through a small state machine driven by processor webhooks, each transition carrying the event ID that caused it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;observed  -&amp;gt;  settled    (charge succeeded)
settled   -&amp;gt;  reversed   (refund, dispute, failed renewal)
observed  -&amp;gt;  expired    (window closed, no matching payment)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a query can ask a real question. Settled and past the dispute window is money. Settled and recent is money that is probably money. Observed is a claim. Expired is the ad platform's count minus the processor's, which is the number people argue about, sitting in a column where you can look at it instead of guessing at it.&lt;/p&gt;

&lt;p&gt;Keep the ad platform's figure too, as its own labelled series. Do not overwrite it and do not average it against the processor's. Two series that disagree tell you something. One blended series tells you nothing, and you cannot recover the inputs later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this lands
&lt;/h2&gt;

&lt;p&gt;We run into the same split on the affiliate side. Our attribution window is a 60-day cookie, and we are built on Stripe, so Stripe is where the money actually lands. When our own tool and Stripe disagree, people believe Stripe every time. The design conclusion we took from that is to stop treating our own count as a competing truth and treat it as the claim that Stripe either confirms or does not.&lt;/p&gt;

&lt;p&gt;The reason to model status rather than a boolean is not accounting hygiene. It is that "not final yet" is a real state your system observes constantly, and if the schema has no place to put it, the code will put it somewhere wrong.&lt;/p&gt;

&lt;p&gt;Disclosure: we build affiliate software for SaaS on top of Stripe, which is how we ended up thinking about this.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>saas</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your pricing page is making a forecast and calling it a fact</title>
      <dc:creator>Mihir kanzariya</dc:creator>
      <pubDate>Fri, 07 Aug 2026 04:07:03 +0000</pubDate>
      <link>https://dev.to/mihirkanzariya/your-pricing-page-is-making-a-forecast-and-calling-it-a-fact-3ae3</link>
      <guid>https://dev.to/mihirkanzariya/your-pricing-page-is-making-a-forecast-and-calling-it-a-fact-3ae3</guid>
      <description>&lt;p&gt;This week someone metering LLM calls asked for help with a sentence. He has to state a usage allowance on a pricing page, and neither way of writing it works. "3 million credits" is honest and means nothing to a buyer, because no human can picture three million credits. "About 1,500 generations" is legible, and it becomes a lie the moment usage patterns change. He wanted wording that survives contact with real customers.&lt;/p&gt;

&lt;p&gt;Better wording is not the fix. His two sentences do not differ only in how clear they are.&lt;/p&gt;

&lt;p&gt;It is a grammar problem.&lt;/p&gt;

&lt;p&gt;One thing first, so you can weigh the rest properly. I do not sell a metered product. I run Referralful, affiliate software for SaaS, and our pricing is flat (free until your first affiliate, then $19.99 a month). I have never had to defend a credit estimate to a customer who blew through it in nine days. What follows is reasoning about how the sentence works, not a war story. Every number below is invented for illustration.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pricing page gets written once and read for years
&lt;/h2&gt;

&lt;p&gt;You write it in an afternoon. Then it sits there through two model migrations, a feature launch that quadruples token use per action, and a slow drift in who your customers even are.&lt;/p&gt;

&lt;p&gt;Nothing on that page knows any of this happened. There is no test that fails, no alert, no customer complaining in month one. The sentence just quietly goes from true to false while the deploy log stays empty.&lt;/p&gt;

&lt;p&gt;Most pricing-page dishonesty is not intent. It is tense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refusing to translate is not the safe option
&lt;/h2&gt;

&lt;p&gt;The obvious defensive move is to retreat to "3 million credits" and let the user work it out. That feels safe because it cannot be wrong.&lt;/p&gt;

&lt;p&gt;It is not safe. It moves the estimating work onto someone with strictly less information than you, at the exact moment they are deciding whether to sign up. They cannot do the math, so they guess, and a guess about whether a plan fits usually resolves as "not yet." You did not avoid the risk. You paid for it at the top of the funnel instead of in a support ticket, and you also lost the chance to learn what a realistic month looks like for that customer.&lt;/p&gt;

&lt;p&gt;Both sentences are bad. They are bad in different directions, which is why splitting the difference produces something that is vague &lt;em&gt;and&lt;/em&gt; wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Change what kind of sentence the number sits in
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Forecast:     About 1,500 generations a month.
Observation:  Customers on this plan ran a median of 1,500
              generations in the 30 days to 1 August.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The forecast makes a promise about the future on the user's behalf. It depends on prompt length, model pricing and feature mix, all of which move without anyone touching the page. It decays.&lt;/p&gt;

&lt;p&gt;The observation describes something that already happened. It cannot decay, because the past does not change. It also carries more information than the estimate did: a median from real customers tells you what people like you actually do, which is what the reader wanted to know in the first place.&lt;/p&gt;

&lt;p&gt;Same shape everywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Forecast:     Enough storage for roughly 50,000 photos.
Observation:  Half of Pro accounts stayed under 12 GB last month.
              Our median upload is 3.4 MB.

Forecast:     Plenty of API calls for a small team.
Observation:  The median team of five made 40,000 calls in July.
              The busiest ten percent made over 300,000.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line matters more than the median. Publishing the top decile tells the heavy user to look at the next plan up, before they find out the hard way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The date is what makes it honest
&lt;/h2&gt;

&lt;p&gt;An observation with no period attached is just a forecast wearing better clothes. "Customers ran a median of 1,500 generations" ages into a claim about now, and then you are back where you started.&lt;/p&gt;

&lt;p&gt;So put the window on the page. "In the 30 days to 1 August." Then set a recurring job that recomputes it and a calendar reminder for whoever owns the page. If you cannot commit to the recompute, do not publish the observation, because a stale median is worse than a rough estimate: it looks like evidence.&lt;/p&gt;

&lt;p&gt;A number with a date on it is a claim you can defend in a support thread. You show the query.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do when there is nothing to observe
&lt;/h2&gt;

&lt;p&gt;You launched last week. You have four customers, two of whom are friends. There is no median, and inventing one is the single worst thing you could do here.&lt;/p&gt;

&lt;p&gt;Say so, and publish the mechanism instead. Tell people exactly what consumes a unit, in the units they think in: one credit per generation up to 1,000 output tokens, a long draft usually costs four to six, image generation costs twelve. Now the reader estimates for themselves with your actual arithmetic rather than your optimism, and nothing on the page can rot except the price list.&lt;/p&gt;

&lt;p&gt;Then replace it with a real median the month you have one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unlimited is the same bug, louder
&lt;/h2&gt;

&lt;p&gt;"Unlimited" is a forecast about your own tolerance. It is true right up until someone builds a business on top of your generosity, at which point you write a fair-use clause and quietly redefine the word.&lt;/p&gt;

&lt;p&gt;That walk-back happens by email, to the one customer causing pain, while the pricing page keeps making the original promise to everyone else.&lt;/p&gt;

&lt;p&gt;A number with a date on it is a claim. The same number with the date stripped off is a promise, and you do not get to decide when you have broken it.&lt;/p&gt;

</description>
      <category>saas</category>
      <category>pricing</category>
      <category>startup</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Your deferred revenue schedule breaks the first time a customer upgrades mid-term</title>
      <dc:creator>Mihir kanzariya</dc:creator>
      <pubDate>Thu, 06 Aug 2026 13:24:59 +0000</pubDate>
      <link>https://dev.to/mihirkanzariya/your-deferred-revenue-schedule-breaks-the-first-time-a-customer-upgrades-mid-term-3ei5</link>
      <guid>https://dev.to/mihirkanzariya/your-deferred-revenue-schedule-breaks-the-first-time-a-customer-upgrades-mid-term-3ei5</guid>
      <description>&lt;p&gt;Yesterday I argued a point over four turns with the author of a revenue-recognition tool, under his post about recognizing Stripe annual subscriptions with the actual journal entries. He conceded the gap was real and shipped a fix into his product about two hours later. The argument is worth writing down, because the shape of the bug is common and the usual trigger people wire it to is the wrong one.&lt;/p&gt;

&lt;p&gt;First, my position. We do not run a deferred-revenue ledger. We build affiliate software for SaaS on Stripe, so everything below comes from the subscription and webhook side, from watching what Stripe actually emits and when. I said that to the author mid-thread, and I think it is why the advice landed. Where the reversal lands in your books is your accountant's call, not mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model that works right up until it doesn't
&lt;/h2&gt;

&lt;p&gt;A customer pays $1,200 for an annual plan. You create a deferred revenue schedule keyed to that invoice and release $100 a month for twelve months. Clean. Auditable. Every textbook draws it this way.&lt;/p&gt;

&lt;p&gt;That model holds exactly as long as nothing changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Month five
&lt;/h2&gt;

&lt;p&gt;The customer upgrades. Or downgrades, or moves from 8 seats to 14. Stripe prorates, and on that date two things move at once: what the customer is entitled to for the rest of the term, and what you still owe them in service.&lt;/p&gt;

&lt;p&gt;Your schedule knows about neither. It keeps releasing $100 a month against a contract that no longer exists in that form. You are recognizing revenue on terms nobody agreed to anymore, and the error compounds every month until the term runs out.&lt;/p&gt;

&lt;p&gt;Mid-term cancellations and refunds have the same shape. The remaining deferred balance has to come off, not continue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Close out and reopen
&lt;/h2&gt;

&lt;p&gt;The fix is to close the original schedule at the change date and open a new one for the amended terms. Two other options look reasonable and both are worse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Leaving the old schedule running and adding a second one for the delta.&lt;/strong&gt; This is the one that bites, and it is the most common instinct because the proration genuinely is a delta. The original schedule keeps releasing revenue against a term that ended in month five. Adding a correct schedule next to an incorrect one gets you a wrong total.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mutating the original schedule in place.&lt;/strong&gt; You end up with an accurate present and no record of what was contracted first. When someone asks in month nine what the customer originally bought, the answer is gone.&lt;/p&gt;

&lt;p&gt;Close-out plus a new schedule matches the shape Stripe already hands you. Proration arrives as new invoice line items, not as an edit to the original invoice. The original invoice is immutable once finalised; Stripe writes credit and debit lines with their own &lt;code&gt;period.start&lt;/code&gt; and &lt;code&gt;period.end&lt;/code&gt; covering the remaining term. Modelling your ledger the same way keeps it aligned with the source system instead of drifting from it every time someone changes plan.&lt;/p&gt;

&lt;p&gt;The practical payoff is provability. Each schedule's deferred balance resolves to zero on its own, over its own window:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;schedule_A: original terms,   Jan 1 -&amp;gt; May 14  (closed out, balance 0)
schedule_B: amended terms,    May 14 -&amp;gt; Dec 31 (releasing)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A schedule amended twice in place never resolves cleanly. The author had described chasing a stranded four cents that would not clear months later, which is the exact symptom this prevents. Rounding residue on a partially-consumed period has nowhere to go when the schedule that created it has been overwritten. Give it its own close-out and the four cents close out with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cancellation is the same shape, and the trigger is where people get it wrong
&lt;/h2&gt;

&lt;p&gt;Close out at the cancellation date so the unearned remainder comes off. Straightforward.&lt;/p&gt;

&lt;p&gt;Then comes the part nobody writes about. Do not trigger the close-out off a credit note existing.&lt;/p&gt;

&lt;p&gt;Walk the actual paths. Cancelling at period end, which is Stripe's default and the normal SaaS flow, produces no proration and no credit note at all. The subscription sets &lt;code&gt;cancel_at_period_end&lt;/code&gt;, the customer runs out the term they already paid for, and at the end Stripe flips the status. Nothing gets credited because nothing needs crediting. Cancelling immediately with proration generally gives you a credit balance on the customer or a proration line on the next invoice, depending on &lt;code&gt;proration_behavior&lt;/code&gt; and whether an invoice gets raised. A refund is its own object again, against the charge or payment intent.&lt;/p&gt;

&lt;p&gt;Credit notes mainly turn up when you credit an invoice that is already finalised.&lt;/p&gt;

&lt;p&gt;So a close-out that fires on &lt;code&gt;credit_note.created&lt;/code&gt; misses the most common cancellation path entirely. Not an edge case. The default one. Every customer who cancels normally keeps releasing deferred revenue on your books until someone notices.&lt;/p&gt;

&lt;p&gt;Key it off the subscription state instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// customer.subscription.updated / customer.subscription.deleted&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;endsAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cancel_at&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;canceled_at&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;currentPeriodEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// period fields moved onto&lt;/span&gt;
                              &lt;span class="c1"&gt;// subscription items in recent&lt;/span&gt;
                              &lt;span class="c1"&gt;// API versions, check yours&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cancel_at_period_end&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;canceled&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;closeOutSchedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;endsAt&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// credit notes: reconcile against, do not trigger on&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Treat a credit note as a second signal, useful for reconciling the amount, never as the thing that starts the process.&lt;/p&gt;

&lt;p&gt;One nuance worth keeping. On a period-end cancellation you do not reverse the months you already earned. The customer consumed that service and you keep it. Close out only the future portion, which on a clean period-end cancellation is often zero anyway, since the term runs to its natural end. The value there is that the schedule stops rather than rolling into a renewal that never happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general shape, not a guarantee
&lt;/h2&gt;

&lt;p&gt;A lot of this is configurable. &lt;code&gt;proration_behavior&lt;/code&gt; alone changes what lands on the invoice, whether an invoice gets raised at all, and whether the money moves through the customer balance. Field locations move between API versions, subscription period fields being the recent example. Test against your own account, on your own version, with a real upgrade and a real cancellation, and read what actually arrives on the webhook rather than what the docs imply should.&lt;/p&gt;

&lt;p&gt;The durable part is the principle. Anything that changes the term changes the schedule, so close the old one and open a new one, and derive your triggers from the subscription object rather than from an artifact that only shows up on some of the paths.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>saas</category>
      <category>billing</category>
      <category>accounting</category>
    </item>
    <item>
      <title>Detecting affiliate self-referrals with Stripe's payment method fingerprint</title>
      <dc:creator>Mihir kanzariya</dc:creator>
      <pubDate>Wed, 05 Aug 2026 04:35:33 +0000</pubDate>
      <link>https://dev.to/mihirkanzariya/detecting-affiliate-self-referrals-with-stripes-payment-method-fingerprint-1clj</link>
      <guid>https://dev.to/mihirkanzariya/detecting-affiliate-self-referrals-with-stripes-payment-method-fingerprint-1clj</guid>
      <description>&lt;h2&gt;
  
  
  The self-referral problem
&lt;/h2&gt;

&lt;p&gt;Someone signs up for your affiliate program, grabs their referral link, opens a private window, creates a second account with a different email, and pays for a subscription through their own link. Now they collect a commission on the money they just paid you. If your commission is recurring, they have effectively given themselves a permanent discount and you are paying the processing fees for the privilege.&lt;/p&gt;

&lt;p&gt;The variant that is harder to argue with: the affiliate has a real second business, or a family member signs up, or an agency refers a client and then pays the client's bill. Some of that is legitimate. Some of it is not. Either way you need to see it before you pay out, not after.&lt;/p&gt;

&lt;h2&gt;
  
  
  The signals most programs check are the ones easiest to defeat
&lt;/h2&gt;

&lt;p&gt;Almost every affiliate system I have looked at checks three things: email address, IP address, and some flavour of device fingerprint.&lt;/p&gt;

&lt;p&gt;A second email costs nothing and takes ten seconds. An IP address is one VPN toggle away, and residential proxy pools are sold by the gigabyte. Device fingerprints get defeated by a fresh browser profile, and anti-detect browsers exist specifically to sell that capability to people who want it.&lt;/p&gt;

&lt;p&gt;There is a pattern here. The signals that are cheap for you to collect are also cheap for the other side to produce. If you want a check that actually costs the attacker something, you have to find something they cannot mint on demand.&lt;/p&gt;

&lt;h2&gt;
  
  
  The card is the expensive signal
&lt;/h2&gt;

&lt;p&gt;Fifty email addresses cost nothing. Fifty distinct working payment cards, each attached to a real funding source, is a different kind of problem. That asymmetry is the whole reason card data is worth checking.&lt;/p&gt;

&lt;p&gt;Stripe exposes a &lt;code&gt;fingerprint&lt;/code&gt; field on the card object of a PaymentMethod. It is an opaque string Stripe generates from the underlying card number, and it stays stable for that card across different Customer records. Two accounts with different emails, different IPs, and different browsers still produce the same fingerprint when the same physical card pays both bills.&lt;/p&gt;

&lt;p&gt;One thing to be precise about, because people build the wrong thing here: &lt;strong&gt;the fingerprint is consistent within a single Stripe account.&lt;/strong&gt; It is not a global identifier shared between merchants. You cannot ask another company whether they have seen this card, and you cannot compare fingerprints across two of your own Stripe accounts. For self-referral detection that limitation does not matter, because you are only ever comparing customers inside your own account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading and storing the fingerprint
&lt;/h2&gt;

&lt;p&gt;You can read it off a PaymentMethod directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paymentMethods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;paymentMethodId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fingerprint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;fingerprint&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;funding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;funding&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also appears on a Charge, which is useful if you are backfilling from historical payments rather than instrumenting signup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charges&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chargeId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payment_method_details&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;fingerprint&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store the fingerprint, never the card number. The fingerprint is an opaque Stripe-generated string with no card data inside it, so persisting it in your own database is safe and does not drag you into extra PCI scope. Keep it in a table that allows several rows per user, because people legitimately change cards.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;user_card_fingerprints&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt;     &lt;span class="n"&gt;uuid&lt;/span&gt;        &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;fingerprint&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;        &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;funding&lt;/span&gt;     &lt;span class="nb"&gt;text&lt;/span&gt;        &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="s1"&gt;'unknown'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;first_seen&lt;/span&gt;  &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write to it from a webhook, so you capture cards attached outside your own checkout flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// invoice.payment_succeeded / setup_intent.succeeded handler&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;recordFingerprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PaymentMethod&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// non-card methods and some tokens have none&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`insert into user_card_fingerprints (user_id, fingerprint, funding)
     values ($1, $2, $3)
     on conflict (user_id, fingerprint) do nothing`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;funding&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The check at conversion time
&lt;/h2&gt;

&lt;p&gt;When a referred customer converts, compare their fingerprints against every fingerprint on record for the affiliate who referred them. Return a signal with reasons attached, not a boolean. You will want the reasons later, when a real affiliate emails you asking why their commission is on hold.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;RiskLevel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;low&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;review&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;high&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;SelfReferralSignal&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RiskLevel&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;matchedFingerprints&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;scoreSelfReferral&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;referredUserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;affiliateUserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;SelfReferralSignal&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;buyer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;affiliate&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nf"&gt;getFingerprints&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;referredUserId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;getFingerprints&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;affiliateUserId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;affiliateSet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;affiliate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;matched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buyer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;affiliateSet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RiskLevel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;low&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;matched&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Buyer paid with a card already on file for the affiliate.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;level&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;high&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buyer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;funding&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;prepaid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Buyer paid with a prepaid card.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;low&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;level&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;review&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;matchedFingerprints&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;matched&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Three limits, and they are not small
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Virtual cards defeat this outright.&lt;/strong&gt; Services that mint a fresh virtual card per merchant give each one a distinct card number, which produces a distinct fingerprint. Ten virtual cards look like ten unrelated strangers. There is no clever query that recovers the link, because the link does not exist in the data you have. Anyone determined enough to run a self-referral ring at scale will find this hole, and the check is worthless against them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prepaid cards are a visible signal, which is a different situation.&lt;/strong&gt; Stripe reports &lt;code&gt;funding&lt;/code&gt; on the card object with values of &lt;code&gt;credit&lt;/code&gt;, &lt;code&gt;debit&lt;/code&gt;, &lt;code&gt;prepaid&lt;/code&gt;, or &lt;code&gt;unknown&lt;/code&gt;. A prepaid card does not tell you fraud is happening, and plenty of legitimate customers pay that way. It belongs in a look-closer tier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;funding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;funding&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// "credit" | "debit" | "prepaid" | "unknown"&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;funding&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;prepaid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;flagForReview&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;prepaid_funding&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Shared corporate cards produce false positives, cutting the opposite way.&lt;/strong&gt; Five colleagues expensing seats on one company card collapse into a single actor under fingerprint matching. If one of them joined your affiliate program and referred the other four, the naive check accuses an entirely honest person. I would rather ship a system that misses some fraud than one that emails a good affiliate to tell them their commission is cancelled for something they did not do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Combine weak signals, then defend the decision
&lt;/h2&gt;

&lt;p&gt;The question worth asking is not which identifier is perfect. None of them are. The question is how you combine several weak signals into a decision you can explain, in plain sentences, to a legitimate affiliate you are about to accuse.&lt;/p&gt;

&lt;p&gt;Card signals earn their place because they are expensive to fake, not because they prove anything on their own. Build three tiers. Low risk pays out normally. Suspicious holds the commission and creates a review item with the reasons attached. Clear-cut, meaning an exact fingerprint match plus supporting evidence like a matching billing name or a signup minutes after the referral click, gets blocked with a written explanation. Never auto-claw-back on a single signal, because the one time you are wrong you will burn an affiliate who was sending you real customers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost, stated plainly
&lt;/h2&gt;

&lt;p&gt;Requiring a card before a trial measurably reduces signups. That is why plenty of product-led teams refuse to do it, and they are not being sloppy. Whether the trade pays off depends on what the abused resource costs you. For a cheap-to-serve SaaS seat, eating some self-referral is usually cheaper than the signups you lose at the gate. For expensive compute where every trial burns real money, the card requirement often pays for itself immediately.&lt;/p&gt;

&lt;p&gt;I work on affiliate software for SaaS built on Stripe, which is how I ended up staring at fingerprint fields for longer than anyone should.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>saas</category>
      <category>node</category>
      <category>security</category>
    </item>
    <item>
      <title>Your referral links lose attribution in the redirect chain, not at checkout</title>
      <dc:creator>Mihir kanzariya</dc:creator>
      <pubDate>Tue, 04 Aug 2026 05:59:11 +0000</pubDate>
      <link>https://dev.to/mihirkanzariya/your-referral-links-lose-attribution-in-the-redirect-chain-not-at-checkout-3m5g</link>
      <guid>https://dev.to/mihirkanzariya/your-referral-links-lose-attribution-in-the-redirect-chain-not-at-checkout-3m5g</guid>
      <description>&lt;p&gt;A referral link is the shortest piece of infrastructure in your product and the one most likely to lose data silently.&lt;/p&gt;

&lt;p&gt;The setup looks trivial. An affiliate gets &lt;code&gt;yoursite.com/?ref=alice&lt;/code&gt;. You read &lt;code&gt;ref&lt;/code&gt; on page load, stash it somewhere, attach it to the Stripe Checkout Session, and pay out on the webhook. You paste the link into your browser once, see &lt;code&gt;alice&lt;/code&gt; in the console, and call attribution done.&lt;/p&gt;

&lt;p&gt;Then payouts come in low, an affiliate emails asking why a sale they clearly drove is not showing, and you go looking at the last hop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The last hop is almost never where it broke
&lt;/h2&gt;

&lt;p&gt;The debugging path is predictable. You check the Checkout Session in the Stripe dashboard, find &lt;code&gt;metadata.ref&lt;/code&gt; empty, and start reading your checkout code. Maybe you add logging around the session creation. Maybe you suspect a race between the cookie write and the click on the pricing button.&lt;/p&gt;

&lt;p&gt;Meanwhile the value was already gone three hops earlier, before your app rendered a single byte.&lt;/p&gt;

&lt;p&gt;The tell is that attribution works perfectly on localhost and fails only in production. Local has no canonical-host redirect, no CDN rule, no locale router, no shortener, no in-app browser. Production has all of them, and several will rebuild your URL from scratch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the query string actually dies
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Canonical host redirects.&lt;/strong&gt; You redirect &lt;code&gt;http&lt;/code&gt; to &lt;code&gt;https&lt;/code&gt;, or bare domain to &lt;code&gt;www&lt;/code&gt;, or the reverse. A lot of redirect rules construct the target by string concatenation and never reattach the query. This is the single most common way a &lt;code&gt;ref&lt;/code&gt; param evaporates, and it is invisible because the page still loads fine.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;# fine: $request_uri is the original path AND query&lt;/span&gt;
    &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;301&lt;/span&gt; &lt;span class="s"&gt;https://www.example.com&lt;/span&gt;&lt;span class="nv"&gt;$request_uri&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;# broken: $uri is the normalized path with no query&lt;/span&gt;
    &lt;span class="c1"&gt;# return 301 https://www.example.com$uri;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One character of difference decides whether your affiliate gets paid. The same trap exists in every layer that can redirect: CDN rules, load balancer listeners, framework config.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.js&lt;/span&gt;
&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;redirects&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/pricing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/plans&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;permanent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next.js forwards query params on redirects by default, but only if you have not written your own destination with a hardcoded query, and only if the hop is actually handled by Next rather than by the platform sitting in front of it. Check the hop that is actually running, not the one in your repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Locale and geo redirects.&lt;/strong&gt; &lt;code&gt;example.com/?ref=alice&lt;/code&gt; becomes &lt;code&gt;example.com/en/&lt;/code&gt;. Same mechanism, different layer, and it usually lives in middleware written by whoever set up i18n rather than by whoever set up the affiliate program.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shorteners and social wrappers.&lt;/strong&gt; An affiliate runs your link through a shortener, or posts it somewhere that rewrites outbound links. Some wrappers preserve the query and append their own. Some pass the whole thing as a single encoded parameter to an interstitial and reconstruct it. Some strip anything they do not recognize.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In-app browsers.&lt;/strong&gt; A click inside a mobile app opens a webview with a fresh cookie jar. Whatever you wrote on a previous visit in the real browser does not exist there, and whatever you write inside it may not survive the user tapping "open in Safari" afterward.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Marketing site to app domain.&lt;/strong&gt; The &lt;code&gt;ref&lt;/code&gt; lands on &lt;code&gt;example.com&lt;/code&gt;, your signup and checkout live on &lt;code&gt;app.example.com&lt;/code&gt;, and the cookie you wrote is scoped to the marketing host. The origin that writes the value is not the origin that later reads it, so at checkout there is nothing to read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Meta refresh and client-side router redirects.&lt;/strong&gt; A &lt;code&gt;&amp;lt;meta http-equiv="refresh" content="0; url=/home"&amp;gt;&lt;/code&gt; or a &lt;code&gt;router.replace("/dashboard")&lt;/code&gt; inside an auth guard rewrites the location without the query. These are especially easy to miss, because your server logs still show the original request with the param present.&lt;/p&gt;

&lt;h2&gt;
  
  
  The principle
&lt;/h2&gt;

&lt;p&gt;Capture the referral at the earliest possible hop and persist it first-party. Do not carry the query string end to end.&lt;/p&gt;

&lt;p&gt;The query string is a transport. It gets one job, which is to hand you a value on first paint, and then it is allowed to disappear. Every hop after that reads from your own storage. If your checkout code still expects to find &lt;code&gt;?ref=&lt;/code&gt; in &lt;code&gt;window.location&lt;/code&gt;, you have built a chain where any single link breaks the whole thing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// runs as early as possible on first paint, on the marketing domain&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;REF_COOKIE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rf_ref&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_AGE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 60 days&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;captureRef&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;search&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ref&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookie&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;REF_COOKIE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;=`&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// first touch wins&lt;/span&gt;

  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookie&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;REF_COOKIE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;path=/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;domain=.example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// top-level, so app.example.com can read it&lt;/span&gt;
    &lt;span class="s2"&gt;`max-age=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;MAX_AGE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;samesite=lax&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;secure&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter more than the rest. The cookie domain is the registrable domain with a leading dot, so subdomains share it. And first touch wins here, so a returning visitor who arrives later through a different affiliate does not silently overwrite the credit. Last touch and first touch produce different payouts, and your affiliates will eventually notice which one you picked, so decide deliberately and put it in your program terms.&lt;/p&gt;

&lt;p&gt;Then at checkout, read your own storage and hand it to Stripe.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createCheckout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rf_ref&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;checkout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;subscription&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;line_items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;priceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="na"&gt;success_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://app.example.com/welcome&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;cancel_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://app.example.com/plans&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;metadata&lt;/code&gt; is the only Stripe-specific piece here. It rides along to the webhook, and the value you read there is whatever your own cookie held, not whatever survived the redirect chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the paths, do not trust them
&lt;/h2&gt;

&lt;p&gt;Most affiliate programs never write a test for this, which is strange given that the whole revenue share depends on it. The test is short. Walk your own referral link through each real entry path and assert the cookie exists at the end.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;http://example.com/?ref=alice
http://www.example.com/?ref=alice
https://example.com/?ref=alice
https://www.example.com/?ref=alice
https://example.com/pricing?ref=alice     # any path with its own redirect rule
https://your-shortener.example/abc123
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For each one, follow redirects and check the final &lt;code&gt;Set-Cookie&lt;/code&gt; and the final URL. &lt;code&gt;curl -sIL&lt;/code&gt; gets you most of the way for the server-side hops. The client-side ones need a real browser, so a short Playwright script that visits each URL and reads &lt;code&gt;document.cookie&lt;/code&gt; covers the rest. Do the in-app browser check by hand once, on a real phone, from whichever app your affiliates actually post in.&lt;/p&gt;

&lt;p&gt;Run it after any change to redirect config, not just once at launch. Redirect rules get edited by people who have no idea an affiliate program depends on them.&lt;/p&gt;

&lt;h2&gt;
  
  
  One caveat, because a cookie is not forever
&lt;/h2&gt;

&lt;p&gt;Fixing the redirect chain gets the value into your storage. It does not make that storage permanent: Safari's Intelligent Tracking Prevention caps the lifetime of cookies written through &lt;code&gt;document.cookie&lt;/code&gt;, which is a separate problem I wrote about in &lt;a href="https://dev.to/mihirkanzariya/your-60-day-affiliate-cookie-is-lying-on-safari-here-is-how-to-fix-attribution-1ig4"&gt;Your 60-day affiliate cookie is lying on Safari&lt;/a&gt;. A cookie set by the server through a &lt;code&gt;Set-Cookie&lt;/code&gt; header is treated differently from a script-written one, which is a real argument for capturing the ref server-side during that first request.&lt;/p&gt;

&lt;p&gt;The shape that survives both problems: capture early, set it server-side where you can, and write the referral against the user record the moment someone signs up. The cookie only has to survive the gap between the click and the account, and that gap is the part you can actually control.&lt;/p&gt;

&lt;p&gt;I work on affiliate software for SaaS companies, and the redirect chain is the first place I would look when a payout number does not match what an affiliate says they drove.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>saas</category>
    </item>
    <item>
      <title>The affiliate commission you never actually pay out</title>
      <dc:creator>Mihir kanzariya</dc:creator>
      <pubDate>Sun, 02 Aug 2026 18:53:15 +0000</pubDate>
      <link>https://dev.to/mihirkanzariya/the-affiliate-commission-you-never-actually-pay-out-3on6</link>
      <guid>https://dev.to/mihirkanzariya/the-affiliate-commission-you-never-actually-pay-out-3on6</guid>
      <description>&lt;h2&gt;
  
  
  The balances that never move
&lt;/h2&gt;

&lt;p&gt;Run this query against your commission table: sum of unpaid commission, grouped by affiliate, where the affiliate has had no new conversion in the last twelve months. On most programs that have been running a while, the result is a long tail of accounts holding 4, 11, 27 units of currency each, and none of it is ever going anywhere.&lt;/p&gt;

&lt;p&gt;That tail is not a bug in your payout code. It is the direct output of a rule you almost certainly wrote on purpose, and then stopped thinking about.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a threshold actually creates
&lt;/h2&gt;

&lt;p&gt;Nearly every program sets a minimum payout. Fifty is a common number. The reasoning is sound: a fixed cost attaches to each transfer, whether that cost is a processor fee, a compliance check, or ten minutes of a human reconciling something. Sending 3 units to two hundred people is a worse use of money than not sending it.&lt;/p&gt;

&lt;p&gt;The consequence, though, is a permanent holding pen. An affiliate signs up, posts one link, drives one sale, earns 12, and drifts away. Twelve is below fifty. It will be below fifty forever, because nothing is going to move it. Your liability line grows by 12. Their balance sits at 12. Neither of you thinks about it again.&lt;/p&gt;

&lt;p&gt;Multiply that by everyone who tried your program once. The arithmetic is not dramatic per account and it is not small in aggregate.&lt;/p&gt;

&lt;p&gt;You have several defensible answers. Run an annual sweep that pays everything out regardless of threshold, eating the fee on the small ones as a cost of running a program. Or write an expiry into the terms, with a stated window and a warning email before it lands. Or drop the threshold to whatever amount makes the fee tolerable and let small balances flow.&lt;/p&gt;

&lt;p&gt;What is not defensible is having no answer, because from the affiliate's side, an indefinite hold and a refusal to pay look exactly the same. They cannot see your reasoning. They can only see that they earned money and never received it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The affiliate who never finished payout onboarding
&lt;/h2&gt;

&lt;p&gt;Second version of the same problem, with a different cause. Someone signs up, drives a real conversion, and never completes the details you need in order to send money. Identity checks, bank details, whatever your processor requires. The commission is real. It is owed. It is unsendable.&lt;/p&gt;

&lt;p&gt;Two options exist and both are fine. Hold it indefinitely, which means carrying an unbounded liability against an account that may never come back. Or expire it after a stated window, with reminders on the way.&lt;/p&gt;

&lt;p&gt;The rule you cannot get away with is deciding this the day someone emails you fourteen months later asking where their 340 went. Whatever you pick has to be written down before it applies to anybody, because a policy invented at the moment of the dispute is not a policy.&lt;/p&gt;

&lt;p&gt;Here is the guard that decides whether a balance is payable at all. Note that a zero return has several distinct causes, and your affiliate dashboard should say which one applies rather than showing a bare "not yet".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Amount you can actually send right now, in minor units.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;payableAmount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;affiliate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;owed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;owedMinor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;affiliate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;owed&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// carried clawback, see the reversal section below&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;owed&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Onboarding incomplete: keep accruing, do not attempt a transfer.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;affiliate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payoutsEnabled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// The sweep is the escape hatch that stops small balances living forever.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;owed&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;thresholdMinor&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isSweepRun&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;owed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Initiated is not paid
&lt;/h2&gt;

&lt;p&gt;Now the failure that damages trust fastest, because your system reports success while the affiliate receives nothing.&lt;/p&gt;

&lt;p&gt;You create a transfer. The API returns an object with an id. Your code writes &lt;code&gt;status = 'paid'&lt;/code&gt; and moves on. Later, the money comes back. The destination bank account was closed. The connected account lost its ability to receive payouts between the time you checked and the time the money moved. Something in the chain rejected it.&lt;/p&gt;

&lt;p&gt;Your ledger now says paid. Your balance says the money is still here. The affiliate says nothing, because they are waiting, and they will wait a while before they write to you.&lt;/p&gt;

&lt;p&gt;The fix is a state name. Creating a transfer means you asked for money to move. It does not mean money moved.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;transfer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;usd&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;affiliate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;connectedAccountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;affiliate_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;affiliate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;batch_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;batchId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// Re-running a batch must not send twice.&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`payout:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;affiliate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;batchId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Not "paid". The request succeeded, the money has not landed.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;affiliate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;batchId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;initiated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;transferId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;transfer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The transition from &lt;code&gt;initiated&lt;/code&gt; to &lt;code&gt;settled&lt;/code&gt; belongs in your webhook handler, not in the function that created the transfer. The transition back to &lt;code&gt;owed&lt;/code&gt; belongs there too, driven by whatever failure or reversal signal your processor sends.&lt;/p&gt;

&lt;p&gt;I am deliberately not naming those events. Stripe exposes events covering transfers, reversals, and changes to a connected account's status, and the exact set available to you depends on your API version and your Connect configuration. Read the event list in your own dashboard and confirm against current docs rather than trusting a name you read in a blog post, this one included. The mechanism is what matters: something asynchronous can undo a transfer you already created, and if you have no handler for it, your ledger will be wrong and nothing will tell you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reversal that lands after the money left
&lt;/h2&gt;

&lt;p&gt;A customer refunds in week six. You paid the commission on that sale in week four. You cannot reach into someone's bank account and take it back.&lt;/p&gt;

&lt;p&gt;So the balance goes negative, and future commission pays it down. That part is easy to build.&lt;/p&gt;

&lt;p&gt;The part worth deciding early: what happens when that affiliate never earns again. You are holding a negative balance against an account that has gone quiet. At some point you write it off, and the only question is whether you decided that in advance or in the moment. Decisions made in the moment tend to be made while annoyed, and they tend to be applied inconsistently across affiliates, which is exactly the thing that turns into a public complaint.&lt;/p&gt;

&lt;p&gt;Also worth writing down: whether refunds after some window stop clawing back at all. Many programs cap the clawback period. If yours does not, say so, since an affiliate who gets a deduction eleven months after a sale will assume you invented the rule that morning.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that is not code
&lt;/h2&gt;

&lt;p&gt;Every state I have described is a promise to a person who sent you customers.&lt;/p&gt;

&lt;p&gt;A threshold nobody mentioned at signup. A balance that expired with no email. A payout your dashboard marked paid that never arrived. To the affiliate, all three read as being cheated, and the fact that your code did precisely what it was written to do is not a defence they can see or verify.&lt;/p&gt;

&lt;p&gt;Two things fix most of this. Your terms and your ledger state names should describe the same reality, in the same words, so that "pending" in the affiliate's dashboard means the thing your terms say it means. And the affiliate should be able to see which state their money is in without asking you, including the unflattering states.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decide these before your first payout run
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;What the minimum payout is, and what happens to balances that will never reach it.&lt;/li&gt;
&lt;li&gt;Whether you run a sweep, when, and whether you absorb the fee on small amounts.&lt;/li&gt;
&lt;li&gt;How long you hold commission for someone who never completed payout onboarding, and how many reminders they get first.&lt;/li&gt;
&lt;li&gt;Which ledger state means requested and which means confirmed landed, and which one your dashboard shows the affiliate.&lt;/li&gt;
&lt;li&gt;What moves a commission back to owed, and which handler owns that transition.&lt;/li&gt;
&lt;li&gt;How long after a sale a refund can still claw back commission.&lt;/li&gt;
&lt;li&gt;What happens to a negative balance on an affiliate who stops earning.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Write the answers into your terms first, then name your database states after them. Doing it in that order is easier than reconciling the two later.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>saas</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your affiliate commission should not include VAT, and invoice.total does</title>
      <dc:creator>Mihir kanzariya</dc:creator>
      <pubDate>Sat, 01 Aug 2026 18:52:33 +0000</pubDate>
      <link>https://dev.to/mihirkanzariya/your-affiliate-commission-should-not-include-vat-and-invoicetotal-does-2ccd</link>
      <guid>https://dev.to/mihirkanzariya/your-affiliate-commission-should-not-include-vat-and-invoicetotal-does-2ccd</guid>
      <description>&lt;p&gt;An affiliate refers a customer in Germany. The customer pays EUR 120. Your affiliate agreement says 30%, so your system pays out EUR 36. Except the plan is EUR 100 and the other EUR 20 is VAT that you are holding on behalf of the German tax authority until you remit it. The honest commission was EUR 30. You just paid EUR 6 out of money that was never yours.&lt;/p&gt;

&lt;p&gt;Nothing looks wrong, so this can sit there for months. The invoice says 120, the payout says 36, and 36 is 30% of 120. The arithmetic is fine. The base is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tax you collect is not revenue you earned
&lt;/h2&gt;

&lt;p&gt;The rule is simple once you say it out loud. Sales tax, VAT and GST are pass-through. The customer pays it, you hold it, you hand it to a government. It sits on your balance sheet as a liability, not as income. So it has no business being in the base you compute a partner's cut from, any more than you would pay commission on a Stripe processing fee.&lt;/p&gt;

&lt;p&gt;It is easy to agree with that sentence and still write &lt;code&gt;invoice.total&lt;/code&gt;, because that is the field with the obvious name.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tax-exclusive pricing is the easy case, inclusive is the trap
&lt;/h2&gt;

&lt;p&gt;Where tax gets added on top of the price, which is the usual US sales tax setup, the bug announces itself. Your $100 plan invoices at $108.75, the payout line reads $32.63 instead of $30.00, and eventually someone in finance or an affiliate with a spreadsheet asks why the number has cents in it.&lt;/p&gt;

&lt;p&gt;Tax-inclusive pricing gives you no such warning. This is standard for EU consumer pricing, where the advertised number has to be the number the customer pays. Your EUR 120 price is EUR 100 of revenue and EUR 20 of VAT that Stripe backs out of the amount charged rather than adding to it. The invoice total is 12000. It was always going to be 12000. There is no odd cent to notice and no line that looks inflated.&lt;/p&gt;

&lt;p&gt;The giveaway is usually not the payout at all. It is that two customers on the same plan, one in a country with VAT and one without, produce different commissions for the same affiliate on the same product. That is the signal worth alerting on if you want a cheap check: same price, same plan, different commission, go and look at the tax treatment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fields, with a warning about your API version
&lt;/h2&gt;

&lt;p&gt;Read this section with your own account's API version open, because Stripe has reshaped the tax fields on Invoice more than once and the names below are not stable across every version you might be pinned to. In the 2025-03-31.basil release, the flat &lt;code&gt;invoice.tax&lt;/code&gt; and the &lt;code&gt;total_tax_amounts&lt;/code&gt; array were reorganised into a &lt;code&gt;total_taxes&lt;/code&gt; array, and line-item tax data moved as well. If you are on an older pinned version you will still see the old shape. If you upgrade, the shape changes underneath you.&lt;/p&gt;

&lt;p&gt;What each amount field means, as of the versions we have run:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;subtotal&lt;/code&gt; is the sum of the line items before any invoice-level discount and before tax that gets added on top. Item-level discounts are already baked in. The word "subtotal" reads like it means "before tax", and under exclusive tax it effectively does. Under inclusive tax it does not, because the tax was never added on top to begin with. It is already inside the line amounts, so it is already inside the subtotal.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;subtotal_excluding_tax&lt;/code&gt; strips tax out but still sits before invoice-level discounts.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;total_excluding_tax&lt;/code&gt; is the one you usually want. It applies every discount and then removes all tax, inclusive tax included.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;total&lt;/code&gt; is the end of the line. Discounts applied, tax applied, this is what the customer owes.&lt;/p&gt;

&lt;p&gt;The distinction that decides your number is &lt;code&gt;subtotal_excluding_tax&lt;/code&gt; versus &lt;code&gt;total_excluding_tax&lt;/code&gt;, and it is entirely about discounts. An invoice-level coupon lands between them. Pick the first and you commission on the list price. Pick the second and you commission on what the customer actually paid you, net of tax. They differ by exactly the invoice-level discount, which means on any invoice without one they agree, which means your test suite will happily pass while the field is wrong.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoiceId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// total_excluding_tax: discounts applied, all tax removed (inclusive tax too).&lt;/span&gt;
&lt;span class="c1"&gt;// Falling back to `total` here would silently commission on VAT.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total_excluding_tax&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;commissionCents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.30&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;??&lt;/code&gt; fallback is a decision, not a formality. If the field comes back null on your version, falling through to &lt;code&gt;total&lt;/code&gt; is the exact bug this article is about. Throwing, or flagging the invoice for a human, costs you an alert. The fallback costs you money on every EU invoice.&lt;/p&gt;

&lt;p&gt;Two things worth checking before you trust any of this in production. Confirm your version returns the field at all rather than undefined, since an undefined value in JavaScript multiplied by a rate gives you &lt;code&gt;NaN&lt;/code&gt; and a payout row that reads as zero. And confirm on an invoice with a discount and inclusive tax together, because that is the case where every field disagrees with every other field.&lt;/p&gt;

&lt;h2&gt;
  
  
  The discount question rides along
&lt;/h2&gt;

&lt;p&gt;If the affiliate's own coupon gave the customer 20% off, do you pay them on the list price or on what the customer paid?&lt;/p&gt;

&lt;p&gt;Both are defensible and plenty of programs pay on list. Just look at what pre-discount does to incentives. The affiliate earns more by discounting harder, and you write a cheque against money that never arrived. A partner with a 40% coupon and a pre-discount base is running a lever you handed them. Post-discount keeps everyone pointed at the same number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refunds inherit the same mistake
&lt;/h2&gt;

&lt;p&gt;Refund half of that EUR 120 invoice and Stripe gives back EUR 60, of which EUR 10 is VAT. Your clawback should be 30% of EUR 50, so EUR 15. Compute it off the gross EUR 60 and you claw back EUR 18, taking EUR 3 out of an affiliate's pocket that they earned.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;refund&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refundId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Apply the same ex-tax ratio the original invoice had.&lt;/span&gt;
&lt;span class="c1"&gt;// refund.amount is gross, so scaling by it directly over-claws on the tax.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;taxRatio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clawbackCents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;taxRatio&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.30&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Over-clawing is worse than overpaying, by the way. Overpaying is a number nobody checks. Clawing back too much is a number your partner checks the same afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then explain it, in one sentence, before anyone asks
&lt;/h2&gt;

&lt;p&gt;Your affiliate opens their dashboard and sees EUR 30. Their referred customer told them the deal was EUR 120. They do the division, get 25%, and email you asking why the rate dropped.&lt;/p&gt;

&lt;p&gt;You are right and you still look like you are shorting them, which is the worst position to argue from. Put one line in the program terms: "Commission is calculated on the net amount excluding VAT, sales tax and GST." That is the whole fix. Getting the arithmetic right and never explaining it is its own kind of failure, and it is the one that generates the support ticket.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check on your own account
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Pull a paid invoice from a VAT-inclusive country and compare &lt;code&gt;total&lt;/code&gt;, &lt;code&gt;subtotal&lt;/code&gt;, &lt;code&gt;subtotal_excluding_tax&lt;/code&gt; and &lt;code&gt;total_excluding_tax&lt;/code&gt; side by side. If all four match, you have not tested the case that matters.&lt;/li&gt;
&lt;li&gt;Grep your commission code for &lt;code&gt;invoice.total&lt;/code&gt; and &lt;code&gt;amount_paid&lt;/code&gt;. Both include tax.&lt;/li&gt;
&lt;li&gt;Pin your API version explicitly and re-read the Invoice object docs at that version rather than the latest ones.&lt;/li&gt;
&lt;li&gt;Run the same plan through a taxed and an untaxed country and confirm the commission comes out identical.&lt;/li&gt;
&lt;li&gt;Check your clawback path separately from your payout path. They are usually written months apart by different people.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>stripe</category>
      <category>saas</category>
      <category>api</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Usage-based pricing breaks affiliate commissions written for flat plans</title>
      <dc:creator>Mihir kanzariya</dc:creator>
      <pubDate>Fri, 31 Jul 2026 18:56:04 +0000</pubDate>
      <link>https://dev.to/mihirkanzariya/usage-based-pricing-breaks-affiliate-commissions-written-for-flat-plans-47l5</link>
      <guid>https://dev.to/mihirkanzariya/usage-based-pricing-breaks-affiliate-commissions-written-for-flat-plans-47l5</guid>
      <description>&lt;p&gt;A flat plan makes affiliate commission trivial. Twenty percent of a $49 invoice is $9.80, every month, forever.&lt;/p&gt;

&lt;p&gt;Add usage-based or hybrid pricing and that one line of code has four bugs in it. None of them throw.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Which amount?
&lt;/h2&gt;

&lt;p&gt;An invoice is not a number. A single Stripe invoice carries &lt;code&gt;subtotal&lt;/code&gt;, &lt;code&gt;total&lt;/code&gt;, &lt;code&gt;subtotal_excluding_tax&lt;/code&gt;, &lt;code&gt;total_excluding_tax&lt;/code&gt; and &lt;code&gt;amount_paid&lt;/code&gt;, and on a usage-based invoice they are frequently all different.&lt;/p&gt;

&lt;p&gt;The common bug is commissioning on &lt;code&gt;total&lt;/code&gt;, which includes tax. You collect that tax and remit it to a government. Paying an affiliate a percentage of it means paying out of margin on money that was never yours.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;amount_paid&lt;/code&gt; has the opposite problem. If the customer had account credit, &lt;code&gt;amount_paid&lt;/code&gt; is lower than what they were actually billed, and your affiliate silently earns less for a reason that has nothing to do with them.&lt;/p&gt;

&lt;p&gt;Pick the base deliberately. For most programs that is the pre-tax subtotal after discounts.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Which lines?
&lt;/h2&gt;

&lt;p&gt;Flat plans have one line. Usage invoices have several: the base subscription, a line per metered price, proration adjustments, one-off charges somebody added in the dashboard.&lt;/p&gt;

&lt;p&gt;If your program pays on subscription revenue but not on overage, or not on professional services, you cannot compute from the invoice total at all. Walk &lt;code&gt;invoice.lines&lt;/code&gt; and filter by price or product. Decide which products are commissionable and encode that decision. Do not infer it from the amount.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The invoice that arrives after cancellation
&lt;/h2&gt;

&lt;p&gt;Metered usage is billed in arrears. The customer cancels on the 3rd and an invoice lands on the 5th for usage they already incurred.&lt;/p&gt;

&lt;p&gt;If your rule is pay commission while the subscription is active, you skip a legitimate invoice. If your rule is pay on every invoice, you also pay on true-ups you meant to exclude.&lt;/p&gt;

&lt;p&gt;The subscription status at the moment the invoice finalises is the wrong test. The period the usage belongs to is the right one, and it is on the line item.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Reversals stop being boolean
&lt;/h2&gt;

&lt;p&gt;On a flat plan a refund is the whole thing, so a refunded flag is a usable trigger.&lt;/p&gt;

&lt;p&gt;On usage-based billing partial refunds are ordinary. A customer disputes one month of overage, not the plan. Clawing back the full commission is wrong. Clawing back nothing is also wrong.&lt;/p&gt;

&lt;p&gt;Claw back in proportion: &lt;code&gt;amount_refunded / amount&lt;/code&gt;, applied to the stored commission.&lt;/p&gt;

&lt;p&gt;And wire &lt;code&gt;charge.dispute.created&lt;/code&gt; as well as &lt;code&gt;charge.refunded&lt;/code&gt;. They are separate events, the money leaves faster on a dispute, and a system listening only for refunds keeps paying commission on revenue it no longer holds.&lt;/p&gt;

&lt;h2&gt;
  
  
  What makes all four survivable
&lt;/h2&gt;

&lt;p&gt;Compute the commission once and store the basis you used on the commission record: which amount, which lines, which period.&lt;/p&gt;

&lt;p&gt;Do not recompute from the invoice later. Invoices are not immutable in practice. Credits get applied, disputes resolve, and a recomputation months later will quietly disagree with what you already paid. When it disagrees you will have no way to tell which number is the bug.&lt;/p&gt;

&lt;p&gt;Store the number and the reason for it. Reconcile from events, not from current state.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>saas</category>
      <category>payments</category>
    </item>
    <item>
      <title>What a failed renewal does to an affiliate commission you already paid</title>
      <dc:creator>Mihir kanzariya</dc:creator>
      <pubDate>Thu, 30 Jul 2026 19:08:15 +0000</pubDate>
      <link>https://dev.to/mihirkanzariya/what-a-failed-renewal-does-to-an-affiliate-commission-you-already-paid-33ip</link>
      <guid>https://dev.to/mihirkanzariya/what-a-failed-renewal-does-to-an-affiliate-commission-you-already-paid-33ip</guid>
      <description>&lt;p&gt;If you run an affiliate program on Stripe subscriptions, you pay commission when an invoice gets paid. The part that quietly breaks is what happens when a renewal that already earned someone a commission stops being paid.&lt;/p&gt;

&lt;p&gt;Most implementations reverse on the wrong event. I had the shape of this wrong myself until I sat down with the retry docs and read what actually fires.&lt;/p&gt;

&lt;h2&gt;
  
  
  The event everyone reverses on is the wrong one
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;invoice.payment_failed&lt;/code&gt; looks like the obvious trigger. Stripe describes it as: "Occurs whenever an invoice payment attempt fails, due to either a declined payment, including soft decline, or to the lack of a stored payment method."&lt;/p&gt;

&lt;p&gt;Attempt is the load-bearing word. It fires once per attempt, and the Smart Retries docs say "The recommended default setting is 8 tries within 2 weeks." So a single doomed invoice can fire that event eight times. If your handler reverses a commission each time, you have clawed back the same commission eight times.&lt;/p&gt;

&lt;p&gt;The second problem is worse, because it is silent. Most failed attempts are temporary. The card works on attempt three, the invoice gets paid, and you have already reversed a commission that was genuinely owed. Nobody files a bug for this. Your affiliate just quietly earns less than they should.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dunning window is an ambiguity window
&lt;/h2&gt;

&lt;p&gt;For up to two weeks on default settings, you do not know whether that commission is owed. That is not a problem to engineer around, it is just true, and the design should say so.&lt;/p&gt;

&lt;p&gt;Give commission rows a third state. Something like &lt;code&gt;pending_recovery&lt;/code&gt;, entered on the first failure, exited on either &lt;code&gt;invoice.paid&lt;/code&gt; or a terminal outcome. During that window you do not pay it out and you do not reverse it. Holding is the honest answer, and it is also the one that stops you paying out money you may need to chase back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which terminal event, and the trap that bit me
&lt;/h2&gt;

&lt;p&gt;When retries run out, the docs are clear that Stripe stops: "After the final payment attempt, we make no further payment attempts."&lt;/p&gt;

&lt;p&gt;What happens to the subscription depends on a setting the merchant chose, and there are three options:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cancel the subscription&lt;/td&gt;
&lt;td&gt;"changes to a &lt;code&gt;canceled&lt;/code&gt; state after the maximum number of days defined in the retry schedule"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mark the subscription as unpaid&lt;/td&gt;
&lt;td&gt;"changes to an &lt;code&gt;unpaid&lt;/code&gt; state ... Invoices continue to be generated and stay in a draft state"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Leave the subscription overdue&lt;/td&gt;
&lt;td&gt;"remains in a &lt;code&gt;past_due&lt;/code&gt; state ... Invoices continue to be generated and charge customer based on retry settings"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the natural move is to reverse on a terminal invoice event, and the two candidates are &lt;code&gt;invoice.marked_uncollectible&lt;/code&gt; ("Occurs whenever an invoice is marked uncollectible") and &lt;code&gt;invoice.voided&lt;/code&gt; ("Occurs whenever an invoice is voided").&lt;/p&gt;

&lt;p&gt;Here is the trap. &lt;strong&gt;&lt;code&gt;uncollectible&lt;/code&gt; is a manual status.&lt;/strong&gt; The invoice workflow docs describe it only as something a person does: "you can update the status of the invoice to be &lt;code&gt;uncollectible&lt;/code&gt;", offered either as a Dashboard action or an API call. The status itself means "The customer is unlikely to pay the invoice. Normally, you treat it as bad debt in your accounting process." There is no documented automatic transition from &lt;code&gt;open&lt;/code&gt; or &lt;code&gt;past_due&lt;/code&gt; into it.&lt;/p&gt;

&lt;p&gt;Which means: if the merchant is on "leave the subscription overdue", nothing marks that invoice uncollectible unless a human does it as a bookkeeping chore. &lt;code&gt;invoice.marked_uncollectible&lt;/code&gt; may never fire at all. Your commission sits in &lt;code&gt;pending_recovery&lt;/code&gt; forever, against revenue that is never arriving.&lt;/p&gt;

&lt;p&gt;Worth separating &lt;code&gt;void&lt;/code&gt; here too, because people reach for it as a synonym and it is not one. Voiding "is conceptually similar to deleting or cancelling it. However, voiding an invoice maintains a paper trail", and "Voided invoices are treated as zero-value for reporting purposes, and aren't payable." Uncollectible is bad debt you still recognise. Void is an invoice that stops existing for reporting. They are different accounting statements and they deserve different commission handling.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do instead
&lt;/h2&gt;

&lt;p&gt;Do not hang reversal on a single invoice event.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Listen for &lt;code&gt;customer.subscription.deleted&lt;/code&gt; and &lt;code&gt;customer.subscription.updated&lt;/code&gt; as well, since two of the three end states are expressed on the subscription rather than the invoice.&lt;/li&gt;
&lt;li&gt;Add a time-based sweep. The retry window is knowable: it is whatever Smart Retry policy the account is on, and the options are 1 week, 2 weeks, 3 weeks, 1 month or 2 months. If a commission has sat in &lt;code&gt;pending_recovery&lt;/code&gt; past that window plus a margin, resolve it yourself instead of waiting for an event that is not coming.&lt;/li&gt;
&lt;li&gt;Record which rule resolved it. When an affiliate asks why a commission vanished, "the invoice was marked uncollectible on this date" and "our sweep timed it out after 21 days" are very different answers, and only one of them is defensible.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The one decline that is not a retry problem
&lt;/h2&gt;

&lt;p&gt;A decline with &lt;code&gt;authentication_required&lt;/code&gt; is not a timing failure. The card is fine and the issuer wants the customer back on-session to complete 3DS. No retry schedule fixes that, so a commission stuck behind one is waiting on a human, not a timer. Treat it differently in the sweep: it should trigger an email to the customer, not a longer wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring what this costs you
&lt;/h2&gt;

&lt;p&gt;Stripe has a page for this that is easy to miss: &lt;code&gt;dashboard.stripe.com/revenue_recovery&lt;/code&gt;, which gives "key performance indicators (KPIs), trends, and reports to help you understand how failed subscription payments are affecting your business."&lt;/p&gt;

&lt;p&gt;Two caveats before you trust the number. It covers the current and previous month only, so any 90 day question still needs an export. And the scope is narrower than it looks: "Data in the revenue recovery overview represents recurring subscription payments only and excludes the first invoice payment following a trial." If most of your failures are trial conversions, they are not in there.&lt;/p&gt;

&lt;p&gt;When you do export, count invoices and not charges. Eight failed charge rows can be one uncollected invoice. Sum the charges and you will overstate what failed renewals cost you by several times, and if you are using that number to size how much commission you need to claw back, you will size it several times too big.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of it
&lt;/h2&gt;

&lt;p&gt;Pay on a settled fact. Hold on an ambiguous one. Reverse on a terminal one, and then go and check that your terminal one actually fires for the merchant configuration you are running.&lt;/p&gt;

&lt;p&gt;Every Stripe quote above is from the Billing and API docs, checked on 2026-07-31: &lt;code&gt;billing/revenue-recovery/smart-retries&lt;/code&gt;, &lt;code&gt;billing/invoices/workflow&lt;/code&gt;, &lt;code&gt;billing/revenue-recovery/recovery-analytics&lt;/code&gt; and &lt;code&gt;api/events/types&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>saas</category>
      <category>webdev</category>
      <category>payments</category>
    </item>
    <item>
      <title>What on_behalf_of actually changes on a Stripe destination charge (and what it does not)</title>
      <dc:creator>Mihir kanzariya</dc:creator>
      <pubDate>Wed, 29 Jul 2026 18:57:07 +0000</pubDate>
      <link>https://dev.to/mihirkanzariya/what-onbehalfof-actually-changes-on-a-stripe-destination-charge-and-what-it-does-not-3afm</link>
      <guid>https://dev.to/mihirkanzariya/what-onbehalfof-actually-changes-on-a-stripe-destination-charge-and-what-it-does-not-3afm</guid>
      <description>&lt;p&gt;I gave someone bad advice about this in a comment three weeks ago. They were about to ship it. Here is the correction, and the full picture of what the parameter actually does.&lt;/p&gt;

&lt;p&gt;The claim I made was that adding &lt;code&gt;on_behalf_of&lt;/code&gt; to a destination charge would let each connected account own its own disputes. That is wrong. It is a comfortable kind of wrong, too, because almost everything else about &lt;code&gt;on_behalf_of&lt;/code&gt; does point in that direction.&lt;/p&gt;

&lt;h2&gt;
  
  
  What on_behalf_of genuinely changes
&lt;/h2&gt;

&lt;p&gt;Set &lt;code&gt;on_behalf_of&lt;/code&gt; to a connected account on a destination charge and Stripe treats that account as the settlement merchant. Per Stripe's Connect charges documentation, it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Settles in the connected account's country.&lt;/strong&gt; Stripe "settles charges in the country of the specified account to minimise declines and avoid currency conversions."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prices with that country's fee structure.&lt;/strong&gt; It "uses the fee structure for the connected account's country."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shows their statement descriptor&lt;/strong&gt;, not yours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shows their address and phone&lt;/strong&gt; on the customer's statement when the two accounts are in different countries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pays out on their schedule.&lt;/strong&gt; Stripe "pays out a connected account, depending on the days specified in its &lt;code&gt;delays_days&lt;/code&gt; setting."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is a real change in who the customer thinks they paid, and a real change in the economics of the charge. If your connected accounts sit in other countries, both the settlement currency and your per-transaction cost move.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does not change
&lt;/h2&gt;

&lt;p&gt;Dispute liability.&lt;/p&gt;

&lt;p&gt;For destination charges, and for separate charges and transfers, with or without &lt;code&gt;on_behalf_of&lt;/code&gt;, Stripe's disputes documentation is explicit: "Stripe debits dispute amounts and fees from your platform account." You recover by reversing the transfer to the connected account.&lt;/p&gt;

&lt;p&gt;Only full direct charges, created with the &lt;code&gt;Stripe-Account&lt;/code&gt; header, move dispute liability onto the connected account.&lt;/p&gt;

&lt;p&gt;So the presentation moves and the money does not. The customer sees the connected account's name on their statement, the connected account's country determines settlement, and then a chargeback lands on your platform balance anyway. Those two facts sit next to each other in the docs and it is easy to read the first and assume the second.&lt;/p&gt;

&lt;h2&gt;
  
  
  The capability cost nobody mentions
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;on_behalf_of&lt;/code&gt; is not free on the onboarding side. Stripe's account capabilities documentation states that "to enable connected accounts to accept a payment method for direct charges or charges with &lt;code&gt;on_behalf_of&lt;/code&gt;, you must request that payment method's capability for those accounts."&lt;/p&gt;

&lt;p&gt;So a plain destination charge needs &lt;code&gt;transfers&lt;/code&gt;. A destination charge with &lt;code&gt;on_behalf_of&lt;/code&gt; needs the payment method capability too, which for cards means &lt;code&gt;card_payments&lt;/code&gt;, and &lt;code&gt;card_payments&lt;/code&gt; requires &lt;code&gt;transfers&lt;/code&gt; as a prerequisite. With &lt;code&gt;transfers&lt;/code&gt; alone, your platform processes the charge.&lt;/p&gt;

&lt;p&gt;In practice that means more verification for every connected account you onboard. If you are weighing &lt;code&gt;on_behalf_of&lt;/code&gt; for presentation reasons, that is the bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cross-border trap worth knowing before you ship
&lt;/h2&gt;

&lt;p&gt;This one costs real money and it is easy to miss.&lt;/p&gt;

&lt;p&gt;When a dispute arrives on a destination charge, the instinct is to reverse the transfer immediately and get your money back from the connected account. On cross-border transfers, do not.&lt;/p&gt;

&lt;p&gt;Stripe's guidance: "Retransferring a previous reversal is subject to cross-border transfer restrictions, meaning you might have no means to repay your connected account. Instead, wait to recover disputed cross-border payment transfers for destination charges with &lt;code&gt;on_behalf_of&lt;/code&gt; until after a dispute is lost."&lt;/p&gt;

&lt;p&gt;Reverse early, win the dispute, and you can end up unable to send the money back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;destination charge&lt;/th&gt;
&lt;th&gt;with &lt;code&gt;on_behalf_of&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;direct charge&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;statement descriptor&lt;/td&gt;
&lt;td&gt;platform&lt;/td&gt;
&lt;td&gt;connected account&lt;/td&gt;
&lt;td&gt;connected account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;settlement country and fees&lt;/td&gt;
&lt;td&gt;platform&lt;/td&gt;
&lt;td&gt;connected account&lt;/td&gt;
&lt;td&gt;connected account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dispute liability&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;platform&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;platform&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;connected account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;capability needed&lt;/td&gt;
&lt;td&gt;&lt;code&gt;transfers&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;card_payments&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;card_payments&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If what you want is for partners to look like the merchant, &lt;code&gt;on_behalf_of&lt;/code&gt; does that. If what you want is for partners to carry the chargeback risk, it does not, and you need direct charges.&lt;/p&gt;

&lt;p&gt;I conflated those two for three weeks. Worth checking which one you actually need before the first dispute arrives and tells you.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>saas</category>
      <category>payments</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Multi-currency affiliate commissions on Stripe: which amount do you actually owe?</title>
      <dc:creator>Mihir kanzariya</dc:creator>
      <pubDate>Wed, 29 Jul 2026 02:03:26 +0000</pubDate>
      <link>https://dev.to/mihirkanzariya/multi-currency-affiliate-commissions-on-stripe-which-amount-do-you-actually-owe-4ph3</link>
      <guid>https://dev.to/mihirkanzariya/multi-currency-affiliate-commissions-on-stripe-which-amount-do-you-actually-owe-4ph3</guid>
      <description>&lt;p&gt;If your customers pay in their own currency and you pay affiliates in yours, every payment carries two different amounts. Pick the wrong one and every commission you calculate is quietly wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two amounts, two currencies
&lt;/h2&gt;

&lt;p&gt;Stripe calls the currency the customer is charged in the &lt;strong&gt;presentment currency&lt;/strong&gt;, and the currency that lands in your account the &lt;strong&gt;settlement currency&lt;/strong&gt;. When they differ, Stripe converts, and the conversion is where commission math goes wrong.&lt;/p&gt;

&lt;p&gt;The invoice tells you what the customer paid. The balance transaction tells you what you actually received.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// invoice.paid&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// presentment: what the customer was charged in&lt;/span&gt;
&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount_paid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// presentment amount, smallest currency unit&lt;/span&gt;

&lt;span class="c1"&gt;// Resolve the charge for this invoice. The field name depends on your API&lt;/span&gt;
&lt;span class="c1"&gt;// version (older versions expose invoice.charge, newer ones invoice.payments),&lt;/span&gt;
&lt;span class="c1"&gt;// so read it from your own version rather than copying a field name.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charges&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chargeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;expand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;balance_transaction&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;balance_transaction&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;bt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// settlement: your account currency&lt;/span&gt;
&lt;span class="nx"&gt;bt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// settled, before Stripe's fee&lt;/span&gt;
&lt;span class="nx"&gt;bt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;net&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// settled, after Stripe's fee&lt;/span&gt;
&lt;span class="nx"&gt;bt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exchange_rate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// the rate used for THIS payment&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most commission code reaches for &lt;code&gt;invoice.amount_paid&lt;/code&gt;, because it is right there in the webhook payload. On a single-currency account that is fine. The moment a customer pays in EUR and your account settles in USD, &lt;code&gt;amount_paid&lt;/code&gt; is a number in a currency you never received.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decide what the commission is a percentage of
&lt;/h2&gt;

&lt;p&gt;There are two defensible answers, and you have to pick one deliberately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A percentage of what the customer paid.&lt;/strong&gt; Stable from the affiliate's side: the same sale always earns the same commission in the customer's currency. You absorb the exchange rate movement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A percentage of what you received.&lt;/strong&gt; Stable from your side: you never pay out more than a fixed share of money you actually hold. The affiliate's commission moves with the rate, and you have to explain that.&lt;/p&gt;

&lt;p&gt;Neither is wrong. Shipping without deciding is, because then the code picks for you, and it picks whichever field was easiest to reach.&lt;/p&gt;

&lt;h2&gt;
  
  
  The refund trap
&lt;/h2&gt;

&lt;p&gt;This is the part that catches people out.&lt;/p&gt;

&lt;p&gt;When a currency-converted payment is refunded or disputed, Stripe converts back at the &lt;strong&gt;current&lt;/strong&gt; rate, not the rate of the original charge. From &lt;a href="https://docs.stripe.com/payments/currencies/disputes-refunds" rel="noopener noreferrer"&gt;Stripe's documentation on conversions for disputes and refunds&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Exchange rates fluctuate with the market, so the rate used during the payment can differ from the rate used when a dispute or refund occurs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Their own worked example: a 60 USD payment at 0.88 EUR per USD settles as 52.80 EUR. If the rate is 0.86 when the refund lands, only 51.60 EUR comes back out of your balance. The customer still gets a full refund in their currency. You carry the difference.&lt;/p&gt;

&lt;p&gt;So if you claw back a commission by recomputing it at today's rate, the reversal will not match what you originally recorded. A few hundred refunds later, that is a reconciliation gap nobody can explain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store the numbers, not just the percentage
&lt;/h2&gt;

&lt;p&gt;The fix is boring and it holds up. On every commission row, write down enough that you never have to re-derive anything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;commission&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;                   &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;affiliate_id&lt;/span&gt;         &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;invoice_id&lt;/span&gt;           &lt;span class="nb"&gt;text&lt;/span&gt;   &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="c1"&gt;-- what the customer paid&lt;/span&gt;
  &lt;span class="n"&gt;presentment_amount&lt;/span&gt;   &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;presentment_currency&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;   &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="c1"&gt;-- what you actually received&lt;/span&gt;
  &lt;span class="n"&gt;settled_amount&lt;/span&gt;       &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;settled_currency&lt;/span&gt;     &lt;span class="nb"&gt;text&lt;/span&gt;   &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;exchange_rate&lt;/span&gt;        &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;balance_transaction&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="c1"&gt;-- what you owe, frozen at calculation time&lt;/span&gt;
  &lt;span class="n"&gt;commission_amount&lt;/span&gt;    &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;commission_currency&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt;   &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;basis&lt;/span&gt;                &lt;span class="nb"&gt;text&lt;/span&gt;   &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- 'presentment' or 'settled'&lt;/span&gt;

  &lt;span class="n"&gt;created_at&lt;/span&gt;           &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;basis&lt;/code&gt; is the column that saves you. A year from now, when someone asks why two commissions on identical-looking sales came out different, the row says which side of the conversion it was calculated from.&lt;/p&gt;

&lt;p&gt;And on a refund, reverse the stored &lt;code&gt;commission_amount&lt;/code&gt;. Do not recompute it. The number you owed is the number you un-owe, wherever the rate has moved since.&lt;/p&gt;

&lt;h2&gt;
  
  
  One sentence for your program terms
&lt;/h2&gt;

&lt;p&gt;If you pay affiliates in their local currency, there is a second conversion between you and them, so the amount landing in their bank will not always match the number in your dashboard. Say that in the terms in one sentence.&lt;/p&gt;

&lt;p&gt;It costs nothing to disclose, and it prevents the support email that opens with "your dashboard says 40 and I received 38."&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>saas</category>
      <category>node</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Self-referral and coupon leaks: the affiliate fraud checks worth building on Stripe</title>
      <dc:creator>Mihir kanzariya</dc:creator>
      <pubDate>Tue, 28 Jul 2026 07:21:11 +0000</pubDate>
      <link>https://dev.to/mihirkanzariya/self-referral-and-coupon-leaks-the-affiliate-fraud-checks-worth-building-on-stripe-1376</link>
      <guid>https://dev.to/mihirkanzariya/self-referral-and-coupon-leaks-the-affiliate-fraud-checks-worth-building-on-stripe-1376</guid>
      <description>&lt;p&gt;Affiliate fraud in a small SaaS program is rarely sophisticated. It is usually one of two things: someone buying through their own link to pocket the commission on their own subscription, or an affiliate's coupon ending up on a deals aggregator where it converts people who were already going to buy.&lt;/p&gt;

&lt;p&gt;Neither one looks like fraud in your dashboard. Both look like growth.&lt;/p&gt;

&lt;p&gt;These are the checks worth wiring in before the program gets big enough for it to hurt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-referral: match on the card, not the email
&lt;/h2&gt;

&lt;p&gt;Email matching is the obvious check and the easiest to beat. Anyone who wants the commission will use a second address.&lt;/p&gt;

&lt;p&gt;Stripe gives you something harder to fake. Every &lt;code&gt;PaymentMethod&lt;/code&gt; carries &lt;code&gt;card.fingerprint&lt;/code&gt;, which stays the same for the same physical card even across different customers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// on invoice.paid, before you write the commission row&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paymentMethods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;default_payment_method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;expand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;card&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fp&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;fp&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;affiliate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cardFingerprint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;holdCommission&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;self_referral:card_match&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This needs the affiliate's fingerprint on file, which you have if they are also a paying customer. If they are not, fall back on cheaper signals and treat them as a score rather than a verdict:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Normalized email match (strip dots and &lt;code&gt;+tags&lt;/code&gt; on Gmail before comparing)&lt;/li&gt;
&lt;li&gt;Same billing postal code and same &lt;code&gt;last4&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Signup IP matching the IP recorded on the referral click&lt;/li&gt;
&lt;li&gt;Under 60 seconds between the click and checkout&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Any one of those alone is noise. Two or three stacking up on an affiliate's first ever conversion is worth a human look.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coupon leaks: compare redemptions to clicks
&lt;/h2&gt;

&lt;p&gt;If you hand affiliates a coupon code as well as a link, the code will eventually escape onto a deals site. Then you are paying recurring commission on customers who found you through Google and searched for a discount on the way to checkout.&lt;/p&gt;

&lt;p&gt;The signal is a redemption count with no relationship to the affiliate's traffic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clicks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;countClicks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;affiliate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;last30Days&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;redemptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;countRedemptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;affiliate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;couponId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;last30Days&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// an affiliate sending real traffic converts a fraction of their clicks&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;redemptions&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;redemptions&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clicks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;flag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;affiliate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;coupon_leak&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An affiliate with 300 redemptions and 20 clicks is not sending you anyone. Their code is just loose on the internet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cookie stuffing: heavy clicks, dead conversion
&lt;/h2&gt;

&lt;p&gt;This one runs the other direction. Someone loads your referral URL in a hidden iframe or an image tag, so every visitor to their site gets tagged, and they collect on whoever was going to convert anyway.&lt;/p&gt;

&lt;p&gt;It shows up as click volume far above your program norm with a conversion rate far below it, usually concentrated in a couple of referrer domains that are not real content pages. Log the &lt;code&gt;Referer&lt;/code&gt; header and user agent on every click and you can spot it in a single query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hold the commission, do not delete it
&lt;/h2&gt;

&lt;p&gt;The mistake I would avoid: auto-rejecting on a flag. False positives happen, and an affiliate who loses a legitimate commission to your fraud rule will tell people about it.&lt;/p&gt;

&lt;p&gt;Mark the commission pending with a reason instead, keep a normal payout hold window (30 days covers most monthly plans), and review the flagged ones by hand. At small volume there will not be many.&lt;/p&gt;




&lt;p&gt;I build &lt;a href="https://referralful.com/?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=devto-articles"&gt;Referralful&lt;/a&gt;, affiliate software for SaaS built on Stripe, so I spend a lot of time in this corner of the API. The checks above are worth thinking through whatever you run your program on, including a homegrown one.&lt;/p&gt;

&lt;p&gt;What have you actually seen get abused in your program?&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>saas</category>
      <category>node</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
