diff --git a/CHANGELOG.md b/CHANGELOG.md index c2dd6795c..c55e0cfab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) + ### 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) diff --git a/packages/db/prisma/migrations/20260618032126_add_seat_usage_event/migration.sql b/packages/db/prisma/migrations/20260618032126_add_seat_usage_event/migration.sql new file mode 100644 index 000000000..7f5948a5e --- /dev/null +++ b/packages/db/prisma/migrations/20260618032126_add_seat_usage_event/migration.sql @@ -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"; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 415b0d3d1..ada0ea9ef 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -308,6 +308,8 @@ model Org { audits Audit[] + seatUsageEvents SeatUsageEvent[] + accountRequests AccountRequest[] searchContexts SearchContext[] @@ -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()) diff --git a/packages/shared/src/entitlements.ts b/packages/shared/src/entitlements.ts index bcfdac6cd..ff5dec627 100644 --- a/packages/shared/src/entitlements.ts +++ b/packages/shared/src/entitlements.ts @@ -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; +type OfflineLicensePayload = z.infer; const ACTIVE_ONLINE_LICENSE_STATUSES: LicenseStatus[] = [ 'active', @@ -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); @@ -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; @@ -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; @@ -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 @@ -179,6 +187,7 @@ export const getOfflineLicenseMetadata = (): OfflineLicenseMetadata | null => { id: license.id, seats: license.seats, expiryDate: license.expiryDate, + startDate: license.startDate, }; } diff --git a/packages/web/src/app/(app)/settings/license/offlineUsageReportCard.tsx b/packages/web/src/app/(app)/settings/license/offlineUsageReportCard.tsx new file mode 100644 index 000000000..ab37a351b --- /dev/null +++ b/packages/web/src/app/(app)/settings/license/offlineUsageReportCard.tsx @@ -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 ( +
+
+

Usage

+

+ The greatest number of users provisioned during each subscription month. + Within five business days of each month's end, send the report to{" "} + + {REPORT_EMAIL} + {" "} + for reconciliation.{" "} + + Learn more + +

