From 5434801c0259955c3fab61cae5207eba486d8120 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:17:16 +0100 Subject: [PATCH 1/4] feat(web): currency detection utilities and public /api/currency endpoint Country to currency mapping mirrors Stripe Checkout's IP localization (GBP for UK and crown dependencies, EUR for eurozone countries and territories, USD otherwise) so displayed prices always match what checkout presents. --- apps/web/__tests__/unit/currency.test.ts | 90 ++++++++++++++++++++++++ apps/web/app/api/currency/route.ts | 21 ++++++ apps/web/hooks/useCurrency.ts | 42 +++++++++++ apps/web/utils/currency.ts | 88 +++++++++++++++++++++++ 4 files changed, 241 insertions(+) create mode 100644 apps/web/__tests__/unit/currency.test.ts create mode 100644 apps/web/app/api/currency/route.ts create mode 100644 apps/web/hooks/useCurrency.ts create mode 100644 apps/web/utils/currency.ts diff --git a/apps/web/__tests__/unit/currency.test.ts b/apps/web/__tests__/unit/currency.test.ts new file mode 100644 index 0000000000..1dd0369864 --- /dev/null +++ b/apps/web/__tests__/unit/currency.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { + currencyForCountry, + currencySymbol, + formatAmount, + SUPPORTED_CURRENCIES, +} from "@/utils/currency"; + +describe("currencyForCountry", () => { + it("maps the UK and crown dependencies to GBP", () => { + for (const country of ["GB", "IM", "JE", "GG"]) { + expect(currencyForCountry(country)).toBe("gbp"); + } + }); + + it("maps eurozone countries and territories to EUR", () => { + for (const country of ["DE", "FR", "IE", "NL", "GF", "YT", "XK"]) { + expect(currencyForCountry(country)).toBe("eur"); + } + }); + + it("falls back to USD for non-euro EU countries", () => { + for (const country of ["SE", "PL", "DK", "CZ", "HU", "NO", "CH"]) { + expect(currencyForCountry(country)).toBe("usd"); + } + }); + + it("falls back to USD for the rest of the world", () => { + for (const country of ["US", "CA", "AU", "IN", "BR", "JP"]) { + expect(currencyForCountry(country)).toBe("usd"); + } + }); + + it("falls back to USD when the country is missing or empty", () => { + expect(currencyForCountry(null)).toBe("usd"); + expect(currencyForCountry(undefined)).toBe("usd"); + expect(currencyForCountry("")).toBe("usd"); + }); + + it("normalizes casing and surrounding whitespace", () => { + expect(currencyForCountry("gb")).toBe("gbp"); + expect(currencyForCountry(" de ")).toBe("eur"); + }); + + it("only ever returns a supported currency", () => { + for (const country of ["GB", "DE", "US", "ZZ", null]) { + expect(SUPPORTED_CURRENCIES).toContain(currencyForCountry(country)); + } + }); +}); + +describe("currencySymbol", () => { + it("returns the symbol for each supported currency", () => { + expect(currencySymbol("usd")).toBe("$"); + expect(currencySymbol("gbp")).toBe("£"); + expect(currencySymbol("eur")).toBe("€"); + }); + + it("accepts uppercase codes", () => { + expect(currencySymbol("GBP")).toBe("£"); + }); + + it("falls back to an uppercase code prefix for other currencies", () => { + expect(currencySymbol("aud")).toBe("AUD "); + expect(currencySymbol("jpy")).toBe("JPY "); + }); +}); + +describe("formatAmount", () => { + it("formats supported currencies with their symbol", () => { + expect(formatAmount(12, "usd")).toBe("$12.00"); + expect(formatAmount(12, "gbp")).toBe("£12.00"); + expect(formatAmount(12, "eur")).toBe("€12.00"); + }); + + it("keeps parity pricing identical apart from the symbol", () => { + const amounts = SUPPORTED_CURRENCIES.map((currency) => + formatAmount(8.16, currency).replace(/^\D+/, ""), + ); + expect(new Set(amounts)).toEqual(new Set(["8.16"])); + }); + + it("formats currencies outside the supported set", () => { + expect(formatAmount(12, "aud")).toContain("12.00"); + }); + + it("falls back without throwing on an invalid currency code", () => { + expect(formatAmount(12, "notacurrency")).toBe("NOTACURRENCY 12.00"); + }); +}); diff --git a/apps/web/app/api/currency/route.ts b/apps/web/app/api/currency/route.ts new file mode 100644 index 0000000000..cfd8fa086f --- /dev/null +++ b/apps/web/app/api/currency/route.ts @@ -0,0 +1,21 @@ +import type { NextRequest } from "next/server"; +import { currencyForCountry } from "@/utils/currency"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + const override = + process.env.VERCEL_ENV !== "production" + ? request.nextUrl.searchParams.get("country") + : null; + const country = override || request.headers.get("x-vercel-ip-country"); + + return Response.json( + { currency: currencyForCountry(country), country: country ?? null }, + { + // The response varies per visitor IP, so it must never land in a shared + // CDN cache where one region's currency would be served to another. + headers: { "Cache-Control": "private, no-store" }, + }, + ); +} diff --git a/apps/web/hooks/useCurrency.ts b/apps/web/hooks/useCurrency.ts new file mode 100644 index 0000000000..a1adf84a00 --- /dev/null +++ b/apps/web/hooks/useCurrency.ts @@ -0,0 +1,42 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + currencySymbol, + isSupportedCurrency, + type SupportedCurrency, +} from "@/utils/currency"; + +let currencyRequest: Promise | null = null; + +function loadCurrency(): Promise { + currencyRequest ??= fetch("/api/currency") + .then((res) => res.json()) + .then((data: { currency?: string }) => + isSupportedCurrency(data.currency) ? data.currency : "usd", + ) + .catch((): SupportedCurrency => { + currencyRequest = null; + return "usd"; + }); + + return currencyRequest; +} + +export function useCurrency() { + // Starts at usd so SSR and the first client render agree; geo detection only + // ever runs in the effect below. + const [currency, setCurrency] = useState("usd"); + + useEffect(() => { + let active = true; + loadCurrency().then((next) => { + if (active) setCurrency(next); + }); + return () => { + active = false; + }; + }, []); + + return { currency, symbol: currencySymbol(currency) }; +} diff --git a/apps/web/utils/currency.ts b/apps/web/utils/currency.ts new file mode 100644 index 0000000000..1c639d2346 --- /dev/null +++ b/apps/web/utils/currency.ts @@ -0,0 +1,88 @@ +export const SUPPORTED_CURRENCIES = ["usd", "gbp", "eur"] as const; + +export type SupportedCurrency = (typeof SUPPORTED_CURRENCIES)[number]; + +const GBP_COUNTRIES = new Set(["GB", "IM", "JE", "GG"]); + +// Mirrors the countries Stripe Checkout's IP localization presents in EUR, so +// the displayed price matches what checkout charges. Non-euro EU countries +// (SE, PL, DK, ...) are deliberately absent and fall through to USD. +const EUR_COUNTRIES = new Set([ + "AD", + "AT", + "AX", + "BE", + "BL", + "CY", + "DE", + "EE", + "ES", + "FI", + "FR", + "GF", + "GP", + "GR", + "HR", + "IE", + "IT", + "LT", + "LU", + "LV", + "MC", + "ME", + "MF", + "MT", + "NL", + "PM", + "PT", + "RE", + "SI", + "SK", + "SM", + "TF", + "VA", + "XK", + "YT", +]); + +const CURRENCY_SYMBOLS: Record = { + usd: "$", + gbp: "£", + eur: "€", +}; + +export function isSupportedCurrency( + value: string | null | undefined, +): value is SupportedCurrency { + return SUPPORTED_CURRENCIES.includes(value as SupportedCurrency); +} + +export function currencyForCountry( + country: string | null | undefined, +): SupportedCurrency { + if (!country) return "usd"; + + const code = country.trim().toUpperCase(); + if (GBP_COUNTRIES.has(code)) return "gbp"; + if (EUR_COUNTRIES.has(code)) return "eur"; + return "usd"; +} + +// Billing surfaces render whatever currency Stripe returns, which may be a code +// we have no symbol for, so unknown codes degrade to a readable prefix. +export function currencySymbol(code: string): string { + const normalized = code.toLowerCase(); + if (isSupportedCurrency(normalized)) return CURRENCY_SYMBOLS[normalized]; + return `${code.toUpperCase()} `; +} + +export function formatAmount(amount: number, currency: string): string { + try { + return new Intl.NumberFormat("en", { + style: "currency", + currency, + }).format(amount); + } catch { + return `${currencySymbol(currency)}${amount.toFixed(2)}`; + } +} From 2564f1cfc42de5b0040ef24eb3283559fde4a05b Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:17:22 +0100 Subject: [PATCH 2/4] feat(web): pricing parity display for GBP and EUR Cap Pro pricing surfaces (pricing cards, compare table, upgrade modal, onboarding upsell, analytics upsell) show the visitor's presentment currency: 12 GBP/EUR/USD monthly, 98 yearly, same digits in every currency. Billing surfaces format amounts in the subscription's actual Stripe currency instead of a hardcoded dollar sign. Support AI pricing context updated. Commercial license and SEO pages stay USD. --- .../analytics/components/AnalyticsDashboard.tsx | 4 +++- .../organization/components/BillingSummaryCard.tsx | 13 +++++++++---- .../organization/components/SeatManagementCard.tsx | 3 ++- .../(org)/onboarding/components/InviteTeamPage.tsx | 9 ++++++--- apps/web/components/UpgradeModal.tsx | 4 +++- .../components/pages/HomePage/Pricing/ProCard.tsx | 8 ++++++-- .../components/pages/_components/ComparePlans.tsx | 11 +++++++++-- apps/web/lib/messenger/constants.ts | 5 +++-- 8 files changed, 41 insertions(+), 16 deletions(-) diff --git a/apps/web/app/(org)/dashboard/analytics/components/AnalyticsDashboard.tsx b/apps/web/app/(org)/dashboard/analytics/components/AnalyticsDashboard.tsx index 3f8d13832f..549f467d5f 100644 --- a/apps/web/app/(org)/dashboard/analytics/components/AnalyticsDashboard.tsx +++ b/apps/web/app/(org)/dashboard/analytics/components/AnalyticsDashboard.tsx @@ -8,6 +8,7 @@ import { useMutation } from "@tanstack/react-query"; import clsx from "clsx"; import { Effect } from "effect"; import { AnimatePresence, motion } from "framer-motion"; +import { useCurrency } from "hooks/useCurrency"; import { Minus, Plus } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { memo, useEffect, useState } from "react"; @@ -48,6 +49,7 @@ export function AnalyticsDashboard() { const capId = searchParams.get("capId"); const user = useCurrentUser(); const stripeCtx = useStripeContext(); + const { currency } = useCurrency(); const { push } = useRouter(); const { activeOrganization, organizationData, spacesData } = useDashboardContext(); @@ -245,7 +247,7 @@ export function AnalyticsDashboard() { className="text-3xl font-medium tabular-nums text-gray-12" format={{ style: "currency", - currency: "USD", + currency: currency.toUpperCase(), }} /> diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/BillingSummaryCard.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/BillingSummaryCard.tsx index bcaf0f4b65..298e980b59 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/BillingSummaryCard.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/BillingSummaryCard.tsx @@ -12,6 +12,7 @@ import { } from "@/actions/organization/get-subscription-details"; import { manageBilling } from "@/actions/organization/manage-billing"; import { useDashboardContext } from "@/app/(org)/dashboard/Contexts"; +import { formatAmount } from "@/utils/currency"; export function BillingSummaryCard() { const { activeOrganization, setUpgradeModalOpen } = useDashboardContext(); @@ -97,6 +98,11 @@ export function BillingSummaryCard() { const intervalLabel = subscription.billingInterval === "year" ? "annually" : "monthly"; const totalAmount = subscription.pricePerSeat * subscription.currentQuantity; + const seatAmountLabel = formatAmount( + subscription.pricePerSeat, + subscription.currency, + ); + const totalAmountLabel = formatAmount(totalAmount, subscription.currency); const nextBillingDate = format( new Date(subscription.currentPeriodEnd * 1000), "MMM d, yyyy", @@ -120,10 +126,9 @@ export function BillingSummaryCard() {

- ${subscription.pricePerSeat.toFixed(2)}/seat/mo ( - {subscription.currentQuantity}{" "} - {subscription.currentQuantity === 1 ? "seat" : "seats"} = $ - {totalAmount.toFixed(2)}/mo, billed {intervalLabel}) + {seatAmountLabel}/seat/mo ({subscription.currentQuantity}{" "} + {subscription.currentQuantity === 1 ? "seat" : "seats"} ={" "} + {totalAmountLabel}/mo, billed {intervalLabel})

{pastDue ? (

diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/SeatManagementCard.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/SeatManagementCard.tsx index e747aa7996..f818156046 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/SeatManagementCard.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/SeatManagementCard.tsx @@ -13,6 +13,7 @@ import { updateSeatQuantity, } from "@/actions/organization/update-seat-quantity"; import { useDashboardContext } from "@/app/(org)/dashboard/Contexts"; +import { formatAmount } from "@/utils/currency"; import { calculateSeats } from "@/utils/organization"; const DEBOUNCE_MS = 500; @@ -169,7 +170,7 @@ export function SeatManagementCard() { {preview.proratedAmount === 0 ? "No prorated adjustment" - : `${preview.proratedAmount > 0 ? "Due now" : "Prorated credit"}: $${Math.abs(preview.proratedAmount / 100).toFixed(2)} ${preview.currency.toUpperCase()}`} + : `${preview.proratedAmount > 0 ? "Due now" : "Prorated credit"}: ${formatAmount(Math.abs(preview.proratedAmount / 100), preview.currency)}`} ) : null}