Explorar el Código

Show discounts on the subscription page (#452)

Bernt Christian Egeland hace 2 semanas
padre
commit
d19ff57e8c

+ 4 - 0
messages/de/settings.json

@@ -1655,7 +1655,11 @@
     "billingMonthly": "Monatlich",
     "billingAnnual": "Jährlich",
     "billingSave": "{percent}% sparen",
+    "billingMonthsFree": "{months, plural, one {# Monat gratis} other {# Monate gratis}}",
     "billedAnnually": "{amount} jährlich abgerechnet",
+    "discountBadge": "{value} Rabatt",
+    "discountOnce": "Erste Zahlung, danach {amount}",
+    "discountRepeating": "{months, plural, one {Erster Monat} other {Erste # Monate}}, danach {amount}",
     "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",

+ 4 - 0
messages/en/settings.json

@@ -1655,7 +1655,11 @@
     "billingMonthly": "Monthly",
     "billingAnnual": "Annual",
     "billingSave": "Save {percent}%",
+    "billingMonthsFree": "{months, plural, one {# month free} other {# months free}}",
     "billedAnnually": "{amount} billed annually",
+    "discountBadge": "{value} off",
+    "discountOnce": "First payment, then {amount}",
+    "discountRepeating": "First {months, plural, one {month} other {# months}}, then {amount}",
     "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",

+ 4 - 0
messages/es/settings.json

@@ -1655,7 +1655,11 @@
     "billingMonthly": "Mensual",
     "billingAnnual": "Anual",
     "billingSave": "Ahorra un {percent}%",
+    "billingMonthsFree": "{months, plural, one {# mes gratis} other {# meses gratis}}",
     "billedAnnually": "{amount} facturado anualmente",
+    "discountBadge": "{value} de descuento",
+    "discountOnce": "Primer pago, después {amount}",
+    "discountRepeating": "{months, plural, one {Primer mes} other {Primeros # meses}}, después {amount}",
     "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",

+ 4 - 0
messages/fr/settings.json

@@ -1655,7 +1655,11 @@
     "billingMonthly": "Mensuel",
     "billingAnnual": "Annuel",
     "billingSave": "Économisez {percent}%",
+    "billingMonthsFree": "{months, plural, one {# mois offert} other {# mois offerts}}",
     "billedAnnually": "{amount} facturé annuellement",
+    "discountBadge": "{value} de réduction",
+    "discountOnce": "Premier paiement, puis {amount}",
+    "discountRepeating": "{months, plural, one {Premier mois} other {# premiers mois}}, puis {amount}",
     "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",

+ 4 - 0
messages/it/settings.json

@@ -1655,7 +1655,11 @@
     "billingMonthly": "Mensile",
     "billingAnnual": "Annuale",
     "billingSave": "Risparmia il {percent}%",
+    "billingMonthsFree": "{months, plural, one {# mese gratis} other {# mesi gratis}}",
     "billedAnnually": "{amount} fatturato annualmente",
+    "discountBadge": "{value} di sconto",
+    "discountOnce": "Primo pagamento, poi {amount}",
+    "discountRepeating": "{months, plural, one {Primo mese} other {Primi # mesi}}, poi {amount}",
     "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",

+ 4 - 0
messages/lt/settings.json

@@ -1655,7 +1655,11 @@
     "billingMonthly": "Kas mėnesį",
     "billingAnnual": "Kasmet",
     "billingSave": "Sutaupykite {percent}%",
+    "billingMonthsFree": "{months, plural, one {# mėnuo nemokamai} few {# mėnesiai nemokamai} other {# mėnesių nemokamai}}",
     "billedAnnually": "{amount} apmokestinama kasmet",
+    "discountBadge": "{value} nuolaida",
+    "discountOnce": "Pirmas mokėjimas, vėliau {amount}",
+    "discountRepeating": "Pirmus {months} mėn., vėliau {amount}",
     "upgradeDialogTitle": "Atnaujinti į Enterprise",
     "upgradeDialogDescription": "Jūsų dabartinis Pro planas bus atnaujintas į Enterprise. Bus apmokestinta tik proporcinga skirtuma likusiam atsiskaitymo laikotarpiui.",
     "upgradeConfirm": "Patvirtinti atnaujinimą",

+ 4 - 0
messages/nb/settings.json

@@ -1655,7 +1655,11 @@
     "billingMonthly": "Månedlig",
     "billingAnnual": "Årlig",
     "billingSave": "Spar {percent}%",
+    "billingMonthsFree": "{months, plural, one {# måned gratis} other {# måneder gratis}}",
     "billedAnnually": "{amount} fakturert årlig",
+    "discountBadge": "{value} rabatt",
+    "discountOnce": "Første betaling, deretter {amount}",
+    "discountRepeating": "{months, plural, one {Første måned} other {Første # måneder}}, deretter {amount}",
     "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",

+ 4 - 0
messages/nl/settings.json

@@ -1655,7 +1655,11 @@
     "billingMonthly": "Maandelijks",
     "billingAnnual": "Jaarlijks",
     "billingSave": "Bespaar {percent}%",
+    "billingMonthsFree": "{months, plural, one {# maand gratis} other {# maanden gratis}}",
     "billedAnnually": "{amount} jaarlijks gefactureerd",
+    "discountBadge": "{value} korting",
+    "discountOnce": "Eerste betaling, daarna {amount}",
+    "discountRepeating": "{months, plural, one {Eerste maand} other {Eerste # maanden}}, daarna {amount}",
     "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",

+ 4 - 0
messages/pl/settings.json

@@ -1655,7 +1655,11 @@
     "billingMonthly": "Miesięcznie",
     "billingAnnual": "Rocznie",
     "billingSave": "Oszczędź {percent}%",
+    "billingMonthsFree": "{months, plural, one {# miesiąc gratis} few {# miesiące gratis} many {# miesięcy gratis} other {# miesiąca gratis}}",
     "billedAnnually": "{amount} rozliczane rocznie",
+    "discountBadge": "{value} zniżki",
+    "discountOnce": "Pierwsza płatność, potem {amount}",
+    "discountRepeating": "Przez pierwsze {months} mies., potem {amount}",
     "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",

+ 4 - 0
messages/pt-BR/settings.json

@@ -1655,7 +1655,11 @@
     "billingMonthly": "Mensal",
     "billingAnnual": "Anual",
     "billingSave": "Economize {percent}%",
+    "billingMonthsFree": "{months, plural, one {# mês grátis} other {# meses grátis}}",
     "billedAnnually": "{amount} cobrado anualmente",
+    "discountBadge": "{value} de desconto",
+    "discountOnce": "Primeiro pagamento, depois {amount}",
+    "discountRepeating": "{months, plural, one {Primeiro mês} other {Primeiros # meses}}, depois {amount}",
     "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",

+ 4 - 0
messages/pt-PT/settings.json

@@ -1655,7 +1655,11 @@
     "billingMonthly": "Mensal",
     "billingAnnual": "Anual",
     "billingSave": "Poupe {percent}%",
+    "billingMonthsFree": "{months, plural, one {# mês grátis} other {# meses grátis}}",
     "billedAnnually": "{amount} faturados anualmente",
+    "discountBadge": "{value} de desconto",
+    "discountOnce": "Primeiro pagamento, depois {amount}",
+    "discountRepeating": "{months, plural, one {Primeiro mês} other {Primeiros # meses}}, depois {amount}",
     "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",

+ 4 - 0
messages/ru/settings.json

@@ -1655,7 +1655,11 @@
     "billingMonthly": "Ежемесячно",
     "billingAnnual": "Ежегодно",
     "billingSave": "Экономия {percent}%",
+    "billingMonthsFree": "{months, plural, one {# месяц бесплатно} few {# месяца бесплатно} many {# месяцев бесплатно} other {# месяца бесплатно}}",
     "billedAnnually": "{amount} оплата за год",
+    "discountBadge": "Скидка {value}",
+    "discountOnce": "Первый платёж, затем {amount}",
+    "discountRepeating": "Первые {months} мес., затем {amount}",
     "upgradeDialogTitle": "Обновить до корпоративного тарифа",
     "upgradeDialogDescription": "Ваш текущий тариф Pro будет обновлён до корпоративного. Вам будет начислена только пропорциональная разница за оставшийся период оплаты.",
     "upgradeConfirm": "Подтвердить обновление",

+ 4 - 0
messages/tr/settings.json

@@ -1655,7 +1655,11 @@
     "billingMonthly": "Aylık",
     "billingAnnual": "Yıllık",
     "billingSave": "%{percent} tasarruf",
+    "billingMonthsFree": "{months} ay ücretsiz",
     "billedAnnually": "{amount} yıllık faturalanır",
+    "discountBadge": "{value} indirim",
+    "discountOnce": "İlk ödeme, sonrasında {amount}",
+    "discountRepeating": "İlk {months} ay, sonrasında {amount}",
     "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",

+ 42 - 0
src/__tests__/lib/features.test.ts

@@ -10,6 +10,7 @@ vi.mock('@/lib/db', () => ({
   db: {
     subscription: { findUnique: vi.fn() },
     appSetting: { findMany: vi.fn() },
+    systemSetting: { findMany: vi.fn() },
     organization: { count: vi.fn() },
     organizationMember: { count: vi.fn(), findMany: vi.fn() },
   },
@@ -35,6 +36,7 @@ import { scheduleLicenseSelfHeal } from '@/lib/license/revalidate'
 
 const mockFindUnique = vi.mocked(db.subscription.findUnique)
 const mockFindMany = vi.mocked(db.appSetting.findMany)
+const mockLimitOverrides = vi.mocked(db.systemSetting.findMany)
 const mockVerify = vi.mocked(verifyLicenseToken)
 const mockSelfHeal = vi.mocked(scheduleLicenseSelfHeal)
 
@@ -46,6 +48,7 @@ beforeEach(() => {
   vi.resetAllMocks()
   vi.unstubAllEnvs()
   mockVerify.mockReturnValue(verification('missing'))
+  mockLimitOverrides.mockResolvedValue([])
 })
 
 describe('getFeatures — cloud mode', () => {
@@ -152,6 +155,45 @@ describe('getFeatures — cloud mode', () => {
   })
 })
 
+describe('limits set by hand for one workshop', () => {
+  const proSubscription = { status: 'active', plan: { name: 'Torq Pro' }, currentPeriodEnd: null }
+  const stored = (limits: Record<string, string>, org: string) =>
+    Object.entries(limits).map(([limit, value]) => ({ key: `limits.${limit}.${org}`, value }))
+
+  beforeEach(() => {
+    vi.stubEnv('TORQVOICE_MODE', 'cloud')
+  })
+
+  it('raise a paid plan above its own limits', async () => {
+    mockFindUnique.mockResolvedValue(proSubscription as any)
+    mockLimitOverrides.mockResolvedValue(
+      stored({ maxUsers: '8', maxOrganizations: '3', maxImagesPerService: '60' }, 'org-l1') as any
+    )
+    const features = await getFeatures('org-l1')
+    expect(features).toMatchObject({ maxUsers: 8, maxOrganizations: 3, maxImagesPerService: 60 })
+    // What was not set stays the plan's.
+    expect(features.maxDocumentsPerService).toBe(PLAN_FEATURES.pro.maxDocumentsPerService)
+    expect(features.customerPortal).toBe(true)
+  })
+
+  it('never lower the plan, and a value that is not a number is ignored', async () => {
+    mockFindUnique.mockResolvedValue(proSubscription as any)
+    mockLimitOverrides.mockResolvedValue(
+      stored({ maxUsers: '2', maxOrganizations: 'lots' }, 'org-l2') as any
+    )
+    const features = await getFeatures('org-l2')
+    expect(features.maxUsers).toBe(PLAN_FEATURES.pro.maxUsers)
+    expect(features.maxOrganizations).toBe(PLAN_FEATURES.pro.maxOrganizations)
+  })
+
+  it('do not follow a workshop down to the free plan', async () => {
+    mockFindUnique.mockResolvedValue(null)
+    mockLimitOverrides.mockResolvedValue(stored({ maxUsers: '8' }, 'org-l3') as any)
+    expect((await getFeatures('org-l3')).maxUsers).toBe(PLAN_FEATURES.free.maxUsers)
+    expect(mockLimitOverrides).not.toHaveBeenCalled()
+  })
+})
+
 describe('getFeatures — self-hosted mode', () => {
   beforeEach(() => {
     vi.stubEnv('TORQVOICE_MODE', 'self-hosted')

+ 5 - 2
src/app/(authenticated)/settings/subscription/page.tsx

@@ -1,7 +1,7 @@
 import { getAuthContext } from '@/lib/get-auth-context'
 import { db } from '@/lib/db'
 import { redirect } from 'next/navigation'
-import { PLAN_FEATURES, type Plan } from '@/lib/features'
+import { getFeatures, 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'
@@ -43,7 +43,10 @@ export default async function SubscriptionPage({
   // admin panel). It carries full plan features but expires at currentPeriodEnd.
   const isDemo = subscription?.status === 'trialing' && !subscription?.stripeSubscriptionId
 
-  const features = PLAN_FEATURES[plan]
+  // The member meter shows the limit that is enforced, which for a workshop
+  // with a team size agreed by hand is higher than its plan's.
+  const enforced = await getFeatures(authContext.organizationId)
+  const features = { ...PLAN_FEATURES[plan], maxUsers: enforced.maxUsers }
 
   const canBuy = plan === 'free' || isDemo
   const [customerCount, memberCount, prices] = await Promise.all([

+ 57 - 6
src/features/subscription/Components/subscription-settings.tsx

@@ -261,20 +261,33 @@ export function SubscriptionSettings({
     new Intl.NumberFormat(locale, {
       style: 'currency',
       currency: currency.toUpperCase(),
-      minimumFractionDigits: 0,
+      // Whole amounts stay bare; anything else gets both decimals ("$14.50").
+      minimumFractionDigits: Number.isInteger(amount) ? 0 : 2,
       maximumFractionDigits: 2,
     }).format(amount)
   const priceLine = (selectedPlan: BillingPlan): string | null => {
     const price = prices?.[selectedPlan]?.[interval]
     if (!price) return null
+    // With a discount on, the line states what checkout will charge.
+    const amount = price.discount?.amount ?? price.amount
     if (interval === 'month') {
-      return `${formatPrice(price.amount, price.currency)}/${t('subscription.perMonth')}`
+      return `${formatPrice(amount, price.currency)}/${t('subscription.perMonth')}`
     }
-    const perMonth = formatPrice(price.amount / 12, price.currency)
+    const perMonth = formatPrice(amount / 12, price.currency)
     return `${perMonth}/${t('subscription.perMonth')} · ${t('subscription.billedAnnually', {
-      amount: formatPrice(price.amount, price.currency),
+      amount: formatPrice(amount, price.currency),
     })}`
   }
+  // A discount that ends says what the plan costs afterwards.
+  const discountEnds = (selectedPlan: BillingPlan): string | null => {
+    const price = prices?.[selectedPlan]?.[interval]
+    const discount = price?.discount
+    if (!price || !discount || discount.duration === 'forever') return null
+    const amount = formatPrice(price.amount, price.currency)
+    return discount.duration === 'once'
+      ? t('subscription.discountOnce', { amount })
+      : t('subscription.discountRepeating', { months: discount.durationInMonths ?? 1, amount })
+  }
 
   // The annual saving against twelve monthly payments, from the Pro prices.
   const annualSaving = (() => {
@@ -283,6 +296,16 @@ export function SubscriptionSettings({
     if (!monthly || !yearly) return 0
     return Math.round((1 - yearly / (monthly * 12)) * 100)
   })()
+  // The same saving as whole months left out ("2 months free"), when the
+  // prices work out that way; 0 when they do not and the percentage is shown.
+  const annualFreeMonths = (() => {
+    const monthly = prices?.pro?.month?.amount
+    const yearly = prices?.pro?.year?.amount
+    if (!monthly || !yearly) return 0
+    const months = 12 - yearly / monthly
+    const whole = Math.round(months)
+    return whole >= 1 && Math.abs(months - whole) < 0.01 ? whole : 0
+  })()
   const offers = (selectedInterval: BillingInterval) =>
     Boolean(prices?.pro?.[selectedInterval] || prices?.enterprise?.[selectedInterval])
   // Both intervals are offered unless the site said one is not for sale.
@@ -468,7 +491,9 @@ export function SubscriptionSettings({
                       : 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 })}
+                        {annualFreeMonths > 0
+                          ? t('subscription.billingMonthsFree', { months: annualFreeMonths })
+                          : t('subscription.billingSave', { percent: annualSaving })}
                       </span>
                     )}
                   </button>
@@ -479,6 +504,9 @@ export function SubscriptionSettings({
           <div className="grid gap-3 sm:grid-cols-2">
             {(['pro', 'enterprise'] as const).map((offer) => {
               const line = priceLine(offer)
+              const price = prices?.[offer]?.[interval]
+              const discount = price?.discount
+              const ends = discountEnds(offer)
               const available = prices === null || Boolean(prices[offer]?.[interval])
               return (
                 <div key={offer} className="flex flex-col gap-2 rounded-md border p-3">
@@ -493,8 +521,31 @@ export function SubscriptionSettings({
                         ? t('subscription.planPro')
                         : t('subscription.planEnterprise')}
                     </span>
+                    {price && discount && (
+                      <span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-xs font-semibold text-primary">
+                        {discount.name ? `${discount.name} · ` : ''}
+                        {t('subscription.discountBadge', {
+                          value: discount.percentOff
+                            ? `${discount.percentOff}%`
+                            : formatPrice(discount.amountOff ?? 0, price.currency),
+                        })}
+                      </span>
+                    )}
                   </div>
-                  {line && <p className="text-sm text-muted-foreground">{line}</p>}
+                  {line && (
+                    <p className="text-sm text-muted-foreground">
+                      {price && discount && (
+                        <span className="mr-1.5 line-through">
+                          {formatPrice(
+                            price.amount / (interval === 'year' ? 12 : 1),
+                            price.currency
+                          )}
+                        </span>
+                      )}
+                      {line}
+                    </p>
+                  )}
+                  {ends && <p className="text-xs text-muted-foreground">{ends}</p>}
                   <Button
                     variant={offer === 'pro' ? 'default' : 'outline'}
                     size="sm"

+ 46 - 5
src/lib/features.ts

@@ -31,6 +31,9 @@ export type PlanFeatures = {
   tireHotel: boolean
 }
 
+// The admin on torqvoice.com shows the Pro and Enterprise limits a workshop
+// starts from (its src/lib/app-billing/workshop-limits.ts, PLAN_LIMITS):
+// change a number here and change it there.
 export const PLAN_FEATURES: Record<Plan, PlanFeatures> = {
   free: {
     maxOrganizations: 1,
@@ -60,12 +63,13 @@ export const PLAN_FEATURES: Record<Plan, PlanFeatures> = {
     tireHotel: false,
   },
   pro: {
-    // 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.
+    // One shop and a small team. A bigger team or several locations is what
+    // Enterprise sells. Lowered from 3 workshops on 4 Oct 2026; every paid
+    // owner had exactly one at the time, so nobody lost one. Seats went to 10
+    // that day and back to 5 on 10 Oct 2026, when no Pro workshop had more.
     maxOrganizations: 1,
     maxCustomers: 999999,
-    maxUsers: 10,
+    maxUsers: 5,
     templates: 999999,
     customTemplates: true,
     reports: true,
@@ -157,6 +161,42 @@ function cloudPlan(plan: Plan): PlanFeatures {
 // cron time to sync. 3 days covers Stripe's initial retry window.
 const SUBSCRIPTION_GRACE_MS = 3 * 24 * 60 * 60 * 1000
 
+/**
+ * The limits that can be raised for one workshop by hand, from the admin on
+ * torqvoice.com (its src/lib/app-billing/workshop-limits.ts writes the same
+ * keys; keep the two lists in step).
+ */
+const OVERRIDABLE_LIMITS = [
+  'maxUsers',
+  'maxOrganizations',
+  'maxImagesPerService',
+  'maxDiagnosticsPerService',
+  'maxDocumentsPerService',
+] as const satisfies readonly (keyof PlanFeatures)[]
+
+/**
+ * A paid plan with whatever was agreed with this workshop on top. A limit
+ * set by hand only ever raises the plan's own; anything else is ignored.
+ */
+async function withLimitOverrides(
+  organizationId: string,
+  features: PlanFeatures
+): Promise<PlanFeatures> {
+  const rows = await db.systemSetting.findMany({
+    where: { key: { in: OVERRIDABLE_LIMITS.map((limit) => `limits.${limit}.${organizationId}`) } },
+    select: { key: true, value: true },
+  })
+  if (rows.length === 0) return features
+
+  const raised = { ...features }
+  for (const limit of OVERRIDABLE_LIMITS) {
+    const row = rows.find((candidate) => candidate.key === `limits.${limit}.${organizationId}`)
+    const value = Number.parseInt(row?.value ?? '', 10)
+    if (Number.isFinite(value) && value > raised[limit]) raised[limit] = value
+  }
+  return raised
+}
+
 export const getFeatures = cache(async (organizationId: string): Promise<PlanFeatures> => {
   if (isCloudMode()) {
     const subscription = await db.subscription.findUnique({
@@ -190,7 +230,8 @@ export const getFeatures = cache(async (organizationId: string): Promise<PlanFea
       : name.includes('pro')
         ? 'pro'
         : 'free'
-    return cloudPlan(planName)
+    const features = cloudPlan(planName)
+    return planName === 'free' ? features : withLimitOverrides(organizationId, features)
   }
 
   // Self-hosted mode — all features unlocked, license only controls branding.

+ 19 - 1
src/lib/torqvoice-com.ts

@@ -257,10 +257,28 @@ export function billingErrorResponse(error: unknown, fallback: string) {
   return NextResponse.json({ error: fallback }, { status: 500 })
 }
 
+/**
+ * The discount torqvoice.com takes off a new purchase at checkout, when one
+ * is on: a Stripe coupon it reads there. Amounts are in whole currency units.
+ */
+export type BillingPriceDiscount = {
+  /** the coupon's name in Stripe, shown beside the amount off */
+  name?: string
+  /** what the plan costs with the discount */
+  amount: number
+  percentOff?: number
+  amountOff?: number
+  /** once: the first payment; repeating: `durationInMonths`; forever: every payment */
+  duration: 'once' | 'repeating' | 'forever'
+  durationInMonths?: number
+}
+
 /** What torqvoice.com sells today, per plan and billing interval. */
 export type BillingPriceList = Record<
   BillingPlan,
-  Partial<Record<BillingInterval, { amount: number; currency: string }>>
+  Partial<
+    Record<BillingInterval, { amount: number; currency: string; discount?: BillingPriceDiscount }>
+  >
 >
 
 /**