Skip to content

Commit 33acd46

Browse files
feat: offline license seat-usage reporting
Track org seat count over time and surface a per-month usage report for offline (air-gapped) license Add-On User reconciliation. - Add a SeatUsageEvent ledger (append-only, absolute counts) with a migration that backfills a baseline row per existing org. - Record a row in the same transaction as every membership change via a recordSeatChange helper, wired into all add/remove chokepoints. - Carry a subscription startDate on the offline license payload (signed and verified) to anchor the reporting "Months". - Add computeMonthlyUsage: buckets the ledger into subscription-anchored UTC months and computes each month's peak provisioned seats. - Render an offline usage report card on the license settings page with a per-month table and a JSON export to send for reconciliation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9320065 commit 33acd46

10 files changed

Lines changed: 562 additions & 11 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
-- CreateTable
2+
CREATE TABLE "SeatUsageEvent" (
3+
"id" TEXT NOT NULL,
4+
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
5+
"seatCount" INTEGER NOT NULL,
6+
"orgId" INTEGER NOT NULL,
7+
8+
CONSTRAINT "SeatUsageEvent_pkey" PRIMARY KEY ("id")
9+
);
10+
11+
-- CreateIndex
12+
CREATE INDEX "SeatUsageEvent_orgId_timestamp_idx" ON "SeatUsageEvent"("orgId", "timestamp");
13+
14+
-- AddForeignKey
15+
ALTER TABLE "SeatUsageEvent" ADD CONSTRAINT "SeatUsageEvent_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
16+
17+
-- Backfill a baseline seat-count row for every existing org, using its current
18+
-- member count. The LEFT JOIN ensures orgs with zero members get a row too.
19+
INSERT INTO "SeatUsageEvent" ("id", "seatCount", "orgId")
20+
SELECT
21+
gen_random_uuid()::text,
22+
COUNT("UserToOrg"."userId")::int,
23+
"Org"."id"
24+
FROM "Org"
25+
LEFT JOIN "UserToOrg" ON "UserToOrg"."orgId" = "Org"."id"
26+
GROUP BY "Org"."id";

