diff --git a/packages/backend/prisma/migrations/20261001100000_add_license_billing/migration.sql b/packages/backend/prisma/migrations/20261001100000_add_license_billing/migration.sql new file mode 100644 index 00000000..5afb58ba --- /dev/null +++ b/packages/backend/prisma/migrations/20261001100000_add_license_billing/migration.sql @@ -0,0 +1,5 @@ +-- The Stripe subscription state the licence site reports for a paid Cloud +-- licence (trial end, cancelled at period end, renewal date, price), so the +-- app can tell the customer when their plan renews or ends. Nullable and +-- additive: the previous release ignores it during a blue/green switch. +ALTER TABLE "licenses" ADD COLUMN "billing" JSONB; diff --git a/packages/backend/prisma/schema.prisma b/packages/backend/prisma/schema.prisma index 6bda440e..0c0aa04f 100644 --- a/packages/backend/prisma/schema.prisma +++ b/packages/backend/prisma/schema.prisma @@ -231,6 +231,10 @@ model License { organizationId String? @map("organization_id") organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull) lastVerifiedAt DateTime? @map("last_verified_at") + /// The Stripe subscription's state as last reported by the licence site + /// (card trial end, cancelled at period end, next renewal, price). Cloud + /// only; null for trials, self-hosted and licences without a subscription. + billing Json? createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/packages/backend/src/license/license-checkout.spec.ts b/packages/backend/src/license/license-checkout.spec.ts index 4a6f257c..f70b5d21 100644 --- a/packages/backend/src/license/license-checkout.spec.ts +++ b/packages/backend/src/license/license-checkout.spec.ts @@ -267,6 +267,23 @@ describe('LicenseController.checkoutLink', () => { expect(licenseService.getCurrentLicense).toHaveBeenCalledWith('org-1'); }); + it('passes a promotion code through, upper-cased, and omits it when absent', async () => { + const { controller, licenseService } = makeController(); + await controller.checkoutLink(adminReq, { + plan: 'team', + billingPeriod: 'monthly', + trial: true, + promo: 'start30', + }); + const [payload] = licenseService.createCheckoutIntent.mock.calls[0] as any[]; + expect(payload.promoCode).toBe('START30'); + + const plain = makeController(); + await plain.controller.checkoutLink(adminReq, { plan: 'team', billingPeriod: 'monthly', trial: true }); + const [plainPayload] = plain.licenseService.createCheckoutIntent.mock.calls[0] as any[]; + expect(plainPayload).not.toHaveProperty('promoCode'); + }); + it('asks to pay now when no trial is requested', async () => { const { controller, licenseService } = makeController(); await controller.checkoutLink(adminReq, { plan: 'business', billingPeriod: 'monthly', trial: false }); diff --git a/packages/backend/src/license/license-checkout.ts b/packages/backend/src/license/license-checkout.ts index 1d530148..c94aacfe 100644 --- a/packages/backend/src/license/license-checkout.ts +++ b/packages/backend/src/license/license-checkout.ts @@ -29,6 +29,8 @@ export interface CheckoutIntentPayload { trialEnd?: string; returnUrl: string; organizationId?: string; + /** A Stripe promotion code the user arrived with; the site pre-applies it. */ + promoCode?: string; adMetadata?: { ad_consent: 'granted'; gclid?: string; diff --git a/packages/backend/src/license/license.controller.ts b/packages/backend/src/license/license.controller.ts index 27f5bd03..0497a971 100644 --- a/packages/backend/src/license/license.controller.ts +++ b/packages/backend/src/license/license.controller.ts @@ -52,12 +52,24 @@ class CheckoutLinkDto { @IsBoolean() trial: boolean; + + // A Stripe promotion code the user arrived with (promo bar → sign-up). The + // licence site pre-applies it when it is a live code and ignores it if not. + @IsOptional() + @IsString() + @Matches(/^[A-Za-z0-9_-]{1,64}$/) + promo?: string; } class BillingPortalDto { @IsOptional() @IsString() returnUrl?: string; + + /** 'cancel': open Stripe's cancellation page directly. */ + @IsOptional() + @IsIn(['cancel']) + flow?: 'cancel'; } @ApiTags('License') @@ -130,9 +142,30 @@ export class LicenseController { lastVerifiedAt: license.lastVerifiedAt, instanceId: license.instanceId, ...(trialDaysLeft !== undefined && { trialDaysLeft }), + // Cloud, paid licences: the subscription's state (card trial end, set + // to cancel at period end, next renewal and price). + ...(license.billing && { billing: license.billing }), }; } + @Post('refresh') + @HttpCode(200) + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Roles('ADMIN') + @Throttle({ default: { limit: 5, ttl: 60_000 } }) + @ApiBearerAuth() + @ApiOperation({ + summary: + 'Re-verify the workspace licence now, e.g. after returning from the billing portal (cloud, ADMIN)', + }) + async refresh(@Req() req: any): Promise<{ refreshed: boolean }> { + if (!this.deployment.isCloud()) throw new NotFoundException(); + const refreshed = await this.licenseService + .refreshLicense(req.user.organizationId) + .catch(() => false); + return { refreshed }; + } + @Get('instance-id') @ApiOperation({ summary: 'Get the instance ID' }) async getInstanceId() { @@ -198,6 +231,7 @@ export class LicenseController { return await this.licenseService.createBillingPortalSession( req.user.organizationId, dto?.returnUrl, + dto?.flow, ); } catch (err: any) { throw new BadRequestException(err.message || 'Failed to open billing portal'); @@ -262,6 +296,7 @@ export class LicenseController { ...(trialEnd && { trialEnd: trialEnd.toISOString() }), returnUrl: `${cloudFrontendOrigin()}/settings/license/activate`, organizationId, + ...(dto.promo && { promoCode: dto.promo.toUpperCase() }), ...(adMetadata && { adMetadata }), }); } catch (err: any) { diff --git a/packages/backend/src/license/license.service.spec.ts b/packages/backend/src/license/license.service.spec.ts index bdb93ec6..85c3cf43 100644 --- a/packages/backend/src/license/license.service.spec.ts +++ b/packages/backend/src/license/license.service.spec.ts @@ -403,3 +403,105 @@ describe('LicenseService — paid licences are re-verified', () => { expect(prisma.license.findMany).not.toHaveBeenCalled(); }); }); + +describe('LicenseService — the subscription state reaches the app', () => { + // The app only knew "active" or not, so a card-trial customer never saw when + // the first charge comes, and a customer who cancelled never saw when the + // plan ends. The licence site now reports the subscription's state. + const axios = require('axios'); + let get: jest.SpyInstance; + beforeEach(() => { + get = jest.spyOn(axios, 'get'); + }); + afterEach(() => get.mockRestore()); + + const BILLING = { + status: 'active', + cancelling: true, + endsAt: '2026-11-01T00:00:00.000Z', + currentPeriodEnd: '2026-11-01T00:00:00.000Z', + trialEnd: null, + amount: 4900, + currency: 'eur', + interval: 'month', + }; + + function make(isCloud = true) { + const updates: any[] = []; + const rows = [{ licenseKey: 'AMCP-EEEE-0000-0000-0005' }]; + const prisma = { + license: { + findFirst: jest.fn().mockResolvedValue(rows[0]), + update: jest.fn(async (args: any) => { + updates.push(args); + return {}; + }), + }, + }; + const deployment = { isCloud: () => isCloud }; + const svc = new LicenseService(prisma as any, {} as any, deployment as any); + return { svc, prisma, updates }; + } + + it('asks the licence site for billing on cloud and stores it', async () => { + const { svc, updates } = make(); + get.mockResolvedValueOnce({ data: { valid: true, plan: 'team', billing: BILLING } }); + await svc.verifyLicense('AMCP-EEEE-0000-0000-0005'); + expect(get.mock.calls[0][1].params).toEqual({ key: 'AMCP-EEEE-0000-0000-0005', billing: '1' }); + expect(updates[0].data.billing).toEqual(BILLING); + }); + + it('does not ask for billing on self-hosted', async () => { + const { svc } = make(false); + get.mockResolvedValueOnce({ data: { valid: true, plan: 'team' } }); + await svc.verifyLicense('AMCP-EEEE-0000-0000-0005'); + expect(get.mock.calls[0][1].params).toEqual({ key: 'AMCP-EEEE-0000-0000-0005' }); + }); + + it('stores nothing usable from a malformed billing block', async () => { + const { svc, updates } = make(); + get.mockResolvedValueOnce({ data: { valid: true, plan: 'team', billing: { status: 42 } } }); + await svc.verifyLicense('AMCP-EEEE-0000-0000-0005'); + expect(updates[0].data.billing).not.toEqual(expect.objectContaining({ status: 42 })); + }); + + it('refreshes at most once a minute per workspace', async () => { + const { svc } = make(); + get.mockResolvedValue({ data: { valid: true, plan: 'team', billing: BILLING } }); + await expect(svc.refreshLicense('org-1')).resolves.toBe(true); + await expect(svc.refreshLicense('org-1')).resolves.toBe(false); + await expect(svc.refreshLicense('org-2')).resolves.toBe(true); + expect(get).toHaveBeenCalledTimes(2); + }); +}); + +describe('parseLicenseBilling', () => { + const { parseLicenseBilling } = require('./license.service'); + it('keeps a well-formed block and drops junk fields', () => { + expect( + parseLicenseBilling({ + status: 'trialing', + cancelling: false, + trialEnd: '2026-10-08T09:00:00.000Z', + endsAt: 'not a date', + amount: 1900, + currency: 'eur', + interval: 'fortnight', + extra: 'x', + }), + ).toEqual({ + status: 'trialing', + cancelling: false, + endsAt: null, + currentPeriodEnd: null, + trialEnd: '2026-10-08T09:00:00.000Z', + amount: 1900, + currency: 'eur', + interval: null, + }); + }); + it('returns null without a status', () => { + expect(parseLicenseBilling({ cancelling: true })).toBeNull(); + expect(parseLicenseBilling(null)).toBeNull(); + }); +}); diff --git a/packages/backend/src/license/license.service.ts b/packages/backend/src/license/license.service.ts index 473ad54c..3d12832b 100644 --- a/packages/backend/src/license/license.service.ts +++ b/packages/backend/src/license/license.service.ts @@ -5,6 +5,7 @@ import { PrismaService } from '../common/prisma.service'; import { DeploymentService } from '../common/deployment.service'; import { SiteSettingsService } from '../settings/site-settings.service'; import { CheckoutIntentPayload, CheckoutUnavailableError } from './license-checkout'; +import { Prisma } from '../generated/prisma/client'; // Production always talks to anythingmcp.com. The licence site decides // which plan an installation runs; a URL taken from the environment would let @@ -28,6 +29,24 @@ const trialRetryBaseMs = () => Number(process.env.TRIAL_RETRY_BASE_MS ?? 600); /** A checkout link is asked for by someone waiting on a spinner: one retry, no more. */ const CHECKOUT_RETRY_ATTEMPTS = 2; +/** + * A paid Cloud licence's Stripe subscription as the licence site reports it + * (GET /api/license/verify?billing=1 with the service token). Dates are ISO + * strings, amount is in the currency's smallest unit. + */ +export interface LicenseBilling { + status: string; + /** Set to end at the period end (or a cancel_at date) instead of renewing. */ + cancelling: boolean; + endsAt: string | null; + currentPeriodEnd: string | null; + /** End of a card trial, while trialing. */ + trialEnd: string | null; + amount: number | null; + currency: string | null; + interval: 'day' | 'week' | 'month' | 'year' | null; +} + export interface LicenseInfo { licenseKey: string; plan: string; @@ -36,6 +55,33 @@ export interface LicenseInfo { expiresAt: Date | null; lastVerifiedAt: Date | null; instanceId: string | null; + billing?: LicenseBilling | null; +} + +/** Shape check on what the licence site sent, so a bad payload stores nothing. */ +export function parseLicenseBilling(raw: unknown): LicenseBilling | null { + if (!raw || typeof raw !== 'object') return null; + const b = raw as Record; + const str = (v: unknown) => (typeof v === 'string' && v.length <= 64 ? v : null); + const date = (v: unknown) => { + const s = str(v); + return s && Number.isFinite(Date.parse(s)) ? s : null; + }; + const status = str(b.status); + if (!status) return null; + const interval = ['day', 'week', 'month', 'year'].includes(b.interval as string) + ? (b.interval as LicenseBilling['interval']) + : null; + return { + status, + cancelling: b.cancelling === true, + endsAt: date(b.endsAt), + currentPeriodEnd: date(b.currentPeriodEnd), + trialEnd: date(b.trialEnd), + amount: typeof b.amount === 'number' && Number.isFinite(b.amount) ? b.amount : null, + currency: str(b.currency), + interval, + }; } export interface RemoteVerifyResponse { @@ -48,6 +94,8 @@ export interface RemoteVerifyResponse { paymentIssue?: boolean; /** End of the payment grace period (also returned as expiresAt). */ graceUntil?: string; + /** The subscription's state (Cloud, service token, paid licences only). */ + billing?: unknown; } @Injectable() @@ -105,6 +153,8 @@ export class LicenseService implements OnModuleInit, OnModuleDestroy { async createBillingPortalSession( organizationId: string, returnUrl?: string, + /** 'cancel' opens Stripe's cancellation page directly. */ + flow?: 'cancel', ): Promise<{ url: string }> { const license = await this.getCurrentLicense(organizationId); if (!license?.licenseKey) { @@ -117,7 +167,7 @@ export class LicenseService implements OnModuleInit, OnModuleDestroy { // signed-in admin's own workspace. const { data } = await axios.post( `${this.apiBase}/api/billing/portal`, - { licenseKey: license.licenseKey, returnUrl }, + { licenseKey: license.licenseKey, returnUrl, ...(flow && { flow }) }, { timeout: 15000, headers: this.serviceHeaders() }, ); if (!data?.url) throw new Error('No portal URL returned.'); @@ -523,7 +573,13 @@ export class LicenseService implements OnModuleInit, OnModuleDestroy { try { const { data } = await axios.get( `${this.apiBase}/api/license/verify`, - { params: { key: licenseKey }, timeout: 10000, headers: this.serviceHeaders() }, + { + // Cloud also asks for the subscription's state (trial end, set to + // cancel, renewal date) so the app can tell the customer. + params: { key: licenseKey, ...(this.deployment.isCloud() && { billing: '1' }) }, + timeout: 10000, + headers: this.serviceHeaders(), + }, ); // Update local record @@ -538,6 +594,8 @@ export class LicenseService implements OnModuleInit, OnModuleDestroy { ? new Date(data.expiresAt) : null; updateData.status = 'active'; + const billing = parseLicenseBilling(data.billing); + updateData.billing = billing ?? Prisma.DbNull; } else { updateData.status = data.error?.includes('revoked') ? 'revoked' @@ -792,6 +850,35 @@ export class LicenseService implements OnModuleInit, OnModuleDestroy { expiresAt: license.expiresAt, lastVerifiedAt: license.lastVerifiedAt, instanceId: license.instanceId, + billing: parseLicenseBilling(license.billing), }; } + + /** Last refresh per workspace, so the licence page cannot hammer the site. */ + private readonly lastRefresh = new Map(); + + /** + * Re-verify a workspace's licence now (Cloud, from the licence page), so a + * plan change, cancellation or card trial made in Stripe shows up right away + * instead of at the next daily re-verification. At most once a minute per + * workspace; a call inside that window does nothing. + */ + async refreshLicense(organizationId: string): Promise { + if (!this.deployment.isCloud()) return false; + const last = this.lastRefresh.get(organizationId) ?? 0; + if (Date.now() - last < 60_000) return false; + this.lastRefresh.set(organizationId, Date.now()); + if (this.lastRefresh.size > 5000) { + // Bounded: drop the oldest half rather than grow forever. + for (const k of [...this.lastRefresh.keys()].slice(0, 2500)) this.lastRefresh.delete(k); + } + const license = await this.prisma.license.findFirst({ + where: { organizationId, status: 'active', plan: { not: 'trial' } }, + orderBy: { createdAt: 'desc' }, + select: { licenseKey: true }, + }); + if (!license) return false; + await this.verifyLicense(license.licenseKey); + return true; + } } diff --git a/packages/frontend/src/app/login/page.tsx b/packages/frontend/src/app/login/page.tsx index b81e5548..bbc4d038 100644 --- a/packages/frontend/src/app/login/page.tsx +++ b/packages/frontend/src/app/login/page.tsx @@ -20,6 +20,8 @@ import { parsePlanIntent, readCardTrialPrompt, savePlanIntent, + parsePromoCode, + savePromoCode, writeCardTrialPrompt, } from '@/lib/card-trial'; @@ -84,6 +86,7 @@ function LoginForm() { const errorParam = searchParams.get('error'); const planParam = searchParams.get('plan'); const periodParam = searchParams.get('period'); + const promoParam = searchParams.get('promo'); const [ssoProviders, setSsoProviders] = useState([]); const [ssoExchanging, setSsoExchanging] = useState(Boolean(ssoCode)); @@ -117,7 +120,11 @@ function LoginForm() { if (!isCloudMode) return; const intent = parsePlanIntent(planParam, periodParam); if (intent) savePlanIntent(intent); - }, [isCloudMode, planParam, periodParam]); + // A promotion code from the pricing page (promo bar) rides along so the + // card trial's checkout applies it. + const promo = parsePromoCode(promoParam); + if (promo) savePromoCode(promo); + }, [isCloudMode, planParam, periodParam, promoParam]); /** * Cloud only: right after the trial starts, an admin headed for the diff --git a/packages/frontend/src/app/settings/layout.tsx b/packages/frontend/src/app/settings/layout.tsx index 6debd095..7f832959 100644 --- a/packages/frontend/src/app/settings/layout.tsx +++ b/packages/frontend/src/app/settings/layout.tsx @@ -42,7 +42,7 @@ const SIDEBAR_SECTIONS: SidebarSection[] = [ // tenant and operator. The backend refuses these routes in cloud, so // showing the entry there would lead an admin to a page that 404s. { href: '/settings/identity-providers', label: 'Single sign-on', description: 'Microsoft, Google, Okta', icon: FingerprintIcon, adminOnly: true, selfHostedOnly: true }, - { href: '/settings/license', label: 'License', description: 'Plan, features', icon: KeyIcon, adminOnly: true }, + { href: '/settings/license', label: 'License', description: 'Plan, billing, subscription', icon: KeyIcon, adminOnly: true }, { href: '/settings/admin', label: 'Administration', description: 'SMTP, footer links', icon: WrenchIcon, adminOnly: true }, ], }, diff --git a/packages/frontend/src/app/settings/license/page.tsx b/packages/frontend/src/app/settings/license/page.tsx index cab128b2..63e6764a 100644 --- a/packages/frontend/src/app/settings/license/page.tsx +++ b/packages/frontend/src/app/settings/license/page.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; import { useAuth } from '@/lib/auth-context'; -import { license } from '@/lib/api'; +import { license, type LicenseBilling } from '@/lib/api'; import { usePricingUrl } from '@/lib/use-pricing-url'; import { useManagePlan } from '@/lib/use-manage-plan'; import { Button, buttonVariants } from '@/components/ui/button'; @@ -12,6 +12,7 @@ import { cn } from '@/lib/utils'; import { useEdition, notifyEditionChanged } from '@/lib/use-edition'; import { EditionCard } from '@/components/edition-card'; import { cardTrialDisplayEnd, cardTrialEligible, formatTrialEnd } from '@/lib/card-trial'; +import { describeBilling } from '@/lib/billing-summary'; interface LicenseStatus { plan: string | null; @@ -21,6 +22,7 @@ interface LicenseStatus { lastVerifiedAt: string | null; instanceId: string | null; trialDaysLeft?: number; + billing?: LicenseBilling; } export default function LicenseSettingsPage() { @@ -46,6 +48,7 @@ export default function LicenseSettingsPage() { status.plan !== 'community'; // Cloud admin on a running free trial: offer the card trial (/start-trial). const cardTrialOffer = cardTrialEligible({ isCloud, role: user?.role, license: status }); + const billingSummary = isCloud ? describeBilling(status?.billing) : null; const cardTrialDate = cardTrialOffer ? formatTrialEnd(cardTrialDisplayEnd(status?.expiresAt), 'long') : null; @@ -61,7 +64,17 @@ export default function LicenseSettingsPage() { useEffect(() => { loadStatus(); - }, [token]); + // Cloud admins land here from the billing portal and from Checkout: ask the + // server to re-verify now, so a cancellation, plan change or new card + // trial shows immediately instead of after the daily re-verification. + // The server throttles this to once a minute per workspace. + if (token && isCloud && user?.role === 'ADMIN') { + license + .refresh(token) + .then((r) => (r.refreshed ? loadStatus() : undefined)) + .catch(() => {}); + } + }, [token, isCloud, user?.role]); const handleActivate = async () => { if (!token || !licenseKey) return; @@ -118,7 +131,7 @@ export default function LicenseSettingsPage() { } }; - const handleBillingPortal = async () => { + const handleBillingPortal = async (flow?: 'cancel') => { if (!token) return; setError(''); setMessage(''); @@ -127,6 +140,7 @@ export default function LicenseSettingsPage() { const { url } = await license.billingPortal( token, typeof window !== 'undefined' ? window.location.href : undefined, + flow, ); window.location.href = url; } catch (err: any) { @@ -269,16 +283,62 @@ export default function LicenseSettingsPage() { )} + {billingSummary && ( +
+ {billingSummary.text} +
+ )} + {status?.plan && ( -
+
{hasBillableSubscription && ( - )} + {/* A clearly labelled way out, in the app itself: opens Stripe's + cancellation page directly. Hidden once the plan is already set + to end ("Keep my plan" above undoes that). */} + {hasBillableSubscription && !billingSummary?.cancelling && ( + + )}
)} diff --git a/packages/frontend/src/app/start-trial/page.tsx b/packages/frontend/src/app/start-trial/page.tsx index 224ab876..95372849 100644 --- a/packages/frontend/src/app/start-trial/page.tsx +++ b/packages/frontend/src/app/start-trial/page.tsx @@ -15,6 +15,7 @@ import { formatTrialEnd, planById, readPlanIntent, + readPromoCode, writeCardTrialPrompt, type PlanSelection, } from '@/lib/card-trial'; @@ -37,6 +38,7 @@ export default function StartTrialPage() { const [trialEnd, setTrialEnd] = useState(null); const [ready, setReady] = useState(false); const [selection, setSelection] = useState(DEFAULT_SELECTION); + const [promo, setPromo] = useState(null); useEffect(() => { if (isLoading || !deploymentModeLoaded) return; @@ -59,6 +61,7 @@ export default function StartTrialPage() { } setTrialEnd(cardTrialDisplayEnd(lic.expiresAt)); setSelection(readPlanIntent() ?? DEFAULT_SELECTION); + setPromo(readPromoCode()); setReady(true); }) .catch(() => { @@ -143,6 +146,11 @@ export default function StartTrialPage() { > {checkout.loading ? 'Opening checkout…' : checkout.error ? 'Try again' : 'Start free trial with card'} + {promo && ( +

+ Code {promo} will be applied at checkout. +

+ )} {checkout.error && (

{checkout.error} diff --git a/packages/frontend/src/components/app-shell.tsx b/packages/frontend/src/components/app-shell.tsx index b0971d6a..1bae6acb 100644 --- a/packages/frontend/src/components/app-shell.tsx +++ b/packages/frontend/src/components/app-shell.tsx @@ -6,6 +6,7 @@ import { useTheme } from '@/lib/theme-context'; import { AppSidebar } from '@/components/app-sidebar'; import { Footer } from '@/components/footer'; import { TrialBanner } from '@/components/trial-banner'; +import { SubscriptionBanner } from '@/components/subscription-banner'; import { UsageBanner } from '@/components/usage-banner'; import { EditionBanner } from '@/components/edition-banner'; import { cn } from '@/lib/utils'; @@ -89,6 +90,7 @@ export function AppShell({ {/* Global banners (in-flow, above the header) */}

+
diff --git a/packages/frontend/src/components/subscription-banner.tsx b/packages/frontend/src/components/subscription-banner.tsx new file mode 100644 index 00000000..b5b0b167 --- /dev/null +++ b/packages/frontend/src/components/subscription-banner.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { license, type LicenseBilling } from '@/lib/api'; +import { useAuth } from '@/lib/auth-context'; +import { describeBilling } from '@/lib/billing-summary'; + +const DAY = 24 * 60 * 60 * 1000; +const DISMISS_KEY = 'amcp_subscription_banner_dismissed'; + +/** + * When a paid Cloud subscription needs the admin's attention, say so above the + * app — not only in an email they may not read: + * - a failed payment, always; + * - a plan cancelled at period end, in its last 7 days ("keep my plan"); + * - a card trial, in its last 3 days ("first charge on X"). + * The free (no-card) trial has its own countdown in TrialBanner. + */ +export function shouldShowSubscriptionBanner(billing: LicenseBilling | undefined, now = Date.now()): boolean { + if (!billing) return false; + if (billing.status === 'past_due' || billing.status === 'unpaid') return true; + const within = (iso: string | null, days: number) => { + const t = iso ? Date.parse(iso) : NaN; + return Number.isFinite(t) && t > now && t - now <= days * DAY; + }; + if (billing.cancelling) return within(billing.endsAt ?? billing.currentPeriodEnd, 7); + if (billing.status === 'trialing') return within(billing.trialEnd, 3); + return false; +} + +export function SubscriptionBanner() { + const { token, user, deploymentMode } = useAuth(); + const [billing, setBilling] = useState(); + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + if (!token || deploymentMode !== 'cloud' || user?.role !== 'ADMIN') return; + try { + // eslint-disable-next-line react-hooks/set-state-in-effect + if (sessionStorage.getItem(DISMISS_KEY) === '1') setDismissed(true); + } catch { + /* storage blocked: show it */ + } + license + .getStatus(token) + .then((s) => setBilling(s.billing)) + .catch(() => {}); + }, [token, deploymentMode, user?.role]); + + if (dismissed || !shouldShowSubscriptionBanner(billing)) return null; + const summary = describeBilling(billing); + if (!summary) return null; + const tone = summary.tone === 'neutral' ? 'info' : summary.tone; + + return ( +
+ {summary.text}{' '} + + {summary.paymentIssue + ? 'Update payment method' + : summary.cancelling + ? 'Keep my plan' + : 'Manage billing'} + + {!summary.paymentIssue && ( + + )} +
+ ); +} diff --git a/packages/frontend/src/lib/api.ts b/packages/frontend/src/lib/api.ts index 4839c41e..f7ce47a4 100644 --- a/packages/frontend/src/lib/api.ts +++ b/packages/frontend/src/lib/api.ts @@ -461,6 +461,21 @@ export const adapters = { }>('/api/adapters/starter-pack/install', { method: 'POST', token, body: { slugs } }), }; +/** A paid Cloud licence's Stripe subscription, as the server last saw it. */ +export interface LicenseBilling { + status: string; + /** Set to end at the period end instead of renewing. */ + cancelling: boolean; + endsAt: string | null; + currentPeriodEnd: string | null; + /** End of a card trial, while trialing. */ + trialEnd: string | null; + /** Price per period in the currency's smallest unit. */ + amount: number | null; + currency: string | null; + interval: 'day' | 'week' | 'month' | 'year' | null; +} + /** A keyless connector offered to a new workspace (see /welcome). */ export interface StarterPackItem { slug: string; @@ -1205,7 +1220,20 @@ export const license = { startBusinessTrial: (token: string) => request('/api/license/business-trial', { method: 'POST', token }), getStatus: (token?: string) => - request<{ plan: string | null; status: string; features: any; expiresAt: string | null; lastVerifiedAt: string | null; instanceId: string | null; trialDaysLeft?: number }>('/api/license/status', { token }), + request<{ + plan: string | null; + status: string; + features: any; + expiresAt: string | null; + lastVerifiedAt: string | null; + instanceId: string | null; + trialDaysLeft?: number; + /** Cloud, paid licences: the Stripe subscription's state. */ + billing?: LicenseBilling; + }>('/api/license/status', { token }), + /** Re-verify now (cloud, admin); at most once a minute per workspace. */ + refresh: (token: string) => + request<{ refreshed: boolean }>('/api/license/refresh', { method: 'POST', token }), activateTrial: (token: string) => request<{ message: string; trialStarted: boolean; licenseKey: string; plan: string; expiresAt: string; trialDaysLeft: number }>('/api/license/activate-trial', { method: 'POST', @@ -1227,10 +1255,10 @@ export const license = { method: 'POST', token, }), - billingPortal: (token: string, returnUrl?: string) => + billingPortal: (token: string, returnUrl?: string, flow?: 'cancel') => request<{ url: string }>('/api/license/billing-portal', { method: 'POST', - body: { returnUrl }, + body: { returnUrl, ...(flow && { flow }) }, token, }), /** @@ -1240,7 +1268,12 @@ export const license = { */ checkoutLink: ( token: string, - body: { plan: 'starter' | 'team' | 'business'; billingPeriod: 'monthly' | 'yearly'; trial: boolean }, + body: { + plan: 'starter' | 'team' | 'business'; + billingPeriod: 'monthly' | 'yearly'; + trial: boolean; + promo?: string; + }, ) => request<{ url: string }>('/api/license/checkout-link', { method: 'POST', diff --git a/packages/frontend/src/lib/billing-summary.ts b/packages/frontend/src/lib/billing-summary.ts new file mode 100644 index 00000000..e6bc2785 --- /dev/null +++ b/packages/frontend/src/lib/billing-summary.ts @@ -0,0 +1,93 @@ +import type { LicenseBilling } from './api'; + +/** + * One plain sentence about a paid Cloud subscription, for the licence page and + * the app banner: when a card trial turns into a charge, when a cancelled plan + * stops, when it renews, or that a payment failed. Null when there is nothing + * worth saying (no subscription data). + */ +export interface BillingSummary { + tone: 'info' | 'warn' | 'danger' | 'neutral'; + /** What the banner/page says. */ + text: string; + /** The subscription is set to end (show "keep my plan", not "cancel"). */ + cancelling: boolean; + /** Payment is failing (show "update payment method"). */ + paymentIssue: boolean; +} + +function formatDate(iso: string | null, locale?: string): string | null { + if (!iso) return null; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return null; + return d.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' }); +} + +export function formatAmount( + amount: number | null, + currency: string | null, + locale?: string, +): string | null { + if (amount == null || !currency) return null; + try { + return new Intl.NumberFormat(locale, { style: 'currency', currency: currency.toUpperCase() }).format( + amount / 100, + ); + } catch { + return `${(amount / 100).toFixed(2)} ${currency.toUpperCase()}`; + } +} + +function per(interval: LicenseBilling['interval']): string { + return interval ? `/${interval}` : ''; +} + +export function describeBilling( + billing: LicenseBilling | null | undefined, + locale?: string, +): BillingSummary | null { + if (!billing) return null; + const price = formatAmount(billing.amount, billing.currency, locale); + const priceText = price ? `${price}${per(billing.interval)}` : null; + + if (billing.status === 'past_due' || billing.status === 'unpaid') { + return { + tone: 'danger', + text: 'Your last payment failed. Update your payment method to keep your plan.', + cancelling: billing.cancelling, + paymentIssue: true, + }; + } + + if (billing.cancelling) { + const end = formatDate(billing.endsAt ?? billing.currentPeriodEnd, locale); + return { + tone: 'warn', + text: + billing.status === 'trialing' + ? `Your trial is cancelled — you won't be charged.${end ? ` Your plan stays active until ${end}.` : ''}` + : `Your subscription is cancelled${end ? ` and ends on ${end}` : ''}. Changed your mind? You can keep your plan.`, + cancelling: true, + paymentIssue: false, + }; + } + + if (billing.status === 'trialing') { + const end = formatDate(billing.trialEnd, locale); + return { + tone: 'info', + text: `Free trial${end ? ` until ${end}` : ''}${priceText ? `, then ${priceText}` : ''}. Cancel any time before then and you pay nothing.`, + cancelling: false, + paymentIssue: false, + }; + } + + const renews = formatDate(billing.currentPeriodEnd, locale); + if (!renews) return null; + return { + tone: 'neutral', + text: `Renews on ${renews}${priceText ? ` for ${priceText}` : ''}.`, + cancelling: false, + paymentIssue: false, + }; +} diff --git a/packages/frontend/src/lib/card-trial.ts b/packages/frontend/src/lib/card-trial.ts index ca079af6..14591689 100644 --- a/packages/frontend/src/lib/card-trial.ts +++ b/packages/frontend/src/lib/card-trial.ts @@ -140,6 +140,40 @@ export function readPlanIntent(): PlanSelection | null { return sel; } +// ── Promotion code from the pricing page ──────────────────────────────────── + +const PROMO_KEY = 'amcp_promo_code'; + +/** A Stripe promotion code as the site passes it (`&promo=START30`), or null. */ +export function parsePromoCode(raw: string | null | undefined): string | null { + const code = (raw ?? '').trim(); + return /^[A-Za-z0-9_-]{1,64}$/.test(code) ? code.toUpperCase() : null; +} + +/** + * Kept beside the plan intent, with the same lifetime, so a visitor who came + * through the promo bar still gets the discount on the card trial they start + * after verifying their email. + */ +export function savePromoCode(code: string): void { + storage.set(PROMO_KEY, JSON.stringify({ code, savedAt: Date.now() })); +} + +export function readPromoCode(now: number = Date.now()): string | null { + const raw = storage.get(PROMO_KEY); + if (!raw) return null; + try { + const data = JSON.parse(raw); + const savedAt = Number(data?.savedAt); + const code = parsePromoCode(data?.code); + if (code && Number.isFinite(savedAt) && savedAt <= now && now - savedAt <= INTENT_TTL_MS) return code; + } catch { + /* fall through */ + } + storage.remove(PROMO_KEY); + return null; +} + // ── Eligibility and the one-time prompt ───────────────────────────────────── export interface TrialStatusLike { diff --git a/packages/frontend/src/lib/use-card-checkout.ts b/packages/frontend/src/lib/use-card-checkout.ts index 90772d11..6bcd0c46 100644 --- a/packages/frontend/src/lib/use-card-checkout.ts +++ b/packages/frontend/src/lib/use-card-checkout.ts @@ -3,7 +3,7 @@ import { useCallback, useState } from 'react'; import { ApiError, license } from './api'; import { useAuth } from './auth-context'; -import type { PlanSelection } from './card-trial'; +import { readPromoCode, type PlanSelection } from './card-trial'; const GENERIC_ERROR = 'We could not open the checkout. Please try again in a moment.'; @@ -25,10 +25,12 @@ export function useCardCheckout() { setLoading(true); setError(null); try { + const promo = readPromoCode(); const { url } = await license.checkoutLink(token, { plan: selection.plan, billingPeriod: selection.period, trial, + ...(promo && { promo }), }); // Stays "loading" on purpose: the page is being left. window.location.assign(url); diff --git a/packages/frontend/tests/e2e/billing-awareness.spec.ts b/packages/frontend/tests/e2e/billing-awareness.spec.ts new file mode 100644 index 00000000..1795e548 --- /dev/null +++ b/packages/frontend/tests/e2e/billing-awareness.spec.ts @@ -0,0 +1,215 @@ +import { expect, test, type Page } from '@playwright/test'; +import { describeBilling } from '../../src/lib/billing-summary'; +import { parsePromoCode } from '../../src/lib/card-trial'; +import { shouldShowSubscriptionBanner } from '../../src/components/subscription-banner'; + +/** + * The app used to know a paid licence only as "active": a card-trial customer + * never saw when the first charge comes, a customer who cancelled never saw + * when the plan ends, and cancelling meant finding Stripe's portal home. The + * licence status now carries the subscription's state. + * + * Also: a promotion code from the pricing page (promo bar) rides through + * sign-up into the card trial's checkout. + */ + +const DAY = 86_400_000; +const iso = (ms: number) => new Date(ms).toISOString(); + +const billing = (over: Record = {}) => ({ + status: 'active', + cancelling: false, + endsAt: null, + currentPeriodEnd: iso(Date.now() + 20 * DAY), + trialEnd: null, + amount: 4900, + currency: 'eur', + interval: 'month', + ...over, +}); + +test.describe('billing helpers', () => { + test('says when a card trial turns into a charge, and for how much', () => { + const s = describeBilling(billing({ status: 'trialing', trialEnd: '2026-10-08T09:00:00Z' }) as any, 'en-GB'); + expect(s?.tone).toBe('info'); + expect(s?.text).toContain('Free trial until 8 October 2026'); + expect(s?.text).toContain('€49.00/month'); + }); + + test('a cancelled plan says when it ends and offers to keep it', () => { + const s = describeBilling(billing({ cancelling: true, endsAt: '2026-11-01T00:00:00Z' }) as any, 'en-GB'); + expect(s).toMatchObject({ tone: 'warn', cancelling: true }); + expect(s?.text).toContain('ends on 1 November 2026'); + }); + + test('a cancelled card trial says nothing will be charged', () => { + const s = describeBilling( + billing({ status: 'trialing', cancelling: true, endsAt: '2026-10-08T09:00:00Z' }) as any, + 'en-GB', + ); + expect(s?.text).toContain("you won't be charged"); + }); + + test('a failed payment is a danger notice', () => { + expect(describeBilling(billing({ status: 'past_due' }) as any)).toMatchObject({ + tone: 'danger', + paymentIssue: true, + }); + }); + + test('the banner shows only when there is something to act on soon', () => { + const now = Date.now(); + expect(shouldShowSubscriptionBanner(billing() as any, now)).toBe(false); + expect(shouldShowSubscriptionBanner(billing({ status: 'past_due' }) as any, now)).toBe(true); + expect( + shouldShowSubscriptionBanner(billing({ cancelling: true, endsAt: iso(now + 5 * DAY) }) as any, now), + ).toBe(true); + expect( + shouldShowSubscriptionBanner(billing({ cancelling: true, endsAt: iso(now + 20 * DAY) }) as any, now), + ).toBe(false); + expect( + shouldShowSubscriptionBanner(billing({ status: 'trialing', trialEnd: iso(now + 2 * DAY) }) as any, now), + ).toBe(true); + expect( + shouldShowSubscriptionBanner(billing({ status: 'trialing', trialEnd: iso(now + 6 * DAY) }) as any, now), + ).toBe(false); + expect(shouldShowSubscriptionBanner(undefined, now)).toBe(false); + }); + + test('reads a promotion code and refuses junk', () => { + expect(parsePromoCode(' start30 ')).toBe('START30'); + expect(parsePromoCode('WINBACK_50')).toBe('WINBACK_50'); + expect(parsePromoCode('bad code!')).toBeNull(); + expect(parsePromoCode(null)).toBeNull(); + }); +}); + +const ADMIN = { + id: 'u1', + email: 'admin@example.test', + name: 'Ada Admin', + role: 'ADMIN', + organizationId: 'o1', + emailVerified: true, +}; + +async function cloudSession( + page: Page, + opts: { license?: Record; seed?: Record; signedIn?: boolean } = {}, +) { + const posted: { path: string; body: unknown }[] = []; + const origin = test.info().project.use.baseURL ?? 'http://localhost:3100'; + if (opts.signedIn !== false) { + await page.context().addCookies([{ name: 'amcp_token', value: 't', url: origin }]); + } + await page.addInitScript( + ({ user, seed, signedIn }) => { + if (signedIn) { + localStorage.setItem('amcp_token', 't'); + localStorage.setItem('amcp_user', JSON.stringify(user)); + } + for (const [k, v] of Object.entries(seed)) localStorage.setItem(k, v); + }, + { user: ADMIN, seed: opts.seed ?? {}, signedIn: opts.signedIn !== false }, + ); + await page.route(/\/(api|health)\//, async (route) => { + const req = route.request(); + const p = new URL(req.url()).pathname; + const json = (body: unknown, status = 200) => + route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); + if (p === '/health/server-info') { + return json({ deploymentMode: 'cloud', mcpAuthMode: 'oauth2', hasUsers: true, registrationEnabled: true, ssoProviders: [] }); + } + if (req.method() === 'POST' && (p.endsWith('/license/billing-portal') || p.endsWith('/license/checkout-link'))) { + posted.push({ path: p, body: req.postDataJSON() }); + return json({ url: 'https://billing.example.test/session' }); + } + if (p.endsWith('/license/refresh')) return json({ refreshed: false }); + if (p.endsWith('/users/me/onboarding-state')) return json({ onboardingCompletedAt: new Date().toISOString() }); + if (p.endsWith('/users/me')) return json(ADMIN); + if (p.endsWith('/license/status')) { + return json(opts.license ?? { plan: 'team', status: 'active', expiresAt: null, features: {}, billing: billing() }); + } + if (p.endsWith('/license/usage')) { + return json({ plan: 'team', connectors: { current: 1, max: 15, isOver: false }, mcpServers: { current: 1, max: 10, isOver: false }, users: { current: 1, max: 3, isOver: false }, isOverAny: false }); + } + if (p.endsWith('/connectors') || p.endsWith('/adapters') || p.endsWith('/mcp-servers')) return json([]); + return json({}); + }); + await page.route('https://billing.example.test/**', (route) => + route.fulfill({ status: 200, contentType: 'text/html', body: '

Stripe portal stub

' }), + ); + return { posted }; +} + +test.describe('subscription state in the app (cloud)', () => { + test('a renewing plan shows its renewal and a labelled way to cancel, straight to Stripe', async ({ page }) => { + const { posted } = await cloudSession(page); + await page.goto('/settings/license'); + await expect(page.getByText(/Renews on .* for €49\.00\/month/)).toBeVisible({ timeout: 15_000 }); + await page.getByRole('button', { name: 'Cancel subscription' }).click(); + await expect(page).toHaveURL('https://billing.example.test/session'); + expect(posted[0].path).toMatch(/billing-portal$/); + expect(posted[0].body).toMatchObject({ flow: 'cancel' }); + }); + + test('a cancelled plan says when it ends and offers to keep it, not to cancel again', async ({ page }) => { + await cloudSession(page, { + license: { + plan: 'team', + status: 'active', + expiresAt: null, + features: {}, + billing: billing({ cancelling: true, endsAt: iso(Date.now() + 4 * DAY) }), + }, + }); + await page.goto('/settings/license'); + await expect(page.getByRole('status').filter({ hasText: 'Your subscription is cancelled' })).toBeVisible({ + timeout: 15_000, + }); + await expect(page.getByRole('button', { name: 'Keep my plan' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Cancel subscription' })).toHaveCount(0); + // In its last week the app-wide banner says so too. + await expect(page.getByRole('link', { name: 'Keep my plan' })).toBeVisible(); + }); + + test('a failed payment shows a banner that cannot be dismissed', async ({ page }) => { + await cloudSession(page, { + license: { plan: 'team', status: 'active', expiresAt: null, features: {}, billing: billing({ status: 'past_due' }) }, + }); + await page.goto('/settings/license'); + await expect(page.getByRole('link', { name: 'Update payment method' })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('button', { name: 'Dismiss' })).toHaveCount(0); + }); +}); + +test.describe('promotion code through sign-up (cloud)', () => { + test('a code on the sign-up link is kept and applied to the card trial checkout', async ({ page }) => { + await cloudSession(page, { signedIn: false }); + await page.goto('/login?mode=register&plan=cloud_team&period=monthly&promo=start30'); + await expect(page.getByRole('button', { name: 'Create Account' })).toBeVisible({ timeout: 15_000 }); + const stored = await page.evaluate(() => localStorage.getItem('amcp_promo_code')); + expect(JSON.parse(stored ?? '{}').code).toBe('START30'); + }); + + test('the trial offer names the code and sends it with the checkout', async ({ page }) => { + const { posted } = await cloudSession(page, { + license: { + plan: 'trial', + status: 'active', + expiresAt: iso(Date.now() + 5 * DAY), + trialDaysLeft: 5, + features: {}, + }, + seed: { + 'amcp_card_trial_prompt:u1': 'shown', + amcp_promo_code: JSON.stringify({ code: 'START30', savedAt: Date.now() }), + }, + }); + await page.goto('/start-trial'); + await expect(page.getByText('Code START30 will be applied at checkout.')).toBeVisible({ timeout: 15_000 }); + await page.getByRole('button', { name: 'Start free trial' }).click(); + await expect(page).toHaveURL('https://billing.example.test/session'); + expect(posted[0].body).toMatchObject({ trial: true, promo: 'START30' }); + }); +});