Skip to content

feat(vat): EU place-of-supply VAT, per-payment snapshot, retire vm_payment#161

Merged
v0l merged 1 commit into
masterfrom
feat/vat-place-of-supply
Jul 15, 2026
Merged

feat(vat): EU place-of-supply VAT, per-payment snapshot, retire vm_payment#161
v0l merged 1 commit into
masterfrom
feat/vat-place-of-supply

Conversation

@v0l

@v0l v0l commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements EU VAT correctly for an IE/OSS-registered company and rolls in the retirement of the defunct vm_payment table.

Previously the tax charged was a flat lookup in a 2-entry config map (IE 23%, placeholder US 1%) keyed on the user's self-declared country — everything else was 0%, with no location evidence and no reverse-charge handling.

What changed

Place-of-supply determination (PricingEngine::determine_tax)

  • Seller = the VM's company; customer = the paying user.
  • B2B with a VIES-validated VAT number: same country → domestic VAT; other EU country → reverse charge (0%); non-EU → out of scope.
  • B2C: place of supply from self-declared country, falling back to IP-derived country → destination rate via OSS; non-EU → out of scope.
  • Undetermined: conservative seller-domestic rate, flagged.

Location evidence (#1)ClientIp extractor (X-Forwarded-For/X-Real-IP) + local MaxMind CountryResolver, captured on account updates and both VM-order endpoints, stored separately from the self-declared country.

Live rate table (#3)EuVatClient → cloneable VatClient with an Arc rate cache, refreshed at startup + daily. Static tax_rate config removed. VAT is 0% until the first successful refresh.

Per-payment snapshot (#4) — each subscription_payment freezes a per-line-item tax_breakdown (JSON, authoritative), a uniform summary (tax_rate/tax_country_code/tax_treatment, NULL when a payment mixes rates/treatments), and customer tax_evidence. The per-line design future-proofs payments whose lines resolve to different sellers. Surfaced in admin time-series reports.

Invoices (#5) — show the applied VAT rate line and print reverse-charge (Article 196) and out-of-scope legal notes.

vm_payment retirement — the table is dropped; the backfill payment phase, models, queries and DTO conversions are removed (the ApiVmPayment response shape and vm_payment RBAC resource are unchanged). Demo data now writes subscription_payment.

Behaviour change

Non-EU customers are now correctly out of scope (0%) (the placeholder US 1% map entry is gone).

Migrations (irreversible)

  • 20260716000000_user_geo_location — geo columns on users
  • 20260716120000_subscription_payment_vat_snapshot — VAT snapshot columns
  • 20260716130000_drop_vm_paymentDROP TABLE vm_payment (assumes prod is already backfilled into subscription_payment)

Tests

400 unit/integration tests pass (incl. an end-to-end renewal asserting the snapshot is persisted), clippy + fmt clean. lnvps_e2e requires a live stack and is not run here.

⚠️ Note

Get the final policy — especially the "undetermined default" rule and the two-evidence requirement — signed off by an Irish VAT/OSS accountant before relying on it.

@v0l
v0l force-pushed the feat/vat-place-of-supply branch 4 times, most recently from b2e2ab3 to 6284fb4 Compare July 15, 2026 14:30
v0l added a commit to LNVPS/web that referenced this pull request Jul 15, 2026
Add VAT section covering reverse charge for EU business customers,
IP-based location determination, and non-EU out-of-scope treatment,
aligning with LNVPS/api#161.
@v0l
v0l force-pushed the feat/vat-place-of-supply branch from 6284fb4 to ca4304e Compare July 15, 2026 14:51
… vm_payment

Implements EU VAT and records the tax basis on each payment. The seller country
is taken from the company's own VAT number (tax_id, i.e. its VIES registration)
when set, otherwise from the company country_code. EU VAT applies only when that
country is in the EU VAT area; a non-EU seller (e.g. a US company) charges no tax
here (other systems such as US sales tax are not handled).

- Rate selection (determine_tax): customer with a VAT number -> same country as
  seller = seller rate, other EU country = 0%, non-EU = 0%; otherwise customer
  country (self-declared, else IP-derived) -> EU country rate, non-EU = 0%; no
  customer country -> seller-country fallback rate.
- Location evidence: ClientIp extractor (X-Forwarded-For/X-Real-IP) + local
  MaxMind CountryResolver, captured on account updates and both VM-order
  endpoints, stored on the user separately from the self-declared country.
- Rates: a single shared VatClient (Arc-backed in-memory cache) is created once
  per process, refreshed at startup and daily, and injected into
  SubscriptionHandler so every PricingEngine shares the same cache. The static
  tax_rate config is removed. Rates are 0 until the first refresh. (VIES number
  validation and refund math use a standalone client, no cache needed.)
- Per-payment record: each subscription_payment stores a per-line tax_breakdown
  (JSON), a uniform summary (tax_rate/tax_country_code/tax_treatment, NULL when
  lines differ) and the customer country signals (tax_evidence). Surfaced in
  admin time-series reports.
- Invoices show the applied rate; EU-specific reverse-charge (Article 196) and
  out-of-scope notes render only for EU sellers.
- Retire the defunct vm_payment table: drop it, remove models, queries and DTO
  conversions. ApiVmPayment response shape and the vm_payment RBAC resource are
  unchanged. Demo data writes subscription_payment.
- Remove the completed VM->subscription startup backfill and its migration-only
  DB helpers (VmForMigration etc.).
- README notes the EU-only scope, the seller-from-VAT-number rule, and that this
  is not tax advice.

Behaviour change: non-EU customers/sellers are 0% (the placeholder US 1% map
entry is gone).
@v0l
v0l force-pushed the feat/vat-place-of-supply branch from ca4304e to 434050d Compare July 15, 2026 15:17
@v0l
v0l merged commit 48e800c into master Jul 15, 2026
8 checks passed
@v0l
v0l deleted the feat/vat-place-of-supply branch July 15, 2026 15:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant