-
Notifications
You must be signed in to change notification settings - Fork 302
feat: offline license seat-usage reporting #1347
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
26 changes: 26 additions & 0 deletions
26
packages/db/prisma/migrations/20260618032126_add_seat_usage_event/migration.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
138 changes: 138 additions & 0 deletions
138
packages/web/src/app/(app)/settings/license/offlineUsageReportCard.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'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)}`; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }, | ||
| }); | ||
| }; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
As per coding guidelines, CHANGELOG.md entries must be a single sentence description followed by a PR link.
📝 Committable suggestion
🤖 Prompt for AI Agents
Source: Coding guidelines