diff --git a/apps/web/src/app/admin/api/organizations/hooks.ts b/apps/web/src/app/admin/api/organizations/hooks.ts
index d8ef9ef136..4daa5e535f 100644
--- a/apps/web/src/app/admin/api/organizations/hooks.ts
+++ b/apps/web/src/app/admin/api/organizations/hooks.ts
@@ -152,6 +152,36 @@ export function useAdminOrganizationKiloPassSummary(organizationId: string) {
);
}
+export function useAdminOrganizationServiceFeeExemption(organizationId: string) {
+ const trpc = useTRPC();
+ return useQuery(
+ trpc.organizations.admin.getServiceFeeExemption.queryOptions({
+ organizationId,
+ })
+ );
+}
+
+export function useSetOrganizationServiceFeeExemption() {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ return useMutation(
+ trpc.organizations.admin.setServiceFeeExemption.mutationOptions({
+ onSuccess: (_data, variables) => {
+ void queryClient.invalidateQueries({
+ queryKey: trpc.organizations.admin.getServiceFeeExemption.queryKey({
+ organizationId: variables.organizationId,
+ }),
+ });
+ void queryClient.invalidateQueries({
+ queryKey: trpc.organizations.admin.getDetails.queryKey({
+ organizationId: variables.organizationId,
+ }),
+ });
+ },
+ })
+ );
+}
+
export function useAdminOrganizationHierarchy(organizationId: string, enabled: boolean) {
const trpc = useTRPC();
return useQuery(
diff --git a/apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminDashboard.tsx b/apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminDashboard.tsx
index abb0cba396..8db527e84e 100644
--- a/apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminDashboard.tsx
+++ b/apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminDashboard.tsx
@@ -8,6 +8,7 @@ import { OrganizationAdminCreditTransactions } from './OrganizationAdminCreditTr
import { OrganizationAdminDelete } from './OrganizationAdminDelete';
import { OrganizationAdminCreditGrant } from './OrganizationAdminCreditGrant';
import { OrganizationAdminCreditNullify } from './OrganizationAdminCreditNullify';
+import { OrganizationAdminServiceFeeExemption } from './OrganizationAdminServiceFeeExemption';
import { OrganizationAdminCreatedBy } from './OrganizationAdminCreatedBy';
import { OrganizationAdminHierarchyManagement } from './OrganizationAdminHierarchyManagement';
import { OrganizationAdminKiloPass } from './OrganizationAdminKiloPass';
@@ -66,6 +67,7 @@ export function OrganizationAdminDashboard({ organizationId }: { organizationId:
+
diff --git a/apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminServiceFeeExemption.dialog-state.test.ts b/apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminServiceFeeExemption.dialog-state.test.ts
new file mode 100644
index 0000000000..feab451bcc
--- /dev/null
+++ b/apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminServiceFeeExemption.dialog-state.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from '@jest/globals';
+import {
+ canSubmitServiceFeeExemption,
+ resolveServiceFeeExemptionDialogOpenChange,
+ SERVICE_FEE_EXEMPTION_REASON_MAX_LENGTH,
+ SERVICE_FEE_EXEMPTION_REASON_MIN_LENGTH,
+ shouldBlockServiceFeeExemptionDialogDismiss,
+} from './OrganizationAdminServiceFeeExemption.dialog-state';
+
+describe('resolveServiceFeeExemptionDialogOpenChange', () => {
+ it('ignores close requests while the mutation is pending', () => {
+ expect(
+ resolveServiceFeeExemptionDialogOpenChange({ requestedOpen: false, isMutationPending: true })
+ ).toBeNull();
+ });
+
+ it('ignores reopen requests while the mutation is pending so state is never reset mid-flight', () => {
+ expect(
+ resolveServiceFeeExemptionDialogOpenChange({ requestedOpen: true, isMutationPending: true })
+ ).toBeNull();
+ });
+
+ it('resets the mutation only when the dialog opens while idle', () => {
+ expect(
+ resolveServiceFeeExemptionDialogOpenChange({ requestedOpen: true, isMutationPending: false })
+ ).toEqual({ open: true, resetMutation: true });
+ });
+
+ it('closes without resetting the mutation while idle', () => {
+ expect(
+ resolveServiceFeeExemptionDialogOpenChange({
+ requestedOpen: false,
+ isMutationPending: false,
+ })
+ ).toEqual({ open: false, resetMutation: false });
+ });
+});
+
+describe('shouldBlockServiceFeeExemptionDialogDismiss', () => {
+ it('blocks Escape, overlay pointer-down, and outside interaction only while pending', () => {
+ expect(shouldBlockServiceFeeExemptionDialogDismiss({ isMutationPending: true })).toBe(true);
+ expect(shouldBlockServiceFeeExemptionDialogDismiss({ isMutationPending: false })).toBe(false);
+ });
+});
+
+describe('canSubmitServiceFeeExemption', () => {
+ it('rejects a pending mutation even with a valid reason to prevent duplicates', () => {
+ expect(
+ canSubmitServiceFeeExemption({
+ trimmedReasonLength: SERVICE_FEE_EXEMPTION_REASON_MIN_LENGTH,
+ isMutationPending: true,
+ })
+ ).toBe(false);
+ });
+
+ it('enforces the trimmed reason length bounds while idle', () => {
+ const idle = { isMutationPending: false };
+ expect(
+ canSubmitServiceFeeExemption({
+ trimmedReasonLength: SERVICE_FEE_EXEMPTION_REASON_MIN_LENGTH - 1,
+ ...idle,
+ })
+ ).toBe(false);
+ expect(
+ canSubmitServiceFeeExemption({
+ trimmedReasonLength: SERVICE_FEE_EXEMPTION_REASON_MIN_LENGTH,
+ ...idle,
+ })
+ ).toBe(true);
+ expect(
+ canSubmitServiceFeeExemption({
+ trimmedReasonLength: SERVICE_FEE_EXEMPTION_REASON_MAX_LENGTH,
+ ...idle,
+ })
+ ).toBe(true);
+ expect(
+ canSubmitServiceFeeExemption({
+ trimmedReasonLength: SERVICE_FEE_EXEMPTION_REASON_MAX_LENGTH + 1,
+ ...idle,
+ })
+ ).toBe(false);
+ });
+});
diff --git a/apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminServiceFeeExemption.dialog-state.ts b/apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminServiceFeeExemption.dialog-state.ts
new file mode 100644
index 0000000000..f18ffce1c5
--- /dev/null
+++ b/apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminServiceFeeExemption.dialog-state.ts
@@ -0,0 +1,65 @@
+/**
+ * Pure dialog-state rules for OrganizationAdminServiceFeeExemption, extracted
+ * so the pending-mutation dismiss guards are testable without a DOM (the repo
+ * has no component-test runner).
+ */
+
+// Mirrors ORGANIZATION_SERVICE_FEE_EXEMPTION_REASON_* in
+// @/lib/service-fees/organization-exemptions, which is server-only and cannot
+// be imported from a client component. The router remains the enforcement
+// boundary; these only drive client-side enablement and hints.
+export const SERVICE_FEE_EXEMPTION_REASON_MIN_LENGTH = 3;
+export const SERVICE_FEE_EXEMPTION_REASON_MAX_LENGTH = 500;
+
+export type ServiceFeeExemptionDialogOpenChange = {
+ open: boolean;
+ resetMutation: boolean;
+};
+
+/**
+ * Radix fires onOpenChange for the trigger, Cancel, the close button, Escape,
+ * and overlay pointer-down. While the set-exemption mutation is in flight,
+ * every open/close request must be ignored: closing would discard the pending
+ * UI, and a close-then-reopen would reset the mutation state, clear the
+ * isPending guard, and allow a duplicate mutation.
+ *
+ * Returns null when the request must be ignored, otherwise the next dialog
+ * state. The mutation is reset only on a fresh open so a previous error does
+ * not leak into the next attempt.
+ */
+export function resolveServiceFeeExemptionDialogOpenChange(input: {
+ requestedOpen: boolean;
+ isMutationPending: boolean;
+}): ServiceFeeExemptionDialogOpenChange | null {
+ if (input.isMutationPending) return null;
+ return { open: input.requestedOpen, resetMutation: input.requestedOpen };
+}
+
+/**
+ * Guarding onOpenChange alone is not enough for a controlled dialog: Radix
+ * processes Escape and overlay pointer-down in its own handlers before asking
+ * React, so DialogContent must also preventDefault those events while the
+ * mutation is pending. This predicate drives all three content-level guards
+ * (onEscapeKeyDown, onPointerDownOutside, onInteractOutside).
+ */
+export function shouldBlockServiceFeeExemptionDialogDismiss(input: {
+ isMutationPending: boolean;
+}): boolean {
+ return input.isMutationPending;
+}
+
+/**
+ * Confirm stays inert until the trimmed reason is within the allowed length
+ * and no mutation is in flight, so double-clicks or repeated Enter presses
+ * cannot fire a duplicate mutation.
+ */
+export function canSubmitServiceFeeExemption(input: {
+ trimmedReasonLength: number;
+ isMutationPending: boolean;
+}): boolean {
+ return (
+ !input.isMutationPending &&
+ input.trimmedReasonLength >= SERVICE_FEE_EXEMPTION_REASON_MIN_LENGTH &&
+ input.trimmedReasonLength <= SERVICE_FEE_EXEMPTION_REASON_MAX_LENGTH
+ );
+}
diff --git a/apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminServiceFeeExemption.tsx b/apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminServiceFeeExemption.tsx
new file mode 100644
index 0000000000..fce265cc2f
--- /dev/null
+++ b/apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminServiceFeeExemption.tsx
@@ -0,0 +1,277 @@
+'use client';
+
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui/dialog';
+import { Label } from '@/components/ui/label';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Textarea } from '@/components/ui/textarea';
+import {
+ useAdminOrganizationServiceFeeExemption,
+ useSetOrganizationServiceFeeExemption,
+} from '@/app/admin/api/organizations/hooks';
+import { Receipt } from 'lucide-react';
+import { useState } from 'react';
+import { toast } from 'sonner';
+import {
+ canSubmitServiceFeeExemption,
+ resolveServiceFeeExemptionDialogOpenChange,
+ SERVICE_FEE_EXEMPTION_REASON_MAX_LENGTH,
+ SERVICE_FEE_EXEMPTION_REASON_MIN_LENGTH,
+ shouldBlockServiceFeeExemptionDialogDismiss,
+} from './OrganizationAdminServiceFeeExemption.dialog-state';
+
+function formatLocalTimestamp(isoTimestamp: string): string {
+ return new Date(isoTimestamp).toLocaleString(undefined, {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ });
+}
+
+function ExemptionStateBadge({ isExempt }: { isExempt: boolean }) {
+ return (
+
+ {isExempt ? 'Exempt' : 'Fees apply'}
+
+ );
+}
+
+export function OrganizationAdminServiceFeeExemption({
+ organizationId,
+}: {
+ organizationId: string;
+}) {
+ const exemptionQuery = useAdminOrganizationServiceFeeExemption(organizationId);
+ const setExemptionMutation = useSetOrganizationServiceFeeExemption();
+
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+ const [reason, setReason] = useState('');
+
+ if (exemptionQuery.isPending) {
+ return
;
+ }
+
+ if (exemptionQuery.isError) {
+ return (
+
+
+ Service fee exemption
+ Unable to load the organization service fee exemption.
+
+
+ void exemptionQuery.refetch()}>
+ Retry
+
+
+
+ );
+ }
+
+ const { current, history } = exemptionQuery.data;
+ const isExempt = current?.isExempt ?? false;
+ const actionLabel = isExempt ? 'Revoke exemption' : 'Grant exemption';
+ const pendingLabel = isExempt ? 'Revoking exemption…' : 'Granting exemption…';
+ const isMutationPending = setExemptionMutation.isPending;
+ const trimmedReasonLength = reason.trim().length;
+ const canSubmit = canSubmitServiceFeeExemption({ trimmedReasonLength, isMutationPending });
+ const blockDismiss = shouldBlockServiceFeeExemptionDialogDismiss({ isMutationPending });
+
+ const handleConfirm = () => {
+ if (!canSubmit) return;
+
+ setExemptionMutation.mutate(
+ {
+ organizationId,
+ isExempt: !isExempt,
+ reason,
+ },
+ {
+ onSuccess: () => {
+ toast.success(
+ isExempt ? 'Service fee exemption revoked' : 'Service fee exemption granted'
+ );
+ setReason('');
+ setIsDialogOpen(false);
+ },
+ // On error the dialog stays open and the reason is kept so the admin
+ // can retry without retyping.
+ }
+ );
+ };
+
+ return (
+
+
+
+
+
+
+ Service fee exemption
+
+
+ Waive Stripe service fees on this organization's new purchases. Exemptions are
+ not inherited by parent or child organizations.
+
+
+
+
+
+
+ {current ? (
+
+
Current reason
+
{current.reason}
+
+ By{' '}
+
+ {current.changedByKiloUserId ?? 'Deleted admin'}
+ {' '}
+ on {formatLocalTimestamp(current.createdAt)}
+
+
+ ) : null}
+
+ {
+ // While the mutation is in flight every close/reopen request
+ // (Cancel, close button, Escape, overlay) is ignored so the
+ // dialog cannot be dismissed, reset, or reopened mid-request.
+ const next = resolveServiceFeeExemptionDialogOpenChange({
+ requestedOpen,
+ isMutationPending,
+ });
+ if (!next) return;
+ if (next.resetMutation) {
+ setExemptionMutation.reset();
+ }
+ setIsDialogOpen(next.open);
+ }}
+ >
+
+
+ {actionLabel}
+
+
+ {
+ if (blockDismiss) event.preventDefault();
+ }}
+ onPointerDownOutside={event => {
+ if (blockDismiss) event.preventDefault();
+ }}
+ onInteractOutside={event => {
+ if (blockDismiss) event.preventDefault();
+ }}
+ >
+
+
+ {isExempt ? 'Revoke service fee exemption' : 'Grant service fee exemption'}
+
+
+ {isExempt
+ ? 'Stripe service fees apply to this organization’s new purchases again.'
+ : 'New purchases by this organization skip the Stripe service fee.'}{' '}
+ The reason is recorded in the admin-only exemption history.
+
+
+
+
+
+
+ setIsDialogOpen(false)}
+ disabled={isMutationPending}
+ >
+ Cancel
+
+
+ {isMutationPending ? pendingLabel : actionLabel}
+
+
+
+
+
+
+
History
+ {history.length === 0 ? (
+
No exemption changes yet.
+ ) : (
+
+ {history.map(entry => (
+
+
+
+
+
+ {formatLocalTimestamp(entry.createdAt)}
+
+
+
{entry.reason}
+
+ {entry.changedByKiloUserId ?? 'Deleted admin'}
+
+
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/src/app/admin/components/RevenueDailyChart.tsx b/apps/web/src/app/admin/components/RevenueDailyChart.tsx
index 79487a3bba..e3843ad33c 100644
--- a/apps/web/src/app/admin/components/RevenueDailyChart.tsx
+++ b/apps/web/src/app/admin/components/RevenueDailyChart.tsx
@@ -21,6 +21,69 @@ type ApiResponse = {
showFreeCredits: boolean;
};
+type ChartDataPoint = {
+ day: string;
+ paidRevenue: number;
+ freeCredits: number;
+ multipliedRevenue: number;
+ unmultipliedRevenue: number;
+ paidPercentage: number;
+ productRevenue: number;
+ serviceFee: number;
+ missedFees: number;
+ exemptedFees: number;
+ disputedFees: number;
+};
+
+type TooltipPayload = {
+ payload: ChartDataPoint;
+ dataKey: string;
+ value: number;
+ name: string;
+ color: string;
+};
+
+type CustomTooltipProps = {
+ active?: boolean;
+ payload?: TooltipPayload[];
+ label?: string;
+};
+
+function RevenueTooltip({ active, payload, label }: CustomTooltipProps) {
+ if (!active || !payload?.length) return null;
+
+ const data = payload[0]?.payload;
+ if (!data) return null;
+
+ return (
+
+
{label}
+
+ {[
+ ['Legacy Gross', data.paidRevenue],
+ ['Free Credits', data.freeCredits],
+ ['Multiplied Legacy Gross', data.multipliedRevenue],
+ ['Unmultiplied Legacy Gross', data.unmultipliedRevenue],
+ ['Product Revenue (net)', data.productRevenue],
+ ['Service Fees (net)', data.serviceFee],
+ ['Missed Fees', data.missedFees],
+ ['Exempted Fees', data.exemptedFees],
+ ['Disputed Fees', data.disputedFees],
+ ].map(([name, value]) => (
+
+ {name}: {' '}
+ {formatDollars(value as number)}
+
+ ))}
+
+ Paid Credits %: {' '}
+ {data.paidPercentage.toFixed(1)}%
+
+
+
+ );
+}
+
export function RevenueDailyChart({ data, showFreeCredits }: ApiResponse) {
const downloadData = () => {
if (data.length === 0) return;
@@ -46,7 +109,7 @@ export function RevenueDailyChart({ data, showFreeCredits }: ApiResponse) {
};
// Transform data for the chart
- const chartData = data.map(item => {
+ const chartData: ChartDataPoint[] = data.map(item => {
const paidRevenue = item.paid_total_dollars;
const freeCredits = item.free_total_dollars;
const totalCredits = paidRevenue + freeCredits;
@@ -56,86 +119,32 @@ export function RevenueDailyChart({ data, showFreeCredits }: ApiResponse) {
day: format(parseISO(item.transaction_day), 'MM/dd'),
paidRevenue: paidRevenue,
freeCredits: freeCredits,
- multipliedRevenue: item.multiplied_total_dollars || 0,
+ multipliedRevenue: item.multiplied_total_dollars,
unmultipliedRevenue: item.unmultiplied_total_dollars,
paidPercentage: paidPercentage,
+ productRevenue: item.product_revenue_dollars,
+ serviceFee: item.collected_service_fee_dollars,
+ missedFees: item.missed_service_fee_dollars,
+ exemptedFees: item.exempted_service_fee_dollars,
+ disputedFees: item.disputed_service_fee_dollars,
};
});
- // Custom tooltip with type-safe access to raw data
- type ChartDataPoint = {
- day: string;
- paidRevenue: number;
- freeCredits: number;
- multipliedRevenue: number;
- unmultipliedRevenue: number;
- paidPercentage: number;
- };
-
- type TooltipPayload = {
- payload: ChartDataPoint;
- dataKey: string;
- value: number;
- name: string;
- color: string;
- };
-
- type CustomTooltipProps = {
- active?: boolean;
- payload?: TooltipPayload[];
- label?: string;
- };
-
- const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => {
- if (active && payload && payload.length > 0) {
- // Access the raw data object directly - much more type-safe!
- const data = payload[0]?.payload;
-
- if (!data) return null;
-
- return (
-
-
{label}
-
-
- Paid Revenue: {' '}
- {formatDollars(data.paidRevenue)}
-
-
- Free Credits: {' '}
- {formatDollars(data.freeCredits)}
-
-
- Multiplied Revenue: {' '}
- {formatDollars(data.multipliedRevenue)}
-
-
- Unmultiplied Revenue: {' '}
- {formatDollars(data.unmultipliedRevenue)}
-
-
- Paid Credits %: {' '}
- {data.paidPercentage.toFixed(1)}%
-
-
-
- );
- }
- return null;
- };
-
// Calculate dynamic Y-axis domain for better visualization
- const maxRevenue = Math.max(
- ...chartData.map(d =>
- Math.max(
- d.paidRevenue,
- showFreeCredits ? d.freeCredits : 0,
- d.multipliedRevenue,
- d.unmultipliedRevenue
- )
- )
- );
- const yAxisMax = Math.ceil(maxRevenue * 1.1); // Add 10% padding
+ const maxRevenue =
+ chartData.length > 0
+ ? Math.max(
+ ...chartData.map(d =>
+ Math.max(
+ d.paidRevenue,
+ showFreeCredits ? d.freeCredits : 0,
+ d.productRevenue + d.serviceFee,
+ d.missedFees + d.exemptedFees + d.disputedFees
+ )
+ )
+ )
+ : 0;
+ const yAxisMax = Math.max(Math.ceil(maxRevenue * 1.1), 1); // Add 10% padding
return (
@@ -143,7 +152,11 @@ export function RevenueDailyChart({ data, showFreeCredits }: ApiResponse) {
Daily Revenue Trend
- Daily breakdown of revenue metrics
+
+ Legacy gross by credit-transaction date; net assessment rows by settled date (UTC),
+ including New Kilo Pass revenue. Missed, exempted, and disputed fees are expected or
+ withdrawn fee amounts, shown as subdued series.
+
-
-
-
-
-
- `$${value.toFixed(0)}`}
- />
- `${value.toFixed(0)}%`}
- />
- } />
-
-
-
- {showFreeCredits && (
+ {chartData.length === 0 ? (
+ No revenue data for this range.
+ ) : (
+
+
+
+
+
+ `$${value.toFixed(0)}`}
+ />
+ `${value.toFixed(0)}%`}
+ />
+ } />
+
- )}
-
-
-
-
+
+ {showFreeCredits && (
+
+ )}
+
+
+
+
+
+
+
+
+
+ )}
);
diff --git a/apps/web/src/app/admin/components/RevenueStats.tsx b/apps/web/src/app/admin/components/RevenueStats.tsx
index 003a95c623..1d9ab0a4eb 100644
--- a/apps/web/src/app/admin/components/RevenueStats.tsx
+++ b/apps/web/src/app/admin/components/RevenueStats.tsx
@@ -9,161 +9,297 @@ type ApiResponse = {
data: RevenueKpiData[];
};
+type NumericKey = Exclude;
+
+function sumKey(data: RevenueKpiData[], key: NumericKey): number {
+ return data.reduce((acc, item) => acc + item[key], 0);
+}
+
export function RevenueStats({ data }: ApiResponse) {
- const latestData = data[data.length - 1];
+ if (data.length === 0) {
+ return (
+
+
+ No revenue data for this range.
+
+
+ );
+ }
- const totals = data.reduce(
- (acc, item) => ({
- paid_total_dollars: acc.paid_total_dollars + item.paid_total_dollars,
- free_total_dollars: acc.free_total_dollars + item.free_total_dollars,
- multiplied_total_dollars: acc.multiplied_total_dollars + item.multiplied_total_dollars,
- unmultiplied_total_dollars: acc.unmultiplied_total_dollars + item.unmultiplied_total_dollars,
- paid_transaction_count: acc.paid_transaction_count + item.paid_transaction_count,
- free_transaction_count: acc.free_transaction_count + item.free_transaction_count,
- multiplied_transaction_count:
- acc.multiplied_transaction_count + item.multiplied_transaction_count,
- unmultiplied_transaction_count:
- acc.unmultiplied_transaction_count + item.unmultiplied_transaction_count,
- }),
- {
- paid_total_dollars: 0,
- free_total_dollars: 0,
- multiplied_total_dollars: 0,
- unmultiplied_total_dollars: 0,
- paid_transaction_count: 0,
- free_transaction_count: 0,
- multiplied_transaction_count: 0,
- unmultiplied_transaction_count: 0,
- }
- );
+ const latestData = data[data.length - 1];
+ const latestDay = format(parseISO(latestData.transaction_day), 'yyyy-MM-dd');
- const averages =
- data.length > 0
- ? {
- paid_total_dollars: totals.paid_total_dollars / data.length,
- free_total_dollars: totals.free_total_dollars / data.length,
- multiplied_total_dollars: totals.multiplied_total_dollars / data.length,
- unmultiplied_total_dollars: totals.unmultiplied_total_dollars / data.length,
- paid_transaction_count: totals.paid_transaction_count / data.length,
- free_transaction_count: totals.free_transaction_count / data.length,
- multiplied_transaction_count: totals.multiplied_transaction_count / data.length,
- unmultiplied_transaction_count: totals.unmultiplied_transaction_count / data.length,
- }
- : {
- paid_total_dollars: 0,
- free_total_dollars: 0,
- multiplied_total_dollars: 0,
- unmultiplied_total_dollars: 0,
- paid_transaction_count: 0,
- free_transaction_count: 0,
- multiplied_transaction_count: 0,
- unmultiplied_transaction_count: 0,
- };
+ const totals = (key: NumericKey) => sumKey(data, key);
+ const averages = (key: NumericKey) => sumKey(data, key) / data.length;
return (
-
-
-
-
-
- Period
-
- Paid Revenue
-
-
- Free Credits
-
-
- Multiplied Revenue
-
-
- Unmultiplied Revenue
-
- Paid Tx
- Free Tx
-
- Multiplied Tx
-
-
- Unmultiplied Tx
-
-
-
-
-
-
- Latest day ({format(parseISO(latestData.transaction_day), 'yyyy-MM-dd')})
-
-
- {formatDollars(latestData.paid_total_dollars)}
-
-
- {formatDollars(latestData.free_total_dollars)}
-
-
- {formatDollars(latestData.multiplied_total_dollars ?? 0)}
-
-
- {formatDollars(latestData.unmultiplied_total_dollars)}
-
- {latestData.paid_transaction_count}
- {latestData.free_transaction_count}
-
- {latestData.multiplied_transaction_count ?? 0}
-
-
- {latestData.unmultiplied_transaction_count}
-
-
+
+
+
+
+
+
+ Period
+
+ Legacy Gross
+
+
+ Free Credits
+
+
+ Multiplied Legacy Gross
+
+
+ Unmultiplied Legacy Gross
+
+
+ Paid Tx
+
+
+ Free Tx
+
+
+ Multiplied Tx
+
+
+ Unmultiplied Tx
+
+
+
+
+
+ Latest day ({latestDay})
+
+ {formatDollars(latestData.paid_total_dollars)}
+
+
+ {formatDollars(latestData.free_total_dollars)}
+
+
+ {formatDollars(latestData.multiplied_total_dollars)}
+
+
+ {formatDollars(latestData.unmultiplied_total_dollars)}
+
+
+ {latestData.paid_transaction_count}
+
+
+ {latestData.free_transaction_count}
+
+
+ {latestData.multiplied_transaction_count}
+
+
+ {latestData.unmultiplied_transaction_count}
+
+
+
+
+
+ Total ({data.length} {data.length === 1 ? 'day' : 'days'})
+
+
+ {formatDollars(totals('paid_total_dollars'))}
+
+
+ {formatDollars(totals('free_total_dollars'))}
+
+
+ {formatDollars(totals('multiplied_total_dollars'))}
+
+
+ {formatDollars(totals('unmultiplied_total_dollars'))}
+
+
+ {totals('paid_transaction_count')}
+
+
+ {totals('free_transaction_count')}
+
+
+ {totals('multiplied_transaction_count')}
+
+
+ {totals('unmultiplied_transaction_count')}
+
+
+
+
+ Average per day
+
+ {formatDollars(averages('paid_total_dollars'))}
+
+
+ {formatDollars(averages('free_total_dollars'))}
+
+
+ {formatDollars(averages('multiplied_total_dollars'))}
+
+
+ {formatDollars(averages('unmultiplied_total_dollars'))}
+
+
+ {averages('paid_transaction_count').toFixed(1)}
+
+
+ {averages('free_transaction_count').toFixed(1)}
+
+
+ {averages('multiplied_transaction_count').toFixed(1)}
+
+
+ {averages('unmultiplied_transaction_count').toFixed(1)}
+
+
+
+
+
+
+ Legacy gross is the raw credit-transaction total with no refund or dispute adjustment.
+ Top-ups with a settled service fee assessment are excluded here and reported as net
+ assessment rows below.
+
+
+
+
+
Net assessment rows (settled date, UTC)
+
+
+
+
+ Period
+
+ Product Revenue
+
+
+ Service Fees
+
+
+ Gross Revenue
+
+
+ Missed Fees
+
+
+ Exempted Fees
+
+
+ Disputed Fees
+
+
+ Collected
+
+ Missed
+ Exempt
+
+
+
+
+ Latest day ({latestDay} UTC)
+
+ {formatDollars(latestData.product_revenue_dollars)}
+
+
+ {formatDollars(latestData.collected_service_fee_dollars)}
+
+
+ {formatDollars(latestData.gross_revenue_dollars)}
+
+
+ {formatDollars(latestData.missed_service_fee_dollars)}
+
+
+ {formatDollars(latestData.exempted_service_fee_dollars)}
+
+
+ {formatDollars(latestData.disputed_service_fee_dollars)}
+
+
+ {latestData.service_fee_collected_count}
+
+
+ {latestData.service_fee_missed_count}
+
+
+ {latestData.service_fee_exempt_count}
+
+
-
-
- Total ({data.length} {data.length === 1 ? 'day' : 'days'})
-
- {formatDollars(totals.paid_total_dollars)}
- {formatDollars(totals.free_total_dollars)}
-
- {formatDollars(totals.multiplied_total_dollars)}
-
-
- {formatDollars(totals.unmultiplied_total_dollars)}
-
- {totals.paid_transaction_count}
- {totals.free_transaction_count}
- {totals.multiplied_transaction_count}
- {totals.unmultiplied_transaction_count}
-
+
+
+ Total ({data.length} {data.length === 1 ? 'day' : 'days'})
+
+
+ {formatDollars(totals('product_revenue_dollars'))}
+
+
+ {formatDollars(totals('collected_service_fee_dollars'))}
+
+
+ {formatDollars(totals('gross_revenue_dollars'))}
+
+
+ {formatDollars(totals('missed_service_fee_dollars'))}
+
+
+ {formatDollars(totals('exempted_service_fee_dollars'))}
+
+
+ {formatDollars(totals('disputed_service_fee_dollars'))}
+
+
+ {totals('service_fee_collected_count')}
+
+
+ {totals('service_fee_missed_count')}
+
+
+ {totals('service_fee_exempt_count')}
+
+
-
- Average per day
-
- {formatDollars(averages.paid_total_dollars)}
-
-
- {formatDollars(averages.free_total_dollars)}
-
-
- {formatDollars(averages.multiplied_total_dollars)}
-
-
- {formatDollars(averages.unmultiplied_total_dollars)}
-
-
- {averages.paid_transaction_count.toFixed(1)}
-
-
- {averages.free_transaction_count.toFixed(1)}
-
-
- {averages.multiplied_transaction_count.toFixed(1)}
-
-
- {averages.unmultiplied_transaction_count.toFixed(1)}
-
-
-
-
+
+ Average per day
+
+ {formatDollars(averages('product_revenue_dollars'))}
+
+
+ {formatDollars(averages('collected_service_fee_dollars'))}
+
+
+ {formatDollars(averages('gross_revenue_dollars'))}
+
+
+ {formatDollars(averages('missed_service_fee_dollars'))}
+
+
+ {formatDollars(averages('exempted_service_fee_dollars'))}
+
+
+ {formatDollars(averages('disputed_service_fee_dollars'))}
+
+
+ {averages('service_fee_collected_count').toFixed(1)}
+
+
+ {averages('service_fee_missed_count').toFixed(1)}
+
+
+ {averages('service_fee_exempt_count').toFixed(1)}
+
+
+
+
+
+
+ Net assessment rows are settled service fee assessments net of refunds and disputes,
+ grouped by settled date (UTC). They include New Kilo Pass revenue, which the legacy
+ series never contained, so the two tables do not reconcile row-for-row. Missed and
+ exempted fees are the expected fee on settled payments where no fee was collected;
+ disputed fees are the fee portion withdrawn by chargebacks.
+
diff --git a/apps/web/src/app/admin/revenue/page.tsx b/apps/web/src/app/admin/revenue/page.tsx
index 99c8def7b5..40cc032326 100644
--- a/apps/web/src/app/admin/revenue/page.tsx
+++ b/apps/web/src/app/admin/revenue/page.tsx
@@ -8,6 +8,7 @@ import type { RevenueKpiResponse } from '@/lib/revenueKpi';
import { format, subDays } from 'date-fns';
import AdminPage from '@/app/admin/components/AdminPage';
import { BreadcrumbItem, BreadcrumbPage } from '@/components/ui/breadcrumb';
+import { revenueDashboardStatus } from '@/app/admin/revenue/revenue-dashboard-status';
const breadcrumbs = (
<>
@@ -60,6 +61,7 @@ export default function RevenuePage() {
refetchInterval: 60000,
});
+ const status = revenueDashboardStatus({ isLoading, error, data });
const multiplierCategoriesNote = data?.multiplierCategories ? (
Multiplier Categories
@@ -69,51 +71,6 @@ export default function RevenuePage() {
) : null;
- if (isLoading) {
- return (
-
-
-
-
Revenue KPI Dashboard
-
-
-
- This dashboard provides insights into revenue metrics, trends, and performance
- indicators.
-
-
-
Loading...
-
-
- );
- }
-
- if (error || !data || !data.data.length) {
- return (
-
-
-
-
Revenue KPI Dashboard
-
-
-
- This dashboard provides insights into revenue metrics, trends, and performance
- indicators.
-
-
-
- Error:{' '}
- {error instanceof Error
- ? error.message
- : !data
- ? 'Response missing'
- : 'An error occurred'}
-
-
-
- );
- }
-
return (
@@ -225,8 +182,28 @@ export default function RevenuePage() {
{multiplierCategoriesNote}
-
-
+ {status === 'loading' ?
Loading...
: null}
+ {status === 'error' ? (
+
+ Error:{' '}
+ {error instanceof Error
+ ? error.message
+ : !data
+ ? 'Response missing'
+ : 'An error occurred'}
+
+ ) : null}
+ {status === 'empty' ? (
+
+ No revenue in this range. Choose Custom to include today; presets end yesterday.
+
+ ) : null}
+ {status === 'ready' && data ? (
+ <>
+
+
+ >
+ ) : null}
);
diff --git a/apps/web/src/app/admin/revenue/revenue-dashboard-status.test.ts b/apps/web/src/app/admin/revenue/revenue-dashboard-status.test.ts
new file mode 100644
index 0000000000..5818685e3b
--- /dev/null
+++ b/apps/web/src/app/admin/revenue/revenue-dashboard-status.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, test } from '@jest/globals';
+import { revenueDashboardStatus } from './revenue-dashboard-status';
+import type { RevenueKpiResponse } from '@/lib/revenueKpi';
+
+const emptyResponse = { data: [] } as unknown as RevenueKpiResponse;
+const readyResponse = { data: [{ date: '2026-08-11' }] } as unknown as RevenueKpiResponse;
+
+describe('revenueDashboardStatus', () => {
+ test('treats an empty daily series as empty, not an error', () => {
+ expect(
+ revenueDashboardStatus({
+ isLoading: false,
+ error: null,
+ data: emptyResponse,
+ })
+ ).toBe('empty');
+ });
+
+ test('keeps fetch failures as errors', () => {
+ expect(
+ revenueDashboardStatus({
+ isLoading: false,
+ error: new Error('Failed to fetch daily revenue statistics'),
+ data: undefined,
+ })
+ ).toBe('error');
+ });
+
+ test('treats a missing response without an error as loading', () => {
+ expect(
+ revenueDashboardStatus({
+ isLoading: false,
+ error: null,
+ data: undefined,
+ })
+ ).toBe('loading');
+ });
+
+ test('is ready when the series has rows', () => {
+ expect(
+ revenueDashboardStatus({
+ isLoading: false,
+ error: null,
+ data: readyResponse,
+ })
+ ).toBe('ready');
+ });
+});
diff --git a/apps/web/src/app/admin/revenue/revenue-dashboard-status.ts b/apps/web/src/app/admin/revenue/revenue-dashboard-status.ts
new file mode 100644
index 0000000000..962aa9db9d
--- /dev/null
+++ b/apps/web/src/app/admin/revenue/revenue-dashboard-status.ts
@@ -0,0 +1,15 @@
+import type { RevenueKpiResponse } from '@/lib/revenueKpi';
+
+export type RevenueDashboardStatus = 'loading' | 'error' | 'empty' | 'ready';
+
+export function revenueDashboardStatus(input: {
+ isLoading: boolean;
+ error: unknown;
+ data: RevenueKpiResponse | undefined;
+}): RevenueDashboardStatus {
+ if (input.isLoading) return 'loading';
+ if (input.error) return 'error';
+ if (!input.data) return 'loading';
+ if (input.data.data.length === 0) return 'empty';
+ return 'ready';
+}
diff --git a/apps/web/src/lib/revenueKpi.test.ts b/apps/web/src/lib/revenueKpi.test.ts
new file mode 100644
index 0000000000..2c1d27a8da
--- /dev/null
+++ b/apps/web/src/lib/revenueKpi.test.ts
@@ -0,0 +1,257 @@
+import { beforeEach, describe, expect, test } from '@jest/globals';
+import {
+ credit_transactions,
+ organization_service_fee_exemptions,
+ stripe_service_fee_assessments,
+} from '@kilocode/db/schema';
+import { format } from 'date-fns';
+
+import { cleanupDbForTest, db } from '@/lib/drizzle';
+import { createOrganization } from '@/lib/organizations/organizations';
+import { getRevenueKpiData, type RevenueKpiData } from '@/lib/revenueKpi';
+import { SERVICE_FEE_VERSION } from '@/lib/service-fees/constants';
+import { insertTestUser } from '@/tests/helpers/user.helper';
+
+beforeEach(async () => {
+ await cleanupDbForTest();
+});
+
+function dayKey(row: RevenueKpiData): string {
+ // node-pg parses `::date` columns into Date objects at runtime even though
+ // RevenueKpiData types transaction_day as a string.
+ const value = row.transaction_day as unknown;
+ return value instanceof Date ? format(value, 'yyyy-MM-dd') : String(value).slice(0, 10);
+}
+
+function baseAssessment(
+ overrides: Partial
+): typeof stripe_service_fee_assessments.$inferInsert {
+ return {
+ assessment_key: `test-${crypto.randomUUID()}`,
+ version: SERVICE_FEE_VERSION,
+ flow: 'personal_top_up',
+ outcome: 'charged',
+ currency: 'usd',
+ eligibility_created_at: '2025-01-10T09:00:00.000Z',
+ eligible_subtotal_minor: 10000,
+ expected_fee_minor: 500,
+ ...overrides,
+ };
+}
+
+describe('getRevenueKpiData service fee reporting', () => {
+ test('counts settled assessments only, nets refunds and disputes, and does not double count matched credit transactions', async () => {
+ const user = await insertTestUser();
+ const admin = await insertTestUser();
+ const organization = await createOrganization(`Org ${crypto.randomUUID()}`, admin.id);
+ const [exemption] = await db
+ .insert(organization_service_fee_exemptions)
+ .values({
+ organization_id: organization.id,
+ is_exempt: true,
+ reason: 'Founding partner exemption',
+ })
+ .returning();
+
+ await db.insert(stripe_service_fee_assessments).values([
+ // Day 2025-01-10 (UTC): plain charged settlement.
+ baseAssessment({
+ kilo_user_id: user.id,
+ stripe_charge_id: 'ch_matched',
+ charged_fee_minor: 500,
+ gross_paid_minor: 10500,
+ settled_product_minor: 10000,
+ settled_at: '2025-01-10T10:00:00.000Z',
+ }),
+ // Day 2025-01-11 (UTC): partial refund, plus a fully disputed charge.
+ // 04:30 UTC is still 2025-01-10 in US timezones, pinning UTC grouping.
+ baseAssessment({
+ kilo_user_id: user.id,
+ eligible_subtotal_minor: 20000,
+ expected_fee_minor: 1000,
+ charged_fee_minor: 1000,
+ gross_paid_minor: 21000,
+ settled_product_minor: 20000,
+ refunded_product_minor: 4000,
+ refunded_fee_minor: 200,
+ settled_at: '2025-01-11T04:30:00.000Z',
+ }),
+ baseAssessment({
+ kilo_user_id: user.id,
+ eligible_subtotal_minor: 5000,
+ expected_fee_minor: 250,
+ charged_fee_minor: 250,
+ gross_paid_minor: 5250,
+ settled_product_minor: 5000,
+ disputed_product_minor: 5000,
+ disputed_fee_minor: 250,
+ settled_at: '2025-01-11T06:00:00.000Z',
+ }),
+ // Day 2025-01-12 (UTC): fee missed and fee exempt, but both payments settled.
+ baseAssessment({
+ kilo_user_id: user.id,
+ outcome: 'missed',
+ failure_code: 'stripe_fee_line_attach_failed',
+ settled_product_minor: 10000,
+ gross_paid_minor: 10000,
+ settled_at: '2025-01-12T00:30:00.000Z',
+ }),
+ baseAssessment({
+ flow: 'organization_top_up',
+ kilo_user_id: null,
+ organization_id: organization.id,
+ outcome: 'exempt',
+ exemption_id: exemption.id,
+ eligible_subtotal_minor: 5000,
+ expected_fee_minor: 250,
+ settled_product_minor: 5000,
+ gross_paid_minor: 5000,
+ settled_at: '2025-01-12T01:00:00.000Z',
+ }),
+ // Never settled: must not contribute anywhere.
+ baseAssessment({
+ kilo_user_id: user.id,
+ eligibility_created_at: '2025-01-13T09:00:00.000Z',
+ stripe_invoice_fee_line_item_id: 'il_unsettled_fee_line',
+ charged_fee_minor: 9999,
+ }),
+ ]);
+
+ await db.insert(credit_transactions).values([
+ // Matches the settled assessment charge id: excluded from the legacy paid
+ // series because the assessment is authoritative for the product amount.
+ // Payment-intent matches are covered by a dedicated double-count test.
+ {
+ kilo_user_id: user.id,
+ amount_microdollars: 100_000_000,
+ is_free: false,
+ stripe_payment_id: 'ch_matched',
+ created_at: '2025-01-10T10:05:00.000Z',
+ },
+ // No settled assessment: stays in the legacy paid series.
+ {
+ kilo_user_id: user.id,
+ amount_microdollars: 50_000_000,
+ is_free: false,
+ stripe_payment_id: 'ch_legacy_only',
+ created_at: '2025-01-10T12:00:00.000Z',
+ },
+ ]);
+
+ const { data } = await getRevenueKpiData(false, '2025-01-09', '2025-01-13');
+ const byDay = new Map(data.map(row => [dayKey(row), row]));
+
+ expect([...byDay.keys()]).toEqual(['2025-01-10', '2025-01-11', '2025-01-12']);
+
+ const day10 = byDay.get('2025-01-10');
+ expect(day10).toMatchObject({
+ paid_transaction_count: 1,
+ paid_total_dollars: 50,
+ product_revenue_dollars: 100,
+ collected_service_fee_dollars: 5,
+ gross_revenue_dollars: 105,
+ missed_service_fee_dollars: 0,
+ exempted_service_fee_dollars: 0,
+ disputed_service_fee_dollars: 0,
+ service_fee_collected_count: 1,
+ service_fee_missed_count: 0,
+ service_fee_exempt_count: 0,
+ });
+
+ const day11 = byDay.get('2025-01-11');
+ expect(day11).toMatchObject({
+ paid_transaction_count: 0,
+ paid_total_dollars: 0,
+ product_revenue_dollars: 160,
+ collected_service_fee_dollars: 8,
+ gross_revenue_dollars: 168,
+ disputed_service_fee_dollars: 2.5,
+ service_fee_collected_count: 2,
+ });
+
+ const day12 = byDay.get('2025-01-12');
+ expect(day12).toMatchObject({
+ product_revenue_dollars: 150,
+ collected_service_fee_dollars: 0,
+ gross_revenue_dollars: 150,
+ missed_service_fee_dollars: 5,
+ exempted_service_fee_dollars: 2.5,
+ service_fee_collected_count: 0,
+ service_fee_missed_count: 1,
+ service_fee_exempt_count: 1,
+ });
+ });
+
+ test('excludes a legacy credit transaction matched by settled assessment payment intent', async () => {
+ const user = await insertTestUser();
+
+ await db.insert(stripe_service_fee_assessments).values(
+ baseAssessment({
+ kilo_user_id: user.id,
+ stripe_charge_id: 'ch_org_topup',
+ stripe_invoice_id: 'in_org_topup',
+ stripe_payment_intent_id: 'pi_org_topup',
+ charged_fee_minor: 500,
+ gross_paid_minor: 10500,
+ settled_product_minor: 10000,
+ settled_at: '2025-03-01T10:00:00.000Z',
+ })
+ );
+
+ await db.insert(credit_transactions).values([
+ {
+ kilo_user_id: user.id,
+ amount_microdollars: 100_000_000,
+ is_free: false,
+ stripe_payment_id: 'pi_org_topup',
+ created_at: '2025-03-01T10:05:00.000Z',
+ },
+ {
+ kilo_user_id: user.id,
+ amount_microdollars: 40_000_000,
+ is_free: false,
+ stripe_payment_id: 'pi_legacy_only',
+ created_at: '2025-03-01T12:00:00.000Z',
+ },
+ ]);
+
+ const { data } = await getRevenueKpiData(false, '2025-03-01', '2025-03-01');
+
+ expect(data).toHaveLength(1);
+ expect(data[0]).toMatchObject({
+ paid_transaction_count: 1,
+ paid_total_dollars: 40,
+ product_revenue_dollars: 100,
+ collected_service_fee_dollars: 5,
+ gross_revenue_dollars: 105,
+ });
+ });
+
+ test('returns zero-valued service fee fields for legacy-only ranges', async () => {
+ const user = await insertTestUser();
+
+ await db.insert(credit_transactions).values({
+ kilo_user_id: user.id,
+ amount_microdollars: 25_000_000,
+ is_free: false,
+ stripe_payment_id: 'ch_legacy_only',
+ created_at: '2025-02-01T12:00:00.000Z',
+ });
+
+ const { data } = await getRevenueKpiData(false, '2025-02-01', '2025-02-01');
+
+ expect(data).toHaveLength(1);
+ expect(data[0]).toMatchObject({
+ paid_total_dollars: 25,
+ product_revenue_dollars: 0,
+ collected_service_fee_dollars: 0,
+ gross_revenue_dollars: 0,
+ missed_service_fee_dollars: 0,
+ exempted_service_fee_dollars: 0,
+ disputed_service_fee_dollars: 0,
+ service_fee_collected_count: 0,
+ service_fee_missed_count: 0,
+ service_fee_exempt_count: 0,
+ });
+ });
+});
diff --git a/apps/web/src/lib/revenueKpi.ts b/apps/web/src/lib/revenueKpi.ts
index e502004eed..4fb7cab0f2 100644
--- a/apps/web/src/lib/revenueKpi.ts
+++ b/apps/web/src/lib/revenueKpi.ts
@@ -29,6 +29,17 @@ function getMultiplierCategories(includeFirstTopupCategories: boolean): string[]
}
// Define the result type for our revenue KPI data
+//
+// Two series with deliberately different meaning share one row per day:
+// - Legacy fields (paid/free/multiplied/unmultiplied) come from credit_transactions
+// amount_microdollars grouped by created_at::date with no refund or dispute
+// adjustment. Settled top-ups that have a stripe_service_fee_assessments row
+// (matched on stripe_payment_id = charge, invoice, or payment_intent id) are
+// excluded so their product revenue is not double counted.
+// - Service-fee fields come from settled stripe_service_fee_assessments rows only,
+// grouped by the UTC calendar date of settled_at, and are net of refunds and
+// disputes. They include Kilo Pass product revenue, which never had a credit
+// transaction, so they are not a restatement of the legacy paid series.
export type RevenueKpiData = {
transaction_day: string;
paid_transaction_count: number;
@@ -39,6 +50,15 @@ export type RevenueKpiData = {
multiplied_total_dollars: number;
unmultiplied_transaction_count: number;
unmultiplied_total_dollars: number;
+ product_revenue_dollars: number;
+ collected_service_fee_dollars: number;
+ gross_revenue_dollars: number;
+ missed_service_fee_dollars: number;
+ exempted_service_fee_dollars: number;
+ disputed_service_fee_dollars: number;
+ service_fee_collected_count: number;
+ service_fee_missed_count: number;
+ service_fee_exempt_count: number;
};
export type RevenueKpiResponse = {
@@ -60,7 +80,25 @@ export async function getRevenueKpiData(
): Promise {
const multiplierCategories = getMultiplierCategories(includeFirstTopupCategories);
const query = sql`
- WITH ranked_paid_multiplier_transactions AS (
+ WITH settled_assessment_stripe_ids AS (
+ -- Settled assessments are authoritative for their product amount, so the
+ -- matching credit transaction (linked by charge, invoice, or payment
+ -- intent id, not a FK) must be excluded from the legacy paid series to
+ -- avoid double counting. A single id column lets PostgreSQL hash the
+ -- anti-join instead of scanning assessments once per paid transaction.
+ SELECT sa.stripe_charge_id AS stripe_payment_id
+ FROM public.stripe_service_fee_assessments sa
+ WHERE sa.settled_at IS NOT NULL AND sa.stripe_charge_id IS NOT NULL
+ UNION ALL
+ SELECT sa.stripe_invoice_id AS stripe_payment_id
+ FROM public.stripe_service_fee_assessments sa
+ WHERE sa.settled_at IS NOT NULL AND sa.stripe_invoice_id IS NOT NULL
+ UNION ALL
+ SELECT sa.stripe_payment_intent_id AS stripe_payment_id
+ FROM public.stripe_service_fee_assessments sa
+ WHERE sa.settled_at IS NOT NULL AND sa.stripe_payment_intent_id IS NOT NULL
+ ),
+ ranked_paid_multiplier_transactions AS (
SELECT
pt.*,
ft.id AS free_id,
@@ -81,6 +119,11 @@ export async function getRevenueKpiData(
sql`, `
)})
WHERE pt.is_free = false and pt.amount_microdollars > 0
+ AND NOT EXISTS (
+ SELECT 1
+ FROM settled_assessment_stripe_ids sasi
+ WHERE sasi.stripe_payment_id = pt.stripe_payment_id
+ )
),
paid_but_multiplied_by_date AS (
SELECT
@@ -98,6 +141,11 @@ export async function getRevenueKpiData(
SUM(pt.amount_microdollars) / 1000000.0 AS total_dollars
FROM public.credit_transactions pt
WHERE pt.is_free = false
+ AND NOT EXISTS (
+ SELECT 1
+ FROM settled_assessment_stripe_ids sasi
+ WHERE sasi.stripe_payment_id = pt.stripe_payment_id
+ )
GROUP BY transaction_day
),
free_by_date AS (
@@ -108,9 +156,27 @@ export async function getRevenueKpiData(
FROM public.credit_transactions ft
WHERE ft.is_free = true
GROUP BY transaction_day
+ ),
+ service_fee_by_date AS (
+ -- Only settled rows contribute. Missed and exempt amounts use the expected
+ -- fee so fail-open and exempted payments still surface as leakage once the
+ -- underlying payment succeeds. Amounts are minor units (cents).
+ SELECT
+ (sa.settled_at AT TIME ZONE 'UTC')::date AS transaction_day,
+ SUM(sa.settled_product_minor - sa.refunded_product_minor - sa.disputed_product_minor) / 100.0 AS product_revenue_dollars,
+ SUM(sa.charged_fee_minor - sa.refunded_fee_minor - sa.disputed_fee_minor) / 100.0 AS collected_service_fee_dollars,
+ COALESCE(SUM(sa.expected_fee_minor) FILTER (WHERE sa.outcome = 'missed'), 0) / 100.0 AS missed_service_fee_dollars,
+ COALESCE(SUM(sa.expected_fee_minor) FILTER (WHERE sa.outcome = 'exempt'), 0) / 100.0 AS exempted_service_fee_dollars,
+ SUM(sa.disputed_fee_minor) / 100.0 AS disputed_service_fee_dollars,
+ COUNT(*) FILTER (WHERE sa.outcome = 'charged') AS service_fee_collected_count,
+ COUNT(*) FILTER (WHERE sa.outcome = 'missed') AS service_fee_missed_count,
+ COUNT(*) FILTER (WHERE sa.outcome = 'exempt') AS service_fee_exempt_count
+ FROM public.stripe_service_fee_assessments sa
+ WHERE sa.settled_at IS NOT NULL
+ GROUP BY transaction_day
)
SELECT
- COALESCE(pbd.transaction_day, fbd.transaction_day) AS transaction_day,
+ COALESCE(pbd.transaction_day, fbd.transaction_day, sfbd.transaction_day) AS transaction_day,
COALESCE(pbd.transaction_count, 0) AS paid_transaction_count,
COALESCE(pbd.total_dollars, 0) AS paid_total_dollars,
COALESCE(fbd.transaction_count, 0) AS free_transaction_count,
@@ -118,12 +184,22 @@ export async function getRevenueKpiData(
COALESCE(pmbd.transaction_count, 0) AS multiplied_transaction_count,
COALESCE(pmbd.total_dollars, 0) AS multiplied_total_dollars,
COALESCE(pbd.transaction_count, 0) - COALESCE(pmbd.transaction_count, 0) AS unmultiplied_transaction_count,
- COALESCE(pbd.total_dollars, 0) - COALESCE(pmbd.total_dollars, 0) AS unmultiplied_total_dollars
+ COALESCE(pbd.total_dollars, 0) - COALESCE(pmbd.total_dollars, 0) AS unmultiplied_total_dollars,
+ COALESCE(sfbd.product_revenue_dollars, 0) AS product_revenue_dollars,
+ COALESCE(sfbd.collected_service_fee_dollars, 0) AS collected_service_fee_dollars,
+ COALESCE(sfbd.product_revenue_dollars, 0) + COALESCE(sfbd.collected_service_fee_dollars, 0) AS gross_revenue_dollars,
+ COALESCE(sfbd.missed_service_fee_dollars, 0) AS missed_service_fee_dollars,
+ COALESCE(sfbd.exempted_service_fee_dollars, 0) AS exempted_service_fee_dollars,
+ COALESCE(sfbd.disputed_service_fee_dollars, 0) AS disputed_service_fee_dollars,
+ COALESCE(sfbd.service_fee_collected_count, 0) AS service_fee_collected_count,
+ COALESCE(sfbd.service_fee_missed_count, 0) AS service_fee_missed_count,
+ COALESCE(sfbd.service_fee_exempt_count, 0) AS service_fee_exempt_count
FROM paid_by_date pbd
FULL OUTER JOIN free_by_date fbd ON pbd.transaction_day = fbd.transaction_day
- LEFT JOIN paid_but_multiplied_by_date pmbd ON COALESCE(pbd.transaction_day, fbd.transaction_day) = pmbd.transaction_day
- WHERE COALESCE(pbd.transaction_day, fbd.transaction_day) BETWEEN ${startDate}::date AND ${endDate}::date
- ORDER BY COALESCE(pbd.transaction_day, fbd.transaction_day) ASC;
+ FULL OUTER JOIN service_fee_by_date sfbd ON sfbd.transaction_day = COALESCE(pbd.transaction_day, fbd.transaction_day)
+ LEFT JOIN paid_but_multiplied_by_date pmbd ON COALESCE(pbd.transaction_day, fbd.transaction_day, sfbd.transaction_day) = pmbd.transaction_day
+ WHERE COALESCE(pbd.transaction_day, fbd.transaction_day, sfbd.transaction_day) BETWEEN ${startDate}::date AND ${endDate}::date
+ ORDER BY COALESCE(pbd.transaction_day, fbd.transaction_day, sfbd.transaction_day) ASC;
`;
const result = await db.execute(query);
@@ -138,6 +214,15 @@ export async function getRevenueKpiData(
multiplied_total_dollars: Number(row.multiplied_total_dollars),
unmultiplied_transaction_count: Number(row.unmultiplied_transaction_count),
unmultiplied_total_dollars: Number(row.unmultiplied_total_dollars),
+ product_revenue_dollars: Number(row.product_revenue_dollars),
+ collected_service_fee_dollars: Number(row.collected_service_fee_dollars),
+ gross_revenue_dollars: Number(row.gross_revenue_dollars),
+ missed_service_fee_dollars: Number(row.missed_service_fee_dollars),
+ exempted_service_fee_dollars: Number(row.exempted_service_fee_dollars),
+ disputed_service_fee_dollars: Number(row.disputed_service_fee_dollars),
+ service_fee_collected_count: Number(row.service_fee_collected_count),
+ service_fee_missed_count: Number(row.service_fee_missed_count),
+ service_fee_exempt_count: Number(row.service_fee_exempt_count),
}));
return {
diff --git a/apps/web/src/lib/service-fees/kilo-pass-classification-audit-script.test.ts b/apps/web/src/lib/service-fees/kilo-pass-classification-audit-script.test.ts
new file mode 100644
index 0000000000..c649538c38
--- /dev/null
+++ b/apps/web/src/lib/service-fees/kilo-pass-classification-audit-script.test.ts
@@ -0,0 +1,74 @@
+import { beforeEach, describe, expect, jest, test } from '@jest/globals';
+import type Stripe from 'stripe';
+
+import { client as stripe } from '@/lib/stripe-client';
+import { retrieveStripeSubscriptionSnapshot } from '../../scripts/service-fees/kilo-pass-classification-audit';
+
+function subscriptionItem(id: string): Stripe.SubscriptionItem {
+ return {
+ id,
+ price: { id: `price_${id}`, product: `prod_${id}` },
+ } as Stripe.SubscriptionItem;
+}
+
+function subscription(): Stripe.Subscription {
+ return {
+ id: 'sub_audit',
+ status: 'active',
+ metadata: {},
+ } as Stripe.Subscription;
+}
+
+function itemPage(
+ data: Stripe.SubscriptionItem[],
+ hasMore: boolean
+): Stripe.ApiList {
+ return {
+ object: 'list',
+ data,
+ has_more: hasMore,
+ url: '/v1/subscription_items',
+ };
+}
+
+describe('retrieveStripeSubscriptionSnapshot', () => {
+ const retrieve = jest.spyOn(stripe.subscriptions, 'retrieve');
+ const list = jest.spyOn(stripe.subscriptionItems, 'list');
+
+ beforeEach(() => {
+ retrieve.mockReset();
+ list.mockReset();
+ retrieve.mockResolvedValue(subscription() as never);
+ });
+
+ test('requests up to 100 expanded subscription items', async () => {
+ list.mockResolvedValue(itemPage([subscriptionItem('si_pass')], false) as never);
+
+ await expect(retrieveStripeSubscriptionSnapshot('sub_audit')).resolves.toMatchObject({
+ id: 'sub_audit',
+ items: [{ id: 'si_pass', priceId: 'price_si_pass', productId: 'prod_si_pass' }],
+ });
+ expect(retrieve).toHaveBeenCalledWith('sub_audit');
+ expect(list).toHaveBeenCalledWith({
+ subscription: 'sub_audit',
+ limit: 100,
+ expand: ['data.price'],
+ });
+ });
+
+ test('paginates instead of silently truncating subscription items', async () => {
+ list
+ .mockResolvedValueOnce(itemPage([subscriptionItem('si_1')], true) as never)
+ .mockResolvedValueOnce(itemPage([subscriptionItem('si_2')], false) as never);
+
+ await expect(retrieveStripeSubscriptionSnapshot('sub_audit')).resolves.toMatchObject({
+ items: [{ id: 'si_1' }, { id: 'si_2' }],
+ });
+ expect(list).toHaveBeenNthCalledWith(2, {
+ subscription: 'sub_audit',
+ limit: 100,
+ expand: ['data.price'],
+ starting_after: 'si_1',
+ });
+ });
+});
diff --git a/apps/web/src/lib/service-fees/kilo-pass-classification-audit.test.ts b/apps/web/src/lib/service-fees/kilo-pass-classification-audit.test.ts
new file mode 100644
index 0000000000..1197cfe90f
--- /dev/null
+++ b/apps/web/src/lib/service-fees/kilo-pass-classification-audit.test.ts
@@ -0,0 +1,324 @@
+import { describe, expect, it } from '@jest/globals';
+import {
+ auditKiloPassClassifications,
+ classifyOrganizationKiloPassSubscription,
+ classifyPersonalKiloPassSubscription,
+ evaluateKiloPassClassificationAudit,
+ type OrganizationKiloPassAuditRow,
+ type PersonalKiloPassAuditRow,
+ type StripeSubscriptionSnapshot,
+} from './kilo-pass-classification-audit';
+
+const KILO_PASS_PRICE = 'price_kilo_pass_49';
+const SEAT_PRODUCT = 'prod_seats';
+const knownKiloPassPriceIds = new Set([KILO_PASS_PRICE]);
+const seatProductIds = new Set([SEAT_PRODUCT]);
+
+function personalRow(overrides: Partial = {}): PersonalKiloPassAuditRow {
+ return {
+ id: 'kps_1',
+ kiloUserId: 'user_1',
+ stripeSubscriptionId: 'sub_personal',
+ status: 'active',
+ tier: 'tier_49',
+ cadence: 'monthly',
+ ...overrides,
+ };
+}
+
+function organizationRow(
+ overrides: Partial = {}
+): OrganizationKiloPassAuditRow {
+ return {
+ id: 'kpoa_1',
+ organizationId: 'org_1',
+ providerSubscriptionId: 'sub_org',
+ providerSeatAddOnItemId: 'si_pass',
+ state: 'active',
+ purchaseChannel: 'self_serve',
+ ...overrides,
+ };
+}
+
+function personalSubscription(
+ overrides: Partial = {}
+): StripeSubscriptionSnapshot {
+ return {
+ id: 'sub_personal',
+ status: 'active',
+ metadata: {
+ type: 'kilo-pass',
+ kiloUserId: 'user_1',
+ tier: 'tier_49',
+ cadence: 'monthly',
+ },
+ items: [{ id: 'si_pass', priceId: KILO_PASS_PRICE, productId: 'prod_kilo_pass' }],
+ ...overrides,
+ };
+}
+
+function organizationSubscription(
+ overrides: Partial = {}
+): StripeSubscriptionSnapshot {
+ return {
+ id: 'sub_org',
+ status: 'active',
+ metadata: {
+ type: 'kilo-pass-org',
+ organizationId: 'org_1',
+ kiloUserId: 'user_1',
+ tier: 'tier_49',
+ cadence: 'monthly',
+ },
+ items: [
+ { id: 'si_seat', priceId: 'price_seats', productId: SEAT_PRODUCT },
+ { id: 'si_pass', priceId: KILO_PASS_PRICE, productId: 'prod_kilo_pass' },
+ ],
+ ...overrides,
+ };
+}
+
+describe('personal Kilo Pass classification', () => {
+ it('classifies an active Stripe-managed subscription by known price and personal metadata', () => {
+ const result = classifyPersonalKiloPassSubscription({
+ row: personalRow(),
+ subscription: personalSubscription(),
+ knownKiloPassPriceIds,
+ });
+
+ expect(result.classifiable).toBe(true);
+ expect(result.resolvedItemId).toBe('si_pass');
+ expect(result.issues).toEqual([]);
+ });
+
+ it('treats a known price without personal metadata as classifiable with a warning', () => {
+ const result = classifyPersonalKiloPassSubscription({
+ row: personalRow(),
+ subscription: personalSubscription({ metadata: {} }),
+ knownKiloPassPriceIds,
+ });
+
+ expect(result.classifiable).toBe(true);
+ expect(result.issues).toEqual([
+ { code: 'missing_personal_kilo_pass_metadata', severity: 'warning' },
+ ]);
+ });
+
+ it('rejects a personal subscription that also carries organization metadata', () => {
+ const result = classifyPersonalKiloPassSubscription({
+ row: personalRow(),
+ subscription: personalSubscription({
+ metadata: {
+ type: 'kilo-pass-org',
+ organizationId: 'org_1',
+ kiloUserId: 'user_1',
+ tier: 'tier_49',
+ cadence: 'monthly',
+ },
+ }),
+ knownKiloPassPriceIds,
+ });
+
+ expect(result.classifiable).toBe(false);
+ expect(result.issues.map(issue => issue.code)).toEqual([
+ 'has_organization_kilo_pass_metadata',
+ 'missing_personal_kilo_pass_metadata',
+ ]);
+ });
+
+ it('rejects a missing Stripe subscription and a subscription without a known Kilo Pass price', () => {
+ expect(
+ classifyPersonalKiloPassSubscription({
+ row: personalRow({ stripeSubscriptionId: null }),
+ subscription: undefined,
+ knownKiloPassPriceIds,
+ }).issues.map(issue => issue.code)
+ ).toEqual(['missing_stripe_subscription_id']);
+
+ expect(
+ classifyPersonalKiloPassSubscription({
+ row: personalRow(),
+ subscription: null,
+ knownKiloPassPriceIds,
+ }).issues.map(issue => issue.code)
+ ).toEqual(['stripe_subscription_not_found']);
+
+ expect(
+ classifyPersonalKiloPassSubscription({
+ row: personalRow(),
+ subscription: personalSubscription({
+ items: [{ id: 'si_other', priceId: 'price_other', productId: 'prod_other' }],
+ }),
+ knownKiloPassPriceIds,
+ }).issues.map(issue => issue.code)
+ ).toEqual(['missing_known_kilo_pass_price']);
+ });
+});
+
+describe('organization Kilo Pass classification', () => {
+ it('resolves the bound non-seat add-on item before falling back to price search', () => {
+ const result = classifyOrganizationKiloPassSubscription({
+ row: organizationRow(),
+ subscription: organizationSubscription(),
+ knownKiloPassPriceIds,
+ seatProductIds,
+ });
+
+ expect(result.classifiable).toBe(true);
+ expect(result.resolvedItemId).toBe('si_pass');
+ expect(result.issues).toEqual([]);
+ });
+
+ it('falls back to a unique known non-seat price when the add-on id is unbound', () => {
+ const result = classifyOrganizationKiloPassSubscription({
+ row: organizationRow({ providerSeatAddOnItemId: null }),
+ subscription: organizationSubscription(),
+ knownKiloPassPriceIds,
+ seatProductIds,
+ });
+
+ expect(result.classifiable).toBe(true);
+ expect(result.resolvedItemId).toBe('si_pass');
+ expect(result.issues).toEqual([{ code: 'unresolved_kilo_pass_item', severity: 'warning' }]);
+ });
+
+ it('rejects org metadata that is missing, personal, or bound to a seat item', () => {
+ expect(
+ classifyOrganizationKiloPassSubscription({
+ row: organizationRow(),
+ subscription: organizationSubscription({ metadata: {} }),
+ knownKiloPassPriceIds,
+ seatProductIds,
+ }).issues.map(issue => issue.code)
+ ).toEqual(['missing_organization_kilo_pass_metadata']);
+
+ expect(
+ classifyOrganizationKiloPassSubscription({
+ row: organizationRow(),
+ subscription: organizationSubscription({
+ metadata: {
+ type: 'kilo-pass',
+ kiloUserId: 'user_1',
+ tier: 'tier_49',
+ cadence: 'monthly',
+ },
+ }),
+ knownKiloPassPriceIds,
+ seatProductIds,
+ }).issues.map(issue => issue.code)
+ ).toEqual(['has_personal_kilo_pass_metadata', 'missing_organization_kilo_pass_metadata']);
+
+ expect(
+ classifyOrganizationKiloPassSubscription({
+ row: organizationRow({ providerSeatAddOnItemId: 'si_seat' }),
+ subscription: organizationSubscription(),
+ knownKiloPassPriceIds,
+ seatProductIds,
+ }).issues.map(issue => issue.code)
+ ).toEqual(['bound_add_on_item_is_seat']);
+ });
+
+ it('rejects a bound item that is missing or not a known Kilo Pass price', () => {
+ expect(
+ classifyOrganizationKiloPassSubscription({
+ row: organizationRow({ providerSeatAddOnItemId: 'si_missing' }),
+ subscription: organizationSubscription(),
+ knownKiloPassPriceIds,
+ seatProductIds,
+ }).issues.map(issue => issue.code)
+ ).toEqual(['bound_add_on_item_not_found']);
+
+ expect(
+ classifyOrganizationKiloPassSubscription({
+ row: organizationRow({ providerSeatAddOnItemId: 'si_other' }),
+ subscription: organizationSubscription({
+ items: [{ id: 'si_other', priceId: 'price_other', productId: 'prod_other' }],
+ }),
+ knownKiloPassPriceIds,
+ seatProductIds,
+ }).issues.map(issue => issue.code)
+ ).toEqual(['bound_add_on_item_unknown_price']);
+ });
+
+ it('rejects multiple unbound known Kilo Pass items as ambiguous', () => {
+ const result = classifyOrganizationKiloPassSubscription({
+ row: organizationRow({ providerSeatAddOnItemId: null }),
+ subscription: organizationSubscription({
+ items: [
+ { id: 'si_pass_a', priceId: KILO_PASS_PRICE, productId: 'prod_kilo_pass' },
+ { id: 'si_pass_b', priceId: KILO_PASS_PRICE, productId: 'prod_kilo_pass' },
+ ],
+ }),
+ knownKiloPassPriceIds,
+ seatProductIds,
+ });
+
+ expect(result.classifiable).toBe(false);
+ expect(result.issues.map(issue => issue.code)).toEqual(['ambiguous_kilo_pass_items']);
+ });
+});
+
+describe('evaluateKiloPassClassificationAudit', () => {
+ it('loads subscriptions only through the injected retrieve function', async () => {
+ const retrieved: string[] = [];
+ const events: Array> = [];
+ const report = await auditKiloPassClassifications({
+ generatedAtIso: '2026-08-09T00:00:00.000Z',
+ knownKiloPassPriceIds,
+ seatProductIds,
+ store: {
+ listPersonalRows: async () => [personalRow()],
+ listOrganizationRows: async () => [organizationRow()],
+ },
+ retrieveSubscription: async subscriptionId => {
+ retrieved.push(subscriptionId);
+ return subscriptionId === 'sub_personal'
+ ? personalSubscription()
+ : organizationSubscription();
+ },
+ log: event => {
+ events.push(event);
+ },
+ });
+
+ expect(retrieved.sort()).toEqual(['sub_org', 'sub_personal']);
+ expect(report.classifiableCount).toBe(2);
+ expect(events[0]).toMatchObject({
+ event: 'service_fee.kilo_pass_classification_audit.started',
+ mode: 'read_only',
+ });
+ expect(events.at(-1)).toMatchObject({
+ event: 'service_fee.kilo_pass_classification_audit.completed',
+ unclassifiableCount: 0,
+ });
+ });
+
+ it('summarizes injected personal and organization rows without mutating them', () => {
+ const personal = personalRow();
+ const organization = organizationRow();
+ const report = evaluateKiloPassClassificationAudit({
+ generatedAtIso: '2026-08-09T00:00:00.000Z',
+ knownKiloPassPriceIds,
+ seatProductIds,
+ personalRows: [personal],
+ organizationRows: [organization],
+ subscriptionsById: new Map([
+ [personal.stripeSubscriptionId ?? '', personalSubscription()],
+ [organization.providerSubscriptionId ?? '', organizationSubscription()],
+ ]),
+ });
+
+ expect(report).toMatchObject({
+ generatedAtIso: '2026-08-09T00:00:00.000Z',
+ personalReviewed: 1,
+ organizationReviewed: 1,
+ classifiableCount: 2,
+ unclassifiableCount: 0,
+ warningCount: 0,
+ });
+ expect(report.results.map(result => result.kind)).toEqual([
+ 'personal_kilo_pass',
+ 'organization_kilo_pass',
+ ]);
+ });
+});
diff --git a/apps/web/src/lib/service-fees/kilo-pass-classification-audit.ts b/apps/web/src/lib/service-fees/kilo-pass-classification-audit.ts
new file mode 100644
index 0000000000..0133a0acac
--- /dev/null
+++ b/apps/web/src/lib/service-fees/kilo-pass-classification-audit.ts
@@ -0,0 +1,380 @@
+import { getOrganizationKiloPassMetadata } from '@/lib/kilo-pass-org/stripe-metadata';
+import { getKiloPassMetadataFromStripeMetadata } from '@/lib/kilo-pass/stripe-handlers-metadata';
+
+export const SERVICE_FEE_KILO_PASS_CLASSIFICATION_EVENT =
+ 'service_fee.kilo_pass_classification_audit';
+
+export const LIVE_PERSONAL_KILO_PASS_STATUSES = [
+ 'active',
+ 'past_due',
+ 'trialing',
+ 'unpaid',
+] as const;
+
+export const LIVE_ORG_KILO_PASS_STATES = ['active', 'cancel_at_period_end'] as const;
+
+export type StripeSubscriptionItemSnapshot = {
+ id: string;
+ priceId: string | null;
+ productId: string | null;
+};
+
+export type StripeSubscriptionSnapshot = {
+ id: string;
+ status: string;
+ metadata: Record;
+ items: readonly StripeSubscriptionItemSnapshot[];
+};
+
+export type PersonalKiloPassAuditRow = {
+ id: string;
+ kiloUserId: string;
+ stripeSubscriptionId: string | null;
+ status: string;
+ tier: string;
+ cadence: string;
+};
+
+export type OrganizationKiloPassAuditRow = {
+ id: string;
+ organizationId: string;
+ providerSubscriptionId: string | null;
+ providerSeatAddOnItemId: string | null;
+ state: string;
+ purchaseChannel: string;
+};
+
+export type KiloPassClassificationIssueCode =
+ | 'missing_stripe_subscription_id'
+ | 'stripe_subscription_not_found'
+ | 'missing_known_kilo_pass_price'
+ | 'has_organization_kilo_pass_metadata'
+ | 'has_personal_kilo_pass_metadata'
+ | 'missing_personal_kilo_pass_metadata'
+ | 'missing_organization_kilo_pass_metadata'
+ | 'bound_add_on_item_not_found'
+ | 'bound_add_on_item_is_seat'
+ | 'bound_add_on_item_unknown_price'
+ | 'ambiguous_kilo_pass_items'
+ | 'unresolved_kilo_pass_item';
+
+export type KiloPassClassificationIssue = {
+ code: KiloPassClassificationIssueCode;
+ severity: 'error' | 'warning';
+};
+
+export type KiloPassClassificationKind = 'personal_kilo_pass' | 'organization_kilo_pass';
+
+export type KiloPassClassificationResult = {
+ kind: KiloPassClassificationKind;
+ recordId: string;
+ stripeSubscriptionId: string | null;
+ classifiable: boolean;
+ resolvedItemId: string | null;
+ issues: KiloPassClassificationIssue[];
+};
+
+export type KiloPassClassificationAuditInput = {
+ generatedAtIso: string;
+ knownKiloPassPriceIds: ReadonlySet;
+ seatProductIds: ReadonlySet;
+ personalRows: readonly PersonalKiloPassAuditRow[];
+ organizationRows: readonly OrganizationKiloPassAuditRow[];
+ subscriptionsById: ReadonlyMap;
+};
+
+export type KiloPassClassificationAuditReport = {
+ generatedAtIso: string;
+ personalReviewed: number;
+ organizationReviewed: number;
+ classifiableCount: number;
+ unclassifiableCount: number;
+ warningCount: number;
+ results: KiloPassClassificationResult[];
+};
+
+export type RetrieveKiloPassSubscription = (
+ subscriptionId: string
+) => Promise;
+
+export type KiloPassClassificationAuditStore = {
+ listPersonalRows: () => Promise;
+ listOrganizationRows: () => Promise;
+};
+
+export function classifyPersonalKiloPassSubscription(input: {
+ row: PersonalKiloPassAuditRow;
+ subscription: StripeSubscriptionSnapshot | null | undefined;
+ knownKiloPassPriceIds: ReadonlySet;
+}): KiloPassClassificationResult {
+ const issues: KiloPassClassificationIssue[] = [];
+ if (!input.row.stripeSubscriptionId) {
+ issues.push({ code: 'missing_stripe_subscription_id', severity: 'error' });
+ return personalResult(input.row, null, issues);
+ }
+ if (!input.subscription) {
+ issues.push({ code: 'stripe_subscription_not_found', severity: 'error' });
+ return personalResult(input.row, null, issues);
+ }
+
+ const personalMetadata = getKiloPassMetadataFromStripeMetadata(input.subscription.metadata);
+ const organizationMetadata = getOrganizationKiloPassMetadata(input.subscription.metadata);
+ const knownPriceItems = input.subscription.items.filter(
+ item => item.priceId !== null && input.knownKiloPassPriceIds.has(item.priceId)
+ );
+
+ if (organizationMetadata) {
+ issues.push({ code: 'has_organization_kilo_pass_metadata', severity: 'error' });
+ }
+ if (knownPriceItems.length === 0) {
+ issues.push({ code: 'missing_known_kilo_pass_price', severity: 'error' });
+ }
+ if (!personalMetadata) {
+ issues.push({
+ code: 'missing_personal_kilo_pass_metadata',
+ severity: knownPriceItems.length > 0 && !organizationMetadata ? 'warning' : 'error',
+ });
+ }
+
+ return personalResult(input.row, knownPriceItems[0]?.id ?? null, issues);
+}
+
+export function classifyOrganizationKiloPassSubscription(input: {
+ row: OrganizationKiloPassAuditRow;
+ subscription: StripeSubscriptionSnapshot | null | undefined;
+ knownKiloPassPriceIds: ReadonlySet;
+ seatProductIds: ReadonlySet;
+}): KiloPassClassificationResult {
+ const issues: KiloPassClassificationIssue[] = [];
+ if (!input.row.providerSubscriptionId) {
+ issues.push({ code: 'missing_stripe_subscription_id', severity: 'error' });
+ return organizationResult(input.row, null, issues);
+ }
+ if (!input.subscription) {
+ issues.push({ code: 'stripe_subscription_not_found', severity: 'error' });
+ return organizationResult(input.row, null, issues);
+ }
+
+ const personalMetadata = getKiloPassMetadataFromStripeMetadata(input.subscription.metadata);
+ const organizationMetadata = getOrganizationKiloPassMetadata(input.subscription.metadata);
+ if (personalMetadata) {
+ issues.push({ code: 'has_personal_kilo_pass_metadata', severity: 'error' });
+ }
+ if (!organizationMetadata) {
+ issues.push({ code: 'missing_organization_kilo_pass_metadata', severity: 'error' });
+ }
+
+ const resolved = resolveOrganizationKiloPassItem({
+ row: input.row,
+ subscription: input.subscription,
+ knownKiloPassPriceIds: input.knownKiloPassPriceIds,
+ seatProductIds: input.seatProductIds,
+ });
+ issues.push(...resolved.issues);
+
+ return organizationResult(input.row, resolved.itemId, issues);
+}
+
+export async function auditKiloPassClassifications(input: {
+ generatedAtIso?: string;
+ knownKiloPassPriceIds: ReadonlySet;
+ seatProductIds: ReadonlySet;
+ store: KiloPassClassificationAuditStore;
+ retrieveSubscription: RetrieveKiloPassSubscription;
+ log?: (event: Record) => void;
+}): Promise {
+ const generatedAtIso = input.generatedAtIso ?? new Date().toISOString();
+ const log = input.log ?? defaultLog;
+ const personalRows = await input.store.listPersonalRows();
+ const organizationRows = await input.store.listOrganizationRows();
+ const subscriptionIds = uniqueIds([
+ ...personalRows.map(row => row.stripeSubscriptionId),
+ ...organizationRows.map(row => row.providerSubscriptionId),
+ ]);
+ const subscriptionsById = new Map();
+
+ log({
+ event: `${SERVICE_FEE_KILO_PASS_CLASSIFICATION_EVENT}.started`,
+ mode: 'read_only',
+ generatedAtIso,
+ personalCandidates: personalRows.length,
+ organizationCandidates: organizationRows.length,
+ stripeSubscriptionIds: subscriptionIds.length,
+ });
+
+ for (const subscriptionId of subscriptionIds) {
+ subscriptionsById.set(subscriptionId, await input.retrieveSubscription(subscriptionId));
+ }
+
+ const report = evaluateKiloPassClassificationAudit({
+ generatedAtIso,
+ knownKiloPassPriceIds: input.knownKiloPassPriceIds,
+ seatProductIds: input.seatProductIds,
+ personalRows,
+ organizationRows,
+ subscriptionsById,
+ });
+
+ for (const result of report.results) {
+ if (result.issues.length === 0) continue;
+ log({
+ event: `${SERVICE_FEE_KILO_PASS_CLASSIFICATION_EVENT}.result`,
+ kind: result.kind,
+ recordId: result.recordId,
+ stripeSubscriptionId: result.stripeSubscriptionId,
+ classifiable: result.classifiable,
+ resolvedItemId: result.resolvedItemId,
+ issues: result.issues,
+ });
+ }
+
+ log({
+ event: `${SERVICE_FEE_KILO_PASS_CLASSIFICATION_EVENT}.completed`,
+ mode: 'read_only',
+ generatedAtIso: report.generatedAtIso,
+ personalReviewed: report.personalReviewed,
+ organizationReviewed: report.organizationReviewed,
+ classifiableCount: report.classifiableCount,
+ unclassifiableCount: report.unclassifiableCount,
+ warningCount: report.warningCount,
+ });
+
+ return report;
+}
+
+export function evaluateKiloPassClassificationAudit(
+ input: KiloPassClassificationAuditInput
+): KiloPassClassificationAuditReport {
+ const results: KiloPassClassificationResult[] = [];
+
+ for (const row of input.personalRows) {
+ const subscription = row.stripeSubscriptionId
+ ? input.subscriptionsById.get(row.stripeSubscriptionId)
+ : undefined;
+ results.push(
+ classifyPersonalKiloPassSubscription({
+ row,
+ subscription,
+ knownKiloPassPriceIds: input.knownKiloPassPriceIds,
+ })
+ );
+ }
+
+ for (const row of input.organizationRows) {
+ const subscription = row.providerSubscriptionId
+ ? input.subscriptionsById.get(row.providerSubscriptionId)
+ : undefined;
+ results.push(
+ classifyOrganizationKiloPassSubscription({
+ row,
+ subscription,
+ knownKiloPassPriceIds: input.knownKiloPassPriceIds,
+ seatProductIds: input.seatProductIds,
+ })
+ );
+ }
+
+ return {
+ generatedAtIso: input.generatedAtIso,
+ personalReviewed: input.personalRows.length,
+ organizationReviewed: input.organizationRows.length,
+ classifiableCount: results.filter(result => result.classifiable).length,
+ unclassifiableCount: results.filter(result => !result.classifiable).length,
+ warningCount: results.reduce(
+ (count, result) => count + result.issues.filter(issue => issue.severity === 'warning').length,
+ 0
+ ),
+ results,
+ };
+}
+
+function resolveOrganizationKiloPassItem(input: {
+ row: OrganizationKiloPassAuditRow;
+ subscription: StripeSubscriptionSnapshot;
+ knownKiloPassPriceIds: ReadonlySet;
+ seatProductIds: ReadonlySet;
+}): { itemId: string | null; issues: KiloPassClassificationIssue[] } {
+ const issues: KiloPassClassificationIssue[] = [];
+ const boundItemId = input.row.providerSeatAddOnItemId;
+ if (boundItemId && !boundItemId.startsWith('pending:')) {
+ const boundItem = input.subscription.items.find(item => item.id === boundItemId);
+ if (!boundItem) {
+ issues.push({ code: 'bound_add_on_item_not_found', severity: 'error' });
+ return { itemId: null, issues };
+ }
+ if (isSeatItem(boundItem, input.seatProductIds)) {
+ issues.push({ code: 'bound_add_on_item_is_seat', severity: 'error' });
+ return { itemId: boundItem.id, issues };
+ }
+ if (!boundItem.priceId || !input.knownKiloPassPriceIds.has(boundItem.priceId)) {
+ issues.push({ code: 'bound_add_on_item_unknown_price', severity: 'error' });
+ return { itemId: boundItem.id, issues };
+ }
+ return { itemId: boundItem.id, issues };
+ }
+
+ const knownNonSeatItems = input.subscription.items.filter(
+ item =>
+ item.priceId !== null &&
+ input.knownKiloPassPriceIds.has(item.priceId) &&
+ !isSeatItem(item, input.seatProductIds)
+ );
+ if (knownNonSeatItems.length === 1) {
+ if (!boundItemId) {
+ issues.push({ code: 'unresolved_kilo_pass_item', severity: 'warning' });
+ }
+ return { itemId: knownNonSeatItems[0]?.id ?? null, issues };
+ }
+ if (knownNonSeatItems.length > 1) {
+ issues.push({ code: 'ambiguous_kilo_pass_items', severity: 'error' });
+ return { itemId: null, issues };
+ }
+
+ issues.push({ code: 'unresolved_kilo_pass_item', severity: 'error' });
+ return { itemId: null, issues };
+}
+
+function isSeatItem(
+ item: StripeSubscriptionItemSnapshot,
+ seatProductIds: ReadonlySet
+): boolean {
+ return item.productId !== null && seatProductIds.has(item.productId);
+}
+
+function personalResult(
+ row: PersonalKiloPassAuditRow,
+ resolvedItemId: string | null,
+ issues: KiloPassClassificationIssue[]
+): KiloPassClassificationResult {
+ return {
+ kind: 'personal_kilo_pass',
+ recordId: row.id,
+ stripeSubscriptionId: row.stripeSubscriptionId,
+ classifiable: issues.every(issue => issue.severity !== 'error'),
+ resolvedItemId,
+ issues,
+ };
+}
+
+function organizationResult(
+ row: OrganizationKiloPassAuditRow,
+ resolvedItemId: string | null,
+ issues: KiloPassClassificationIssue[]
+): KiloPassClassificationResult {
+ return {
+ kind: 'organization_kilo_pass',
+ recordId: row.id,
+ stripeSubscriptionId: row.providerSubscriptionId,
+ classifiable: issues.every(issue => issue.severity !== 'error'),
+ resolvedItemId,
+ issues,
+ };
+}
+
+function uniqueIds(values: Array): string[] {
+ return [...new Set(values.filter((value): value is string => Boolean(value)))];
+}
+
+function defaultLog(event: Record): void {
+ console.log(JSON.stringify(event));
+}
diff --git a/apps/web/src/lib/service-fees/restricted-coupon-audit.test.ts b/apps/web/src/lib/service-fees/restricted-coupon-audit.test.ts
new file mode 100644
index 0000000000..49e0d99055
--- /dev/null
+++ b/apps/web/src/lib/service-fees/restricted-coupon-audit.test.ts
@@ -0,0 +1,351 @@
+import { describe, expect, it } from '@jest/globals';
+import {
+ AdminSlackNotificationError,
+ type AdminSlackNotification,
+} from '@/lib/slack/admin-notifications';
+import {
+ assertServiceFeeAuditReadOnly,
+ auditRestrictedCoupons,
+ buildRestrictedCouponSlackNotification,
+ couponSnapshotFromListCoupon,
+ couponSnapshotFromResolvedCoupon,
+ evaluateRestrictedCouponAudit,
+ findRestrictedFeeBearingCoupons,
+ listAllStripePages,
+ listCouponSnapshotsEnsuringAppliesTo,
+ parseRestrictedCouponAuditArgs,
+ SERVICE_FEE_RESTRICTED_COUPON_EVENT,
+ SERVICE_FEE_RESTRICTED_COUPON_NAMESPACE,
+ type StripeCouponSnapshot,
+ type StripeCouponSnapshotSource,
+} from './restricted-coupon-audit';
+
+const FEE_PRODUCTS = new Set(['prod_kilo_pass', 'prod_top_up']);
+
+function coupon(overrides: Partial = {}): StripeCouponSnapshot {
+ return {
+ id: 'co_restricted',
+ valid: true,
+ appliesToProductIds: ['prod_kilo_pass'],
+ ...overrides,
+ };
+}
+
+describe('findRestrictedFeeBearingCoupons', () => {
+ it('ignores unrestricted coupons and coupons restricted to non-fee products', () => {
+ expect(
+ findRestrictedFeeBearingCoupons({
+ coupons: [
+ coupon({ id: 'co_open', appliesToProductIds: null }),
+ coupon({ id: 'co_empty', appliesToProductIds: [] }),
+ coupon({ id: 'co_seats', appliesToProductIds: ['prod_seats'] }),
+ coupon({ id: 'co_claw', appliesToProductIds: ['prod_kiloclaw'] }),
+ ],
+ feeBearingProductIds: FEE_PRODUCTS,
+ })
+ ).toEqual([]);
+ });
+
+ it('lists coupons whose applies_to products intersect Kilo Pass or top-up products', () => {
+ expect(
+ findRestrictedFeeBearingCoupons({
+ coupons: [
+ coupon({ id: 'co_pass', appliesToProductIds: ['prod_kilo_pass', 'prod_seats'] }),
+ coupon({
+ id: 'co_topup',
+ valid: false,
+ appliesToProductIds: ['prod_top_up'],
+ }),
+ coupon({ id: 'co_other', appliesToProductIds: ['prod_seats'] }),
+ ],
+ feeBearingProductIds: FEE_PRODUCTS,
+ })
+ ).toEqual([
+ {
+ couponId: 'co_pass',
+ valid: true,
+ intersectingProductIds: ['prod_kilo_pass'],
+ appliesToProductCount: 2,
+ },
+ {
+ couponId: 'co_topup',
+ valid: false,
+ intersectingProductIds: ['prod_top_up'],
+ appliesToProductCount: 1,
+ },
+ ]);
+ });
+});
+
+describe('evaluateRestrictedCouponAudit', () => {
+ it('builds a namespaced non-sensitive alert only when restricted coupons exist', () => {
+ const clear = evaluateRestrictedCouponAudit({
+ generatedAtIso: '2026-08-09T00:00:00.000Z',
+ coupons: [coupon({ id: 'co_open', appliesToProductIds: null })],
+ feeBearingProductIds: FEE_PRODUCTS,
+ });
+ expect(clear.alert).toBeNull();
+ expect(clear.findings).toEqual([]);
+
+ const report = evaluateRestrictedCouponAudit({
+ generatedAtIso: '2026-08-09T00:00:00.000Z',
+ coupons: [
+ coupon({ id: 'co_pass' }),
+ coupon({ id: 'co_topup', appliesToProductIds: ['prod_top_up'] }),
+ ],
+ feeBearingProductIds: FEE_PRODUCTS,
+ });
+
+ expect(report.alert).toEqual({
+ namespace: SERVICE_FEE_RESTRICTED_COUPON_NAMESPACE,
+ event: SERVICE_FEE_RESTRICTED_COUPON_EVENT,
+ generatedAtIso: '2026-08-09T00:00:00.000Z',
+ couponCount: 2,
+ couponIds: ['co_pass', 'co_topup'],
+ intersectingProductIds: ['prod_kilo_pass', 'prod_top_up'],
+ });
+ });
+
+ it('keeps Slack copy limited to namespaced identifiers and validity', () => {
+ const report = evaluateRestrictedCouponAudit({
+ generatedAtIso: '2026-08-09T00:00:00.000Z',
+ coupons: [coupon({ id: 'co_pass', appliesToProductIds: ['prod_kilo_pass'] })],
+ feeBearingProductIds: FEE_PRODUCTS,
+ });
+ if (!report.alert) throw new Error('expected alert');
+
+ const notification = buildRestrictedCouponSlackNotification(report.alert, report.findings);
+ const serialized = JSON.stringify(notification);
+
+ expect(notification.text).toContain(SERVICE_FEE_RESTRICTED_COUPON_EVENT);
+ expect(serialized).toContain('co_pass');
+ expect(serialized).toContain('prod_kilo_pass');
+ expect(serialized).not.toContain('secret');
+ expect(serialized).not.toContain('sk_');
+ expect(serialized).not.toContain('@');
+ });
+});
+
+describe('restricted coupon audit args and orchestration', () => {
+ it('defaults to read-only alerting and rejects mutating flags', () => {
+ expect(parseRestrictedCouponAuditArgs([])).toEqual({ alert: true });
+ expect(parseRestrictedCouponAuditArgs(['--no-alert'])).toEqual({ alert: false });
+ expect(() => assertServiceFeeAuditReadOnly(['--execute'])).toThrow(
+ 'service_fee_audit_is_read_only'
+ );
+ expect(() => parseRestrictedCouponAuditArgs(['--run-actually'])).toThrow(
+ 'service_fee_audit_is_read_only'
+ );
+ });
+
+ it('alerts through injected functions with a namespaced payload and no secrets', async () => {
+ const notifications: AdminSlackNotification[] = [];
+ const captured: unknown[] = [];
+ const events: Array> = [];
+
+ const report = await auditRestrictedCoupons({
+ generatedAtIso: '2026-08-09T00:00:00.000Z',
+ listCoupons: async () => [coupon({ id: 'co_pass' })],
+ listFeeBearingProductIds: async () => ['prod_kilo_pass', 'prod_top_up'],
+ sendAlert: async notification => {
+ notifications.push(notification);
+ },
+ capture: payload => {
+ captured.push(payload);
+ },
+ log: event => {
+ events.push(event);
+ },
+ });
+
+ expect(report.findings).toHaveLength(1);
+ expect(captured).toEqual([
+ expect.objectContaining({
+ namespace: SERVICE_FEE_RESTRICTED_COUPON_NAMESPACE,
+ event: SERVICE_FEE_RESTRICTED_COUPON_EVENT,
+ couponIds: ['co_pass'],
+ }),
+ ]);
+ expect(notifications).toHaveLength(1);
+ expect(JSON.stringify(notifications[0])).not.toContain('sk_');
+ expect(
+ events.some(event => event.event === `${SERVICE_FEE_RESTRICTED_COUPON_EVENT}.alerted`)
+ ).toBe(true);
+ });
+
+ it('records only Slack kind/status when the injected alert fails', async () => {
+ const events: Array> = [];
+
+ await expect(
+ auditRestrictedCoupons({
+ generatedAtIso: '2026-08-09T00:00:00.000Z',
+ listCoupons: async () => [coupon({ id: 'co_pass' })],
+ listFeeBearingProductIds: async () => ['prod_kilo_pass'],
+ sendAlert: async () => {
+ throw new AdminSlackNotificationError('upstream', 500);
+ },
+ log: event => {
+ events.push(event);
+ },
+ })
+ ).rejects.toMatchObject({ kind: 'upstream', status: 500 });
+
+ expect(events).toContainEqual(
+ expect.objectContaining({
+ event: `${SERVICE_FEE_RESTRICTED_COUPON_EVENT}.alert_failed`,
+ kind: 'upstream',
+ status: 500,
+ couponCount: 1,
+ })
+ );
+ expect(JSON.stringify(events)).not.toContain('hooks.slack.com');
+ });
+});
+
+describe('coupon snapshots from Stripe payloads', () => {
+ it('treats a list payload that omits applies_to as unresolved, never as unrestricted', () => {
+ const omitted: StripeCouponSnapshotSource = { id: 'co_omitted', valid: true };
+ expect(couponSnapshotFromListCoupon(omitted)).toBeNull();
+ });
+
+ it('reads the expanded applies_to restriction from the list payload without a retrieve', () => {
+ expect(
+ couponSnapshotFromListCoupon({
+ id: 'co_restricted',
+ valid: true,
+ applies_to: { products: ['prod_kilo_pass'] },
+ })
+ ).toEqual({
+ id: 'co_restricted',
+ valid: true,
+ appliesToProductIds: ['prod_kilo_pass'],
+ });
+ });
+
+ it('maps an explicitly empty applies_to to an unrestricted snapshot', () => {
+ expect(
+ couponSnapshotFromResolvedCoupon({ id: 'co_open', valid: false, applies_to: null })
+ ).toEqual({ id: 'co_open', valid: false, appliesToProductIds: null });
+ expect(
+ couponSnapshotFromResolvedCoupon({
+ id: 'co_empty',
+ valid: true,
+ applies_to: { products: [] },
+ })
+ ).toEqual({ id: 'co_empty', valid: true, appliesToProductIds: [] });
+ });
+});
+
+describe('listCouponSnapshotsEnsuringAppliesTo', () => {
+ it('uses the expanded list restriction and never retrieves when applies_to is present', async () => {
+ const retrieved: string[] = [];
+ const snapshots = await listCouponSnapshotsEnsuringAppliesTo({
+ listPage: async () => ({
+ data: [
+ { id: 'co_a', valid: true, applies_to: { products: ['prod_kilo_pass'] } },
+ { id: 'co_b', valid: false, applies_to: { products: [] } },
+ ],
+ has_more: false,
+ }),
+ retrieveCoupon: async couponId => {
+ retrieved.push(couponId);
+ throw new Error(`unexpected retrieve for ${couponId}`);
+ },
+ log: () => {},
+ });
+
+ expect(retrieved).toEqual([]);
+ expect(snapshots).toEqual([
+ { id: 'co_a', valid: true, appliesToProductIds: ['prod_kilo_pass'] },
+ { id: 'co_b', valid: false, appliesToProductIds: [] },
+ ]);
+ });
+
+ it('retrieves coupons whose list payload omitted applies_to so restrictions are never missed', async () => {
+ const events: Array> = [];
+ const snapshots = await listCouponSnapshotsEnsuringAppliesTo({
+ listPage: async () => ({
+ data: [
+ { id: 'co_omitted', valid: true },
+ { id: 'co_present', valid: true, applies_to: { products: [] } },
+ ],
+ has_more: false,
+ }),
+ retrieveCoupon: async couponId => {
+ expect(couponId).toBe('co_omitted');
+ return { id: couponId, valid: true, applies_to: { products: ['prod_top_up'] } };
+ },
+ log: event => {
+ events.push(event);
+ },
+ });
+
+ expect(snapshots).toEqual([
+ { id: 'co_omitted', valid: true, appliesToProductIds: ['prod_top_up'] },
+ { id: 'co_present', valid: true, appliesToProductIds: [] },
+ ]);
+ expect(events).toEqual([
+ {
+ event: `${SERVICE_FEE_RESTRICTED_COUPON_EVENT}.applies_to_retrieved`,
+ mode: 'read_only',
+ retrievedCount: 1,
+ couponCount: 2,
+ },
+ ]);
+ });
+
+ it('paginates with starting_after and retrieves omitted coupons across pages', async () => {
+ const listCalls: Array = [];
+ const retrieved: string[] = [];
+
+ const snapshots = await listCouponSnapshotsEnsuringAppliesTo({
+ listPage: async startingAfter => {
+ listCalls.push(startingAfter);
+ if (!startingAfter) {
+ return {
+ data: [
+ { id: 'co_1', valid: true, applies_to: { products: ['prod_kilo_pass'] } },
+ { id: 'co_2', valid: true },
+ ],
+ has_more: true,
+ };
+ }
+ return { data: [{ id: 'co_3', valid: false }], has_more: false };
+ },
+ retrieveCoupon: async couponId => {
+ retrieved.push(couponId);
+ return { id: couponId, valid: couponId !== 'co_3', applies_to: null };
+ },
+ log: () => {},
+ });
+
+ expect(listCalls).toEqual([undefined, 'co_2']);
+ expect(retrieved).toEqual(['co_2', 'co_3']);
+ expect(snapshots.map(snapshot => snapshot.id)).toEqual(['co_1', 'co_2', 'co_3']);
+ expect(snapshots[0].appliesToProductIds).toEqual(['prod_kilo_pass']);
+ expect(snapshots[1].appliesToProductIds).toBeNull();
+ expect(snapshots[2]).toEqual({ id: 'co_3', valid: false, appliesToProductIds: null });
+ });
+});
+
+describe('listAllStripePages', () => {
+ it('walks injected pages until has_more is false', async () => {
+ const calls: Array = [];
+ const rows = await listAllStripePages(async startingAfter => {
+ calls.push(startingAfter);
+ if (!startingAfter) {
+ return { data: [{ id: 'co_1' }, { id: 'co_2' }], has_more: true };
+ }
+ return { data: [{ id: 'co_3' }], has_more: false };
+ });
+
+ expect(calls).toEqual([undefined, 'co_2']);
+ expect(rows.map(row => row.id)).toEqual(['co_1', 'co_2', 'co_3']);
+ });
+
+ it('fails when a page claims more results without a cursor', async () => {
+ await expect(listAllStripePages(async () => ({ data: [], has_more: true }))).rejects.toThrow(
+ 'stripe page is marked has_more without a cursor'
+ );
+ });
+});
diff --git a/apps/web/src/lib/service-fees/restricted-coupon-audit.ts b/apps/web/src/lib/service-fees/restricted-coupon-audit.ts
new file mode 100644
index 0000000000..1f3339aaaa
--- /dev/null
+++ b/apps/web/src/lib/service-fees/restricted-coupon-audit.ts
@@ -0,0 +1,348 @@
+import {
+ AdminSlackNotificationError,
+ type AdminSlackNotification,
+} from '@/lib/slack/admin-notifications';
+import { assertServiceFeeAuditReadOnly } from '@/lib/service-fees/read-only';
+
+export { assertServiceFeeAuditReadOnly } from '@/lib/service-fees/read-only';
+
+export const SERVICE_FEE_RESTRICTED_COUPON_EVENT = 'service_fee.restricted_coupon_detected';
+export const SERVICE_FEE_RESTRICTED_COUPON_NAMESPACE = 'service_fee';
+export const SERVICE_FEE_RESTRICTED_COUPON_SENTRY_TAG = 'service_fee_restricted_coupon_detected';
+
+const MAX_SLACK_COUPON_LINES = 20;
+
+export type StripeCouponSnapshot = {
+ id: string;
+ valid: boolean;
+ appliesToProductIds: readonly string[] | null;
+};
+
+export type RestrictedCouponFinding = {
+ couponId: string;
+ valid: boolean;
+ intersectingProductIds: string[];
+ appliesToProductCount: number;
+};
+
+export type RestrictedCouponAlertPayload = {
+ namespace: typeof SERVICE_FEE_RESTRICTED_COUPON_NAMESPACE;
+ event: typeof SERVICE_FEE_RESTRICTED_COUPON_EVENT;
+ generatedAtIso: string;
+ couponCount: number;
+ couponIds: string[];
+ intersectingProductIds: string[];
+};
+
+export type RestrictedCouponAuditReport = {
+ generatedAtIso: string;
+ couponReviewed: number;
+ feeBearingProductCount: number;
+ findings: RestrictedCouponFinding[];
+ alert: RestrictedCouponAlertPayload | null;
+};
+
+export function parseRestrictedCouponAuditArgs(args: readonly string[]): { alert: boolean } {
+ assertServiceFeeAuditReadOnly(args);
+ if (args.length === 0) return { alert: true };
+ if (args.length === 1 && args[0] === '--no-alert') return { alert: false };
+ throw new Error(
+ [
+ 'Usage:',
+ ' pnpm --filter web script:run service-fees restricted-coupon-audit',
+ ' pnpm --filter web script:run service-fees restricted-coupon-audit --no-alert',
+ ].join('\n')
+ );
+}
+
+export type RestrictedCouponAuditDeps = {
+ generatedAtIso?: string;
+ listCoupons: () => Promise;
+ listFeeBearingProductIds: () => Promise;
+ sendAlert?: (notification: AdminSlackNotification) => Promise;
+ capture?: (payload: RestrictedCouponAlertPayload) => void;
+ log?: (event: Record) => void;
+};
+
+export type StripePage = {
+ data: readonly T[];
+ has_more: boolean;
+};
+
+export async function listAllStripePages(
+ listPage: (startingAfter: string | undefined) => Promise>
+): Promise {
+ const rows: T[] = [];
+ let startingAfter: string | undefined;
+
+ for (;;) {
+ const page = await listPage(startingAfter);
+ rows.push(...page.data);
+ if (!page.has_more) return rows;
+ const cursor = page.data.at(-1)?.id;
+ if (!cursor) {
+ throw new Error('stripe page is marked has_more without a cursor');
+ }
+ startingAfter = cursor;
+ }
+}
+
+/**
+ * Structural view of the Stripe coupon fields this audit reads. `applies_to`
+ * is optional because Stripe list payloads may omit the nested object
+ * entirely; a per-coupon retrieve returns the authoritative object.
+ */
+export type StripeCouponSnapshotSource = {
+ id: string;
+ valid: boolean;
+ applies_to?: { products?: readonly string[] | null } | null;
+};
+
+/**
+ * Snapshot from a coupon whose applies_to field is known to be resolved (a
+ * list payload that serialized the field, or a per-coupon retrieve). A coupon
+ * with no product restriction resolves to null product ids.
+ */
+export function couponSnapshotFromResolvedCoupon(
+ coupon: StripeCouponSnapshotSource
+): StripeCouponSnapshot {
+ return {
+ id: coupon.id,
+ valid: coupon.valid,
+ appliesToProductIds: coupon.applies_to?.products ?? null,
+ };
+}
+
+/**
+ * Snapshot from a coupon list payload, or null when the payload omitted
+ * applies_to entirely. An omission must never be treated as "unrestricted":
+ * that would silently hide exactly the coupons this audit exists to find, so
+ * the caller must retrieve the coupon individually instead.
+ */
+export function couponSnapshotFromListCoupon(
+ coupon: StripeCouponSnapshotSource
+): StripeCouponSnapshot | null {
+ if (coupon.applies_to === undefined) return null;
+ return couponSnapshotFromResolvedCoupon(coupon);
+}
+
+export type ListCouponSnapshotsDeps = {
+ listPage: (startingAfter: string | undefined) => Promise>;
+ retrieveCoupon: (couponId: string) => Promise;
+ log?: (event: Record) => void;
+};
+
+/**
+ * Lists every coupon (paginated, read-only) and guarantees each snapshot
+ * carries the real applies_to restriction. applies_to is a nested object, not
+ * an expandable ID reference, so when a list payload omits the field the
+ * coupon is re-fetched with an authoritative read-only coupons.retrieve.
+ */
+export async function listCouponSnapshotsEnsuringAppliesTo(
+ deps: ListCouponSnapshotsDeps
+): Promise {
+ const log = deps.log ?? defaultLog;
+ const coupons = await listAllStripePages(deps.listPage);
+ const snapshots: StripeCouponSnapshot[] = [];
+ let retrievedCount = 0;
+
+ for (const coupon of coupons) {
+ const fromList = couponSnapshotFromListCoupon(coupon);
+ if (fromList) {
+ snapshots.push(fromList);
+ continue;
+ }
+ retrievedCount += 1;
+ snapshots.push(couponSnapshotFromResolvedCoupon(await deps.retrieveCoupon(coupon.id)));
+ }
+
+ if (retrievedCount > 0) {
+ log({
+ event: `${SERVICE_FEE_RESTRICTED_COUPON_EVENT}.applies_to_retrieved`,
+ mode: 'read_only',
+ retrievedCount,
+ couponCount: coupons.length,
+ });
+ }
+
+ return snapshots;
+}
+
+export function findRestrictedFeeBearingCoupons(input: {
+ coupons: readonly StripeCouponSnapshot[];
+ feeBearingProductIds: ReadonlySet;
+}): RestrictedCouponFinding[] {
+ const findings: RestrictedCouponFinding[] = [];
+
+ for (const coupon of input.coupons) {
+ if (!coupon.appliesToProductIds || coupon.appliesToProductIds.length === 0) continue;
+ const intersectingProductIds = uniqueSorted(
+ coupon.appliesToProductIds.filter(productId => input.feeBearingProductIds.has(productId))
+ );
+ if (intersectingProductIds.length === 0) continue;
+ findings.push({
+ couponId: coupon.id,
+ valid: coupon.valid,
+ intersectingProductIds,
+ appliesToProductCount: coupon.appliesToProductIds.length,
+ });
+ }
+
+ return findings.sort((left, right) => left.couponId.localeCompare(right.couponId));
+}
+
+export async function auditRestrictedCoupons(
+ input: RestrictedCouponAuditDeps
+): Promise {
+ const generatedAtIso = input.generatedAtIso ?? new Date().toISOString();
+ const log = input.log ?? defaultLog;
+ const [coupons, feeBearingProductIds] = await Promise.all([
+ input.listCoupons(),
+ input.listFeeBearingProductIds(),
+ ]);
+
+ log({
+ event: `${SERVICE_FEE_RESTRICTED_COUPON_EVENT}.started`,
+ mode: 'read_only',
+ generatedAtIso,
+ couponCandidates: coupons.length,
+ feeBearingProductCount: feeBearingProductIds.length,
+ });
+
+ const report = evaluateRestrictedCouponAudit({
+ generatedAtIso,
+ coupons,
+ feeBearingProductIds: new Set(feeBearingProductIds),
+ });
+
+ for (const finding of report.findings) {
+ log({
+ event: `${SERVICE_FEE_RESTRICTED_COUPON_EVENT}.finding`,
+ couponId: finding.couponId,
+ valid: finding.valid,
+ intersectingProductIds: finding.intersectingProductIds,
+ appliesToProductCount: finding.appliesToProductCount,
+ });
+ }
+
+ if (report.alert) {
+ input.capture?.(report.alert);
+ if (input.sendAlert) {
+ try {
+ await input.sendAlert(
+ buildRestrictedCouponSlackNotification(report.alert, report.findings)
+ );
+ log({
+ event: `${SERVICE_FEE_RESTRICTED_COUPON_EVENT}.alerted`,
+ couponCount: report.alert.couponCount,
+ couponIds: report.alert.couponIds,
+ });
+ } catch (error) {
+ log({
+ event: `${SERVICE_FEE_RESTRICTED_COUPON_EVENT}.alert_failed`,
+ kind: error instanceof AdminSlackNotificationError ? error.kind : 'unknown',
+ status: error instanceof AdminSlackNotificationError ? error.status : undefined,
+ couponCount: report.alert.couponCount,
+ });
+ throw error;
+ }
+ }
+ }
+
+ log({
+ event: `${SERVICE_FEE_RESTRICTED_COUPON_EVENT}.completed`,
+ mode: 'read_only',
+ generatedAtIso: report.generatedAtIso,
+ couponReviewed: report.couponReviewed,
+ feeBearingProductCount: report.feeBearingProductCount,
+ findingCount: report.findings.length,
+ });
+
+ return report;
+}
+
+export function evaluateRestrictedCouponAudit(input: {
+ generatedAtIso: string;
+ coupons: readonly StripeCouponSnapshot[];
+ feeBearingProductIds: ReadonlySet;
+}): RestrictedCouponAuditReport {
+ const findings = findRestrictedFeeBearingCoupons(input);
+ return {
+ generatedAtIso: input.generatedAtIso,
+ couponReviewed: input.coupons.length,
+ feeBearingProductCount: input.feeBearingProductIds.size,
+ findings,
+ alert:
+ findings.length === 0
+ ? null
+ : buildRestrictedCouponAlertPayload({
+ generatedAtIso: input.generatedAtIso,
+ findings,
+ }),
+ };
+}
+
+export function buildRestrictedCouponAlertPayload(input: {
+ generatedAtIso: string;
+ findings: readonly RestrictedCouponFinding[];
+}): RestrictedCouponAlertPayload {
+ return {
+ namespace: SERVICE_FEE_RESTRICTED_COUPON_NAMESPACE,
+ event: SERVICE_FEE_RESTRICTED_COUPON_EVENT,
+ generatedAtIso: input.generatedAtIso,
+ couponCount: input.findings.length,
+ couponIds: input.findings.map(finding => finding.couponId),
+ intersectingProductIds: uniqueSorted(
+ input.findings.flatMap(finding => finding.intersectingProductIds)
+ ),
+ };
+}
+
+export function buildRestrictedCouponSlackNotification(
+ payload: RestrictedCouponAlertPayload,
+ findings: readonly RestrictedCouponFinding[]
+): AdminSlackNotification {
+ const preview = findings.slice(0, MAX_SLACK_COUPON_LINES);
+ const remaining = findings.length - preview.length;
+ const lines = preview.map(finding => {
+ const products = finding.intersectingProductIds.join(',');
+ return `• \`${finding.couponId}\` products=\`${products}\` valid=${finding.valid}`;
+ });
+ if (remaining > 0) {
+ lines.push(`• +${remaining} more`);
+ }
+
+ return {
+ text: `${SERVICE_FEE_RESTRICTED_COUPON_EVENT}: ${payload.couponCount} coupon(s) apply to fee-bearing products`,
+ blocks: [
+ {
+ type: 'section',
+ text: {
+ type: 'mrkdwn',
+ text: [
+ `*${SERVICE_FEE_RESTRICTED_COUPON_EVENT}*`,
+ `${payload.couponCount} coupon(s) have \`applies_to.products\` intersecting fee-bearing Kilo Pass or top-up products.`,
+ `products=${payload.intersectingProductIds.map(id => `\`${id}\``).join(', ') || '(none)'}`,
+ ].join('\n'),
+ },
+ },
+ {
+ type: 'section',
+ text: {
+ type: 'mrkdwn',
+ text: lines.join('\n') || '• (none)',
+ },
+ },
+ ],
+ unfurl_links: false,
+ unfurl_media: false,
+ };
+}
+
+function uniqueSorted(values: readonly string[]): string[] {
+ return [...new Set(values)].sort((left, right) => left.localeCompare(right));
+}
+
+function defaultLog(event: Record): void {
+ console.log(JSON.stringify(event));
+}
diff --git a/apps/web/src/routers/organizations/organization-admin-router.test.ts b/apps/web/src/routers/organizations/organization-admin-router.test.ts
index 3d05dd64a9..604c3dc456 100644
--- a/apps/web/src/routers/organizations/organization-admin-router.test.ts
+++ b/apps/web/src/routers/organizations/organization-admin-router.test.ts
@@ -5,6 +5,7 @@ import {
credit_transactions,
organization_seats_purchases,
organization_memberships,
+ organization_service_fee_exemptions,
platform_integrations,
kilo_pass_org_agreements,
kilo_pass_org_allocation_plans,
@@ -14,7 +15,11 @@ import {
} from '@kilocode/db/schema';
import { eq, and, inArray, sql } from 'drizzle-orm';
import { insertTestUser } from '@/tests/helpers/user.helper';
-import { createOrganization, addUserToOrganization } from '@/lib/organizations/organizations';
+import {
+ createOrganization,
+ addUserToOrganization,
+ markOrganizationAsDeleted,
+} from '@/lib/organizations/organizations';
import { KiloPassCadence, KiloPassTier } from '@/lib/kilo-pass/enums';
import { KiloPassOrgBonusMode } from '@kilocode/db/schema-types';
import { fetchExpiringTransactionsForOrganization } from '@/lib/creditExpiration';
@@ -1815,6 +1820,250 @@ describe('organization admin router', () => {
});
});
+ describe('serviceFeeExemption', () => {
+ async function createExemptionTestOrganization(name = 'Service fee exemption') {
+ return createOrganization(`${name} ${crypto.randomUUID()}`, adminUser.id);
+ }
+
+ async function cleanupExemptionTestOrganization(organizationId: string) {
+ await db
+ .delete(organization_service_fee_exemptions)
+ .where(eq(organization_service_fee_exemptions.organization_id, organizationId));
+ await db
+ .delete(organization_memberships)
+ .where(eq(organization_memberships.organization_id, organizationId));
+ await db.delete(organizations).where(eq(organizations.id, organizationId));
+ }
+
+ it('grants an exemption with a trimmed reason, actor, and history row', async () => {
+ const org = await createExemptionTestOrganization();
+ try {
+ const caller = await createCallerForUser(adminUser.id);
+ const result = await caller.organizations.admin.setServiceFeeExemption({
+ organizationId: org.id,
+ isExempt: true,
+ reason: ' nonprofit partner ',
+ });
+
+ expect(result.current).toMatchObject({
+ organizationId: org.id,
+ isExempt: true,
+ reason: 'nonprofit partner',
+ changedByKiloUserId: adminUser.id,
+ });
+ expect(result.current.id).toBe(result.history.id);
+ expect(result.history).toMatchObject({
+ organizationId: org.id,
+ isExempt: true,
+ reason: 'nonprofit partner',
+ changedByKiloUserId: adminUser.id,
+ });
+ // Timestamps are normalized to UTC ISO at the API boundary.
+ expect(new Date(result.current.createdAt).toISOString()).toBe(result.current.createdAt);
+ expect(new Date(result.history.createdAt).toISOString()).toBe(result.history.createdAt);
+
+ const view = await caller.organizations.admin.getServiceFeeExemption({
+ organizationId: org.id,
+ });
+ expect(view.current?.isExempt).toBe(true);
+ expect(view.current?.reason).toBe('nonprofit partner');
+ expect(view.history).toHaveLength(1);
+ expect(view.history[0].id).toBe(result.history.id);
+ } finally {
+ await cleanupExemptionTestOrganization(org.id);
+ }
+ });
+
+ it('revokes an exemption and returns the newest history first', async () => {
+ const org = await createExemptionTestOrganization();
+ try {
+ const caller = await createCallerForUser(adminUser.id);
+ await caller.organizations.admin.setServiceFeeExemption({
+ organizationId: org.id,
+ isExempt: true,
+ reason: 'initial grant',
+ });
+ const revoked = await caller.organizations.admin.setServiceFeeExemption({
+ organizationId: org.id,
+ isExempt: false,
+ reason: 'revoked after contract review',
+ });
+
+ expect(revoked.current.isExempt).toBe(false);
+ expect(revoked.current.reason).toBe('revoked after contract review');
+ expect(revoked.current.id).toBe(revoked.history.id);
+
+ const view = await caller.organizations.admin.getServiceFeeExemption({
+ organizationId: org.id,
+ });
+ expect(view.current?.isExempt).toBe(false);
+ expect(view.history.map(row => row.reason)).toEqual([
+ 'revoked after contract review',
+ 'initial grant',
+ ]);
+ expect(view.history.map(row => row.isExempt)).toEqual([false, true]);
+ expect(view.history.every(row => row.changedByKiloUserId === adminUser.id)).toBe(true);
+ expect(
+ view.history.every(row => new Date(row.createdAt).toISOString() === row.createdAt)
+ ).toBe(true);
+ } finally {
+ await cleanupExemptionTestOrganization(org.id);
+ }
+ });
+
+ it('allows repeating the same state with a new reason and appends another row', async () => {
+ const org = await createExemptionTestOrganization();
+ try {
+ const caller = await createCallerForUser(adminUser.id);
+ const first = await caller.organizations.admin.setServiceFeeExemption({
+ organizationId: org.id,
+ isExempt: true,
+ reason: 'initial grant',
+ });
+ const second = await caller.organizations.admin.setServiceFeeExemption({
+ organizationId: org.id,
+ isExempt: true,
+ reason: 'renewed with updated documentation',
+ });
+
+ expect(second.current.isExempt).toBe(true);
+ expect(second.current.reason).toBe('renewed with updated documentation');
+ expect(second.current.id).toBe(second.history.id);
+ expect(second.current.id).not.toBe(first.current.id);
+
+ const view = await caller.organizations.admin.getServiceFeeExemption({
+ organizationId: org.id,
+ });
+ expect(view.history).toHaveLength(2);
+ expect(view.history.map(row => row.reason)).toEqual([
+ 'renewed with updated documentation',
+ 'initial grant',
+ ]);
+ } finally {
+ await cleanupExemptionTestOrganization(org.id);
+ }
+ });
+
+ it('rejects non-admin users from reading and mutating exemption state', async () => {
+ const org = await createExemptionTestOrganization();
+ try {
+ const caller = await createCallerForUser(nonAdminUser.id);
+
+ await expect(
+ caller.organizations.admin.getServiceFeeExemption({ organizationId: org.id })
+ ).rejects.toThrow('Admin access required');
+ await expect(
+ caller.organizations.admin.setServiceFeeExemption({
+ organizationId: org.id,
+ isExempt: true,
+ reason: 'non-admin attempt',
+ })
+ ).rejects.toThrow('Admin access required');
+ } finally {
+ await cleanupExemptionTestOrganization(org.id);
+ }
+ });
+
+ it('rejects blank and undersized reasons', async () => {
+ const org = await createExemptionTestOrganization();
+ try {
+ const caller = await createCallerForUser(adminUser.id);
+
+ await expect(
+ caller.organizations.admin.setServiceFeeExemption({
+ organizationId: org.id,
+ isExempt: true,
+ reason: ' ',
+ })
+ ).rejects.toThrow();
+ await expect(
+ caller.organizations.admin.setServiceFeeExemption({
+ organizationId: org.id,
+ isExempt: true,
+ reason: 'no',
+ })
+ ).rejects.toThrow();
+
+ const view = await caller.organizations.admin.getServiceFeeExemption({
+ organizationId: org.id,
+ });
+ expect(view.current).toBeNull();
+ expect(view.history).toEqual([]);
+ } finally {
+ await cleanupExemptionTestOrganization(org.id);
+ }
+ });
+
+ it('rejects oversized reasons', async () => {
+ const org = await createExemptionTestOrganization();
+ try {
+ const caller = await createCallerForUser(adminUser.id);
+
+ await expect(
+ caller.organizations.admin.setServiceFeeExemption({
+ organizationId: org.id,
+ isExempt: true,
+ reason: 'x'.repeat(501),
+ })
+ ).rejects.toThrow();
+ } finally {
+ await cleanupExemptionTestOrganization(org.id);
+ }
+ });
+
+ it('maps deleted and missing organizations to NOT_FOUND', async () => {
+ const org = await createExemptionTestOrganization();
+ try {
+ await markOrganizationAsDeleted(org.id);
+ const caller = await createCallerForUser(adminUser.id);
+
+ await expect(
+ caller.organizations.admin.setServiceFeeExemption({
+ organizationId: org.id,
+ isExempt: true,
+ reason: 'deleted organization',
+ })
+ ).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'Organization not found' });
+ await expect(
+ caller.organizations.admin.setServiceFeeExemption({
+ organizationId: '550e8400-e29b-41d4-a716-446655440099',
+ isExempt: true,
+ reason: 'missing organization',
+ })
+ ).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'Organization not found' });
+ } finally {
+ await cleanupExemptionTestOrganization(org.id);
+ }
+ });
+
+ it('does not expose exemption fields through customer organization APIs', async () => {
+ const org = await createOrganization(
+ `Customer surface ${crypto.randomUUID()}`,
+ nonAdminUser.id
+ );
+ try {
+ const adminCaller = await createCallerForUser(adminUser.id);
+ await adminCaller.organizations.admin.setServiceFeeExemption({
+ organizationId: org.id,
+ isExempt: true,
+ reason: 'granted for customer surface check',
+ });
+
+ const customerCaller = await createCallerForUser(nonAdminUser.id);
+ const customerOrganizations = await customerCaller.organizations.list();
+ const row = customerOrganizations.find(entry => entry.organizationId === org.id);
+
+ expect(row).toBeDefined();
+ expect(Object.keys(row ?? {}).some(key => /exempt/i.test(key))).toBe(false);
+ expect(JSON.stringify(row)).not.toMatch(
+ /service_fee_exemption|serviceFeeExemption|isExempt/
+ );
+ } finally {
+ await cleanupExemptionTestOrganization(org.id);
+ }
+ });
+ });
+
describe('getKiloPassSummary', () => {
it('returns the parent agreement as read-only information for a child organization', async () => {
const parent = await createOrganization(
diff --git a/apps/web/src/routers/organizations/organization-admin-router.ts b/apps/web/src/routers/organizations/organization-admin-router.ts
index 11d7c8b932..7ed16a4008 100644
--- a/apps/web/src/routers/organizations/organization-admin-router.ts
+++ b/apps/web/src/routers/organizations/organization-admin-router.ts
@@ -70,6 +70,14 @@ import {
createChildOrganization,
validateParentOrganizationChange,
} from '@/lib/organizations/organization-hierarchy';
+import {
+ getOrganizationServiceFeeExemption,
+ OrganizationServiceFeeExemptionError,
+ ORGANIZATION_SERVICE_FEE_EXEMPTION_REASON_MAX_LENGTH,
+ ORGANIZATION_SERVICE_FEE_EXEMPTION_REASON_MIN_LENGTH,
+ setOrganizationServiceFeeExemption,
+} from '@/lib/service-fees/organization-exemptions';
+import { createDefaultOrganizationServiceFeeExemptionStore } from '@/lib/service-fees/drizzle-store';
const OrganizationListInputSchema = z.object({
page: z.number().int().min(1).default(1),
@@ -203,6 +211,30 @@ const AdminOrganizationHierarchySchema = z.object({
children: z.array(OrganizationHierarchySummarySchema),
});
+const SetServiceFeeExemptionInputSchema = z.object({
+ organizationId: z.uuid(),
+ isExempt: z.boolean(),
+ reason: z
+ .string()
+ .trim()
+ .min(ORGANIZATION_SERVICE_FEE_EXEMPTION_REASON_MIN_LENGTH)
+ .max(ORGANIZATION_SERVICE_FEE_EXEMPTION_REASON_MAX_LENGTH),
+});
+
+const AdminOrganizationServiceFeeExemptionRecordSchema = z.object({
+ id: z.uuid(),
+ organizationId: z.uuid(),
+ isExempt: z.boolean(),
+ reason: z.string(),
+ changedByKiloUserId: z.string().nullable(),
+ createdAt: z.iso.datetime(),
+});
+
+const AdminOrganizationServiceFeeExemptionViewSchema = z.object({
+ current: AdminOrganizationServiceFeeExemptionRecordSchema.nullable(),
+ history: z.array(AdminOrganizationServiceFeeExemptionRecordSchema),
+});
+
const GrantCreditInputSchema = z
.object({
organizationId: z.uuid(),
@@ -529,6 +561,47 @@ export const organizationAdminRouter = createTRPCRouter({
})
),
+ getServiceFeeExemption: adminProcedure
+ .input(OrganizationIdInputSchema)
+ .output(AdminOrganizationServiceFeeExemptionViewSchema)
+ .query(({ input }) =>
+ getOrganizationServiceFeeExemption({
+ store: createDefaultOrganizationServiceFeeExemptionStore(),
+ organizationId: input.organizationId,
+ })
+ ),
+
+ setServiceFeeExemption: adminProcedure
+ .input(SetServiceFeeExemptionInputSchema)
+ .output(
+ z.object({
+ current: AdminOrganizationServiceFeeExemptionRecordSchema,
+ history: AdminOrganizationServiceFeeExemptionRecordSchema,
+ })
+ )
+ .mutation(async ({ input, ctx }) => {
+ try {
+ return await setOrganizationServiceFeeExemption({
+ store: createDefaultOrganizationServiceFeeExemptionStore(),
+ organizationId: input.organizationId,
+ isExempt: input.isExempt,
+ reason: input.reason,
+ changedByKiloUserId: ctx.user.id,
+ });
+ } catch (error) {
+ if (
+ error instanceof OrganizationServiceFeeExemptionError &&
+ error.code === 'organization_not_found'
+ ) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Organization not found',
+ });
+ }
+ throw error;
+ }
+ }),
+
getHierarchy: adminProcedure
.input(OrganizationIdInputSchema)
.output(AdminOrganizationHierarchySchema)
diff --git a/apps/web/src/scripts/service-fees/kilo-pass-classification-audit.ts b/apps/web/src/scripts/service-fees/kilo-pass-classification-audit.ts
new file mode 100644
index 0000000000..8e1fcf4ad8
--- /dev/null
+++ b/apps/web/src/scripts/service-fees/kilo-pass-classification-audit.ts
@@ -0,0 +1,138 @@
+/**
+ * Read-only pre-release audit: can active Stripe-managed Personal Kilo Pass and
+ * self-service org Kilo Pass subscriptions be classified for service-fee invoices?
+ *
+ * Usage:
+ * pnpm --filter web script:run service-fees kilo-pass-classification-audit
+ *
+ * This script never creates, updates, or deletes Stripe or database records.
+ */
+
+import { and, eq, inArray } from 'drizzle-orm';
+import type Stripe from 'stripe';
+import { kilo_pass_org_agreements, kilo_pass_subscriptions } from '@kilocode/db/schema';
+import { KiloPassOrgPurchaseChannel, KiloPassPaymentProvider } from '@kilocode/db/schema-types';
+import { db } from '@/lib/drizzle';
+import { SEAT_PRODUCT_IDS } from '@/lib/organizations/stripe-seat-line-items';
+import { getKnownStripePriceIdsForKiloPass } from '@/lib/kilo-pass/stripe-price-ids.server';
+import {
+ auditKiloPassClassifications,
+ LIVE_ORG_KILO_PASS_STATES,
+ LIVE_PERSONAL_KILO_PASS_STATUSES,
+ type KiloPassClassificationAuditStore,
+ type StripeSubscriptionSnapshot,
+} from '@/lib/service-fees/kilo-pass-classification-audit';
+import { assertServiceFeeAuditReadOnly } from '@/lib/service-fees/read-only';
+import { client as stripe } from '@/lib/stripe-client';
+
+export function createDatabaseKiloPassClassificationStore(): KiloPassClassificationAuditStore {
+ return {
+ async listPersonalRows() {
+ return db
+ .select({
+ id: kilo_pass_subscriptions.id,
+ kiloUserId: kilo_pass_subscriptions.kilo_user_id,
+ stripeSubscriptionId: kilo_pass_subscriptions.stripe_subscription_id,
+ status: kilo_pass_subscriptions.status,
+ tier: kilo_pass_subscriptions.tier,
+ cadence: kilo_pass_subscriptions.cadence,
+ })
+ .from(kilo_pass_subscriptions)
+ .where(
+ and(
+ eq(kilo_pass_subscriptions.payment_provider, KiloPassPaymentProvider.Stripe),
+ inArray(kilo_pass_subscriptions.status, [...LIVE_PERSONAL_KILO_PASS_STATUSES])
+ )
+ );
+ },
+ async listOrganizationRows() {
+ return db
+ .select({
+ id: kilo_pass_org_agreements.id,
+ organizationId: kilo_pass_org_agreements.parent_organization_id,
+ providerSubscriptionId: kilo_pass_org_agreements.provider_subscription_id,
+ providerSeatAddOnItemId: kilo_pass_org_agreements.provider_seat_add_on_item_id,
+ state: kilo_pass_org_agreements.state,
+ purchaseChannel: kilo_pass_org_agreements.purchase_channel,
+ })
+ .from(kilo_pass_org_agreements)
+ .where(
+ and(
+ eq(kilo_pass_org_agreements.purchase_channel, KiloPassOrgPurchaseChannel.SelfServe),
+ inArray(kilo_pass_org_agreements.state, [...LIVE_ORG_KILO_PASS_STATES])
+ )
+ );
+ },
+ };
+}
+
+export async function retrieveStripeSubscriptionSnapshot(
+ subscriptionId: string
+): Promise {
+ try {
+ const subscription = await stripe.subscriptions.retrieve(subscriptionId);
+ const items: Stripe.SubscriptionItem[] = [];
+ let startingAfter: string | undefined;
+ for (;;) {
+ const page = await stripe.subscriptionItems.list({
+ subscription: subscriptionId,
+ limit: 100,
+ expand: ['data.price'],
+ ...(startingAfter ? { starting_after: startingAfter } : {}),
+ });
+ items.push(...page.data);
+ if (!page.has_more) break;
+ const cursor = page.data.at(-1)?.id;
+ if (!cursor) {
+ throw new Error(
+ `Stripe subscription ${subscriptionId} item page is marked has_more without a cursor`
+ );
+ }
+ startingAfter = cursor;
+ }
+ return {
+ id: subscription.id,
+ status: subscription.status,
+ metadata: subscription.metadata ?? {},
+ items: items.map(item => ({
+ id: item.id,
+ priceId: item.price?.id ?? null,
+ productId: productIdFromPrice(item.price?.product),
+ })),
+ };
+ } catch (error) {
+ if (isMissingStripeResource(error)) return null;
+ throw error;
+ }
+}
+
+export async function run(...args: string[]): Promise {
+ assertServiceFeeAuditReadOnly(args);
+ const report = await auditKiloPassClassifications({
+ knownKiloPassPriceIds: new Set(getKnownStripePriceIdsForKiloPass()),
+ seatProductIds: SEAT_PRODUCT_IDS,
+ store: createDatabaseKiloPassClassificationStore(),
+ retrieveSubscription: retrieveStripeSubscriptionSnapshot,
+ });
+ if (report.unclassifiableCount > 0) {
+ process.exitCode = 1;
+ }
+}
+
+function productIdFromPrice(product: unknown): string | null {
+ if (typeof product === 'string') return product;
+ if (product && typeof product === 'object' && 'id' in product) {
+ const id = product.id;
+ return typeof id === 'string' ? id : null;
+ }
+ return null;
+}
+
+function isMissingStripeResource(error: unknown): boolean {
+ return (
+ typeof error === 'object' &&
+ error !== null &&
+ 'code' in error &&
+ (error as { code?: unknown }).code === 'resource_missing'
+ );
+}
diff --git a/apps/web/src/scripts/service-fees/restricted-coupon-audit.ts b/apps/web/src/scripts/service-fees/restricted-coupon-audit.ts
new file mode 100644
index 0000000000..176ac631e8
--- /dev/null
+++ b/apps/web/src/scripts/service-fees/restricted-coupon-audit.ts
@@ -0,0 +1,95 @@
+/**
+ * Read-only restricted-coupon audit: list Stripe coupons whose applies_to.products
+ * intersects known fee-bearing Kilo Pass or top-up products and alert Admin Slack.
+ *
+ * Usage:
+ * pnpm --filter web script:run service-fees restricted-coupon-audit
+ * pnpm --filter web script:run service-fees restricted-coupon-audit --no-alert
+ *
+ * This script never creates, updates, or deletes Stripe coupons or products.
+ */
+
+import { captureMessage } from '@sentry/nextjs';
+import { getEnvVariable } from '@/lib/dotenvx';
+import { getKnownStripePriceIdsForKiloPass } from '@/lib/kilo-pass/stripe-price-ids.server';
+import {
+ auditRestrictedCoupons,
+ listCouponSnapshotsEnsuringAppliesTo,
+ parseRestrictedCouponAuditArgs,
+ SERVICE_FEE_RESTRICTED_COUPON_SENTRY_TAG,
+ type RestrictedCouponAlertPayload,
+ type StripeCouponSnapshot,
+} from '@/lib/service-fees/restricted-coupon-audit';
+import { sendAdminSlackNotification } from '@/lib/slack/admin-notifications';
+import { client as stripe } from '@/lib/stripe-client';
+
+/**
+ * Lists coupons paginated and guarantees applies_to is actually retrieved:
+ * applies_to is not an expandable ID reference, so list payloads that omit it
+ * fall back to an authoritative read-only stripe.coupons.retrieve. Both calls
+ * are GETs; this function never mutates Stripe state.
+ */
+export async function listStripeCouponSnapshots(): Promise {
+ return listCouponSnapshotsEnsuringAppliesTo({
+ listPage: startingAfter =>
+ stripe.coupons.list({
+ limit: 100,
+ ...(startingAfter ? { starting_after: startingAfter } : {}),
+ }),
+ retrieveCoupon: couponId => stripe.coupons.retrieve(couponId),
+ });
+}
+
+export async function listFeeBearingStripeProductIds(): Promise {
+ const priceIds = [...getKnownStripePriceIdsForKiloPass(), requireEnv('STRIPE_TOP_UP_PRICE_ID')];
+ const productIds = new Set();
+
+ for (const priceId of priceIds) {
+ const price = await stripe.prices.retrieve(priceId);
+ const productId = productIdFromPrice(price.product);
+ if (!productId) {
+ throw new Error(`fee-bearing price ${priceId} has no product id`);
+ }
+ productIds.add(productId);
+ }
+
+ return [...productIds].sort((left, right) => left.localeCompare(right));
+}
+
+export async function run(...args: string[]): Promise {
+ const parsed = parseRestrictedCouponAuditArgs(args);
+ const report = await auditRestrictedCoupons({
+ listCoupons: listStripeCouponSnapshots,
+ listFeeBearingProductIds: listFeeBearingStripeProductIds,
+ sendAlert: parsed.alert ? sendAdminSlackNotification : undefined,
+ capture: parsed.alert ? captureRestrictedCouponDetection : undefined,
+ });
+ if (report.findings.length > 0) {
+ process.exitCode = 1;
+ }
+}
+
+function captureRestrictedCouponDetection(payload: RestrictedCouponAlertPayload): void {
+ captureMessage(SERVICE_FEE_RESTRICTED_COUPON_SENTRY_TAG, {
+ level: 'error',
+ tags: { [SERVICE_FEE_RESTRICTED_COUPON_SENTRY_TAG]: 'true' },
+ extra: payload,
+ });
+}
+
+function requireEnv(name: string): string {
+ const value = getEnvVariable(name).trim();
+ if (!value) {
+ throw new Error(`Missing required env var for restricted-coupon audit: ${name}`);
+ }
+ return value;
+}
+
+function productIdFromPrice(product: unknown): string | null {
+ if (typeof product === 'string') return product;
+ if (product && typeof product === 'object' && 'id' in product) {
+ const id = product.id;
+ return typeof id === 'string' ? id : null;
+ }
+ return null;
+}