Skip to content

Commit de8d57d

Browse files
feat(web): trial banner + plumbing
Adds a trial banner to the owner-facing banner stack and the surrounding plumbing: - Schema: License.trialEnd, License.hasPaymentMethod, Org.trialUsedAt (durable flag that survives license deactivation). Two migrations. - servicePing persists trialEnd / hasPaymentMethod and flips Org.trialUsedAt on first trial sync. - Trial banner (owner, dismissible, priority 25): title uses formatDistance ("Your trial ends in 10 days"); copy + action branch on hasPaymentMethod. With-PM variant links to /settings/license; no-PM variant opens the Stripe portal via a new OpenBillingPortalButton (LoadingButton + createPortalSession). - currentPlanCard gains a "Trial ends on" fallback column for the trial-without-PM case (where nextRenewalAt is null). - activationCodeCard accepts isTrialEligible and flips its checkout button from "Purchase a license" to "Start a free trial" when the org hasn't trialed yet, passing requestTrial through to the checkout endpoint. - Types mirror the new lighthouse fields (trialEnd, hasPaymentMethod) and the checkout request additions (installId, requestTrial). Side-trips to Stripe (portal, checkout) now append ?refresh=true so the license resyncs on return; trial-checkout also appends ?trial_used=true so Org.trialUsedAt flips immediately (closes the UX gap between checkout completion and activation-code entry). page.tsx handles both params, preserves any other query params, and redirects to a clean URL. Also: fetchWithRetry now only retries 5xx, 408, and 429 — 4xx errors (e.g. TRIAL_ALREADY_USED at 409) propagate immediately instead of retrying pointlessly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9760419 commit de8d57d

18 files changed

Lines changed: 307 additions & 22 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- AlterTable
2+
ALTER TABLE "License" ADD COLUMN "trialEnd" TIMESTAMP(3);
3+
4+
-- AlterTable
5+
ALTER TABLE "Org" ADD COLUMN "trialUsedAt" TIMESTAMP(3);
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "License" ADD COLUMN "hasPaymentMethod" BOOLEAN;

packages/db/prisma/schema.prisma

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,10 @@ model Org {
293293
chats Chat[]
294294
295295
license License?
296+
297+
/// Set the first time this instance is seen to be on a trial subscription.
298+
/// Never cleared. Used to gate the "Start trial" CTA in the UI.
299+
trialUsedAt DateTime?
296300
}
297301

