157 lines
4.9 KiB
Markdown
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` |
|