diff --git a/packages/frontend/src/app/start-trial/page.tsx b/packages/frontend/src/app/start-trial/page.tsx index a4e265ee9..60be375e7 100644 --- a/packages/frontend/src/app/start-trial/page.tsx +++ b/packages/frontend/src/app/start-trial/page.tsx @@ -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'; @@ -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(null); @@ -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 diff --git a/packages/frontend/src/components/plan-picker.tsx b/packages/frontend/src/components/plan-picker.tsx index 9fef79980..7b37d5cf6 100644 --- a/packages/frontend/src/components/plan-picker.tsx +++ b/packages/frontend/src/components/plan-picker.tsx @@ -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. */ @@ -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 (
@@ -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 ( @@ -142,7 +162,7 @@ export function PlanPicker({ })}

- Prices in EUR, VAT included. More users, single sign-on or SCIM?{' '} + {footer} More users, single sign-on or SCIM?{' '} ; + 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 | null = null; + +function fetchLocalPricing(): Promise { + 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(null); + useEffect(() => { + if (!enabled) return; + let cancelled = false; + fetchLocalPricing().then((p) => { + if (!cancelled) setPricing(p); + }); + return () => { + cancelled = true; + }; + }, [enabled]); + return enabled ? pricing : null; +} diff --git a/packages/frontend/tests/e2e/card-trial.spec.ts b/packages/frontend/tests/e2e/card-trial.spec.ts index 88306ce97..7cb9e469e 100644 --- a/packages/frontend/tests/e2e/card-trial.spec.ts +++ b/packages/frontend/tests/e2e/card-trial.spec.ts @@ -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 @@ -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; } = {}, ) { const user = opts.user ?? ADMIN; @@ -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); @@ -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: '', 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);