4.9 KiB
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 pointvalidateVIES(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+vatNumberkey
EmbeddedCheckoutHandler changes:
- Call
CalculateVATinstead ofGetSubscriptionAmountfor the subscription amount - Store VAT breakdown in payment metadata:
vat_rate,vat_amount,base_amount,total_amount
handleFirstPaymentCompleted changes:
- Read
total_amountfrom metadata for the recurring subscription charge (notamount)
ChangePlanHandler changes:
- Call
CalculateVATusing storedbilling_country+vat_idfromorganization_billing
Section 3: Frontend Changes
State lifting in CheckoutContent (app/checkout/page.tsx):
countryandvatIdstate lifted up fromPaymentFormtoCheckoutContent- Passed down to both
PlanSummary(display) andPaymentForm(submission)
PlanSummary.tsx:
- Accepts
country,vatId,onCountryChange,onVatIdChangeprops - Country selector and VAT ID input moved here from
PaymentForm - Calls
POST /api/billing/calculate-vaton 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,vatIdas props - Submit button shows: "Start free trial · €8.47/mo"
- Passes
country+vat_idtocreateEmbeddedCheckoutas 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 |