Handling EU VAT OSS/MOSS for Digital Goods
You hit this the moment you sell a digital subscription to a consumer in another EU member state. Under the EU rules for electronically supplied services, VAT is due in the customer’s country at the customer’s rate — not yours — and you report it all through a single One-Stop-Shop (OSS) return instead of registering in 27 countries. The old Mini One-Stop-Shop (MOSS) was folded into the broader OSS scheme in July 2021. For the determination mechanics this builds on, see the parent guide on VAT & GST tax calculation. This page covers the three things OSS specifically demands: two non-contradictory pieces of location evidence per sale, per-country rate application, and the quarterly return aggregation.
The defining constraint is evidence. For B2C digital sales you must obtain and retain two non-contradictory pieces of evidence of the customer’s location, and you charge the rate of the member state those proofs point to. There is also a simplification: if your total pan-EU cross-border B2C digital sales stay under EUR 10,000 per year, you may charge your home-country rate instead — but once you cross it, you charge destination rates on everything.
What makes this different from ordinary sales tax is that the taxable event and its rate are fixed at the moment of supply and can never be recomputed later from the customer record alone. A customer who moves from Germany to Austria six months after a purchase does not retroactively change the VAT on the invoice you already issued; the member state resolved at checkout is frozen onto that invoice_id. This is why the evidence and the rate must be captured inline and stored immutably rather than derived on demand — the current value of customer.country is not an acceptable input to a return you file for a past quarter. Treat every OSS-relevant figure as a fact about a transaction, not an attribute of an account.
Trade-offs
The EUR 10,000 pan-EU threshold is the pivot: below it you charge your home rate and file a domestic return; above it you charge 27 destination rates and file OSS. The map shows the four approaches split by that boundary.
| Approach | Setup effort | Per-country rate accuracy | Evidence handling | Filing burden | Best when |
|---|---|---|---|---|---|
| Stay under EUR 10k, home rate | Minimal | N/A (home rate) | Single proof sufficient | Domestic VAT return only | Early-stage, low EU volume |
| OSS via Stripe Tax | Low | Maintained for you | Stripe captures IP + address | Stripe outputs OSS figures | Already on Stripe |
| OSS via Avalara | Medium | Maintained for you | You feed evidence | Avalara prepares returns | Multi-processor / mixed stack |
| Custom rate table + OSS aggregation | High | You maintain tax_rates |
You design evidence capture | You aggregate and file | Few products, control needed |
Crossing EUR 10,000 is the decision point: below it the home-rate simplification removes nearly all complexity; above it you must apply 27 destination rates and file an OSS return, which is when a maintained engine earns its cost.
The hidden cost of the custom column is not the rate table itself — 27 standard rates plus a handful of reduced rates is a small dataset — but keeping it correct over time and defending it under audit. Rates move: a member state can announce a change with a few weeks’ notice, and a temporary reduction can expire and revert. Your tax_rates rows therefore need valid_from and valid_to bounds and a lookup that resolves against the invoice date, not now(). A maintained engine such as Stripe Tax or Avalara absorbs that maintenance and, more importantly, timestamps the rate version it applied, so an auditor asking “why did you charge 20% on this March invoice when the rate was 19%” gets an answer from the record rather than from a developer’s memory. The break-even is rarely about volume; it is about how much you want to own the liability for a rate being stale on the day of supply.
One trade-off the table understates is invoicing itself. OSS does not merely change the number you collect — it changes what the invoice must display. A destination-rate invoice to an Italian consumer shows the Italian rate and, in most member states, must be denominated so the VAT figure is legible in EUR even when you bill in another currency. If you convert at the daily European Central Bank reference rate you must store the rate used and the converted tax_amount_cents, because the OSS return is filed in EUR and any drift between the invoice conversion and the return conversion becomes a reconciliation break you will have to explain.
Step-by-Step Implementation
The four steps turn evidence into a filed return: collect two agreeing proofs, apply the destination (or home) rate, persist the evidence immutably, then aggregate per member state at quarter close. The flow shows evidence becoming a return.
1. Collect two non-contradictory proofs
Capture independent signals at checkout and require at least two that agree on a member state.
from dataclasses import dataclass
@dataclass
class OssEvidence:
billing_country: str | None # self-declared address
ip_country: str | None # geolocated at purchase
bin_country: str | None # card issuer country
def consumer_member_state(ev: OssEvidence) -> str:
proofs = [c for c in (ev.billing_country, ev.ip_country, ev.bin_country)
if c and is_eu(c)]
agreeing = max(set(proofs), key=proofs.count, default=None)
if agreeing and proofs.count(agreeing) >= 2:
return agreeing # ✅ two non-contradictory proofs
raise EvidenceConflict(proofs) # ✗ cannot safely determine member state
The is_eu filter matters as much as the majority vote. A card issued in the United States, an IP resolving to Switzerland, and a billing address in France are not three votes for France with two abstentions — they are one EU signal, which is below the two-proof standard. Discard non-EU signals before counting so a single EU proof can never masquerade as sufficient. In practice the three sources have different failure modes worth weighting in your own head even though the code treats them equally: the billing address is self-declared and trivially falsified, the BIN country is stable but reflects where the card was issued rather than where the person lives, and the IP is accurate for a stationary customer but the first thing a VPN breaks. Requiring two of the three to agree is the regulation’s way of surviving any single one of these being wrong.
Decide up front what “collect” means for a renewal. On the first charge you have a live checkout session with an IP; on the automatic renewal eleven months later there is no browser, so the IP proof is stale or absent. The defensible pattern is to freeze the member state determined at first supply onto the subscription_id and re-affirm it only when the customer’s stored billing country or card changes, rather than re-geolocating a background job that has no human behind it.
2. Apply the destination rate (or home rate under threshold)
Check the running annual cross-border total against EUR 10,000, then pick the rate.
def oss_rate_bps(member_state: str, ytd_cross_border_cents: int,
home_country: str, invoice_date: str) -> tuple[str, int]:
if ytd_cross_border_cents < 1_000_000: # EUR 10,000 in cents
return home_country, lookup_rate(home_country, invoice_date)
return member_state, lookup_rate(member_state, invoice_date) # destination rate
The comparison against 1_000_000 cents hides a subtlety: the threshold is measured against the running annual total excluding the current sale, and the flip happens on the transaction that crosses it, not the one after. If your year-to-date is EUR 9,980 and a EUR 30 sale arrives, that sale is charged at the destination rate — the threshold is breached during it, not before it. Storing ytd_cross_border_cents as a pre-computed running sum keyed by calendar year lets you make this decision in one integer comparison at charge time instead of re-summing the whole ledger on every invoice. Reset the counter at the start of each calendar year, and be careful that a refund or chargeback that reduces the running total does not silently drop you back under the line for later sales in the same period; once you have opted into destination rates for the year, the pragmatic and defensible choice is to stay there.
Note also that lookup_rate returns basis points, not a float. VAT of 20% is 2000 bps, and the tax on a base_cents of 1999 is base_cents * rate_bps // 10000 with an explicit rounding rule, computed in integer arithmetic. Never carry a rate as 0.20 through a float multiply — the half-cent errors accumulate across a quarter’s aggregation and surface as a return that is a few cents off the ledger, which is exactly the discrepancy the reconciliation step exists to catch.
3. Persist evidence on the tax transaction
Store both proofs in location_evidence so the filed return is reproducible and audit-defensible.
INSERT INTO tax_transactions
(invoice_id, customer_id, jurisdiction, treatment, tax_rate_id,
taxable_base_cents, tax_amount_cents, location_evidence)
VALUES
(:invoice_id, :customer_id, :member_state, 'standard', :tax_rate_id,
:base_cents, :tax_cents,
jsonb_build_object('billing', :billing, 'ip', :ip, 'bin', :bin));
Persist the tax_rate_id alongside the evidence, not just the resolved percentage. The rate row carries its own valid_from/valid_to provenance, so referencing it means the transaction records which version of which country’s rate was applied, and a later rate change cannot mutate history through a shared lookup. Make the row insert idempotent on (invoice_id, jurisdiction) — a retried webhook or a re-fired billing job must not create a second tax_transaction for the same supply, because the aggregation query in step four sums blindly and a duplicate row doubles that member state’s VAT on the return. Carrying the originating idempotency_key on the row makes the duplicate visible during investigation.
Treat these rows as append-only. A correction to a supply — a partial refund, a post-issue address dispute — is a new, signed reversing transaction, never an UPDATE to the original. This keeps the table a faithful history that a filed return can be regenerated from months later, and it means the OSS figure you submitted for Q2 is still reproducible after Q3’s data has landed on top of it.
4. Aggregate the quarterly OSS return
Sum taxable base and VAT per member state for the quarter — this is the OSS filing.
SELECT jurisdiction AS member_state,
SUM(taxable_base_cents) AS net_cents,
SUM(tax_amount_cents) AS vat_cents
FROM tax_transactions
WHERE treatment = 'standard'
AND determined_at >= :quarter_start
AND determined_at < :quarter_end
GROUP BY jurisdiction
ORDER BY jurisdiction; -- one row per member state on the OSS return
The OSS return is filed quarterly on calendar quarters — the periods end 31 March, 30 June, 30 September and 31 December regardless of your fiscal year — with the return and payment due by the end of the month following the quarter. Your quarter_start and quarter_end bounds must therefore align to those calendar boundaries, and the window is half-open (>= start AND < end) so a transaction stamped at midnight on the first day of the next quarter lands in exactly one return, never zero and never two. Bound the aggregation on the determined_at timestamp — the moment of supply — rather than a settlement or payout date, because it is the supply date that decides which return a sale belongs to. A payment that clears in July for a service supplied on 30 June is still a Q2 line.
Group only rows whose treatment places them in scope. Reverse-charge B2B supplies, exempt lines, and sales made under the home-rate simplification each belong on a different return or none, so filtering on treatment = 'standard' is what keeps the OSS figure from silently absorbing transactions that should have been reported elsewhere.
Verification & Testing
The tests target the three OSS-specific rules: evidence sufficiency, per-country rate correctness on the invoice date, and the threshold flip. Plus a reconciliation that ties the aggregated return to the ledger. The panel lists them.
Assert that a sale with only one proof, or two contradicting proofs, raises rather than guessing a member state. Drive a fixture customer in each of several member states and assert the applied rate matches the rate in force on the invoice date. Cross the EUR 10,000 boundary in a test: the invoice immediately below the threshold uses the home rate and the one above uses the destination rate. Run the aggregation query against a seeded quarter and assert each member-state subtotal equals the sum of its constituent tax_transactions. Reconcile the aggregated vat_cents against the vat_payable ledger lines for the same period — they must be equal, tying the OSS return back to the double-entry ledger posting.
Gotchas & Production Pitfalls
The pitfalls cluster around the threshold’s scope, the evidence standard, and the B2C-only boundary of OSS. The map groups them so each fix is one rule.
- The EUR 10,000 threshold is pan-EU and annual, not per country. It aggregates all cross-border B2C digital sales across every member state; teams who track it per country cross it without noticing and under-collect.
- One proof is not enough. A single billing-address country fails the evidence standard. If you only have one signal, you cannot safely apply OSS — capture IP and BIN country too.
- VPNs and travelling customers create contradictions. An IP country that disagrees with the billing and BIN countries should be down-weighted; let two of three agree rather than trusting IP alone.
- Rates change mid-quarter. A member state can adjust its VAT rate on a date inside your filing period; the time-bounded rate lookup must apply each sale’s date, or one return mixes two rates incorrectly.
- OSS is B2C only. A validated business customer is reverse charge, not an OSS sale — route them through reverse charge B2B VAT validation with VIES and exclude them from the OSS aggregation.
Frequently Asked Questions
Which country’s rate applies to a digital service? The customer’s, for consumer sales, which is why location evidence matters so much. Business sales to another member state are usually handled by reverse charge instead.
What counts as sufficient location evidence? Typically two non-contradictory pieces from a defined list, such as billing address and the country of the payment instrument. Store what you used, not just the conclusion.
Does the scheme remove the need for local registrations? For in-scope digital services, largely yes — one return covers the member states. It does not cover everything a business might sell, so the scope needs checking per product.
What happens when the evidence disagrees? Have a documented tie-break rule and record which evidence won. An undocumented ad-hoc decision is the finding an audit writes up, more than the rate itself.