From 260038a88768edc507402155f5d913671e5fd334 Mon Sep 17 00:00:00 2001 From: Matteo Date: Thu, 1 Oct 2026 15:01:46 +0200 Subject: [PATCH] Billing awareness: the app knows when a plan renews, ends or turns into a charge The app only knew a paid licence 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 the Stripe portal home behind a button called 'Manage subscription & billing'. - Licence verification (cloud) now asks the licence site for the subscription's state (status, cancelling + end date, trial end, renewal date, price) and stores it in a new nullable licenses.billing column (additive migration). /api/license/status returns it. - POST /api/license/refresh (cloud, admin, throttled once a minute per workspace) re-verifies right away; the licence page calls it on load, so a change made in the billing portal or Checkout shows immediately. - Settings -> License shows one plain sentence: 'Free trial until X, then EUR 49/month', 'Your subscription is cancelled and ends on X', 'Renews on X' or 'Your last payment failed'. The main button becomes 'Keep my plan' or 'Update payment method' when that is what is needed, and a labelled 'Cancel subscription' opens Stripe's cancellation page directly (billing-portal flow=cancel). - A SubscriptionBanner tells admins above the app: a failed payment (always), a cancelled plan in its last 7 days, a card trial in its last 3 days. - Settings nav describes the licence page as 'Plan, billing, subscription'. Promotion code through sign-up: a code from the pricing page (?promo=) is kept beside the plan intent, shown on /start-trial ('Code START30 will be applied at checkout') and sent with the checkout link, which passes it to the licence site. Tests: billing parsing/storage/refresh throttle, promo pass-through (backend); billing helpers, licence page states, banner, promo flow (e2e). Full backend suite and all 83 e2e pass. --- .../migration.sql | 5 + packages/backend/prisma/schema.prisma | 4 + .../src/license/license-checkout.spec.ts | 17 ++ .../backend/src/license/license-checkout.ts | 2 + .../backend/src/license/license.controller.ts | 35 +++ .../src/license/license.service.spec.ts | 102 +++++++++ .../backend/src/license/license.service.ts | 91 +++++++- packages/frontend/src/app/login/page.tsx | 9 +- packages/frontend/src/app/settings/layout.tsx | 2 +- .../src/app/settings/license/page.tsx | 72 +++++- .../frontend/src/app/start-trial/page.tsx | 8 + .../frontend/src/components/app-shell.tsx | 2 + .../src/components/subscription-banner.tsx | 88 +++++++ packages/frontend/src/lib/api.ts | 41 +++- packages/frontend/src/lib/billing-summary.ts | 93 ++++++++ packages/frontend/src/lib/card-trial.ts | 34 +++ .../frontend/src/lib/use-card-checkout.ts | 4 +- .../tests/e2e/billing-awareness.spec.ts | 215 ++++++++++++++++++ 18 files changed, 809 insertions(+), 15 deletions(-) create mode 100644 packages/backend/prisma/migrations/20261001100000_add_license_billing/migration.sql create mode 100644 packages/frontend/src/components/subscription-banner.tsx create mode 100644 packages/frontend/src/lib/billing-summary.ts create mode 100644 packages/frontend/tests/e2e/billing-awareness.spec.ts 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' }); + }); +});