Files
pulse/docs/plans/27-03-2026-vat-implementation-design.md
2026-03-27 11:07:20 +01:00

4.9 KiB

VAT Implementation Design

Goal

Add EU VAT handling to the Pulse checkout and subscription flow. Prices are shown and charged excl. VAT; VAT is calculated at checkout based on customer country and VAT ID, and added to the Mollie charge.

VAT Rules

Customer Rate Reason
Country = BE 21% Belgian VAT
EU country + valid VIES VAT ID 0% Reverse charge (intra-community)
EU country + no/invalid VAT ID 21% Belgian VAT (no OSS registration)
Non-EU country 0% Outside EU scope

Architecture

Backend-only VAT calculation. Frontend calls a new endpoint when country/VAT ID changes and displays the breakdown. Backend validates VIES, calculates totals, and charges the VAT-inclusive amount through Mollie.


Section 1: New Endpoint — POST /api/billing/calculate-vat

Request:

{
  "plan_id": "solo",
  "interval": "month",
  "limit": 10000,
  "country": "BE",
  "vat_id": ""
}

Response:

{
  "base_amount": "7.00",
  "vat_rate": 21,
  "vat_amount": "1.47",
  "total_amount": "8.47",
  "vat_exempt": false,
  "vat_reason": ""
}

VAT-exempt example (valid EU VAT ID):

{
  "base_amount": "7.00",
  "vat_rate": 0,
  "vat_amount": "0.00",
  "total_amount": "7.00",
  "vat_exempt": true,
  "vat_reason": "Reverse charge (intra-community)"
}

Section 2: New Backend Module — internal/billing/vat.go

Functions:

  • CalculateVAT(planID, interval string, limit int64, country, vatID string) (*VATResult, error) — main entry point
  • validateVIES(countryCode, vatNumber string) (bool, error) — calls EU VIES REST API (https://ec.europa.eu/taxation_customs/vies/rest-api/ms/{country}/vat/{number})
  • isEUCountry(country string) bool — static list of 27 EU member state codes
  • In-memory VIES cache: 24h TTL per countryCode+vatNumber key

EmbeddedCheckoutHandler changes:

  • Call CalculateVAT instead of GetSubscriptionAmount for the subscription amount
  • Store VAT breakdown in payment metadata: vat_rate, vat_amount, base_amount, total_amount

handleFirstPaymentCompleted changes:

  • Read total_amount from metadata for the recurring subscription charge (not amount)

ChangePlanHandler changes:

  • Call CalculateVAT using stored billing_country + vat_id from organization_billing

Section 3: Frontend Changes

State lifting in CheckoutContent (app/checkout/page.tsx):

  • country and vatId state lifted up from PaymentForm to CheckoutContent
  • Passed down to both PlanSummary (display) and PaymentForm (submission)

PlanSummary.tsx:

  • Accepts country, vatId, onCountryChange, onVatIdChange props
  • Country selector and VAT ID input moved here from PaymentForm
  • Calls POST /api/billing/calculate-vat on country/VAT ID change (debounced 400ms)
  • Shows VAT breakdown when country is selected:
    €7.00 /mo    excl. VAT
    VAT 21%      €1.47
    ──────────────────────
    Total        €8.47 /mo
    
  • Shows "Reverse charge" note when VAT-exempt
  • Loading spinner while VIES validates

PaymentForm.tsx:

  • Country + VAT ID inputs removed
  • Receives totalAmount, country, vatId as props
  • Submit button shows: "Start free trial · €8.47/mo"
  • Passes country + vat_id to createEmbeddedCheckout as before

PricingSection.tsx:

  • Add small "excl. VAT" label under each plan price

Data Flow

User selects country / enters VAT ID
  → PlanSummary debounce 400ms
  → POST /api/billing/calculate-vat
    → CalculateVAT()
      → validateVIES() if VAT ID provided (cached 24h)
      → return VATResult
  → Display breakdown in PlanSummary
  → Pass totalAmount to PaymentForm submit button

User clicks "Start free trial"
  → POST /api/billing/checkout-embedded (country + vat_id + card_token)
    → CalculateVAT() (re-validated server-side)
    → CreateFirstPaymentWithToken("0.00") — trial mandate, no charge
    → handleFirstPaymentCompleted()
      → CreateSubscription(totalAmount) — VAT-inclusive recurring amount

Error Handling

  • VIES API down → treat as invalid VAT ID (charge 21%) — log warning
  • VIES validation failed → show "VAT ID could not be verified, 21% VAT will apply"
  • Country not selected → no VAT breakdown shown, checkout blocked

Files Changed

Action File
New pulse-backend/internal/billing/vat.go
New POST /api/billing/calculate-vat in billing_handlers.go
Modified EmbeddedCheckoutHandler in billing_handlers.go
Modified handleFirstPaymentCompleted in billing_handlers.go
Modified ChangePlanHandler in billing_handlers.go
Modified pulse-frontend/app/checkout/page.tsx
Modified pulse-frontend/components/checkout/PlanSummary.tsx
Modified pulse-frontend/components/checkout/PaymentForm.tsx
Modified pulse-frontend/components/PricingSection.tsx