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

157 lines
4.9 KiB
Markdown

# 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:**
```json
{
"plan_id": "solo",
"interval": "month",
"limit": 10000,
"country": "BE",
"vat_id": ""
}
```
**Response:**
```json
{
"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):
```json
{
"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` |