298302
model License {
@@ -311,6 +315,8 @@ model License {
311315
nextRenewalAt DateTime?
312316
nextRenewalAmount Int?
313317
cancelAt DateTime?
318+
trialEnd DateTime?
319+
hasPaymentMethod Boolean?
314320
lastSyncAt DateTime?
315321
createdAt DateTime @default(now())
316322
updatedAt DateTime @updatedAt

packages/shared/src/entitlements.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ const makeLicense = (overrides: Partial<License> = {}): License => ({
6363
nextRenewalAt: null,
6464
nextRenewalAmount: null,
6565
cancelAt: null,
66+
trialEnd: null,
67+
hasPaymentMethod: null,
6668
lastSyncAt: new Date(),
6769
createdAt: new Date(),
6870
updatedAt: new Date(),

packages/web/src/__mocks__/prisma.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ export const MOCK_ORG: Org = {
2020
metadata: null,
2121
memberApprovalRequired: false,
2222
inviteLinkEnabled: false,
23-
inviteLinkId: null
23+
inviteLinkId: null,
24+
trialUsedAt: null,
2425
}
2526

2627
export const MOCK_API_KEY: ApiKey = {

packages/web/src/app/(app)/components/banners/bannerResolver.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ vi.mock('./licenseExpiredBanner', () => ({ LicenseExpiredBanner: () => null }));
1818
vi.mock('./licenseExpiryHeadsUpBanner', () => ({ LicenseExpiryHeadsUpBanner: () => null }));
1919
vi.mock('./invoicePastDueBanner', () => ({ InvoicePastDueBanner: () => null }));
2020
vi.mock('./servicePingFailedBanner', () => ({ ServicePingFailedBanner: () => null }));
21+
vi.mock('./trialBanner', () => ({ TrialBanner: () => null }));
2122

2223
import { resolveActiveBanner, type BannerContext } from './bannerResolver';
2324

@@ -43,6 +44,8 @@ const makeLicense = (overrides: Partial<License> = {}): License => ({
4344
nextRenewalAt: null,
4445
nextRenewalAmount: null,
4546
cancelAt: null,
47+
trialEnd: null,
48+
hasPaymentMethod: null,
4649
lastSyncAt: NOW,
4750
createdAt: NOW,
4851
updatedAt: NOW,
@@ -444,6 +447,92 @@ describe('resolveActiveBanner', () => {
444447
});
445448
});
446449

450+
describe('trial', () => {
451+
test('status trialing + future trialEnd → trial banner', () => {
452+
const result = resolveActiveBanner(makeContext({
453+
license: makeLicense({
454+
status: 'trialing',
455+
trialEnd: daysFromNow(10),
456+
}),
457+
}));
458+
expect(result?.id).toBe('trial');
459+
});
460+
461+
test('trialing but trialEnd in past → no banner', () => {
462+
const result = resolveActiveBanner(makeContext({
463+
license: makeLicense({
464+
status: 'trialing',
465+
trialEnd: hoursFromNow(-1),
466+
}),
467+
}));
468+
expect(result).toBeNull();
469+
});
470+
471+
test('trialing but no trialEnd → no banner', () => {
472+
const result = resolveActiveBanner(makeContext({
473+
license: makeLicense({ status: 'trialing', trialEnd: null }),
474+
}));
475+
expect(result).toBeNull();
476+
});
477+
478+
test('hidden from non-owners', () => {
479+
const result = resolveActiveBanner(makeContext({
480+
role: OrgRole.MEMBER,
481+
license: makeLicense({
482+
status: 'trialing',
483+
trialEnd: daysFromNow(5),
484+
}),
485+
}));
486+
expect(result).toBeNull();
487+
});
488+
489+
test('suppressed by offline license', () => {
490+
const result = resolveActiveBanner(makeContext({
491+
offlineLicense: makeOfflineLicense(),
492+
license: makeLicense({
493+
status: 'trialing',
494+
trialEnd: daysFromNow(5),
495+
}),
496+
}));
497+
expect(result).toBeNull();
498+
});
499+
500+
test('dismissible: today cookie filters out', () => {
501+
const result = resolveActiveBanner(makeContext({
502+
license: makeLicense({
503+
status: 'trialing',
504+
trialEnd: daysFromNow(5),
505+
}),
506+
dismissals: { trial: TODAY },
507+
}));
508+
expect(result).toBeNull();
509+
});
510+
511+
test('outranks license expiry heads-up', () => {
512+
const result = resolveActiveBanner(makeContext({
513+
license: makeLicense({
514+
status: 'trialing',
515+
trialEnd: daysFromNow(5),
516+
cancelAt: daysFromNow(5),
517+
}),
518+
}));
519+
expect(result?.id).toBe('trial');
520+
});
521+
522+
test('invoice past due outranks trial', () => {
523+
// In reality Stripe statuses are mutually exclusive (a sub can't
524+
// be trialing AND past_due at once), but the priority ordering
525+
// should hold if both descriptors were somehow produced.
526+
const result = resolveActiveBanner(makeContext({
527+
license: makeLicense({
528+
status: 'past_due',
529+
trialEnd: daysFromNow(5),
530+
}),
531+
}));
532+
expect(result?.id).toBe('invoicePastDue');
533+
});
534+
});
535+
447536
describe('permission sync', () => {
448537
test('entitlement + pending → permissionSync', () => {
449538
const result = resolveActiveBanner(makeContext({

packages/web/src/app/(app)/components/banners/bannerResolver.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { LicenseExpiredBanner } from "./licenseExpiredBanner";
1111
import { LicenseExpiryHeadsUpBanner } from "./licenseExpiryHeadsUpBanner";
1212
import { InvoicePastDueBanner } from "./invoicePastDueBanner";
1313
import { ServicePingFailedBanner } from "./servicePingFailedBanner";
14+
import { TrialBanner } from "./trialBanner";
1415

1516
const EXPIRY_HEADS_UP_WINDOW_MS = 14 * 24 * 60 * 60 * 1000;
1617

@@ -65,6 +66,29 @@ function buildCandidates(ctx: BannerContext): BannerDescriptor[] {
6566
});
6667
}
6768

69+
if (
70+
!ctx.offlineLicense
71+
&& ctx.license?.status === 'trialing'
72+
&& ctx.license.trialEnd
73+
&& ctx.license.trialEnd.getTime() > ctx.now.getTime()
74+
) {
75+
const trialEndIso = ctx.license.trialEnd.toISOString();
76+
const hasPaymentMethod = ctx.license.hasPaymentMethod ?? false;
77+
banners.push({
78+
id: 'trial',
79+
priority: BannerPriority.TRIAL,
80+
dismissible: true,
81+
audience: 'owner',
82+
render: (props) => (
83+
<TrialBanner
84+
{...props}
85+
trialEnd={trialEndIso}
86+
hasPaymentMethod={hasPaymentMethod}
87+
/>
88+
),
89+
});
90+
}
91+
6892
if (!ctx.offlineLicense && ctx.license?.status === 'past_due') {
6993
banners.push({
7094
id: 'invoicePastDue',
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
'use client';
2+
3+
import { useCallback, useState } from "react";
4+
import { useRouter } from "next/navigation";
5+
import { ExternalLink } from "lucide-react";
6+
import { LoadingButton } from "@/components/ui/loading-button";
7+
import { useToast } from "@/components/hooks/use-toast";
8+
import { createPortalSession } from "@/ee/features/lighthouse/actions";
9+
import { isServiceError } from "@/lib/utils";
10+
11+
interface OpenBillingPortalButtonProps {
12+
label: string;
13+
}
14+
15+
export function OpenBillingPortalButton({ label }: OpenBillingPortalButtonProps) {
16+
const [isOpeningPortal, setIsOpeningPortal] = useState(false);
17+
const router = useRouter();
18+
const { toast } = useToast();
19+
20+
const handleClick = useCallback(() => {
21+
setIsOpeningPortal(true);
22+
createPortalSession()
23+
.then((response) => {
24+
if (isServiceError(response)) {
25+
toast({
26+
description: `Failed to open billing portal: ${response.message}`,
27+
variant: "destructive",
28+
});
29+
setIsOpeningPortal(false);
30+
} else {
31+
router.push(response.url);
32+
}
33+
});
34+
}, [router, toast]);
35+
36+
return (
37+
<LoadingButton
38+
size="sm"
39+
variant="outline"
40+
onClick={handleClick}
41+
loading={isOpeningPortal}
42+
>
43+
{label}
44+
<ExternalLink className="h-3.5 w-3.5" />
45+
</LoadingButton>
46+
);
47+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import Link from "next/link";
2+
import { Clock } from "lucide-react";
3+
import { formatDistance } from "date-fns";
4+
import { Button } from "@/components/ui/button";
5+
import { BannerShell } from "./bannerShell";
6+
import { OpenBillingPortalButton } from "./openBillingPortalButton";
7+
import type { BannerProps } from "./types";
8+
9+
interface TrialBannerProps extends BannerProps {
10+
// ISO 8601 — serializable across the server component boundary.
11+
trialEnd: string;
12+
hasPaymentMethod: boolean;
13+
}
14+
15+
export function TrialBanner({ id, dismissible, now, trialEnd, hasPaymentMethod }: TrialBannerProps) {
16+
const trialEndDate = new Date(trialEnd);
17+
const relative = formatDistance(trialEndDate, now, { addSuffix: true });
18+
19+
const description = hasPaymentMethod
20+
? "Your subscription will start automatically at the end of the trial."
21+
: "Add a payment method to keep enterprise access after your trial ends.";
22+
23+
return (
24+
<BannerShell
25+
id={id}
26+
dismissible={dismissible}
27+
icon={<Clock className="h-4 w-4 mt-0.5" />}
28+
title={`Your trial ends ${relative}`}
29+
description={description}
30+
action={hasPaymentMethod ? (
31+
<Button asChild size="sm" variant="outline">
32+
<Link href="/settings/license">Manage subscription</Link>
33+
</Button>
34+
) : (
35+
<OpenBillingPortalButton label="Add payment method" />
36+
)}
37+
/>
38+
);
39+
}

packages/web/src/app/(app)/components/banners/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ export const BannerPriority = {
33
SERVICE_PING_ENFORCED: 95,
44
INVOICE_PAST_DUE: 90,
55
PERMISSION_SYNC: 50,
6+
TRIAL: 25,
67
LICENSE_EXPIRY_HEADS_UP: 20,
7-
TRIAL: 15,
88
SERVICE_PING_FAILED: 10,
99
} as const;
1010

0 commit comments

Comments
 (0)