packages/db/prisma/schema.prisma

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,8 @@ model Org {
308308
309309
audits Audit[]
310310
311+
seatUsageEvents SeatUsageEvent[]
312+
311313
accountRequests AccountRequest[]
312314
313315
searchContexts SearchContext[]
@@ -426,6 +428,17 @@ model Audit {
426428
@@index([actorId, timestamp], map: "idx_audit_actor_time_full")
427429
}
428430

431+
/// Append-only ledger of an org's seat count over time, used to generate
432+
/// usage reports for offline license reconciliation.
433+
model SeatUsageEvent {
434+
id String @id @default(cuid())
435+
timestamp DateTime @default(now())
436+
seatCount Int
437+
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
438+
orgId Int
439+
@@index([orgId, timestamp])
440+
}
441+
429442
// @see : https://authjs.dev/concepts/database-models#user
430443
model User {
431444
id String @id @default(cuid())

packages/shared/src/entitlements.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ const offlineLicensePayloadSchema = z.object({
1414
seats: z.number().optional(),
1515
// ISO 8601 date string
1616
expiryDate: z.string().datetime(),
17+
// ISO 8601 instant. Optional for back-compat
18+
startDate: z.string().datetime().optional(),
1719
sig: z.string(),
1820
});
1921

20-
type getValidOfflineLicense = z.infer<typeof offlineLicensePayloadSchema>;
22+
type OfflineLicensePayload = z.infer<typeof offlineLicensePayloadSchema>;
2123

2224
const ACTIVE_ONLINE_LICENSE_STATUSES: LicenseStatus[] = [
2325
'active',
@@ -44,16 +46,21 @@ const ALL_ENTITLEMENTS = [
4446
] as const;
4547
export type Entitlement = (typeof ALL_ENTITLEMENTS)[number];
4648

47-
const decodeOfflineLicenseKeyPayload = (payload: string): getValidOfflineLicense | null => {
49+
const decodeOfflineLicenseKeyPayload = (payload: string): OfflineLicensePayload | null => {
4850
try {
4951
const decodedPayload = base64Decode(payload);
5052
const payloadJson = JSON.parse(decodedPayload);
5153
const licenseData = offlineLicensePayloadSchema.parse(payloadJson);
5254

55+
// NOTE: key order here is the signed-blob serialization order and must
56+
// match the signer exactly. `startDate` is appended last so
57+
// that for older licenses (where it is undefined) JSON.stringify omits
58+
// it, leaving the blob byte-identical and existing signatures valid.
5359
const dataToVerify = JSON.stringify({
5460
expiryDate: licenseData.expiryDate,
5561
id: licenseData.id,
56-
seats: licenseData.seats
62+
seats: licenseData.seats,
63+
startDate: licenseData.startDate
5764
});
5865

5966
const isSignatureValid = verifySignature(dataToVerify, licenseData.sig, env.SOURCEBOT_PUBLIC_KEY_PATH);
@@ -69,7 +76,7 @@ const decodeOfflineLicenseKeyPayload = (payload: string): getValidOfflineLicense
6976
}
7077
}
7178

72-
const getDecodedOfflineLicense = (): getValidOfflineLicense | null => {
79+
const getDecodedOfflineLicense = (): OfflineLicensePayload | null => {
7380
const licenseKey = env.SOURCEBOT_EE_LICENSE_KEY;
7481
if (!licenseKey || !licenseKey.startsWith(offlineLicensePrefix)) {
7582
return null;
@@ -78,7 +85,7 @@ const getDecodedOfflineLicense = (): getValidOfflineLicense | null => {
7885
return decodeOfflineLicenseKeyPayload(licenseKey.substring(offlineLicensePrefix.length));
7986
}
8087

81-
const getValidOfflineLicense = (): getValidOfflineLicense | null => {
88+
const getValidOfflineLicense = (): OfflineLicensePayload | null => {
8289
const payload = getDecodedOfflineLicense();
8390
if (!payload) {
8491
return null;
@@ -164,6 +171,7 @@ export type OfflineLicenseMetadata = {
164171
id: string;
165172
seats?: number;
166173
expiryDate: string;
174+
startDate?: string;
167175
}
168176

169177
// Returns the metadata of the offline license if one is configured, even
@@ -179,6 +187,7 @@ export const getOfflineLicenseMetadata = (): OfflineLicenseMetadata | null => {
179187
id: license.id,
180188
seats: license.seats,
181189
expiryDate: license.expiryDate,
190+
startDate: license.startDate,
182191
};
183192
}
184193

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
'use client';
2+
3+
import { useCallback } from "react";
4+
import { Download } from "lucide-react";
5+
import { Badge } from "@/components/ui/badge";
6+
import { Button } from "@/components/ui/button";
7+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
8+
import type { MonthlyUsage } from "@/features/billing/seatUsageReport";
9+
import { SettingsCard } from "../components/settingsCard";
10+
11+
const DOCS_URL = "https://docs.sourcebot.dev/docs/seat-reconciliation";
12+
const REPORT_EMAIL = "ar@sourcebot.dev";
13+
14+
interface OfflineUsageReportCardProps {
15+
licenseId: string;
16+
// ISO 8601 subscription start date.
17+
startDate: string;
18+
months: MonthlyUsage[];
19+
}
20+
21+
export function OfflineUsageReportCard({ licenseId, startDate, months }: OfflineUsageReportCardProps) {
22+
// Most recent first; the in-progress Month (if any) sits at the top.
23+
const rows = [...months].reverse();
24+
const completedMonths = months.filter((m) => m.isComplete);
25+
26+
const handleExport = useCallback(() => {
27+
const report = {
28+
licenseId,
29+
startDate,
30+
// Only completed Months are reportable; an in-progress Month's peak
31+
// can still rise before the Month closes.
32+
months: completedMonths.map((m) => ({
33+
monthNumber: m.monthNumber,
34+
windowStart: m.windowStart.toISOString(),
35+
windowEnd: m.windowEnd.toISOString(),
36+
peakProvisioned: m.peakProvisioned,
37+
peakAt: m.peakAt.toISOString(),
38+
})),
39+
};
40+
41+
const blob = new Blob([JSON.stringify(report, null, 2)], { type: "application/json" });
42+
const url = URL.createObjectURL(blob);
43+
const anchor = document.createElement("a");
44+
anchor.href = url;
45+
anchor.download = `sourcebot-usage-${licenseId}.json`;
46+
anchor.click();
47+
URL.revokeObjectURL(url);
48+
}, [licenseId, startDate, completedMonths]);
49+
50+
return (
51+
<div className="flex flex-col gap-3">
52+
<div>
53+
<h3 className="text-lg font-medium">Usage</h3>
54+
<p className="text-sm text-muted-foreground">
55+
The greatest number of users provisioned during each subscription month.
56+
Within five business days of each month&apos;s end, send the report to{" "}
57+
<a href={`mailto:${REPORT_EMAIL}`} className="text-link hover:underline">
58+
{REPORT_EMAIL}
59+
</a>{" "}
60+
for reconciliation.{" "}
61+
<a
62+
href={DOCS_URL}
63+
target="_blank"
64+
rel="noopener noreferrer"
65+
className="text-link hover:underline"
66+
>
67+
Learn more
68+
</a>
69+
</p>
70+
</div>
71+
<SettingsCard>
72+
<div className="flex flex-col gap-4">
73+
<div className="flex items-center justify-end">
74+
<Button
75+
variant="outline"
76+
size="sm"
77+
onClick={handleExport}
78+
disabled={completedMonths.length === 0}
79+
>
80+
<Download className="h-3.5 w-3.5" />
81+
Export report
82+
</Button>
83+
</div>
84+
<Table>
85+
<TableHeader>
86+
<TableRow>
87+
<TableHead>Month</TableHead>
88+
<TableHead className="text-right">Peak users</TableHead>
89+
<TableHead className="text-right">Reached</TableHead>
90+
<TableHead className="text-right">At month end</TableHead>
91+
</TableRow>
92+
</TableHeader>
93+
<TableBody>
94+
{rows.map((month) => (
95+
<TableRow key={month.monthNumber}>
96+
<TableCell className="flex items-center gap-2">
97+
{formatWindow(month.windowStart, month.windowEnd)}
98+
{!month.isComplete && (
99+
<Badge variant="outline" className="text-muted-foreground">
100+
In progress
101+
</Badge>
102+
)}
103+
</TableCell>
104+
<TableCell className="text-right font-medium">
105+
{month.peakProvisioned}
106+
</TableCell>
107+
<TableCell className="text-right text-muted-foreground">
108+
{formatDate(month.peakAt)}
109+
</TableCell>
110+
<TableCell className="text-right text-muted-foreground">
111+
{month.endProvisioned}
112+
</TableCell>
113+
</TableRow>
114+
))}
115+
</TableBody>
116+
</Table>
117+
</div>
118+
</SettingsCard>
119+
</div>
120+
);
121+
}
122+
123+
// Month boundaries are UTC instants, so format in UTC to avoid the local
124+
// timezone shifting the displayed day across a midnight boundary.
125+
function formatDate(date: Date): string {
126+
return new Date(date).toLocaleDateString('en-US', {
127+
month: 'short',
128+
day: 'numeric',
129+
year: 'numeric',
130+
timeZone: 'UTC',
131+
});
132+
}
133+
134+
// The window is half-open [start, end); display the inclusive last day.
135+
function formatWindow(start: Date, end: Date): string {
136+
const inclusiveEnd = new Date(end.getTime() - 1);
137+
return `${formatDate(start)}${formatDate(inclusiveEnd)}`;
138+
}

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import { redirect } from "next/navigation";
77
import { ActivationCodeCard } from "./activationCodeCard";
88
import { OnlineLicenseCard } from "./onlineLicenseCard/onlineLicenseCard";
99
import { OfflineLicenseCard } from "./offlineLicenseCard";
10+
import { OfflineUsageReportCard } from "./offlineUsageReportCard";
1011
import { RecentInvoicesCard } from "./recentInvoicesCard";
1112
import { YearlyTermSeatsUsageCard } from "./yearlyTermSeatsUsageCard";
13+
import { computeMonthlyUsage } from "@/features/billing/seatUsageReport";
1214
import { SettingsCard } from "../components/settingsCard";
1315
import { UpsellPanel } from "@/features/billing/upsellDialog";
1416
import { getAllInvoices } from "@/ee/features/lighthouse/actions";
@@ -51,6 +53,21 @@ export default authenticatedPage<LicensePageProps>(async ({ prisma, org }, props
5153
const yearlyTermStatus = getYearlyTermStatus(license);
5254
const currentUserCount = await prisma.userToOrg.count({ where: { orgId: org.id } });
5355

56+
// Usage-based offline licenses (those carrying a subscription start date)
57+
// reconcile Add-On Users from the seat-usage ledger. Bucket it into
58+
// subscription-anchored Months for the in-app report.
59+
const seatUsageMonths = offlineLicense?.startDate
60+
? computeMonthlyUsage(
61+
await prisma.seatUsageEvent.findMany({
62+
where: { orgId: org.id },
63+
orderBy: { timestamp: 'asc' },
64+
select: { timestamp: true, seatCount: true },
65+
}),
66+
new Date(offlineLicense.startDate),
67+
new Date(),
68+
)
69+
: null;
70+
5471
const invoicesResult = license ? await getAllInvoices() : null;
5572
const invoices = invoicesResult && !isServiceError(invoicesResult) ? invoicesResult : [];
5673

@@ -92,6 +109,13 @@ export default authenticatedPage<LicensePageProps>(async ({ prisma, org }, props
92109
{offlineLicense && (
93110
<OfflineLicenseCard license={offlineLicense} isExpired={isOfflineLicenseExpired} />
94111
)}
112+
{offlineLicense && seatUsageMonths && (
113+
<OfflineUsageReportCard
114+
licenseId={offlineLicense.id}
115+
startDate={offlineLicense.startDate!}
116+
months={seatUsageMonths}
117+
/>
118+
)}
95119
{license && <OnlineLicenseCard license={license} />}
96120
{license
97121
&& !isOnlineLicenseInactive
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Prisma } from "@sourcebot/db";
2+
3+
/**
4+
* Appends a row to the org's seat-usage ledger recording its *current*
5+
* member count. Call this in the same transaction as any mutation that
6+
* adds or removes a member, AFTER the mutation has been applied, so the
7+
* recorded count and the actual membership can never disagree.
8+
*
9+
* The count is absolute (not a delta), so peak usage over any period is
10+
* MAX(seatCount) over the rows in that window. These rows are the source
11+
* of truth for offline license usage reports.
12+
*
13+
* Recording is unconditional: if a mutation turns out to be a no-op (e.g.
14+
* an upsert of an already-existing member), this writes a row with the
15+
* same count as the previous one. That duplicate is harmless for a
16+
* high-water-mark report and keeps the contract simple — every membership
17+
* code path records, none has to reason about whether the count changed.
18+
*/
19+
export const recordSeatChange = async (
20+
tx: Prisma.TransactionClient,
21+
orgId: number,
22+
): Promise<void> => {
23+
const seatCount = await tx.userToOrg.count({
24+
where: { orgId },
25+
});
26+
27+
await tx.seatUsageEvent.create({
28+
data: {
29+
orgId,
30+
seatCount,
31+
},
32+
});
33+
};

0 commit comments

Comments
 (0)