Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions apps/desktop/src/routes/(window-chrome)/upgrade.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -471,7 +474,8 @@ export default function Page() {
</div>
<div class="flex flex-col justify-center items-center">
<h3 class="text-4xl leading-6 text-gray-1">
{isProAnnual() ? "$8.16" : "$12"}
{proSymbol()}
{isProAnnual() ? "8.16" : "12"}
<span class="text-gray-10 text-[16px]">.00 /</span>
</h3>
{isProAnnual() && (
Expand All @@ -493,8 +497,8 @@ export default function Page() {
Switch to {isProAnnual() ? "monthly" : "yearly"}:{" "}
<span class="font-medium">
{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`}
</span>
</p>
</div>
Expand Down
28 changes: 28 additions & 0 deletions apps/desktop/src/utils/currency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { fetch } from "@tauri-apps/plugin-http";

import { getConfiguredServerUrl } from "./web-api";

const CURRENCY_SYMBOLS: Record<string, string> = {
usd: "$",
gbp: "£",
eur: "€",
};

export async function getPresentmentCurrencySymbol(): Promise<string> {
try {
const serverUrl = await getConfiguredServerUrl();
const headers: Record<string, string> = {};
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 "$";
}
}
90 changes: 90 additions & 0 deletions apps/web/__tests__/unit/currency.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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(),
}}
/>
<span className="mb-2 ml-2 text-gray-11">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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",
Expand All @@ -120,10 +126,9 @@ export function BillingSummaryCard() {
</div>
<div className="flex flex-col gap-1 text-sm text-gray-11">
<p>
${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})
</p>
{pastDue ? (
<p className="text-red-700">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -169,7 +170,7 @@ export function SeatManagementCard() {
<span className="text-sm text-gray-11">
{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)}`}
</span>
) : null}
<Button
Expand Down
9 changes: 6 additions & 3 deletions apps/web/app/(org)/onboarding/components/InviteTeamPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import NumberFlow from "@number-flow/react";
import { useMutation } from "@tanstack/react-query";
import clsx from "clsx";
import { useCurrency } from "hooks/useCurrency";
import { useRouter } from "next/navigation";
import { type MouseEvent, startTransition, useId, useState } from "react";
import { toast } from "sonner";
Expand All @@ -17,6 +18,7 @@ import { Base } from "./Base";
export function InviteTeamPage() {
const billingCycleId = useId();
const stripeCtx = useStripeContext();
const { currency, symbol } = useCurrency();
const [users, setUsers] = useState(1);
const [isAnnually, setIsAnnually] = useState(true);
const router = useRouter();
Expand Down Expand Up @@ -116,7 +118,8 @@ export function InviteTeamPage() {
>
<div className="text-center">
<span className="mr-2 text-2xl tabular-nums lg:text-3xl text-gray-12">
$<NumberFlow suffix="/mo" value={currentTotalPrice} />
{symbol}
<NumberFlow suffix="/mo" value={currentTotalPrice} />
</span>
<span className="text-base tabular-nums text-gray-10">
{" "}
Expand All @@ -131,7 +134,7 @@ export function InviteTeamPage() {
format={{
notation: "compact",
style: "currency",
currency: "USD",
currency: currency.toUpperCase(),
}}
suffix="/mo"
/>{" "}
Expand All @@ -155,7 +158,7 @@ export function InviteTeamPage() {
format={{
notation: "compact",
style: "currency",
currency: "USD",
currency: currency.toUpperCase(),
}}
suffix="/mo"
/>{" "}
Expand Down
55 changes: 55 additions & 0 deletions apps/web/app/api/currency/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import {
HttpApi,
HttpApiBuilder,
HttpApiEndpoint,
HttpApiGroup,
HttpServerRequest,
HttpServerResponse,
} from "@effect/platform";
import { Effect, Layer, Schema } from "effect";
import { apiToHandler } from "@/lib/server";
import { currencyForCountry } from "@/utils/currency";

export const dynamic = "force-dynamic";

const GetCurrencyParams = Schema.Struct({
country: Schema.optional(Schema.String),
});

class Api extends HttpApi.make("CapCurrencyApi").add(
HttpApiGroup.make("root").add(
HttpApiEndpoint.get("getCurrency")`/api/currency`.setUrlParams(
GetCurrencyParams,
),
),
) {}

const ApiLive = HttpApiBuilder.api(Api).pipe(
Layer.provide(
HttpApiBuilder.group(Api, "root", (handlers) =>
handlers.handle("getCurrency", ({ urlParams }) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest;
const override =
process.env.VERCEL_ENV !== "production" ? urlParams.country : null;
const country =
override || request.headers["x-vercel-ip-country"] || null;

return HttpServerResponse.unsafeJson(
{ currency: currencyForCountry(country), country },
{
// 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" },
},
);
}),
),
),
),
);

const handler = apiToHandler(ApiLive);

export const GET = handler;
4 changes: 3 additions & 1 deletion apps/web/components/UpgradeModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { buildEnv } from "@cap/env";
import { Button, Dialog, DialogContent, Switch } from "@cap/ui";
import NumberFlow from "@number-flow/react";
import { useMutation } from "@tanstack/react-query";
import { useCurrency } from "hooks/useCurrency";
import {
BarChart3,
Database,
Expand Down Expand Up @@ -69,6 +70,7 @@ const UpgradeModalImpl = ({
dismissible = true,
}: UpgradeModalProps) => {
const stripeCtx = useStripeContext();
const { currency } = useCurrency();
const [isAnnual, setIsAnnual] = useState(true);
const [proQuantity, setProQuantity] = useState(1);
const { push } = useRouter();
Expand Down Expand Up @@ -238,7 +240,7 @@ const UpgradeModalImpl = ({
className="text-3xl font-medium tabular-nums text-gray-12"
format={{
style: "currency",
currency: "USD",
currency: currency.toUpperCase(),
}}
/>
<span className="mb-2 ml-2 text-gray-11">
Expand Down
8 changes: 6 additions & 2 deletions apps/web/components/pages/HomePage/Pricing/ProCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { Button } from "@cap/ui";
import NumberFlow from "@number-flow/react";
import { useMutation } from "@tanstack/react-query";
import { useCurrency } from "hooks/useCurrency";
import { useRef, useState } from "react";
import { toast } from "sonner";
import { useStripeContext } from "@/app/Layout/StripeContext";
Expand All @@ -17,6 +18,7 @@ const copy = homepageCopy.pricing.pro;

export const ProCard = () => {
const stripeCtx = useStripeContext();
const { symbol } = useCurrency();
const [users, setUsers] = useState(1);
const [isAnnually, setIsAnnually] = useState(false);
const artRef = useRef<ProArtRef>(null);
Expand Down Expand Up @@ -103,7 +105,8 @@ export const ProCard = () => {

<div className="flex gap-1.5 items-baseline mt-6">
<span className="text-4xl font-semibold tracking-tight tabular-nums text-gray-12">
$<NumberFlow value={perUser} />
{symbol}
<NumberFlow value={perUser} />
</span>
<span className="text-sm text-gray-10">/ user / month</span>
</div>
Expand Down Expand Up @@ -132,7 +135,8 @@ export const ProCard = () => {
<p className="text-sm text-gray-10">
Total:{" "}
<span className="font-medium text-gray-12">
$<NumberFlow value={isAnnually ? yearlyTotal : monthlyTotal} />
{symbol}
<NumberFlow value={isAnnually ? yearlyTotal : monthlyTotal} />
</span>{" "}
{isAnnually ? "/ year" : "/ month"}
</p>
Expand Down
Loading
Loading