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.

Trade-offs

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.

Step-by-Step Implementation

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

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

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));

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

Verification & Testing

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