+
+ +
+
+ +
+ + + + Month + Peak users + Reached + At month end + + + + {rows.map((month) => ( + + + {formatWindow(month.windowStart, month.windowEnd)} + {!month.isComplete && ( + + In progress + + )} + + + {month.peakProvisioned} + + + {formatDate(month.peakAt)} + + + {month.endProvisioned} + + + ))} + +
+
+
+
+ ); +} + +// 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)}`; +} diff --git a/packages/web/src/app/(app)/settings/license/page.tsx b/packages/web/src/app/(app)/settings/license/page.tsx index 419422ee8..28c727b26 100644 --- a/packages/web/src/app/(app)/settings/license/page.tsx +++ b/packages/web/src/app/(app)/settings/license/page.tsx @@ -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"; @@ -51,6 +53,21 @@ export default authenticatedPage(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 : []; @@ -92,6 +109,13 @@ export default authenticatedPage(async ({ prisma, org }, props {offlineLicense && ( )} + {offlineLicense && seatUsageMonths && ( + + )} {license && } {license && !isOnlineLicenseInactive diff --git a/packages/web/src/features/billing/seatUsage.ts b/packages/web/src/features/billing/seatUsage.ts new file mode 100644 index 000000000..22f4033b6 --- /dev/null +++ b/packages/web/src/features/billing/seatUsage.ts @@ -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 => { + const seatCount = await tx.userToOrg.count({ + where: { orgId }, + }); + + await tx.seatUsageEvent.create({ + data: { + orgId, + seatCount, + }, + }); +}; diff --git a/packages/web/src/features/billing/seatUsageReport.test.ts b/packages/web/src/features/billing/seatUsageReport.test.ts new file mode 100644 index 000000000..3d278ca37 --- /dev/null +++ b/packages/web/src/features/billing/seatUsageReport.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from 'vitest'; +import { computeMonthlyUsage, SeatUsageLedgerEntry } from './seatUsageReport'; + +const d = (iso: string) => new Date(iso); +const entry = (iso: string, seatCount: number): SeatUsageLedgerEntry => ({ + timestamp: d(iso), + seatCount, +}); + +describe('computeMonthlyUsage', () => { + test('headline case: peak mid-month above the end-of-month count', () => { + // Start Jan 1 with 10, grow to 15 on the 18th, drop to 9 on the 25th. + const ledger = [ + entry('2026-01-01T00:00:00Z', 10), + entry('2026-01-18T12:00:00Z', 15), + entry('2026-01-25T08:00:00Z', 9), + ]; + + const [jan] = computeMonthlyUsage(ledger, d('2026-01-01T00:00:00Z'), d('2026-01-31T00:00:00Z')); + + expect(jan.monthNumber).toBe(1); + expect(jan.windowStart).toEqual(d('2026-01-01T00:00:00Z')); + expect(jan.windowEnd).toEqual(d('2026-02-01T00:00:00Z')); + expect(jan.peakProvisioned).toBe(15); + expect(jan.peakAt).toEqual(d('2026-01-18T12:00:00Z')); + expect(jan.endProvisioned).toBe(9); + }); + + test('carries the count into a Month with no events of its own', () => { + // Only January has events; February should inherit January's ending 9. + const ledger = [ + entry('2026-01-01T00:00:00Z', 10), + entry('2026-01-25T08:00:00Z', 9), + ]; + + const months = computeMonthlyUsage(ledger, d('2026-01-01T00:00:00Z'), d('2026-02-15T00:00:00Z')); + + const feb = months.find((m) => m.monthNumber === 2)!; + expect(feb.peakProvisioned).toBe(9); + // The peak was already in effect at the window start, so peakAt is the start. + expect(feb.peakAt).toEqual(d('2026-02-01T00:00:00Z')); + expect(feb.endProvisioned).toBe(9); + }); + + test('a prior-period high carried into the window is the peak', () => { + const ledger = [ + entry('2026-01-10T00:00:00Z', 20), + entry('2026-02-05T00:00:00Z', 12), // drops inside February + ]; + + const feb = computeMonthlyUsage(ledger, d('2026-01-01T00:00:00Z'), d('2026-02-28T00:00:00Z')) + .find((m) => m.monthNumber === 2)!; + + // 20 was in effect at the Feb 1 start, before dropping to 12 on the 5th. + expect(feb.peakProvisioned).toBe(20); + expect(feb.peakAt).toEqual(d('2026-02-01T00:00:00Z')); + expect(feb.endProvisioned).toBe(12); + }); + + test('Months are anchored to the start date, not the calendar', () => { + // Subscription starts on the 15th: Month 1 is Jan 15 - Feb 14. + const ledger = [ + entry('2026-01-15T00:00:00Z', 5), + entry('2026-02-10T00:00:00Z', 8), // still inside Month 1 + entry('2026-02-20T00:00:00Z', 12), // inside Month 2 + ]; + + const months = computeMonthlyUsage(ledger, d('2026-01-15T00:00:00Z'), d('2026-03-01T00:00:00Z')); + + const m1 = months[0]; + expect(m1.windowStart).toEqual(d('2026-01-15T00:00:00Z')); + expect(m1.windowEnd).toEqual(d('2026-02-15T00:00:00Z')); + expect(m1.peakProvisioned).toBe(8); // the Feb 10 bump falls in Month 1 + + const m2 = months[1]; + expect(m2.windowStart).toEqual(d('2026-02-15T00:00:00Z')); + expect(m2.peakProvisioned).toBe(12); // the Feb 20 bump falls in Month 2 + expect(m2.peakAt).toEqual(d('2026-02-20T00:00:00Z')); + }); + + test('clamps day-of-month when the start day overflows a shorter month', () => { + // Start Jan 31: Month 1 ends Feb 28 (2026 is not a leap year). + const ledger = [entry('2026-01-31T00:00:00Z', 3)]; + + const months = computeMonthlyUsage(ledger, d('2026-01-31T00:00:00Z'), d('2026-03-31T00:00:00Z')); + + expect(months[0].windowStart).toEqual(d('2026-01-31T00:00:00Z')); + expect(months[0].windowEnd).toEqual(d('2026-02-28T00:00:00Z')); + // Month 2 runs Feb 28 -> Mar 31 (the anchor day returns once the month is long enough). + expect(months[1].windowStart).toEqual(d('2026-02-28T00:00:00Z')); + expect(months[1].windowEnd).toEqual(d('2026-03-31T00:00:00Z')); + }); + + test('flags the in-progress Month as incomplete', () => { + const ledger = [entry('2026-01-01T00:00:00Z', 4)]; + + // asOf falls inside Month 2. + const months = computeMonthlyUsage(ledger, d('2026-01-01T00:00:00Z'), d('2026-02-10T00:00:00Z')); + + expect(months).toHaveLength(2); + expect(months[0].isComplete).toBe(true); + expect(months[1].isComplete).toBe(false); + }); + + test('does not generate Months beyond the one containing asOf', () => { + const ledger = [entry('2026-01-01T00:00:00Z', 4)]; + const months = computeMonthlyUsage(ledger, d('2026-01-01T00:00:00Z'), d('2026-01-15T00:00:00Z')); + expect(months).toHaveLength(1); + }); + + test('treats events exactly on the boundary as belonging to the new Month', () => { + const ledger = [ + entry('2026-01-05T00:00:00Z', 10), + entry('2026-02-01T00:00:00Z', 20), // exactly on the Month 1/2 boundary + ]; + + const months = computeMonthlyUsage(ledger, d('2026-01-01T00:00:00Z'), d('2026-02-15T00:00:00Z')); + + // The boundary event is excluded from Month 1 (window end is exclusive)... + expect(months[0].peakProvisioned).toBe(10); + // ...and included in Month 2. + expect(months[1].peakProvisioned).toBe(20); + expect(months[1].peakAt).toEqual(d('2026-02-01T00:00:00Z')); + }); + + test('reports 0 for a Month entirely before any ledger history', () => { + // Subscription started Jan 1 but the ledger only begins (backfill) Feb 10. + const ledger = [entry('2026-02-10T00:00:00Z', 7)]; + + const months = computeMonthlyUsage(ledger, d('2026-01-01T00:00:00Z'), d('2026-02-28T00:00:00Z')); + + expect(months[0].peakProvisioned).toBe(0); + expect(months[1].peakProvisioned).toBe(7); + }); + + test('empty ledger yields zero-seat Months', () => { + const months = computeMonthlyUsage([], d('2026-01-01T00:00:00Z'), d('2026-01-20T00:00:00Z')); + expect(months).toHaveLength(1); + expect(months[0].peakProvisioned).toBe(0); + expect(months[0].endProvisioned).toBe(0); + }); +}); diff --git a/packages/web/src/features/billing/seatUsageReport.ts b/packages/web/src/features/billing/seatUsageReport.ts new file mode 100644 index 000000000..074faf42c --- /dev/null +++ b/packages/web/src/features/billing/seatUsageReport.ts @@ -0,0 +1,152 @@ +/** + * Pure logic for the offline-license usage report. + * + * Buckets an org's seat-usage ledger into subscription-anchored "Months" + * (each successive one-month period from the subscription start date, per the + * Order Form's Add-On User terms) and computes, for each Month, the greatest + * number of provisioned seats at any time during that Month — the figure the + * customer reports for reconciliation. + * + * All boundary math is in UTC so the windows are unambiguous regardless of the + * deployment's local timezone. + */ + +export interface SeatUsageLedgerEntry { + /** When the seat count changed to `seatCount`. */ + timestamp: Date; + /** Absolute provisioned-seat count as of `timestamp`. */ + seatCount: number; +} + +export interface MonthlyUsage { + /** 1-based index; Month 1 begins on the subscription start date. */ + monthNumber: number; + /** Inclusive window start (UTC). */ + windowStart: Date; + /** Exclusive window end (UTC). */ + windowEnd: Date; + /** Greatest provisioned-seat count at any time during the window. */ + peakProvisioned: number; + /** When the peak was first in effect within the window. */ + peakAt: Date; + /** Provisioned-seat count in effect at the end of the window. */ + endProvisioned: number; + /** False while the window still extends past `asOf` (Month in progress). */ + isComplete: boolean; +} + +/** + * Adds `k` calendar months to `start` in UTC, clamping the day-of-month to the + * target month's last day (e.g. Jan 31 + 1 month -> Feb 28/29). This matches + * the conventional monthly-anniversary semantics for a subscription that starts + * on a day that doesn't exist in a later month. + */ +const addUtcMonths = (start: Date, k: number): Date => { + const year = start.getUTCFullYear(); + const month = start.getUTCMonth() + k; + const day = start.getUTCDate(); + + // Day 0 of the following month is the last day of the target month. + const lastDayOfTargetMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); + const clampedDay = Math.min(day, lastDayOfTargetMonth); + + return new Date(Date.UTC( + year, + month, + clampedDay, + start.getUTCHours(), + start.getUTCMinutes(), + start.getUTCSeconds(), + start.getUTCMilliseconds(), + )); +}; + +/** + * Seat count in effect immediately before `t`: the seatCount of the latest + * event strictly before `t`, or 0 if none exists (i.e. before the ledger has + * any history for this org). + * + * `events` must be sorted ascending by timestamp. + */ +const seatCountBefore = (events: SeatUsageLedgerEntry[], t: Date): number => { + let value = 0; + for (const event of events) { + if (event.timestamp.getTime() < t.getTime()) { + value = event.seatCount; + } else { + break; + } + } + return value; +}; + +/** + * Computes per-Month usage from a seat-usage ledger. + * + * Produces one entry per subscription Month from `startDate` up to and + * including the Month containing `asOf`. The in-progress Month (if any) is + * included with `isComplete: false`. + * + * The peak for a Month accounts for the seat count carried into the window from + * a prior change (a count set in December is still "in effect" through January + * even with no January events), not just events that occur within the window. + * + * NOTE: a Month entirely before the ledger's first event reports 0 — there is + * no history to derive a count from. In practice the migration backfills a + * baseline row, so this only affects periods predating that backfill. + */ +export const computeMonthlyUsage = ( + ledger: SeatUsageLedgerEntry[], + startDate: Date, + asOf: Date, +): MonthlyUsage[] => { + const events = [...ledger].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); + + const months: MonthlyUsage[] = []; + + let monthNumber = 1; + // Each boundary is computed from `startDate` directly (not by repeatedly + // adding a month) so day-clamping can't accumulate drift across Months. + let windowStart = startDate; + + while (windowStart.getTime() <= asOf.getTime()) { + const windowEnd = addUtcMonths(startDate, monthNumber); + + // The count entering the window — the peak is at least this, since it + // was in effect at the very start of the Month. + const priorValue = seatCountBefore(events, windowStart); + + let peakProvisioned = priorValue; + let peakAt = windowStart; + + for (const event of events) { + const t = event.timestamp.getTime(); + if (t < windowStart.getTime()) { + continue; + } + if (t >= windowEnd.getTime()) { + break; + } + // Strictly greater: keep the earliest moment a given peak is reached. + if (event.seatCount > peakProvisioned) { + peakProvisioned = event.seatCount; + peakAt = event.timestamp; + } + } + + months.push({ + monthNumber, + windowStart, + windowEnd, + peakProvisioned, + peakAt, + endProvisioned: seatCountBefore(events, windowEnd), + isComplete: windowEnd.getTime() <= asOf.getTime(), + }); + + windowStart = windowEnd; + monthNumber += 1; + } + + return months; +}; diff --git a/packages/web/src/features/userManagement/actions.ts b/packages/web/src/features/userManagement/actions.ts index 8a6bc334f..8d5c93b90 100644 --- a/packages/web/src/features/userManagement/actions.ts +++ b/packages/web/src/features/userManagement/actions.ts @@ -2,6 +2,7 @@ import { createAudit } from "@/ee/features/audit/audit"; import { syncWithLighthouse } from "@/features/billing/servicePing"; +import { recordSeatChange } from "@/features/billing/seatUsage"; import InviteUserEmail from "@/emails/inviteUserEmail"; import JoinRequestApprovedEmail from "@/emails/joinRequestApprovedEmail"; import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils"; @@ -122,6 +123,8 @@ const _removeUserFromOrg = async ( } }); + await recordSeatChange(tx, orgId); + return null; }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); diff --git a/packages/web/src/lib/authUtils.ts b/packages/web/src/lib/authUtils.ts index ef0dd6551..b87bcade4 100644 --- a/packages/web/src/lib/authUtils.ts +++ b/packages/web/src/lib/authUtils.ts @@ -8,6 +8,7 @@ import { createAudit } from "@/ee/features/audit/audit"; import { StatusCodes } from "http-status-codes"; import { ErrorCode } from "./errorCodes"; import { syncWithLighthouse } from "@/features/billing/servicePing"; +import { recordSeatChange } from "@/features/billing/seatUsage"; import { hasEntitlement } from "./entitlements"; const logger = createLogger('web-auth-utils'); @@ -83,6 +84,8 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => { } } }); + + await recordSeatChange(tx, SINGLE_TENANT_ORG_ID); }); await createAudit({ @@ -126,12 +129,18 @@ export const onCreateUser = async ({ user }: { user: AuthJsUser }) => { const hasOrgManagement = await hasEntitlement("org-management"); - await __unsafePrisma.userToOrg.create({ - data: { - userId: user.id, - orgId: SINGLE_TENANT_ORG_ID, - role: hasOrgManagement ? OrgRole.MEMBER : OrgRole.OWNER, - } + await __unsafePrisma.$transaction(async (tx) => { + await tx.userToOrg.create({ + data: { + // Non-null: guarded by the `!user.id` throw at the top of + // onCreateUser; the narrowing is lost inside this closure. + userId: user.id!, + orgId: SINGLE_TENANT_ORG_ID, + role: hasOrgManagement ? OrgRole.MEMBER : OrgRole.OWNER, + } + }); + + await recordSeatChange(tx, SINGLE_TENANT_ORG_ID); }); await createAudit({ @@ -238,6 +247,8 @@ export const addUserToOrganization = async (userId: string, orgId: number): Prom update: {}, }); + await recordSeatChange(tx, org.id); + // Delete the account request if it exists since we've added the user to the org const accountRequest = await tx.accountRequest.findUnique({ where: {