Changelog

Information tool, not tax advice. 5 releases.

Unreleased

Unreleased

Phase 4 — Acquisition Surface — fully shipped this session (Waves 0–6 of 6). UAT 13/13 pass with 0 issues. Awaiting /gsd-complete-phase 4 to roll into [0.4.0].

Fixed

  • PWA service worker serving stale buildsCacheFirst with 30-day TTL on all JS/CSS/HTML meant users were stuck on old builds after deployment. Switched to StaleWhileRevalidate (serve cached, refresh in background; 7-day TTL), added cleanupOutdatedCaches: true, skipWaiting: true, and clientsClaim: true so new SWs activate immediately across all open tabs. Added no-cache, no-store header for sw.js and workbox-*.js in vercel.json so browsers always check for SW updates instead of serving a stale worker file from Vercel's CDN.

  • Magic link login broken (cross-browser) — PKCE flow (flowType:'pkce') requires the code_verifier to be in the same browser storage where the magic link was requested. When users click the link from an email client that opens a WebView or different browser, the verifier is absent and the exchange silently fails, showing "The login link is invalid or has expired." Reverted flowType to 'implicit' in supabase.ts so Supabase redirects with tokens in the URL hash, which works across any browser or context.

  • Magic link login broken (double exchange)AuthCallback.tsx called exchangeCodeForSession() manually 100ms after mount, but detectSessionInUrl:true already auto-exchanges the code on Supabase client init. The second call failed with "code already used", setError() fired after setSuccess(), and the error UI replaced the success state. Fixed by removing the manual exchange call entirely, subscribing to onAuthStateChange as the primary path, and adding a 4-second getSession() fallback guarded by a handled flag to prevent double-firing.

  • CI: backend migration test shimback/tests/sql/00_test_auth_shim.sql now creates the anon role alongside authenticated and service_role, fixing ERROR: role "anon" does not exist when CI runs Supabase migrations against a plain Postgres 15 container.

  • CI: SSG build missing Supabase env — added placeholder VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY, and VITE_API_URL env vars to the build step in .github/workflows/ssg-checks.yml, preventing the prerender crash caused by the supabase.ts module-load guard.

  • AuthContext session-expiry tests — two tests were asserting sessionExpiring after 25 min, a leftover from a previous 30-min idle timeout; corrected the timer advance to the real threshold (IDLE_TIMEOUT_MS − WARNING_BEFORE_TIMEOUT_MS = 7h 45m) so both tests pass against the current 8h / 15min constants.

