diff --git a/apps/desktop/src/routes/(window-chrome)/upgrade.tsx b/apps/desktop/src/routes/(window-chrome)/upgrade.tsx index c9fd22ef7c8..1a4c3b26f7f 100644 --- a/apps/desktop/src/routes/(window-chrome)/upgrade.tsx +++ b/apps/desktop/src/routes/(window-chrome)/upgrade.tsx @@ -1,8 +1,9 @@ import { Button } from "@cap/ui-solid"; import { createMutation, useQueryClient } from "@tanstack/solid-query"; import { getCurrentWindow, Window } from "@tauri-apps/api/window"; -import { type Accessor, createSignal, Show } from "solid-js"; +import { type Accessor, createResource, createSignal, Show } from "solid-js"; import { generalSettingsStore } from "~/store"; +import { getPresentmentCurrencySymbol } from "~/utils/currency"; import { getProPlanId } from "~/utils/plans"; import { createLicenseQuery } from "~/utils/queries"; import { createRive } from "~/utils/rive"; @@ -38,6 +39,8 @@ export default function Page() { const signIn = createSignInMutation(); const license = createLicenseQuery(); const [openLicenseDialog, setOpenLicenseDialog] = createSignal(false); + const [currencySymbol] = createResource(getPresentmentCurrencySymbol); + const proSymbol = () => currencySymbol() ?? "$"; const resetLicense = createMutation(() => ({ mutationFn: async () => { @@ -471,7 +474,8 @@ export default function Page() {

- {isProAnnual() ? "$8.16" : "$12"} + {proSymbol()} + {isProAnnual() ? "8.16" : "12"} .00 /

{isProAnnual() && ( @@ -493,8 +497,8 @@ export default function Page() { Switch to {isProAnnual() ? "monthly" : "yearly"}:{" "} {isProAnnual() - ? "$12 per user, billed monthly" - : "$8.16 per user, billed annually"} + ? `${proSymbol()}12 per user, billed monthly` + : `${proSymbol()}8.16 per user, billed annually`}

diff --git a/apps/desktop/src/utils/currency.ts b/apps/desktop/src/utils/currency.ts new file mode 100644 index 00000000000..f36fb715977 --- /dev/null +++ b/apps/desktop/src/utils/currency.ts @@ -0,0 +1,28 @@ +import { fetch } from "@tauri-apps/plugin-http"; + +import { getConfiguredServerUrl } from "./web-api"; + +const CURRENCY_SYMBOLS: Record = { + usd: "$", + gbp: "£", + eur: "€", +}; + +export async function getPresentmentCurrencySymbol(): Promise { + try { + const serverUrl = await getConfiguredServerUrl(); + const headers: Record = {}; + const bypassSecret = import.meta.env.VITE_VERCEL_AUTOMATION_BYPASS_SECRET; + if (bypassSecret) headers["x-vercel-protection-bypass"] = bypassSecret; + + const resp = await fetch(new URL("/api/currency", serverUrl).toString(), { + headers, + }); + if (!resp.ok) return "$"; + + const body = (await resp.json()) as { currency?: string }; + return CURRENCY_SYMBOLS[body.currency ?? ""] ?? "$"; + } catch { + return "$"; + } +} diff --git a/apps/web/__tests__/unit/currency.test.ts b/apps/web/__tests__/unit/currency.test.ts new file mode 100644 index 00000000000..1dd03698640 --- /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/(org)/dashboard/analytics/components/AnalyticsDashboard.tsx b/apps/web/app/(org)/dashboard/analytics/components/AnalyticsDashboard.tsx index 3f8d13832fe..549f467d5f8 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 bcaf0f4b659..298e980b594 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 e747aa79962..f8181560469 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}