Pārlūkot izejas kodu

Monthly and annual billing (#442)

* Offer monthly and annual billing and adjust plan limits

* Show plan prices with the currency symbol; pin the clock in the QuickBooks pull tests
Bernt Christian Egeland 3 nedēļas atpakaļ
vecāks
revīzija
e2c4b8b969

+ 10 - 1
e2e/specs/cloud/subscription-handoff.spec.ts

@@ -102,19 +102,28 @@ test.describe('buying a plan', () => {
     await expect(page.getByRole('heading', { name: 'Stand-in checkout' })).toBeVisible()
     await expect(page.getByTestId('org')).toHaveText(organizationId)
     await expect(page.getByTestId('plan')).toHaveText('pro')
+    await expect(page.getByTestId('interval')).toHaveText('year')
     await expect(page.getByTestId('email')).toHaveText(EMAIL)
     await expect(page.getByTestId('appUrl')).toHaveText(/^http/)
   })
 
-  test('names this app as the origin of the purchase', async ({ page, baseURL }) => {
+  test('names this app as the origin of the purchase, and the interval chosen', async ({
+    page,
+    baseURL,
+  }) => {
     await page.goto('/settings/subscription')
     await settle(page)
+    // The prices come from the stand-in, quoted per month either way.
+    await expect(page.getByText('$290 billed annually')).toBeVisible()
+    await page.getByRole('radio', { name: 'Monthly' }).click()
+    await expect(page.getByText('$79')).toBeVisible()
     await page
       .getByRole('button', { name: /Enterprise/ })
       .first()
       .click()
     await page.waitForURL(`${torqvoiceComUrl()}/checkout?**`, { timeout: 30_000 })
     await expect(page.getByTestId('plan')).toHaveText('enterprise')
+    await expect(page.getByTestId('interval')).toHaveText('month')
     await expect(page.getByTestId('appUrl')).toHaveText(new URL(baseURL ?? '').origin)
   })
 })

+ 11 - 0
e2e/torqvoice-com-standin.ts

@@ -116,6 +116,7 @@ const server = createServer(async (req, res) => {
     return html(res, 200, 'Stand-in checkout', {
       org: String(result.org),
       plan: String(result.plan),
+      interval: String(result.interval),
       email: String(result.email),
       appUrl: String(result.appUrl),
     })
@@ -169,6 +170,16 @@ const server = createServer(async (req, res) => {
         return json(res, 200, { success: true })
       case 'sync':
         return json(res, 200, { checked: 1, synced: 0, errors: 0 })
+      case 'prices':
+        return json(res, 200, {
+          prices: {
+            pro: { month: { amount: 29, currency: 'usd' }, year: { amount: 290, currency: 'usd' } },
+            enterprise: {
+              month: { amount: 79, currency: 'usd' },
+              year: { amount: 790, currency: 'usd' },
+            },
+          },
+        })
       default:
         return json(res, 404, { error: 'Not found' })
     }

+ 8 - 3
messages/de/settings.json

@@ -1638,9 +1638,14 @@
     "upgradeTitle": "Plan upgraden",
     "upgradeDescription": "Schalten Sie weitere Funktionen frei, indem Sie Ihren Plan upgraden.",
     "upgradeToProDescription": "Upgraden Sie, um unbegrenzte Kunden, Teammitglieder, Berichte, SMTP, API-Zugang und mehr freizuschalten.",
-    "upgradeToEnterpriseDescription": "Benötigen Sie mehr als 5 Teammitglieder? Upgraden Sie auf Enterprise für bis zu 50 Benutzer.",
-    "upgradeToPro": "Auf Pro upgraden — $99/Jahr",
-    "upgradeToEnterprise": "Enterprise — $140/Jahr",
+    "upgradeToEnterpriseDescription": "Mehr als eine Werkstatt? Enterprise umfasst bis zu 10 Werkstätten und 50 Teammitglieder.",
+    "upgradeToPro": "Auf Pro upgraden",
+    "upgradeToEnterprise": "Enterprise",
+    "billingInterval": "Abrechnungszeitraum",
+    "billingMonthly": "Monatlich",
+    "billingAnnual": "Jährlich",
+    "billingSave": "{percent}% sparen",
+    "billedAnnually": "{amount} jährlich abgerechnet",
     "upgradeDialogTitle": "Auf Enterprise upgraden",
     "upgradeDialogDescription": "Ihr aktueller Pro-Plan wird auf Enterprise aufgerüstet. Es wird Ihnen nur die anteilige Differenz für den verbleibenden Abrechnungszeitraum berechnet.",
     "upgradeConfirm": "Upgrade bestätigen",

+ 8 - 3
messages/en/settings.json

@@ -1638,9 +1638,14 @@
     "upgradeTitle": "Upgrade Plan",
     "upgradeDescription": "Unlock more features by upgrading your plan.",
     "upgradeToProDescription": "Upgrade to unlock unlimited customers, team members, reports, SMTP, API access, and more.",
-    "upgradeToEnterpriseDescription": "Need more than 5 team members? Upgrade to Enterprise for up to 50 users.",
-    "upgradeToPro": "Upgrade to Pro — $99/yr",
-    "upgradeToEnterprise": "Enterprise — $140/yr",
+    "upgradeToEnterpriseDescription": "Running more than one workshop? Enterprise covers up to 10 workshops and 50 team members.",
+    "upgradeToPro": "Upgrade to Pro",
+    "upgradeToEnterprise": "Enterprise",
+    "billingInterval": "Billing interval",
+    "billingMonthly": "Monthly",
+    "billingAnnual": "Annual",
+    "billingSave": "Save {percent}%",
+    "billedAnnually": "{amount} billed annually",
     "upgradeDialogTitle": "Upgrade to Enterprise",
     "upgradeDialogDescription": "Your current Pro plan will be upgraded to Enterprise. You will only be charged the prorated difference for the remaining billing period.",
     "upgradeConfirm": "Confirm Upgrade",

+ 8 - 3
messages/es/settings.json

@@ -1638,9 +1638,14 @@
     "upgradeTitle": "Mejorar plan",
     "upgradeDescription": "Desbloquee más funciones mejorando su plan.",
     "upgradeToProDescription": "Mejore para desbloquear clientes ilimitados, miembros del equipo, informes, SMTP, acceso API y más.",
-    "upgradeToEnterpriseDescription": "¿Necesita más de 5 miembros del equipo? Mejore a Enterprise para hasta 50 usuarios.",
-    "upgradeToPro": "Mejorar a Pro — $99/año",
-    "upgradeToEnterprise": "Enterprise — $140/año",
+    "upgradeToEnterpriseDescription": "¿Tienes más de un taller? Enterprise cubre hasta 10 talleres y 50 miembros del equipo.",
+    "upgradeToPro": "Mejorar a Pro",
+    "upgradeToEnterprise": "Enterprise",
+    "billingInterval": "Periodo de facturación",
+    "billingMonthly": "Mensual",
+    "billingAnnual": "Anual",
+    "billingSave": "Ahorra un {percent}%",
+    "billedAnnually": "{amount} facturado anualmente",
     "upgradeDialogTitle": "Mejorar a Enterprise",
     "upgradeDialogDescription": "Su plan Pro actual se mejorará a Enterprise. Solo se le cobrará la diferencia prorrateada por el período de facturación restante.",
     "upgradeConfirm": "Confirmar mejora",

+ 8 - 3
messages/fr/settings.json

@@ -1638,9 +1638,14 @@
     "upgradeTitle": "Améliorer le plan",
     "upgradeDescription": "Débloquez plus de fonctionnalités en améliorant votre plan.",
     "upgradeToProDescription": "Améliorez pour débloquer des clients illimités, des membres d'équipe, des rapports, SMTP, l'accès API et plus encore.",
-    "upgradeToEnterpriseDescription": "Besoin de plus de 5 membres d'équipe ? Passez à Enterprise pour jusqu'à 50 utilisateurs.",
-    "upgradeToPro": "Passer à Pro — 99 $/an",
-    "upgradeToEnterprise": "Enterprise — 140 $/an",
+    "upgradeToEnterpriseDescription": "Plusieurs ateliers ? Enterprise couvre jusqu'à 10 ateliers et 50 membres d'équipe.",
+    "upgradeToPro": "Passer à Pro",
+    "upgradeToEnterprise": "Enterprise",
+    "billingInterval": "Période de facturation",
+    "billingMonthly": "Mensuel",
+    "billingAnnual": "Annuel",
+    "billingSave": "Économisez {percent}%",
+    "billedAnnually": "{amount} facturé annuellement",
     "upgradeDialogTitle": "Passer à Enterprise",
     "upgradeDialogDescription": "Votre plan Pro actuel sera mis à niveau vers Enterprise. Seule la différence au prorata pour la période de facturation restante vous sera facturée.",
     "upgradeConfirm": "Confirmer la mise à niveau",

+ 8 - 3
messages/it/settings.json

@@ -1638,9 +1638,14 @@
     "upgradeTitle": "Aggiorna piano",
     "upgradeDescription": "Sblocca più funzionalità aggiornando il tuo piano.",
     "upgradeToProDescription": "Aggiorna per sbloccare clienti illimitati, membri del team, report, SMTP, accesso API e altro.",
-    "upgradeToEnterpriseDescription": "Hai bisogno di più di 5 membri del team? Passa a Enterprise per fino a 50 utenti.",
-    "upgradeToPro": "Passa a Pro — $99/anno",
-    "upgradeToEnterprise": "Enterprise — $140/anno",
+    "upgradeToEnterpriseDescription": "Gestisci più di un'officina? Enterprise copre fino a 10 officine e 50 membri del team.",
+    "upgradeToPro": "Passa a Pro",
+    "upgradeToEnterprise": "Enterprise",
+    "billingInterval": "Periodo di fatturazione",
+    "billingMonthly": "Mensile",
+    "billingAnnual": "Annuale",
+    "billingSave": "Risparmia il {percent}%",
+    "billedAnnually": "{amount} fatturato annualmente",
     "upgradeDialogTitle": "Passa a Enterprise",
     "upgradeDialogDescription": "Il tuo piano Pro attuale verrà aggiornato a Enterprise. Ti verrà addebitata solo la differenza proporzionale per il periodo di fatturazione rimanente.",
     "upgradeConfirm": "Conferma aggiornamento",

+ 8 - 3
messages/lt/settings.json

@@ -1638,9 +1638,14 @@
     "upgradeTitle": "Atnaujinti planą",
     "upgradeDescription": "Atrakinkite daugiau funkcijų atnaujindami planą.",
     "upgradeToProDescription": "Atnaujinkite, kad atrakintumėte neribotą klientų, komandos narių, ataskaitų, SMTP, API prieigos skaičių ir daugiau.",
-    "upgradeToEnterpriseDescription": "Reikia daugiau nei 5 komandos narių? Atnaujinkite į Enterprise iki 50 naudotojų.",
-    "upgradeToPro": "Atnaujinti į Pro — $99/m.",
-    "upgradeToEnterprise": "Enterprise — $140/m.",
+    "upgradeToEnterpriseDescription": "Turite daugiau nei vieną dirbtuvę? Enterprise apima iki 10 dirbtuvių ir 50 komandos narių.",
+    "upgradeToPro": "Atnaujinti į Pro",
+    "upgradeToEnterprise": "Enterprise",
+    "billingInterval": "Atsiskaitymo laikotarpis",
+    "billingMonthly": "Kas mėnesį",
+    "billingAnnual": "Kasmet",
+    "billingSave": "Sutaupykite {percent}%",
+    "billedAnnually": "{amount} apmokestinama kasmet",
     "upgradeDialogTitle": "Atnaujinti į Enterprise",
     "upgradeDialogDescription": "Jūsų dabartinis Pro planas bus atnaujintas į Enterprise. Bus apmokestinta tik proporcinga skirtuma likusiam atsiskaitymo laikotarpiui.",
     "upgradeConfirm": "Patvirtinti atnaujinimą",

+ 8 - 3
messages/nb/settings.json

@@ -1638,9 +1638,14 @@
     "upgradeTitle": "Oppgrader plan",
     "upgradeDescription": "Lås opp flere funksjoner ved å oppgradere planen din.",
     "upgradeToProDescription": "Oppgrader for å låse opp ubegrensede kunder, teammedlemmer, rapporter, SMTP, API-tilgang og mer.",
-    "upgradeToEnterpriseDescription": "Trenger du mer enn 5 teammedlemmer? Oppgrader til Enterprise for opptil 50 brukere.",
-    "upgradeToPro": "Oppgrader til Pro ($99/år)",
-    "upgradeToEnterprise": "Enterprise ($140/år)",
+    "upgradeToEnterpriseDescription": "Driver du flere verksteder? Enterprise dekker opptil 10 verksteder og 50 teammedlemmer.",
+    "upgradeToPro": "Oppgrader til Pro",
+    "upgradeToEnterprise": "Enterprise",
+    "billingInterval": "Faktureringsperiode",
+    "billingMonthly": "Månedlig",
+    "billingAnnual": "Årlig",
+    "billingSave": "Spar {percent}%",
+    "billedAnnually": "{amount} fakturert årlig",
     "upgradeDialogTitle": "Oppgrader til Enterprise",
     "upgradeDialogDescription": "Din gjeldende Pro-plan vil bli oppgradert til Enterprise. Du vil kun bli belastet for den forholdsmessige differansen for den gjenværende faktureringsperioden.",
     "upgradeConfirm": "Bekreft oppgradering",

+ 8 - 3
messages/nl/settings.json

@@ -1638,9 +1638,14 @@
     "upgradeTitle": "Plan upgraden",
     "upgradeDescription": "Ontgrendel meer functies door uw plan te upgraden.",
     "upgradeToProDescription": "Upgrade om onbeperkte klanten, teamleden, rapporten, SMTP, API-toegang en meer te ontgrendelen.",
-    "upgradeToEnterpriseDescription": "Meer dan 5 teamleden nodig? Upgrade naar Enterprise voor maximaal 50 gebruikers.",
-    "upgradeToPro": "Upgraden naar Pro — $99/jaar",
-    "upgradeToEnterprise": "Enterprise — $140/jaar",
+    "upgradeToEnterpriseDescription": "Meer dan één werkplaats? Enterprise dekt tot 10 werkplaatsen en 50 teamleden.",
+    "upgradeToPro": "Upgraden naar Pro",
+    "upgradeToEnterprise": "Enterprise",
+    "billingInterval": "Factureringsperiode",
+    "billingMonthly": "Maandelijks",
+    "billingAnnual": "Jaarlijks",
+    "billingSave": "Bespaar {percent}%",
+    "billedAnnually": "{amount} jaarlijks gefactureerd",
     "upgradeDialogTitle": "Upgraden naar Enterprise",
     "upgradeDialogDescription": "Uw huidige Pro-plan wordt geüpgraded naar Enterprise. U wordt alleen het evenredige verschil voor de resterende factureringsperiode in rekening gebracht.",
     "upgradeConfirm": "Upgrade bevestigen",

+ 8 - 3
messages/pl/settings.json

@@ -1638,9 +1638,14 @@
     "upgradeTitle": "Ulepsz plan",
     "upgradeDescription": "Odblokuj więcej funkcji, ulepszając swój plan.",
     "upgradeToProDescription": "Ulepsz, aby odblokować nieograniczoną liczbę klientów, członków zespołu, raportów, SMTP, dostęp API i więcej.",
-    "upgradeToEnterpriseDescription": "Potrzebujesz więcej niż 5 członków zespołu? Przejdź na Enterprise dla maksymalnie 50 użytkowników.",
-    "upgradeToPro": "Przejdź na Pro — $99/rok",
-    "upgradeToEnterprise": "Enterprise — $140/rok",
+    "upgradeToEnterpriseDescription": "Prowadzisz więcej niż jeden warsztat? Enterprise obejmuje do 10 warsztatów i 50 członków zespołu.",
+    "upgradeToPro": "Przejdź na Pro",
+    "upgradeToEnterprise": "Enterprise",
+    "billingInterval": "Okres rozliczeniowy",
+    "billingMonthly": "Miesięcznie",
+    "billingAnnual": "Rocznie",
+    "billingSave": "Oszczędź {percent}%",
+    "billedAnnually": "{amount} rozliczane rocznie",
     "upgradeDialogTitle": "Przejdź na Enterprise",
     "upgradeDialogDescription": "Twój aktualny plan Pro zostanie ulepszony do Enterprise. Zostaniesz obciążony jedynie proporcjonalną różnicą za pozostały okres rozliczeniowy.",
     "upgradeConfirm": "Potwierdź ulepszenie",

+ 8 - 3
messages/pt-BR/settings.json

@@ -1638,9 +1638,14 @@
     "upgradeTitle": "Atualizar plano",
     "upgradeDescription": "Desbloqueie mais recursos atualizando seu plano.",
     "upgradeToProDescription": "Atualize para desbloquear clientes ilimitados, membros da equipe, relatórios, SMTP, acesso à API e mais.",
-    "upgradeToEnterpriseDescription": "Precisa de mais de 5 membros da equipe? Atualize para Enterprise com até 50 usuários.",
-    "upgradeToPro": "Atualizar para Pro: $99/ano",
-    "upgradeToEnterprise": "Enterprise: $140/ano",
+    "upgradeToEnterpriseDescription": "Tem mais de uma oficina? O Enterprise cobre até 10 oficinas e 50 membros da equipe.",
+    "upgradeToPro": "Atualizar para Pro",
+    "upgradeToEnterprise": "Enterprise",
+    "billingInterval": "Período de cobrança",
+    "billingMonthly": "Mensal",
+    "billingAnnual": "Anual",
+    "billingSave": "Economize {percent}%",
+    "billedAnnually": "{amount} cobrado anualmente",
     "upgradeDialogTitle": "Atualizar para Enterprise",
     "upgradeDialogDescription": "Seu plano Pro atual será atualizado para Enterprise. Você será cobrado apenas pela diferença proporcional do período de faturamento restante.",
     "upgradeConfirm": "Confirmar atualização",

+ 8 - 3
messages/pt-PT/settings.json

@@ -1638,9 +1638,14 @@
     "upgradeTitle": "Mudar de plano",
     "upgradeDescription": "Desbloqueie mais funcionalidades ao mudar para um plano superior.",
     "upgradeToProDescription": "Mude de plano para desbloquear clientes ilimitados, membros da equipa, relatórios, SMTP, acesso à API e mais.",
-    "upgradeToEnterpriseDescription": "Precisa de mais de 5 membros na equipa? Mude para Enterprise e tenha até 50 utilizadores.",
-    "upgradeToPro": "Mudar para Pro: 99 USD/ano",
-    "upgradeToEnterprise": "Enterprise: 140 USD/ano",
+    "upgradeToEnterpriseDescription": "Tem mais do que uma oficina? O Enterprise cobre até 10 oficinas e 50 membros da equipa.",
+    "upgradeToPro": "Mudar para Pro",
+    "upgradeToEnterprise": "Enterprise",
+    "billingInterval": "Período de faturação",
+    "billingMonthly": "Mensal",
+    "billingAnnual": "Anual",
+    "billingSave": "Poupe {percent}%",
+    "billedAnnually": "{amount} faturados anualmente",
     "upgradeDialogTitle": "Mudar para Enterprise",
     "upgradeDialogDescription": "O seu plano Pro atual passará para Enterprise. Só lhe será cobrada a diferença proporcional relativa ao período de faturação restante.",
     "upgradeConfirm": "Confirmar mudança",

+ 8 - 3
messages/ru/settings.json

@@ -1638,9 +1638,14 @@
     "upgradeTitle": "Обновить тариф",
     "upgradeDescription": "Разблокируйте больше функций, обновив тариф.",
     "upgradeToProDescription": "Обновитесь для неограниченных клиентов, участников команды, отчётов, SMTP, доступа к API и многого другого.",
-    "upgradeToEnterpriseDescription": "Нужно более 5 участников команды? Обновитесь до корпоративного тарифа для 50 пользователей.",
-    "upgradeToPro": "Обновить до Pro — $99/год",
-    "upgradeToEnterprise": "Корпоративный — $140/год",
+    "upgradeToEnterpriseDescription": "Несколько автосервисов? Корпоративный план включает до 10 автосервисов и 50 участников команды.",
+    "upgradeToPro": "Обновить до Pro",
+    "upgradeToEnterprise": "Корпоративный",
+    "billingInterval": "Период оплаты",
+    "billingMonthly": "Ежемесячно",
+    "billingAnnual": "Ежегодно",
+    "billingSave": "Экономия {percent}%",
+    "billedAnnually": "{amount} оплата за год",
     "upgradeDialogTitle": "Обновить до корпоративного тарифа",
     "upgradeDialogDescription": "Ваш текущий тариф Pro будет обновлён до корпоративного. Вам будет начислена только пропорциональная разница за оставшийся период оплаты.",
     "upgradeConfirm": "Подтвердить обновление",

+ 8 - 3
messages/tr/settings.json

@@ -1638,9 +1638,14 @@
     "upgradeTitle": "Planı Yükselt",
     "upgradeDescription": "Planınızı yükselterek daha fazla özelliğin kilidini açın.",
     "upgradeToProDescription": "Sınırsız müşteri, ekip üyesi, rapor, SMTP, API erişimi ve daha fazlasının kilidini açmak için yükseltin.",
-    "upgradeToEnterpriseDescription": "5'ten fazla ekip üyesine mi ihtiyacınız var? 50 kullanıcıya kadar Enterprise'a yükseltin.",
-    "upgradeToPro": "Pro'ya Yükselt — $99/yıl",
-    "upgradeToEnterprise": "Enterprise — $140/yıl",
+    "upgradeToEnterpriseDescription": "Birden fazla atölyeniz mi var? Enterprise 10 atölyeye ve 50 ekip üyesine kadar kapsar.",
+    "upgradeToPro": "Pro'ya Yükselt",
+    "upgradeToEnterprise": "Enterprise",
+    "billingInterval": "Faturalama dönemi",
+    "billingMonthly": "Aylık",
+    "billingAnnual": "Yıllık",
+    "billingSave": "%{percent} tasarruf",
+    "billedAnnually": "{amount} yıllık faturalanır",
     "upgradeDialogTitle": "Enterprise'a Yükselt",
     "upgradeDialogDescription": "Mevcut Pro planınız Enterprise'a yükseltilecek. Yalnızca kalan fatura dönemi için orantılı fark tahsil edilecektir.",
     "upgradeConfirm": "Yükseltmeyi Onayla",

+ 7 - 1
src/__tests__/features/integrations/quickbooks-server.test.ts

@@ -1,5 +1,5 @@
 import { realmRef } from '@/integrations/quickbooks/mapping'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import type {
   AccountingCustomer,
   AccountingInvoice,
@@ -895,6 +895,12 @@ describe('QuickBooks: payments', () => {
 })
 
 describe('QuickBooks: pulling changes', () => {
+  // The change feed reaches back 30 days from now, and these fixtures are
+  // dated September 2026, so the clock is pinned beside them. Without this
+  // the test went stale on 4 October 2026, when the floor passed the fixture.
+  beforeEach(() => vi.useFakeTimers({ now: new Date('2026-09-04T12:00:00Z'), toFake: ['Date'] }))
+  afterEach(() => vi.useRealTimers())
+
   it('records a payment taken in QuickBooks against an invoice from here', async () => {
     const t = makeCtx({
       state: { lastPullAt: '2026-09-04T09:00:00.000Z' },

+ 4 - 2
src/__tests__/lib/torqvoice-com.test.ts

@@ -61,11 +61,12 @@ describe('createHandoffToken', () => {
   const input = {
     organizationId: 'org-1',
     plan: 'pro' as const,
+    interval: 'year' as const,
     email: 'owner@example.com',
     name: 'Owner',
   }
 
-  it('signs the organization, plan, buyer and this app origin with the shared secret', () => {
+  it('signs the organization, plan, interval, buyer and this app origin with the shared secret', () => {
     const now = 1_700_000_000_000
     const token = createHandoffToken(input, now)
     const { prefix, payload, signature, encodedPayload } = decode(token)
@@ -75,6 +76,7 @@ describe('createHandoffToken', () => {
       v: 1,
       org: 'org-1',
       plan: 'pro',
+      interval: 'year',
       email: 'owner@example.com',
       name: 'Owner',
       appUrl: 'https://app.torqvoice.com',
@@ -206,7 +208,7 @@ describe('billingRequest', () => {
 
   it('caps the name at what the site accepts', () => {
     const token = createHandoffToken({
-      ...{ organizationId: 'o', plan: 'pro', email: 'a@b.c' },
+      ...{ organizationId: 'o', plan: 'pro', interval: 'month', email: 'a@b.c' },
       name: 'x'.repeat(500),
     })
     expect(decode(token).payload.name).toHaveLength(200)

+ 9 - 3
src/app/(authenticated)/settings/subscription/page.tsx

@@ -5,7 +5,7 @@ import { PLAN_FEATURES, type Plan } from '@/lib/features'
 import { isCloudLinked } from '@/lib/torqvoice-com-link'
 import { SubscriptionSettings } from '@/features/subscription/Components/subscription-settings'
 import { countCustomersTowardLimit } from '@/lib/customer-limit'
-import { isTorqvoiceComBillingConfigured } from '@/lib/torqvoice-com'
+import { fetchBillingPrices, isTorqvoiceComBillingConfigured } from '@/lib/torqvoice-com'
 
 export default async function SubscriptionPage({
   searchParams,
@@ -30,9 +30,11 @@ export default async function SubscriptionPage({
     include: { plan: true },
   })
 
+  // Plan rows are named per price ("Torq Pro (monthly)", "Enterprise
+  // (annual)"), so the plan is the word in the name, as getFeatures reads it.
   const plan: Plan =
     subscription?.status === 'active' || subscription?.status === 'trialing'
-      ? subscription.plan.name.toLowerCase() === 'enterprise'
+      ? subscription.plan.name.toLowerCase().includes('enterprise')
         ? 'enterprise'
         : 'pro'
       : 'free'
@@ -43,11 +45,14 @@ export default async function SubscriptionPage({
 
   const features = PLAN_FEATURES[plan]
 
-  const [customerCount, memberCount] = await Promise.all([
+  const canBuy = plan === 'free' || isDemo
+  const [customerCount, memberCount, prices] = await Promise.all([
     countCustomersTowardLimit(authContext.organizationId),
     db.organizationMember.count({
       where: { organizationId: authContext.organizationId },
     }),
+    // Only a page with buy buttons needs the amounts.
+    canBuy ? fetchBillingPrices(authContext.organizationId) : Promise.resolve(null),
   ])
 
   return (
@@ -65,6 +70,7 @@ export default async function SubscriptionPage({
       accountLinkAvailable={isTorqvoiceComBillingConfigured()}
       usage={{ customers: customerCount, members: memberCount }}
       features={features}
+      prices={prices}
     />
   )
 }

+ 11 - 0
src/app/api/protected/subscription/checkout/route.ts

@@ -5,6 +5,7 @@ import { isDemoMode } from '@/lib/demo'
 import {
   checkoutUrl,
   createHandoffToken,
+  isBillingInterval,
   isTorqvoiceComBillingConfigured,
 } from '@/lib/torqvoice-com'
 
@@ -31,6 +32,9 @@ export async function POST(request: Request) {
 
     const body = await request.json()
     const plan = body.plan as string
+    // Annual unless asked otherwise, which is what the page offered before
+    // monthly billing existed.
+    const interval = body.interval ?? 'year'
 
     if (plan !== 'pro' && plan !== 'enterprise') {
       return NextResponse.json(
@@ -38,6 +42,12 @@ export async function POST(request: Request) {
         { status: 400 }
       )
     }
+    if (!isBillingInterval(interval)) {
+      return NextResponse.json(
+        { error: "Invalid interval. Must be 'month' or 'year'" },
+        { status: 400 }
+      )
+    }
 
     if (!isTorqvoiceComBillingConfigured()) {
       return NextResponse.json({ error: 'Billing is not configured' }, { status: 500 })
@@ -54,6 +64,7 @@ export async function POST(request: Request) {
     const token = createHandoffToken({
       organizationId: ctx.organizationId,
       plan,
+      interval,
       email: user.email,
       name: user.name ?? '',
     })

+ 117 - 29
src/features/subscription/Components/subscription-settings.tsx

@@ -37,6 +37,7 @@ import {
   resumeSubscription,
 } from '@/features/subscription/Actions/subscriptionActions'
 import type { PlanFeatures } from '@/lib/features'
+import type { BillingInterval, BillingPlan, BillingPriceList } from '@/lib/torqvoice-com'
 
 type Props = {
   plan: string
@@ -54,6 +55,8 @@ type Props = {
   accountLinkAvailable: boolean
   usage: { customers: number; members: number }
   features: PlanFeatures
+  /** Today's prices from torqvoice.com, or null when it could not be asked. */
+  prices: BillingPriceList | null
 }
 
 export function SubscriptionSettings({
@@ -70,11 +73,13 @@ export function SubscriptionSettings({
   accountLinkAvailable,
   usage,
   features,
+  prices,
 }: Props) {
   const t = useTranslations('settings')
   const locale = useLocale()
   const router = useRouter()
-  const [checkoutLoading, setCheckoutLoading] = useState<'pro' | 'enterprise' | null>(null)
+  const [checkoutLoading, setCheckoutLoading] = useState<BillingPlan | null>(null)
+  const [interval, setInterval] = useState<BillingInterval>('year')
   const [cancelLoading, setCancelLoading] = useState(false)
   const [resumeLoading, setResumeLoading] = useState(false)
   const [billingLoading, setBillingLoading] = useState(false)
@@ -108,13 +113,13 @@ export function SubscriptionSettings({
       ? Math.max(0, Math.ceil((new Date(currentPeriodEnd).getTime() - Date.now()) / 86_400_000))
       : null
 
-  const handleCheckout = async (selectedPlan: 'pro' | 'enterprise') => {
+  const handleCheckout = async (selectedPlan: BillingPlan) => {
     setCheckoutLoading(selectedPlan)
     try {
       const res = await fetch('/api/protected/subscription/checkout', {
         method: 'POST',
         headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ plan: selectedPlan }),
+        body: JSON.stringify({ plan: selectedPlan, interval }),
       })
       const data = await res.json()
       if (data.url) {
@@ -247,6 +252,42 @@ export function SubscriptionSettings({
   const intervalLabel =
     planInterval === 'month' ? t('subscription.perMonth') : t('subscription.perYear')
 
+  // What a plan costs at the selected interval, quoted per month either way,
+  // with the yearly total beside it for annual billing. Nothing is shown when
+  // the site could not be asked; the checkout page states the price anyway.
+  // Plan prices are shown the way the pricing page shows them ("$29",
+  // "$24.17"), with the symbol, not as an accounting figure.
+  const formatPrice = (amount: number, currency: string) =>
+    new Intl.NumberFormat(locale, {
+      style: 'currency',
+      currency: currency.toUpperCase(),
+      minimumFractionDigits: 0,
+      maximumFractionDigits: 2,
+    }).format(amount)
+  const priceLine = (selectedPlan: BillingPlan): string | null => {
+    const price = prices?.[selectedPlan]?.[interval]
+    if (!price) return null
+    if (interval === 'month') {
+      return `${formatPrice(price.amount, price.currency)}/${t('subscription.perMonth')}`
+    }
+    const perMonth = formatPrice(price.amount / 12, price.currency)
+    return `${perMonth}/${t('subscription.perMonth')} · ${t('subscription.billedAnnually', {
+      amount: formatPrice(price.amount, price.currency),
+    })}`
+  }
+
+  // The annual saving against twelve monthly payments, from the Pro prices.
+  const annualSaving = (() => {
+    const monthly = prices?.pro?.month?.amount
+    const yearly = prices?.pro?.year?.amount
+    if (!monthly || !yearly) return 0
+    return Math.round((1 - yearly / (monthly * 12)) * 100)
+  })()
+  const offers = (selectedInterval: BillingInterval) =>
+    Boolean(prices?.pro?.[selectedInterval] || prices?.enterprise?.[selectedInterval])
+  // Both intervals are offered unless the site said one is not for sale.
+  const showIntervalSwitch = prices === null || (offers('month') && offers('year'))
+
   const planIcon =
     plan === 'enterprise' ? (
       <Crown className="h-5 w-5" />
@@ -521,33 +562,80 @@ export function SubscriptionSettings({
           title={t('subscription.upgradeTitle')}
           description={t('subscription.upgradeToProDescription')}
         >
-          <div className="flex gap-2">
-            <Button
-              variant="default"
-              size="sm"
-              onClick={() => handleCheckout('pro')}
-              disabled={checkoutLoading !== null}
-            >
-              {checkoutLoading === 'pro' ? (
-                <Loader2 className="mr-2 h-4 w-4 animate-spin" />
-              ) : (
-                <Zap className="mr-2 h-4 w-4" />
-              )}
-              {t('subscription.upgradeToPro')}
-            </Button>
-            <Button
-              variant="outline"
-              size="sm"
-              onClick={() => handleCheckout('enterprise')}
-              disabled={checkoutLoading !== null}
+          {showIntervalSwitch && (
+            <div
+              role="radiogroup"
+              aria-label={t('subscription.billingInterval')}
+              className="mb-4 inline-flex items-center rounded-full border bg-muted/40 p-1 text-sm"
             >
-              {checkoutLoading === 'enterprise' ? (
-                <Loader2 className="mr-2 h-4 w-4 animate-spin" />
-              ) : (
-                <Crown className="mr-2 h-4 w-4" />
-              )}
-              {t('subscription.upgradeToEnterprise')}
-            </Button>
+              {(['month', 'year'] as const).map((value) => {
+                const active = interval === value
+                return (
+                  <button
+                    key={value}
+                    type="button"
+                    role="radio"
+                    aria-checked={active}
+                    onClick={() => setInterval(value)}
+                    className={`flex items-center gap-2 rounded-full px-3 py-1 font-medium transition-colors ${
+                      active
+                        ? 'bg-background text-foreground shadow-sm'
+                        : 'text-muted-foreground hover:text-foreground'
+                    }`}
+                  >
+                    {value === 'month'
+                      ? t('subscription.billingMonthly')
+                      : t('subscription.billingAnnual')}
+                    {value === 'year' && annualSaving > 0 && (
+                      <span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-xs font-semibold text-primary">
+                        {t('subscription.billingSave', { percent: annualSaving })}
+                      </span>
+                    )}
+                  </button>
+                )
+              })}
+            </div>
+          )}
+          <div className="grid gap-3 sm:grid-cols-2">
+            {(['pro', 'enterprise'] as const).map((offer) => {
+              const line = priceLine(offer)
+              const available = prices === null || Boolean(prices[offer]?.[interval])
+              return (
+                <div key={offer} className="flex flex-col gap-2 rounded-md border p-3">
+                  <div className="flex items-center gap-2">
+                    {offer === 'pro' ? (
+                      <Zap className="h-4 w-4 text-blue-600" />
+                    ) : (
+                      <Crown className="h-4 w-4 text-purple-600" />
+                    )}
+                    <span className="text-sm font-medium">
+                      {offer === 'pro'
+                        ? t('subscription.planPro')
+                        : t('subscription.planEnterprise')}
+                    </span>
+                  </div>
+                  {line && <p className="text-sm text-muted-foreground">{line}</p>}
+                  <Button
+                    variant={offer === 'pro' ? 'default' : 'outline'}
+                    size="sm"
+                    className="mt-auto"
+                    onClick={() => handleCheckout(offer)}
+                    disabled={checkoutLoading !== null || !available}
+                  >
+                    {checkoutLoading === offer ? (
+                      <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+                    ) : offer === 'pro' ? (
+                      <Zap className="mr-2 h-4 w-4" />
+                    ) : (
+                      <Crown className="mr-2 h-4 w-4" />
+                    )}
+                    {offer === 'pro'
+                      ? t('subscription.upgradeToPro')
+                      : t('subscription.upgradeToEnterprise')}
+                  </Button>
+                </div>
+              )
+            })}
           </div>
           <p className="mt-3 text-xs text-muted-foreground">
             {t('subscription.checkoutOnTorqvoice')}

+ 5 - 2
src/lib/features.ts

@@ -60,9 +60,12 @@ export const PLAN_FEATURES: Record<Plan, PlanFeatures> = {
     tireHotel: false,
   },
   pro: {
-    maxOrganizations: 3,
+    // One shop, the whole team. Several locations is what Enterprise sells.
+    // Raised from 5 seats and lowered from 3 workshops on 4 Oct 2026; every
+    // paid owner had exactly one workshop at the time, so nobody lost one.
+    maxOrganizations: 1,
     maxCustomers: 999999,
-    maxUsers: 5,
+    maxUsers: 10,
     templates: 999999,
     customTemplates: true,
     reports: true,

+ 59 - 5
src/lib/torqvoice-com.ts

@@ -12,8 +12,10 @@ import { isCloudMode } from './features'
  * is the same value on both sides:
  *
  * - The purchase goes through the customer's browser, so the app mints a
- *   signed, short-lived handoff naming the organization, the plan and the
- *   buyer, and sends the browser to torqvoice.com with it.
+ *   signed, short-lived handoff naming the organization, the plan, the
+ *   billing interval and the buyer, and sends the browser to torqvoice.com
+ *   with it. The amounts live in Stripe and are read on the site; this app
+ *   never stores a price.
  * - Everything else (billing portal, cancel, resume, upgrade, the daily
  *   sync) is a server-to-server call with the secret as a bearer token.
  *
@@ -39,12 +41,21 @@ export const HANDOFF_PREFIX = 'tvh1'
 /** How long a checkout link stays valid; torqvoice.com refuses older ones. */
 export const HANDOFF_TTL_SECONDS = 15 * 60
 
+export type BillingPlan = 'pro' | 'enterprise'
+export type BillingInterval = 'month' | 'year'
+
+export function isBillingInterval(value: unknown): value is BillingInterval {
+  return value === 'month' || value === 'year'
+}
+
 export type HandoffPayload = {
   v: 1
   /** one-time id: the site starts one checkout per handoff */
   jti: string
   org: string
-  plan: 'pro' | 'enterprise'
+  plan: BillingPlan
+  /** monthly or annual billing; the site reads the amount from Stripe */
+  interval: BillingInterval
   email: string
   name: string
   appUrl: string
@@ -73,7 +84,13 @@ export function appOrigin(): string {
 }
 
 export function createHandoffToken(
-  input: { organizationId: string; plan: 'pro' | 'enterprise'; email: string; name: string },
+  input: {
+    organizationId: string
+    plan: BillingPlan
+    interval: BillingInterval
+    email: string
+    name: string
+  },
   now = Date.now()
 ): string {
   const secret = serviceSecret()
@@ -85,6 +102,7 @@ export function createHandoffToken(
     jti: randomBytes(16).toString('base64url'),
     org: input.organizationId,
     plan: input.plan,
+    interval: input.interval,
     email: input.email,
     name: input.name.slice(0, 200),
     appUrl: appOrigin(),
@@ -180,7 +198,16 @@ export class TorqvoiceComError extends Error {
  * account, the organization belongs to.
  */
 export async function billingRequest<T>(
-  path: 'portal' | 'cancel' | 'resume' | 'end' | 'upgrade-preview' | 'upgrade' | 'sync' | 'ping',
+  path:
+    | 'portal'
+    | 'cancel'
+    | 'resume'
+    | 'end'
+    | 'upgrade-preview'
+    | 'upgrade'
+    | 'sync'
+    | 'ping'
+    | 'prices',
   body: Record<string, unknown>,
   timeoutMs = 20_000
 ): Promise<T> {
@@ -229,3 +256,30 @@ export function billingErrorResponse(error: unknown, fallback: string) {
   console.error('[subscription]', error)
   return NextResponse.json({ error: fallback }, { status: 500 })
 }
+
+/** What torqvoice.com sells today, per plan and billing interval. */
+export type BillingPriceList = Record<
+  BillingPlan,
+  Partial<Record<BillingInterval, { amount: number; currency: string }>>
+>
+
+/**
+ * The amounts the subscription page shows beside its buttons. They come
+ * from torqvoice.com, which reads them from Stripe, so a price change there
+ * is a price change here. When the site cannot be reached the page still
+ * works; it just offers the plans without amounts.
+ */
+export async function fetchBillingPrices(organizationId: string): Promise<BillingPriceList | null> {
+  if (!isTorqvoiceComBillingConfigured()) return null
+  try {
+    const data = await billingRequest<{ prices?: BillingPriceList }>(
+      'prices',
+      { organizationId },
+      5_000
+    )
+    return data.prices ?? null
+  } catch (error) {
+    console.error('[torqvoice.com] prices unavailable:', error)
+    return null
+  }
+}