Added

  • US 1040-NR treaty-rate auto-lookup: the /api/forms/f1040nr/generate endpoint now resolves the Schedule NEC dividend rate from the filer's residence country (new optional residence_country on the request, falling back to citizenship/foreign-address) via services.tax_calc.resolve_rate. The rate is applied only when a real treaty exists; countries with no US treaty keep the 30% statutory rate, and a per-dividend treaty_rate always wins. (ITIN/W-7 and Form 1042-S guidance is surfaced on the cover sheet via the f1040nr registry note.)
  • Form filability status: a per-form registry (back/services/form_status.py) classifies every generated form as an official fileable form, a worksheet to transcribe into an official e-filing, or an application letter. The PDF disclaimer cover sheet now shows a matching status banner, a GET /api/forms/{form_code}/status endpoint exposes it, and the Claims page shows a status chip. Stops presenting non-fileable reconstructions (ES Modelo 210, DE Anlage KAP, UK SA106, FR 2047, IT, PL, CH, etc.) as official filings.
  • vite-react-ssg + @mdx-js/rollup build pipeline; 7 public routes (/, /pricing, /security, /sources, /privacy, /terms, /changelog) prerendered with title, meta, OG/Twitter, JSON-LD, and persistent disclaimer.
  • All 12 legacy programmatic-SEO routes (/tax-guide/*, /popular-*, /recent-interest-bonds, etc.) ported onto the same SSG pipeline — 455 prerendered HTML files (was 15) with strictly richer content vs the legacy prerender.mjs template-injection approach.
  • Single-pipeline sitemap.xml (front/scripts/build-sitemap.mjs) covering all 455 routes.
  • Landing page rewritten (Index.tsx): SSR-safe Hero with worked-example metric, TrustStrip, ValueProp, HowItWorks, FAQ, dual disclaimer (hero-adjacent + persistent footer), Organization + WebSite + SoftwareApplication JSON-LD.
  • LandingCalculatorWidget (4-input embedded calculator) calling /api/calc/refund anonymously with localStorage-persisted inputs.
  • Anonymous-to-authenticated claim migration: migrateAnonClaim.ts (extracted async function, idempotent via Set<userId> guard) wired into AuthCallback (await-before-redirect), AuthContext.onAuthStateChange (fire-and-forget), and AppShell (defense-in-depth).
  • ?next= redirect allow-list ({checkout, dashboard}) protecting the post-auth navigation path.
  • /sources page with build-time citation table generated from back/data/rates/{treaty_rates.json,deadlines.json} provenance fields (build-sources-table.mjs prebuild step).
  • /changelog page parses repo-root CHANGELOG.md via build-changelog.mjs prebuild; per-entry JSON-LD Article objects.
  • /security MDX page with infrastructure table and vulnerability-reporting section.
  • /privacy and /terms (TSX) with full GDPR-aware policy / ToS copy.
  • /pricing page with Free vs Pro comparison table generated from pricing.ts (mirrors backend PLAN_LIMITS), 6-entry FAQ accordion, Product + Offer JSON-LD, signed-in-aware CTA: anonymous → /register?next=checkout, authenticated → BillingPage Stripe Checkout.
  • Cross-language PLAN_LIMITS drift detection: Python pytest at back/tests/services/test_plan_limits_sync.py reads front/src/content/pricing.snapshot.json, asserts keysets match backend entitlements.PLAN_LIMITS. CI fails on drift.
  • BillingPage auto-fires Stripe Checkout when mounted with ?next=checkout query, completing the post-auth conversion path.
  • 14 build-artifact validators (check-prerender.mjs, check-jsonld.mjs, check-sitemap.mjs, check-csr-routes.mjs, check-disclaimer.mjs, check-mdx-pages.mjs, check-mobile-overflow.mjs) wired into the npm run verify:prerender aggregate and a new .github/workflows/ssg-checks.yml CI workflow.
  • Frontend test scaffolding for V-01..V-14 + V-07b validation anchors (apiClient anonymous-Authorization-header pin, widget submission, localStorage persistence, anon-claim migration ordering, pricing rows derivation).
  • Mobile-360 V-12 layout assertion (Plan 04-09 / D-15 / H1): Playwright
    • chromium installed, front/playwright.config.ts and front/tests/mobile-360.spec.ts iterating the 10 D-15 surfaces at 360x640 and asserting scrollWidth <= innerWidth plus pricing-CTA tap-target >= 44px (M4 / WCAG 2.2 AA). New npm run test:mobile script. Structural-overflow lint (check-mobile-overflow.mjs) is green and bundled into verify:prerender.

Changed

  • Migrated all programmatic-SEO routes from the legacy template-injection prerender.mjs script onto vite-react-ssg.
  • Pinned react-helmet-async to ^1.3.0 for vite-react-ssg compatibility (v2 ESM/CJS interop fails with the current SSG runner).
  • i18n SSR preload: server-side EN namespace mirroring under front/src/i18n/locales-en/ to prevent translation-key leakage into prerendered HTML on programmatic-SEO routes.
  • Build script: added prebuild (sources + changelog generators) and postbuild (sitemap) steps; build now uses vite-react-ssg build.

Fixed

  • FR 2047 / UK SA106 foreign tax credit cap: the credit is now capped at the residence country's tax on the foreign income (FR: impôt français correspondant; UK: UK tax on the income), preventing over-claiming. The FR/UK endpoints accept an optional residence_tax_on_income; when provided, the credit shown is the lower of the treaty-rate foreign tax and that figure, with both lines displayed. When omitted, the figure is shown with a prominent limitation note rather than silently overstated.
  • Remaining-forms audit (DK/FI + template-fillers, web-verified): FI Form 16 retitled to 16B ("Selvitys ulkomaantuloista (pääomatulot)", code 3062) with a credit-cap caveat (it was a bare "Form 16"); SE se-dividend reclassified as a worksheet (the default endpoint emits a reconstruction; the real SKV 3740 is an XFA PDF needing Adobe Reader); DK 06.030 registry note now points to the online portal (udbytterefusion.skat.dk), the residence-cert/ DKK-bank requirements, and the 5-year deadline. The template-filler field maps (NL a12119, CA NR7-R, AU NAT, JP 250, FI 6167) were verified to reference only real AcroForm fields — no silent drops.
  • Residence-credit forms audit (web-verified against official sources): the form-status registry now carries the verified channel/deadline/caveats for IT/PL/AT/BE/IE/NL/SE/NO, and the generators were corrected where they misstated the form or credit:
    • SE: relabelled K4 (SKV 2104, securities-sales form) → SKV 2703 "Avräkning av utländsk skatt", the actual foreign-tax-credit form.
    • BE: flagged that QFIE is not available on foreign dividends (interest/ royalties only, narrow France-treaty exception) — dividends are taxed at 30% on the net and reported in code 1444/2444.
    • IT: flagged that Quadro CE is for business income; a retail dividend credit (art. 165 TUIR) goes on Quadro CR when taxed ordinarily, and gets no credit under the 26% imposta sostitutiva regime.
    • NO RF-1147: flagged as retired from income year 2023 (credit now entered in the online skattemelding; valid for 2022 and earlier).
    • Registry caveats added for PL (treaty credit capped at min(treaty,19%), not "ulga abolicyjna"; PIT-38 for dividends), AT (E 1kv, KZ 863/998, 15% cap), IE (Form 12/myAccount for sub-€5,000), NL (Te verrekenen belasting, tweede- limiet cap + carry-forward).
  • DE Anlage KAP (de_anlage_aus_generator.py): the German foreign-dividend withholding-tax credit generator produced the wrong form (Anlage AUS). It now produces an Anlage KAP worksheet with the correct lines (Zeile 19 foreign capital income, Zeile 41 creditable foreign tax), cites ELSTER/the Finanzamt (www.elster.de) instead of the incorrect www.bzst.de, and is framed as a transcription aid for ELSTER. generate_de_anlage_kap_pdf added as the preferred alias (legacy symbol/endpoint kept).
  • NO RF-1534 (rf1534_generator.py, main.py): the VPS/nominee securities-account number (required by Skatteetaten) was hardcoded empty; it is now a vps_account field threaded through the endpoint and filled on each dividend row. Docstring and templates/no/instructions.txt note that RF-1534 is the legacy paper form and the current route is RF-1552e/1553e/1554e via rdt.skatteetaten.no (5-year deadline, residence certificate required).
  • US 1040-NR Schedule NEC (us_1040nr_generator.py, main.py): US-source dividends paid to a non-resident are FDAP income taxed at the treaty rate on Schedule NEC, not effectively-connected income. The generator previously put them on line 3b (graduated rates) with withholding on line 25b (1099) and never produced Schedule NEC — wrong tax and wrong refund. Dividends now flow to Schedule NEC (grouped by treaty rate; a missing rate falls back to the 30% statutory column), the NEC tax lands on line 23a, the US tax withheld on line 25g (Form 1042-S), and the filled Schedule NEC page is appended to the output. The US endpoint now accepts a treaty_rate per dividend to drive the rate column. Refund correctly equals withheld tax minus treaty-rate tax.
  • PDF-form accuracy (from the 2026-05-29 official-form audit):
    • ES Modelo 210 (model_210_generator.py): no longer stamps a fabricated random "Número de justificante" (that receipt number is assigned by AEAT on filing); and the IRNR rate is derived from residence (EU/EEA 19%, others 24%) instead of a blind 19% default.
    • FR 2047 (fr_2047_generator.py): foreign-dividend tax credit box corrected from 8TK to 8VL (8TK is the salaries/pensions method); dropped spurious 2BH.
    • UK SA106 (gb_sa106_generator.py): replaced non-existent box labels F1–F4/F20–F22 with the real SA106 references (columns A–F, boxes 5/6/2).
  • PDF rendering across the 11 WHT-reclaim generators (back/pdf_services/{bg,cz,ee,hr,hu,lt,lv,ro,rs,si,sk}_whtr_generator.py): table-cell figures were clipped by a crude character slice (val[:int(w/2.0)]) that dropped digits and currency codes (e.g. 300.000,0, 125.430,00 C); numeric columns are now right-aligned with width-aware fitting (fit) and shrink-to-fit (shrink_to_fit) so amounts are never truncated. Totals row no longer overflows the page edge, and section-label banner text is no longer vertically clipped.
  • Non-Latin-1 glyphs (Czech/Polish/Hungarian/Baltic — ě č ř ł ő ű ā…) rendered as empty "tofu" boxes under Helvetica. Bundled DejaVu Sans (back/pdf_services/fonts/) via a shared back/pdf_services/_pdf_fonts.py module (idempotent registration, graceful Helvetica fallback); all 11 WHT-reclaim generators now use it so titles, names, and claimant data render correctly. Regression tests added in back/tests/test_pdf_visual.py.
  • B2 (route-table conflict): all front/src/routes.tsx writes confined to Wave 1 (Plans 04-02 + 04-03) by pre-registering all 7 public routes with placeholder modules upfront. Wave 2+ plans modify only page-module bodies — structurally impossible to conflict on the route table.
  • B3 (anonymous-claim migration on ?next=checkout path): AuthCallback now awaits migrateAnonClaim before any window.location.assign to Stripe Checkout, ensuring ACQ-03 success criterion #6 holds on the highest-value conversion path.
  • H6: every public prerendered route composes the canonical LandingFooter, not just /. check-disclaimer.mjs extended to assert presence on all 7 routes.
  • D-15 mobile audit-and-patch (Plan 04-09): all decorative blur orbs in marketing sections (FAQSection, Footer, HowItWorksSection, StatisticsSection, TaxCalculator, TaxCreditsVsRefundsSection, TestimonialsSection, TrustSection) gated to md: breakpoints with pointer-events-none, eliminating any chance of off-screen layout bleed at 360px. Genuine false positives (modal-bound widths, max-w-[Npx] regex collisions, sub-360px Selects, decorative 1px separator) recorded in front/.mobile-overflow-allowlist with per-entry rationale.

Removed

  • Legacy front/scripts/prerender.mjs post-build template-injection script (Plan 04-08, D-01 single pipeline). vite-react-ssg is now the sole producer of prerendered HTML across all 455 public routes.
  • Legacy front/scripts/generate-sitemap.mjs script. dist/sitemap.xml is produced exclusively by front/scripts/build-sitemap.mjs walking the SSG build output (postbuild step).
  • Static front/public/sitemap.xml. The build-time dist/sitemap.xml is the single canonical sitemap.
  • build:legacy npm script (and its vite build && node scripts/prerender.mjs invocation). npm run build is the only build entry point.

0.4.0

Phase 4: Acquisition Surface

Anonymous-visitor acquisition flow: prerendered marketing site, embedded calculator, pricing tied to entitlements.

Added

  • Marketing landing page (/) rebuilt in fintech-polish style with embedded calculator widget reachable above the fold.
  • Public routes prerendered via vite-react-ssg: /, /pricing, /security, /sources, /privacy, /terms, /changelog — real HTML with title, meta, OG/Twitter cards, JSON-LD, and persistent disclaimer.
  • Pricing page (/pricing) with Free vs Pro rows derived from the Phase 02 entitlements config; signed-in-aware CTA into Stripe Checkout.
  • /sources page rendered from the calculator's actual provenance fields (treaty_rates.json, deadlines.json) at build time — citations cannot drift from the rates the calculator uses.
  • Single-pipeline sitemap.xml covering all 455 public + programmatic-SEO routes.
  • MDX pipeline for content pages.
  • Anonymous-to-authenticated calculator state migration via localStorage.
  • Mobile audit pass at 360px viewport (audit-and-patch, no redesign).

Changed

  • Migrated all programmatic-SEO routes (/tax-guide/*, /popular-*, /recent-interest-bonds, etc.) from the legacy template-injection prerender.mjs script onto vite-react-ssg.
  • Pinned react-helmet-async to ^1.3.0 for vite-react-ssg compatibility.

Removed

  • Legacy front/scripts/prerender.mjs post-build template injection.

0.3.0

2026-05-07 — Phase 3: Calculation Quality & Trust Data

Treaty-rate audit, source citations, and edge-case verification — the core moat is now defensible.

Added

  • back/services/tax_calc.py pure-function core: compute_refund, credit_quality, treaty-rate normalisation. 19 tests, 98% coverage.
  • /api/calc/refund endpoint (anonymous-friendly) and /api/treaty-rate
    • /api/wht-rates thin wrappers.
  • /api/deadlines/{country} per-country deadline configuration.
  • RateBadge component on the frontend showing "as of" dates and citations next to every rate the calculator displays.
  • Per-rate provenance fields (source, source_url, as_of_date) on treaty_rates.json for the top user-relevant country pairs.
  • Edge-case golden fixtures for Swiss DA-1 vs Form 70, Netherlands 2-year deadline, Form 60 umbrella for Southern/Eastern Europe.
  • 239 calc tests; audit-matrix completeness sweep.

Changed

  • Replaced ad-hoc local arithmetic in the frontend TaxCalculator with calls to the backend resolver — single source of truth.
  • CI splits backend / frontend test workflows; PR-comment coverage report via py-cov-action.

0.2.0

2026-05-07 — Phase 2: Billing Core

Stripe-backed Pro subscription, EU VAT collection, and server-side paywall enforcement.

Added

  • Stripe Checkout integration; /api/billing/checkout, /portal, /sync, /entitlements.
  • Webhook handler with idempotent event processing (webhook_events.event_id unique constraint replays cleanly).
  • Server-side entitlement gates on PDF generation, multi-country aggregation, and the optimizer — Free callers receive HTTP 402 regardless of frontend state.
  • EU VAT collection via Stripe Tax; B2B VAT ID validation through VIES.
  • 14-day right-of-withdrawal refund flow exposed through the Stripe Customer Portal.
  • useEntitlements React hook + PaywallModal + BillingPage.
  • GDPR data-export and account-deletion endpoints.

0.1.0

2026-05-01 — Phase 1: Foundation & Plumbing

Structural groundwork: migrations, router skeleton, observability, test harness, and current-year tax-data freshness gate.

Added

  • back/api/*.py router pattern; httpx.AsyncClient test infrastructure.
  • Database tables subscriptions, usage_counters, webhook_events, email_log, analytics_events with Row Level Security enabled.
  • profiles.organization_id defaults to user_id (single-user-as-org model).
  • Year-bound utility (datetime.now().year + 1); CI fails when the current-year WHT/treaty rate file is missing.
  • Sentry initialisation on backend + frontend with environment separation and PII scrubbing in before_send.
  • GitHub Actions CI workflow.