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
11 changes: 9 additions & 2 deletions packages/frontend/src/app/start-trial/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
type PlanSelection,
} from '@/lib/card-trial';
import { useCardCheckout } from '@/lib/use-card-checkout';
import { formatLocal, useLocalPricing } from '@/lib/local-currency';
import { fetchCardTrialKind } from '@/lib/use-card-trial-eligibility';
import { parseCheckoutSource, type CheckoutSource } from '@/lib/checkout-source';

Expand Down Expand Up @@ -51,6 +52,7 @@ import { parseCheckoutSource, type CheckoutSource } from '@/lib/checkout-source'
*/
export default function StartTrialPage() {
const { token, user, isLoading, deploymentMode, deploymentModeLoaded } = useAuth();
const localPricing = useLocalPricing(deploymentMode === 'cloud');
const router = useRouter();
const checkout = useCardCheckout();
const [trialEnd, setTrialEnd] = useState<string | null>(null);
Expand Down Expand Up @@ -148,8 +150,13 @@ export default function StartTrialPage() {

const endLong = formatTrialEnd(trialEnd, 'long');
const plan = planById(selection.plan);
const price = formatPlanPrice(plan, selection.period);
const dueToday = formatEur(selection.period === 'yearly' ? plan.yearly : plan.monthly);
// Outside the euro area Stripe charges in the visitor's currency: say
// roughly how much, next to the exact euro price.
const euros = selection.period === 'yearly' ? plan.yearly : plan.monthly;
const local = formatLocal(euros, localPricing);
const exactPrice = formatPlanPrice(plan, selection.period);
const price = local ? `≈ ${local}/${selection.period === 'yearly' ? 'year' : 'month'} (${exactPrice})` : exactPrice;
const dueToday = local ? `≈ ${local} (${formatEur(euros)})` : formatEur(euros);
const startLabel = checkout.loading
? 'Opening checkout…'
: checkout.error
Expand Down
28 changes: 24 additions & 4 deletions packages/frontend/src/components/plan-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
type PlanSelection,
} from '@/lib/card-trial';
import { buildEnterpriseContactUrl } from '@/lib/marketing';
import { useAuth } from '@/lib/auth-context';
import { formatLocal, useLocalPricing } from '@/lib/local-currency';
import { cn } from '@/lib/utils';

/** Monthly / yearly switch. Yearly is ten months' price for twelve. */
Expand Down Expand Up @@ -71,6 +73,17 @@ export function PlanPicker({
compact?: boolean;
disabled?: boolean;
}) {
const { deploymentMode } = useAuth();
// Outside the euro area Stripe charges in the visitor's currency: lead with
// that estimate and keep the exact euro price under it, as the pricing page
// does. Outside the EU no VAT is charged.
const pricing = useLocalPricing(deploymentMode === 'cloud');
const per = value.period === 'yearly' ? 'year' : 'month';
const footer = pricing?.currency
? `Prices in EUR, paid in ${pricing.currency} at checkout${pricing.vat === false ? '' : ', VAT included'}.`
: pricing?.vat === false
? 'Prices in EUR.'
: 'Prices in EUR, VAT included.';
return (
<div className="space-y-4">
<div className="flex justify-center">
Expand All @@ -88,6 +101,7 @@ export function PlanPicker({
>
{CLOUD_PLANS.map((plan) => {
const selected = value.plan === plan.id;
const local = formatLocal(value.period === 'yearly' ? plan.yearly : plan.monthly, pricing);
return (
<button
key={plan.id}
Expand Down Expand Up @@ -129,20 +143,26 @@ export function PlanPicker({
</div>
<div className={cn(compact ? 'text-right flex-shrink-0' : 'mt-3')}>
<div className="text-sm font-semibold text-[var(--text)] whitespace-nowrap">
{formatPlanPrice(plan, value.period)}
{local ? `≈ ${local}/${per}` : formatPlanPrice(plan, value.period)}
</div>
{value.period === 'yearly' && (
{local ? (
<div className="text-[11px] text-[var(--text-3)] whitespace-nowrap">
≈ {formatEur(yearlyPerMonth(plan))}/month
{formatPlanPrice(plan, value.period)} exactly
</div>
) : (
value.period === 'yearly' && (
<div className="text-[11px] text-[var(--text-3)] whitespace-nowrap">
≈ {formatEur(yearlyPerMonth(plan))}/month
</div>
)
)}
</div>
</button>
);
})}
</div>
<p className="text-center text-[11px] text-[var(--text-3)]">
Prices in EUR, VAT included. More users, single sign-on or SCIM?{' '}
{footer} More users, single sign-on or SCIM?{' '}
<a
href={buildEnterpriseContactUrl()}
target="_blank"
Expand Down
78 changes: 78 additions & 0 deletions packages/frontend/src/lib/local-currency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
'use client';

import { useEffect, useState } from 'react';
import { getMarketingUrl } from './marketing';

/**
* What a visitor outside the euro area will roughly pay in their own currency.
*
* Plan prices are in euros, and so are invoices. Stripe Checkout charges a
* visitor from the US in dollars, one from the UK in pounds (Adaptive
* Pricing), and only buyers in the EU pay VAT. The marketing site's
* /api/price-estimate knows the visitor's country (Cloudflare) and the rate,
* so the app asks it from the browser, as the pricing page does. Cloud only.
*/
export interface LocalPricing {
/** ISO code of the checkout currency, or null when the visitor pays in euros. */
currency: string | null;
/** Approximate units of `currency` per euro, Stripe's fee included. */
rate: number | null;
/** Whether the visitor pays VAT: false outside the EU, null when unknown. */
vat: boolean | null;
}

/** Shape check on the site's answer; anything else counts as no answer. */
export function parseLocalPricing(raw: unknown): LocalPricing | null {
if (!raw || typeof raw !== 'object') return null;
const b = raw as Record<string, unknown>;
const currency = typeof b.currency === 'string' && /^[A-Z]{3}$/.test(b.currency) ? b.currency : null;
const rate = typeof b.rate === 'number' && Number.isFinite(b.rate) && b.rate > 0 ? b.rate : null;
const vat = typeof b.vat === 'boolean' ? b.vat : null;
return { currency: currency && rate ? currency : null, rate: currency && rate ? rate : null, vat };
}

/** "≈ $22.15" for 19 € at 1.1656, or null when the visitor pays in euros. */
export function formatLocal(euros: number, pricing: LocalPricing | null): string | null {
if (!pricing?.currency || !pricing.rate) return null;
const amount = euros * pricing.rate;
if (!Number.isFinite(amount) || amount <= 0) return null;
try {
return new Intl.NumberFormat('en', {
style: 'currency',
currency: pricing.currency,
currencyDisplay: 'narrowSymbol',
}).format(amount);
} catch {
return null;
}
}

let request: Promise<LocalPricing | null> | null = null;

function fetchLocalPricing(): Promise<LocalPricing | null> {
request ??= fetch(`${getMarketingUrl()}/api/price-estimate`, { credentials: 'omit' })
.then((res) => (res.ok ? res.json() : null))
.then(parseLocalPricing)
.catch(() => null);
return request;
}

/**
* The visitor's checkout currency and VAT status, asked once per page load.
* Null until it arrives, and for good when `enabled` is false (self-hosted)
* or the site cannot be reached: then everything stays in euros.
*/
export function useLocalPricing(enabled: boolean): LocalPricing | null {
const [pricing, setPricing] = useState<LocalPricing | null>(null);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
fetchLocalPricing().then((p) => {
if (!cancelled) setPricing(p);
});
return () => {
cancelled = true;
};
}, [enabled]);
return enabled ? pricing : null;
}
60 changes: 60 additions & 0 deletions packages/frontend/tests/e2e/card-trial.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
yearlyPerMonth,
yearlySaving,
} from '../../src/lib/card-trial';
import { formatLocal, parseLocalPricing } from '../../src/lib/local-currency';

/**
* The card trial on AnythingMCP Cloud: right after sign-up an admin on a
Expand Down Expand Up @@ -106,6 +107,8 @@ async function cloudSession(
eligibility?: 'trial' | 'pay' | 'none';
/** GET /license/pricing-link-token: the address token for pricing links. */
pricingToken?: string | null;
/** The marketing site's /api/price-estimate for this visitor. */
priceEstimate?: Record<string, unknown>;
} = {},
) {
const user = opts.user ?? ADMIN;
Expand All @@ -128,6 +131,7 @@ async function cloudSession(
if (p === '/health/server-info') {
return json({ deploymentMode: 'cloud', mcpAuthMode: 'oauth2', hasUsers: true, registrationEnabled: true, ssoProviders: [] });
}
if (p === '/api/price-estimate') return json(opts.priceEstimate ?? { currency: null, vat: true });
if (p.endsWith('/license/checkout-link') && req.method() === 'POST') {
const body = req.postDataJSON();
posted.push(body);
Expand Down Expand Up @@ -160,6 +164,62 @@ async function cloudSession(
return { posted };
}

test.describe('local currency helpers', () => {
test('reads the site\'s answer and rejects anything malformed', () => {
expect(parseLocalPricing({ currency: 'USD', rate: 1.1656, vat: false })).toEqual({ currency: 'USD', rate: 1.1656, vat: false });
expect(parseLocalPricing({ currency: null, vat: true })).toEqual({ currency: null, rate: null, vat: true });
expect(parseLocalPricing({ currency: 'USD', rate: -1, vat: false })).toEqual({ currency: null, rate: null, vat: false });
expect(parseLocalPricing({ currency: '<b>', rate: 1 })).toEqual({ currency: null, rate: null, vat: null });
expect(parseLocalPricing('nonsense')).toBeNull();
});

test('converts like the pricing page, and stays in euros without a currency', () => {
const usd = { currency: 'USD', rate: 1.165632, vat: false };
expect(formatLocal(19, usd)).toBe('$22.15');
expect(formatLocal(490, usd)).toBe('$571.16');
expect(formatLocal(19, { currency: null, rate: null, vat: true })).toBeNull();
expect(formatLocal(19, null)).toBeNull();
});
});

test.describe('prices in the visitor\'s currency (cloud)', () => {
test('a US visitor sees dollars first, the exact euro price under it, and no VAT', async ({ page }) => {
await cloudSession(page, {
seed: { 'amcp_card_trial_prompt:u1': 'shown' },
priceEstimate: { currency: 'USD', rate: 1.165632, vat: false },
});
await page.goto('/start-trial');
const starter = page.getByRole('radio', { name: /Starter/ });
await expect(starter).toContainText('≈ $22.15/month', { timeout: 15_000 });
await expect(starter).toContainText('19 €/month exactly');
await expect(page.getByText('Prices in EUR, paid in USD at checkout.')).toBeVisible();
await expect(page.getByText(/VAT included/)).toHaveCount(0);

await page.getByRole('radio', { name: 'Yearly' }).click();
await expect(page.getByRole('radio', { name: /Team/ })).toContainText('≈ $571.16/year');
await expect(page.getByRole('radio', { name: /Team/ })).toContainText('490 €/year exactly');
});

test('a visitor in the euro area sees euros and VAT as before', async ({ page }) => {
await cloudSession(page, {
seed: { 'amcp_card_trial_prompt:u1': 'shown' },
priceEstimate: { currency: null, vat: true },
});
await page.goto('/start-trial');
await expect(page.getByRole('radio', { name: /Starter/ })).toContainText('19 €/month', { timeout: 15_000 });
await expect(page.getByText(/≈ \$/)).toHaveCount(0);
await expect(page.getByText('Prices in EUR, VAT included.')).toBeVisible();
});

test('an unreachable site leaves everything in euros', async ({ page }) => {
await cloudSession(page, { seed: { 'amcp_card_trial_prompt:u1': 'shown' } });
await page.route('**/api/price-estimate', (route) => route.abort());
await page.goto('/start-trial');
await expect(page.getByRole('radio', { name: /Starter/ })).toContainText('19 €/month', { timeout: 15_000 });
await expect(page.getByText('Prices in EUR, VAT included.')).toBeVisible();
});
});

test.describe('card-trial offer (cloud)', () => {
test('a new cloud admin is offered the card trial once, and can skip it quietly', async ({ page }) => {
await cloudSession(page);
Expand Down
Loading