DEV Community

Mihir kanzariya
Mihir kanzariya

Posted on

Your affiliate commission should not include VAT, and invoice.total does

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.

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.

Tax you collect is not revenue you earned

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.

It is easy to agree with that sentence and still write invoice.total, because that is the field with the obvious name.

Tax-exclusive pricing is the easy case, inclusive is the trap

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.

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.

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.

The fields, with a warning about your API version

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 invoice.tax and the total_tax_amounts array were reorganised into a total_taxes 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.

What each amount field means, as of the versions we have run:

subtotal 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.

subtotal_excluding_tax strips tax out but still sits before invoice-level discounts.

total_excluding_tax is the one you usually want. It applies every discount and then removes all tax, inclusive tax included.

total is the end of the line. Discounts applied, tax applied, this is what the customer owes.

The distinction that decides your number is subtotal_excluding_tax versus total_excluding_tax, 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.

const invoice = await stripe.invoices.retrieve(invoiceId);

// total_excluding_tax: discounts applied, all tax removed (inclusive tax too).
// Falling back to `total` here would silently commission on VAT.
const base = invoice.total_excluding_tax ?? invoice.total;

const commissionCents = Math.round(base * 0.30);
Enter fullscreen mode Exit fullscreen mode

That ?? fallback is a decision, not a formality. If the field comes back null on your version, falling through to total 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.

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 NaN 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.

The discount question rides along

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?

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.

Refunds inherit the same mistake

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.

const refund = await stripe.refunds.retrieve(refundId);

// Apply the same ex-tax ratio the original invoice had.
// refund.amount is gross, so scaling by it directly over-claws on the tax.
const taxRatio = base / invoice.total;
const clawbackCents = Math.round(refund.amount * taxRatio * 0.30);
Enter fullscreen mode Exit fullscreen mode

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.

Then explain it, in one sentence, before anyone asks

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.

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.

What to check on your own account

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

Top comments (4)

Collapse
 
to21as profile image
Tobias

This is the same trap the EU invoicing standard walked into, and the way EN 16931 handled it maps almost one-to-one onto the Stripe fields you list.

The standard does not give you a "total" and a "subtotal". Every amount gets its own identifier and the relationships between them are written down as rules. The sum of the line net amounts (BT-106) is a separate field from the invoice total without VAT (BT-109), and what sits between the two is document-level allowances and charges (BT-107 and BT-108). Rule BR-CO-13 states it outright: BT-109 = sum of line nets − BT-107 + BT-108.

Your subtotal_excluding_tax is BT-106, your total_excluding_tax is BT-109, and the invoice-level coupon you correctly identify as the deciding difference is exactly BT-107. Whoever wrote that rule hit your bug first.

The part worth stealing is not the vocabulary though, it is that the standard makes the arithmetic a checkable rule rather than a field you have to pick correctly. A validator rejects a document whose totals do not reconcile, so the wrong field cannot pass silently. Your commission code has the same structure and no such check, which is precisely why the test suite stays green on any invoice without a document-level discount. Asserting total_excluding_tax + total_tax == total before every payout (that is BR-CO-15) catches the null fallback, the NaN and the wrong field in one line.

Collapse
 
mihirkanzariya profile image
Mihir kanzariya

The framing I should have used is yours: make the arithmetic a checkable rule rather than a field you pick correctly. One refinement though. BR-CO-15 validates the invoice against itself. It catches the null fallback and the NaN, but it says nothing about which amount I assigned to the commission base. Set base = subtotal_excluding_tax and it still passes cleanly, and the payout lands on list price rather than on what the customer actually paid.

The rule that catches that is the other one you quoted. If BT-109 is the line net sum minus BT-107 plus BT-108, then the gap between subtotal_excluding_tax and total_excluding_tax is the document-level allowance. So the commission-side assert compares those two and requires their difference to equal the invoice discount, which forces a deliberate choice about which side of the gap you pay on. A validator can reject a document, but a payout run has to decide something, so that check has to fail one row for a human rather than halt the batch.

Collapse
 
to21as profile image
Tobias

Fair correction, and the "wrong field" part of mine was just wrong. BR-CO-15 is an identity inside one document, and both BT-106 and BT-109 are correct amounts on a correct invoice, so no amount of validating the invoice tells you which one belongs in a payout. The decision happens outside the document and no intra-document rule reaches it.

On the check you would put in its place, it depends where the discount figure comes from. Source it independently, from the discount object rather than from the two totals, and it is a genuine cross-check that survives the field reshuffles you warn about. Derive it from the same two amounts and it is BR-CO-13 restated, so it cannot fail.

Either way I do not think it forces the choice, because the gap is zero on most invoices. What forces it is the weaker signal: flag the row whenever the gap is non-zero at all. That is exactly the population where your two candidate bases disagree, and the same set your test suite never covers.

The other half is that the choice has to be legible to the affiliate, which loops back to your closing fix. "Commission is calculated on the net amount excluding VAT, sales tax and GST" settles the tax question completely and leaves the discount one open, so a partner running a 20% coupon still cannot tell from the terms whether it comes out of their base. That sentence probably wants a second clause.

Your last point is the sharper end of the whole thing. The rulesets do make that distinction, as it happens: rules carry a severity, and a validation report separates errors that reject a document from warnings that only annotate it. A payout run wants the warning kind here, one row held for a human and a batch that still finishes.

Thread Thread
 
mihirkanzariya profile image
Mihir kanzariya

The circularity is real: if the discount figure comes out of the same two totals, the assert is BR-CO-13 restated and it cannot fail, so it only earns its keep when the discount is read off the discount or coupon object itself. And your trigger is the better one. Mine passes quietly on every invoice where the gap is zero, which is most of them, while flagging any non-zero gap fires exactly on the rows where subtotal_excluding_tax and total_excluding_tax disagree, and that is the population nobody writes fixtures for.

On the terms language, here is the second clause I would add rather than just agreeing one is needed: "Commission is calculated on the net amount excluding VAT, sales tax and GST, and after any discount, coupon or credit applied to the order." That sentence commits you to paying on what the customer actually paid instead of list price, which is a decision about money and not a wording cleanup, so anyone who means the opposite now has to say the opposite out loud. Your severity point lands the same way operationally: warning-level only works if the payout run has a per-row hold state, since a batch-level abort means one annotated row keeps everybody else from getting paid.