Skip to content

Commit bf8de44

Browse files
[v5] feat(web): Add usage information for yearly subs (#1245)
* schema * feat
1 parent c110a4b commit bf8de44

14 files changed

Lines changed: 464 additions & 1 deletion

File tree

packages/db/prisma/migrations/20260509000000_add_license_table/migration.sql

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,16 @@ CREATE TABLE "License" (
1616
"cancelAt" TIMESTAMP(3),
1717
"trialEnd" TIMESTAMP(3),
1818
"hasPaymentMethod" BOOLEAN,
19+
"yearlyCommittedSeats" INTEGER,
20+
"yearlyCurrentQuarterEndsAt" TIMESTAMP(3),
21+
"yearlyCurrentQuarterNumber" INTEGER,
22+
"yearlyCurrentQuarterStartedAt" TIMESTAMP(3),
23+
"yearlyOverageSeats" INTEGER,
24+
"yearlyBillableOverageSeats" INTEGER,
25+
"yearlyPeakSeats" INTEGER,
26+
"yearlyTermEndsAt" TIMESTAMP(3),
27+
"yearlyTermStartedAt" TIMESTAMP(3),
28+
"yearlyTotalQuartersInTerm" INTEGER,
1929
"lastSyncAt" TIMESTAMP(3),
2030
"lastSyncErrorCode" TEXT,
2131
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

packages/db/prisma/schema.prisma

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,20 @@ model License {
315315
cancelAt DateTime?
316316
trialEnd DateTime?
317317
hasPaymentMethod Boolean?
318+
319+
// Yearly-only fields, mirroring `yearlyTermStatus` on the lighthouse ping
320+
// response. All null for monthly subs and for unactivated/canceled licenses.
321+
yearlyTermStartedAt DateTime?
322+
yearlyTermEndsAt DateTime?
323+
yearlyTotalQuartersInTerm Int?
324+
yearlyCurrentQuarterNumber Int?
325+
yearlyCurrentQuarterStartedAt DateTime?
326+
yearlyCurrentQuarterEndsAt DateTime?
327+
yearlyCommittedSeats Int?
328+
yearlyOverageSeats Int?
329+
yearlyBillableOverageSeats Int?
330+
yearlyPeakSeats Int?
331+
318332
lastSyncAt DateTime?
319333
lastSyncErrorCode String?
320334
createdAt DateTime @default(now())

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,16 @@ const makeLicense = (overrides: Partial<License> = {}): License => ({
4848
cancelAt: null,
4949
trialEnd: null,
5050
hasPaymentMethod: null,
51+
yearlyTermStartedAt: null,
52+
yearlyTermEndsAt: null,
53+
yearlyTotalQuartersInTerm: null,
54+
yearlyCurrentQuarterNumber: null,
55+
yearlyCurrentQuarterStartedAt: null,
56+
yearlyCurrentQuarterEndsAt: null,
57+
yearlyCommittedSeats: null,
58+
yearlyOverageSeats: null,
59+
yearlyBillableOverageSeats: null,
60+
yearlyPeakSeats: null,
5161
lastSyncAt: NOW,
5262
lastSyncErrorCode: null,
5363
createdAt: NOW,

packages/web/src/app/(app)/settings/license/licenseInactiveBanner.tsx renamed to packages/web/src/app/(app)/settings/license/onlineLicenseCard/licenseInactiveBanner.tsx

File renamed without changes.

packages/web/src/app/(app)/settings/license/onlineLicenseCard.tsx renamed to packages/web/src/app/(app)/settings/license/onlineLicenseCard/onlineLicenseCard.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { cn, formatCurrency } from "@/lib/utils";
1111
import { LicenseInactiveBanner } from "./licenseInactiveBanner";
1212
import { PlanActionsMenu } from "./planActionsMenu";
1313
import { TrialMissingPaymentMethodBanner } from "./trialMissingPaymentMethodBanner";
14+
import { UpcomingYearlyRenewalBanner } from "./upcomingYearlyRenewalBanner";
1415

1516
interface OnlineLicenseCardProps {
1617
license: License;
@@ -123,6 +124,18 @@ export function OnlineLicenseCard({ license }: OnlineLicenseCardProps) {
123124
{isLicenseActive && license.status === 'trialing' && license.hasPaymentMethod === false && (
124125
<TrialMissingPaymentMethodBanner />
125126
)}
127+
{isLicenseActive
128+
&& license.interval === 'year'
129+
&& license.yearlyCurrentQuarterNumber !== null
130+
&& license.yearlyTotalQuartersInTerm !== null
131+
&& license.yearlyCurrentQuarterNumber === license.yearlyTotalQuartersInTerm
132+
&& license.nextRenewalAt !== null
133+
&& license.seats !== null && (
134+
<UpcomingYearlyRenewalBanner
135+
renewalAt={license.nextRenewalAt}
136+
seats={license.seats}
137+
/>
138+
)}
126139
</div>
127140
);
128141
}

packages/web/src/app/(app)/settings/license/planActionsMenu.tsx renamed to packages/web/src/app/(app)/settings/license/onlineLicenseCard/planActionsMenu.tsx

File renamed without changes.

packages/web/src/app/(app)/settings/license/removeActivationCodeDialog.tsx renamed to packages/web/src/app/(app)/settings/license/onlineLicenseCard/removeActivationCodeDialog.tsx

File renamed without changes.

packages/web/src/app/(app)/settings/license/trialMissingPaymentMethodBanner.tsx renamed to packages/web/src/app/(app)/settings/license/onlineLicenseCard/trialMissingPaymentMethodBanner.tsx

File renamed without changes.
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"use client";
2+
3+
import { useCallback, useState } from "react";
4+
import { useRouter } from "next/navigation";
5+
import { Info } from "lucide-react";
6+
import { useToast } from "@/components/hooks/use-toast";
7+
import { createPortalSession } from "@/ee/features/lighthouse/actions";
8+
import { isServiceError } from "@/lib/utils";
9+
10+
// @note take care to keep this in sync with
11+
// the value in constants.ts in lighthouse.
12+
const YEARLY_CANCELLATION_NOTICE_DAYS = 30;
13+
const DAY_MS = 24 * 60 * 60 * 1000;
14+
15+
interface UpcomingRenewalBannerProps {
16+
renewalAt: Date;
17+
seats: number;
18+
}
19+
20+
export function UpcomingYearlyRenewalBanner({ renewalAt, seats }: UpcomingRenewalBannerProps) {
21+
const router = useRouter();
22+
const { toast } = useToast();
23+
const [isOpeningPortal, setIsOpeningPortal] = useState(false);
24+
const [nowMs] = useState(() => Date.now());
25+
26+
const handleCancelClick = useCallback(() => {
27+
setIsOpeningPortal(true);
28+
createPortalSession().then((response) => {
29+
if (isServiceError(response)) {
30+
toast({
31+
description: `Failed to open subscription portal: ${response.message}`,
32+
variant: "destructive",
33+
});
34+
setIsOpeningPortal(false);
35+
return;
36+
}
37+
router.push(response.url);
38+
});
39+
}, [router, toast]);
40+
41+
const daysUntilRenewal = (renewalAt.getTime() - nowMs) / DAY_MS;
42+
const noticeDeadlineHasPassed = daysUntilRenewal < YEARLY_CANCELLATION_NOTICE_DAYS;
43+
const noticeDeadline = new Date(renewalAt.getTime() - YEARLY_CANCELLATION_NOTICE_DAYS * DAY_MS);
44+
45+
return (
46+
<div className="flex items-start gap-3 border-t bg-blue-500/10 px-4 py-2.5">
47+
<Info className="h-4 w-4 mt-0.5 flex-shrink-0 text-blue-400" />
48+
<div className="text-sm">
49+
<p>
50+
<span className="font-medium">
51+
Auto-renewing on {formatDate(renewalAt)}
52+
</span>{" "}
53+
<span className="text-muted-foreground">
54+
with {seats} {seats === 1 ? 'seat' : 'seats'}.
55+
</span>
56+
</p>
57+
{!noticeDeadlineHasPassed && (
58+
<p className="text-muted-foreground mt-1">
59+
You have until {formatDate(noticeDeadline)} to{" "}
60+
<button
61+
type="button"
62+
onClick={handleCancelClick}
63+
disabled={isOpeningPortal}
64+
className="text-link hover:underline disabled:opacity-50"
65+
>
66+
cancel
67+
</button>{" "}
68+
your subscription or{" "}
69+
<a
70+
href="mailto:ar@sourcebot.dev"
71+
className="text-link hover:underline"
72+
>
73+
request
74+
</a>{" "}
75+
a different seat count for renewal.
76+
</p>
77+
)}
78+
</div>
79+
</div>
80+
);
81+
}
82+
83+
function formatDate(date: Date): string {
84+
return new Date(date).toLocaleDateString('en-US', {
85+
month: 'short',
86+
day: 'numeric',
87+
year: 'numeric',
88+
});
89+
}

packages/web/src/app/(app)/settings/license/page.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,16 @@ import { Button } from "@/components/ui/button";
55
import { ExternalLink } from "lucide-react";
66
import { redirect } from "next/navigation";
77
import { ActivationCodeCard } from "./activationCodeCard";
8-
import { OnlineLicenseCard } from "./onlineLicenseCard";
8+
import { OnlineLicenseCard } from "./onlineLicenseCard/onlineLicenseCard";
99
import { OfflineLicenseCard } from "./offlineLicenseCard";
1010
import { RecentInvoicesCard } from "./recentInvoicesCard";
11+
import { YearlyTermSeatsUsageCard } from "./yearlyTermSeatsUsageCard";
1112
import { SettingsCard } from "../components/settingsCard";
1213
import { UpsellPanel } from "@/ee/features/lighthouse/upsellDialog";
1314
import { getAllInvoices } from "@/ee/features/lighthouse/actions";
1415
import { syncWithLighthouse } from "@/ee/features/lighthouse/servicePing";
1516
import { isServiceError } from "@/lib/utils";
17+
import { getYearlyTermStatus } from "./types";
1618

1719
type LicensePageProps = {
1820
searchParams?: Promise<Record<string, string | string[] | undefined>>;
@@ -36,6 +38,7 @@ export default authenticatedPage<LicensePageProps>(async ({ prisma, org }, props
3638
redirect(suffix ? `/settings/license?${suffix}` : '/settings/license');
3739
}
3840

41+
3942
const offlineLicense = getOfflineLicenseMetadata();
4043
const isOfflineLicenseExpired = offlineLicense
4144
? new Date(offlineLicense.expiryDate).getTime() < Date.now()
@@ -45,6 +48,9 @@ export default authenticatedPage<LicensePageProps>(async ({ prisma, org }, props
4548
? null
4649
: await prisma.license.findUnique({ where: { orgId: org.id } });
4750

51+
const yearlyTermStatus = getYearlyTermStatus(license);
52+
const currentUserCount = await prisma.userToOrg.count({ where: { orgId: org.id } });
53+
4854
const invoicesResult = license ? await getAllInvoices() : null;
4955
const invoices = invoicesResult && !isServiceError(invoicesResult) ? invoicesResult : [];
5056

@@ -87,6 +93,13 @@ export default authenticatedPage<LicensePageProps>(async ({ prisma, org }, props
8793
<OfflineLicenseCard license={offlineLicense} isExpired={isOfflineLicenseExpired} />
8894
)}
8995
{license && <OnlineLicenseCard license={license} />}
96+
{license
97+
&& yearlyTermStatus && (
98+
<YearlyTermSeatsUsageCard
99+
currentUsers={currentUserCount}
100+
status={yearlyTermStatus}
101+
/>
102+
)}
90103
{!offlineLicense && !license && <ActivationCodeCard />}
91104
{license && <RecentInvoicesCard invoices={invoices} />}
92105
</div>

0 commit comments

Comments
 (0)