Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- [EE] Added a seat-usage report to the license settings page for offline license reconciliation. It tracks the org's seat count over time and surfaces the peak number of provisioned users for each subscription month, with a JSON export to send for reconciliation. [#1347](https://github.com/sourcebot-dev/sourcebot/pull/1347)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use a single sentence for the changelog entry.

This entry currently uses two sentences, but the changelog format requires one sentence followed by the PR link.

✏️ Suggested edit
-- [EE] Added a seat-usage report to the license settings page for offline license reconciliation. It tracks the org's seat count over time and surfaces the peak number of provisioned users for each subscription month, with a JSON export to send for reconciliation. [`#1347`](https://github.com/sourcebot-dev/sourcebot/pull/1347)
+- [EE] Added a seat-usage report to the license settings page for offline license reconciliation that tracks org seat counts over time and surfaces each subscription month’s peak provisioned users with a JSON export for reconciliation. [`#1347`](https://github.com/sourcebot-dev/sourcebot/pull/1347)

As per coding guidelines, CHANGELOG.md entries must be a single sentence description followed by a PR link.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- [EE] Added a seat-usage report to the license settings page for offline license reconciliation. It tracks the org's seat count over time and surfaces the peak number of provisioned users for each subscription month, with a JSON export to send for reconciliation. [#1347](https://github.com/sourcebot-dev/sourcebot/pull/1347)
- [EE] Added a seat-usage report to the license settings page for offline license reconciliation that tracks org seat counts over time and surfaces each subscription month's peak provisioned users with a JSON export for reconciliation. [`#1347`](https://github.com/sourcebot-dev/sourcebot/pull/1347)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` at line 11, The CHANGELOG.md entry for PR `#1347` currently
contains two sentences separated by a period, which violates the changelog
format requirement of a single sentence followed by the PR link. Combine the two
sentences into one cohesive sentence that describes the seat-usage report
feature while maintaining all the key information about what it does (tracks
seat count over time, surfaces peak provisioned users, provides JSON export for
reconciliation), then ensure only the PR link follows the sentence.

Source: Coding guidelines


### Fixed
- Upgraded `@grpc/grpc-js` to `^1.14.4`. [#1315](https://github.com/sourcebot-dev/sourcebot/pull/1315)
- Upgraded `vite` to `^8.0.16`. [#1313](https://github.com/sourcebot-dev/sourcebot/pull/1313)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
-- CreateTable
CREATE TABLE "SeatUsageEvent" (
"id" TEXT NOT NULL,
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"seatCount" INTEGER NOT NULL,
"orgId" INTEGER NOT NULL,

CONSTRAINT "SeatUsageEvent_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "SeatUsageEvent_orgId_timestamp_idx" ON "SeatUsageEvent"("orgId", "timestamp");

-- AddForeignKey
ALTER TABLE "SeatUsageEvent" ADD CONSTRAINT "SeatUsageEvent_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- Backfill a baseline seat-count row for every existing org, using its current
-- member count. The LEFT JOIN ensures orgs with zero members get a row too.
INSERT INTO "SeatUsageEvent" ("id", "seatCount", "orgId")
SELECT
gen_random_uuid()::text,
COUNT("UserToOrg"."userId")::int,
"Org"."id"
FROM "Org"
LEFT JOIN "UserToOrg" ON "UserToOrg"."orgId" = "Org"."id"
GROUP BY "Org"."id";
13 changes: 13 additions & 0 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,8 @@ model Org {

audits Audit[]

seatUsageEvents SeatUsageEvent[]

accountRequests AccountRequest[]

searchContexts SearchContext[]
Expand Down Expand Up @@ -426,6 +428,17 @@ model Audit {
@@index([actorId, timestamp], map: "idx_audit_actor_time_full")
}

/// Append-only ledger of an org's seat count over time, used to generate
/// usage reports for offline license reconciliation.
model SeatUsageEvent {
id String @id @default(cuid())
timestamp DateTime @default(now())
seatCount Int
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
orgId Int
@@index([orgId, timestamp])
}

// @see : https://authjs.dev/concepts/database-models#user
model User {
id String @id @default(cuid())
Expand Down
19 changes: 14 additions & 5 deletions packages/shared/src/entitlements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ const offlineLicensePayloadSchema = z.object({
seats: z.number().optional(),
// ISO 8601 date string
expiryDate: z.string().datetime(),
// ISO 8601 instant. Optional for back-compat
startDate: z.string().datetime().optional(),
sig: z.string(),
});

type getValidOfflineLicense = z.infer<typeof offlineLicensePayloadSchema>;
type OfflineLicensePayload = z.infer<typeof offlineLicensePayloadSchema>;

const ACTIVE_ONLINE_LICENSE_STATUSES: LicenseStatus[] = [
'active',
Expand All @@ -44,16 +46,21 @@ const ALL_ENTITLEMENTS = [
] as const;
export type Entitlement = (typeof ALL_ENTITLEMENTS)[number];

const decodeOfflineLicenseKeyPayload = (payload: string): getValidOfflineLicense | null => {
const decodeOfflineLicenseKeyPayload = (payload: string): OfflineLicensePayload | null => {
try {
const decodedPayload = base64Decode(payload);
const payloadJson = JSON.parse(decodedPayload);
const licenseData = offlineLicensePayloadSchema.parse(payloadJson);

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

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

const getDecodedOfflineLicense = (): getValidOfflineLicense | null => {
const getDecodedOfflineLicense = (): OfflineLicensePayload | null => {
const licenseKey = env.SOURCEBOT_EE_LICENSE_KEY;
if (!licenseKey || !licenseKey.startsWith(offlineLicensePrefix)) {
return null;
Expand All @@ -78,7 +85,7 @@ const getDecodedOfflineLicense = (): getValidOfflineLicense | null => {
return decodeOfflineLicenseKeyPayload(licenseKey.substring(offlineLicensePrefix.length));
}

const getValidOfflineLicense = (): getValidOfflineLicense | null => {
const getValidOfflineLicense = (): OfflineLicensePayload | null => {
const payload = getDecodedOfflineLicense();
if (!payload) {
return null;
Expand Down Expand Up @@ -164,6 +171,7 @@ export type OfflineLicenseMetadata = {
id: string;
seats?: number;
expiryDate: string;
startDate?: string;
}

// Returns the metadata of the offline license if one is configured, even
Expand All @@ -179,6 +187,7 @@ export const getOfflineLicenseMetadata = (): OfflineLicenseMetadata | null => {
id: license.id,
seats: license.seats,
expiryDate: license.expiryDate,
startDate: license.startDate,
};
}

Expand Down
138 changes: 138 additions & 0 deletions packages/web/src/app/(app)/settings/license/offlineUsageReportCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
'use client';

import { useCallback } from "react";
import { Download } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import type { MonthlyUsage } from "@/features/billing/seatUsageReport";
import { SettingsCard } from "../components/settingsCard";

const DOCS_URL = "https://docs.sourcebot.dev/docs/seat-reconciliation";
const REPORT_EMAIL = "ar@sourcebot.dev";

interface OfflineUsageReportCardProps {
licenseId: string;
// ISO 8601 subscription start date.
startDate: string;
months: MonthlyUsage[];
}

export function OfflineUsageReportCard({ licenseId, startDate, months }: OfflineUsageReportCardProps) {
// Most recent first; the in-progress Month (if any) sits at the top.
const rows = [...months].reverse();
const completedMonths = months.filter((m) => m.isComplete);

const handleExport = useCallback(() => {
const report = {
licenseId,
startDate,
// Only completed Months are reportable; an in-progress Month's peak
// can still rise before the Month closes.
months: completedMonths.map((m) => ({
monthNumber: m.monthNumber,
windowStart: m.windowStart.toISOString(),
windowEnd: m.windowEnd.toISOString(),
peakProvisioned: m.peakProvisioned,
peakAt: m.peakAt.toISOString(),
})),
};

const blob = new Blob([JSON.stringify(report, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `sourcebot-usage-${licenseId}.json`;
anchor.click();
URL.revokeObjectURL(url);
}, [licenseId, startDate, completedMonths]);

return (
<div className="flex flex-col gap-3">
<div>
<h3 className="text-lg font-medium">Usage</h3>
<p className="text-sm text-muted-foreground">
The greatest number of users provisioned during each subscription month.
Within five business days of each month&apos;s end, send the report to{" "}
<a href={`mailto:${REPORT_EMAIL}`} className="text-link hover:underline">
{REPORT_EMAIL}
</a>{" "}
for reconciliation.{" "}
<a
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="text-link hover:underline"
>
Learn more
</a>
</p>
</div>
<SettingsCard>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-end">
<Button
variant="outline"
size="sm"
onClick={handleExport}
disabled={completedMonths.length === 0}
>
<Download className="h-3.5 w-3.5" />
Export report
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Month</TableHead>
<TableHead className="text-right">Peak users</TableHead>
<TableHead className="text-right">Reached</TableHead>
<TableHead className="text-right">At month end</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((month) => (
<TableRow key={month.monthNumber}>
<TableCell className="flex items-center gap-2">
{formatWindow(month.windowStart, month.windowEnd)}
{!month.isComplete && (
<Badge variant="outline" className="text-muted-foreground">
In progress
</Badge>
)}
</TableCell>
<TableCell className="text-right font-medium">
{month.peakProvisioned}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{formatDate(month.peakAt)}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{month.endProvisioned}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</SettingsCard>
</div>
);
}

// Month boundaries are UTC instants, so format in UTC to avoid the local
// timezone shifting the displayed day across a midnight boundary.
function formatDate(date: Date): string {
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
});
}

// The window is half-open [start, end); display the inclusive last day.
function formatWindow(start: Date, end: Date): string {
const inclusiveEnd = new Date(end.getTime() - 1);
return `${formatDate(start)} – ${formatDate(inclusiveEnd)}`;
}
24 changes: 24 additions & 0 deletions packages/web/src/app/(app)/settings/license/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import { redirect } from "next/navigation";
import { ActivationCodeCard } from "./activationCodeCard";
import { OnlineLicenseCard } from "./onlineLicenseCard/onlineLicenseCard";
import { OfflineLicenseCard } from "./offlineLicenseCard";
import { OfflineUsageReportCard } from "./offlineUsageReportCard";
import { RecentInvoicesCard } from "./recentInvoicesCard";
import { YearlyTermSeatsUsageCard } from "./yearlyTermSeatsUsageCard";
import { computeMonthlyUsage } from "@/features/billing/seatUsageReport";
import { SettingsCard } from "../components/settingsCard";
import { UpsellPanel } from "@/features/billing/upsellDialog";
import { getAllInvoices } from "@/ee/features/lighthouse/actions";
Expand Down Expand Up @@ -51,6 +53,21 @@ export default authenticatedPage<LicensePageProps>(async ({ prisma, org }, props
const yearlyTermStatus = getYearlyTermStatus(license);
const currentUserCount = await prisma.userToOrg.count({ where: { orgId: org.id } });

// Usage-based offline licenses (those carrying a subscription start date)
// reconcile Add-On Users from the seat-usage ledger. Bucket it into
// subscription-anchored Months for the in-app report.
const seatUsageMonths = offlineLicense?.startDate
? computeMonthlyUsage(
await prisma.seatUsageEvent.findMany({
where: { orgId: org.id },
orderBy: { timestamp: 'asc' },
select: { timestamp: true, seatCount: true },
}),
new Date(offlineLicense.startDate),
new Date(),
)
: null;

const invoicesResult = license ? await getAllInvoices() : null;
const invoices = invoicesResult && !isServiceError(invoicesResult) ? invoicesResult : [];

Expand Down Expand Up @@ -92,6 +109,13 @@ export default authenticatedPage<LicensePageProps>(async ({ prisma, org }, props
{offlineLicense && (
<OfflineLicenseCard license={offlineLicense} isExpired={isOfflineLicenseExpired} />
)}
{offlineLicense && seatUsageMonths && (
<OfflineUsageReportCard
licenseId={offlineLicense.id}
startDate={offlineLicense.startDate!}
months={seatUsageMonths}
/>
)}
{license && <OnlineLicenseCard license={license} />}
{license
&& !isOnlineLicenseInactive
Expand Down
33 changes: 33 additions & 0 deletions packages/web/src/features/billing/seatUsage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Prisma } from "@sourcebot/db";

/**
* Appends a row to the org's seat-usage ledger recording its *current*
* member count. Call this in the same transaction as any mutation that
* adds or removes a member, AFTER the mutation has been applied, so the
* recorded count and the actual membership can never disagree.
*
* The count is absolute (not a delta), so peak usage over any period is
* MAX(seatCount) over the rows in that window. These rows are the source
* of truth for offline license usage reports.
*
* Recording is unconditional: if a mutation turns out to be a no-op (e.g.
* an upsert of an already-existing member), this writes a row with the
* same count as the previous one. That duplicate is harmless for a
* high-water-mark report and keeps the contract simple — every membership
* code path records, none has to reason about whether the count changed.
*/
export const recordSeatChange = async (
tx: Prisma.TransactionClient,
orgId: number,
): Promise<void> => {
const seatCount = await tx.userToOrg.count({
where: { orgId },
});

await tx.seatUsageEvent.create({
data: {
orgId,
seatCount,
},
});
};
Loading
Loading