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.

OSS approach trade-offs Below the threshold the home-rate simplification applies; above it, Stripe Tax, Avalara, or a custom rate table handle destination rates and OSS filing. Under EUR 10k home rate one proof OK domestic return Stripe Tax OSS rates maintained captures evidence on Stripe already Avalara OSS rates maintained prepares returns mixed stack Custom you maintain rates you aggregate few products
The EUR 10,000 threshold is the decision point — below it the home-rate simplification removes nearly all complexity.
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.

OSS implementation flow Two agreeing proofs resolve a member state, the destination rate applies, the evidence persists on an immutable transaction, and quarterly aggregation produces the OSS return. 1 Two proofs member state 2 Rate dest / home 3 Persist evidence JSONB 4 Aggregate OSS return
Evidence becomes a filed return — two agreeing proofs in, one row per member state out.

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.

OSS verification tests One or contradictory proofs raise, per-country rates match the invoice date, the EUR 10k boundary flips home to destination, and aggregated VAT ties to vat_payable. Evidence one / conflicting raises Per-country rate on date matches Threshold cross EUR 10k home → dest Reconcile return vs ledger equal
Four tests — the threshold-flip and the ledger reconciliation are the two that prove the return is correct.

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.

OSS pitfalls The threshold is pan-EU not per-country, one proof is insufficient, VPNs create contradictions, rates change mid-quarter, and OSS excludes B2B reverse charge. Threshold pan-EU annual → not per-country One proof insufficient → capture IP+BIN VPN IP disagrees → two of three Mid-quarter rate change → date lookup B2C only excludes B2B → RC separately
Five pitfalls — the pan-EU threshold scope and the two-proof standard are the ones teams get wrong first.
  • 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.