From 4e9439770ff12252c76dce0a5307d11a532e1744 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 27 Mar 2026 11:07:20 +0100 Subject: [PATCH] docs: add VAT implementation design --- .../27-03-2026-vat-implementation-design.md | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 docs/plans/27-03-2026-vat-implementation-design.md diff --git a/docs/plans/27-03-2026-vat-implementation-design.md b/docs/plans/27-03-2026-vat-implementation-design.md new file mode 100644 index 0000000..8a04f13 --- /dev/null +++ b/docs/plans/27-03-2026-vat-implementation-design.md @@ -0,0 +1,156 @@ +# 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` |