From ba157ed4ae15af7017125fa9e49c89897f80de93 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Wed, 12 Aug 2026 17:15:02 +0200 Subject: [PATCH 1/4] feat(admin): add service fee exemptions, revenue split, and audits Add the org exemption admin UI, fee-vs-product revenue reporting, and read-only Kilo Pass classification and restricted-coupon audits. --- .../src/app/admin/api/organizations/hooks.ts | 30 ++ .../OrganizationAdminDashboard.tsx | 2 + ...inServiceFeeExemption.dialog-state.test.ts | 83 ++++ ...onAdminServiceFeeExemption.dialog-state.ts | 65 +++ .../OrganizationAdminServiceFeeExemption.tsx | 277 +++++++++++ .../admin/components/RevenueDailyChart.tsx | 351 ++++++++------ .../src/app/admin/components/RevenueStats.tsx | 430 ++++++++++++------ apps/web/src/app/admin/revenue/page.tsx | 71 +-- .../revenue/revenue-dashboard-status.test.ts | 48 ++ .../admin/revenue/revenue-dashboard-status.ts | 15 + apps/web/src/lib/revenueKpi.test.ts | 259 +++++++++++ apps/web/src/lib/revenueKpi.ts | 96 +++- .../kilo-pass-classification-audit.test.ts | 324 +++++++++++++ .../kilo-pass-classification-audit.ts | 380 ++++++++++++++++ .../restricted-coupon-audit.test.ts | 351 ++++++++++++++ .../service-fees/restricted-coupon-audit.ts | 348 ++++++++++++++ .../organization-admin-router.test.ts | 255 ++++++++++- .../organization-admin-router.ts | 83 ++++ .../kilo-pass-classification-audit.ts | 120 +++++ .../service-fees/restricted-coupon-audit.ts | 95 ++++ 20 files changed, 3334 insertions(+), 349 deletions(-) create mode 100644 apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminServiceFeeExemption.dialog-state.test.ts create mode 100644 apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminServiceFeeExemption.dialog-state.ts create mode 100644 apps/web/src/app/admin/components/OrganizationAdmin/OrganizationAdminServiceFeeExemption.tsx create mode 100644 apps/web/src/app/admin/revenue/revenue-dashboard-status.test.ts create mode 100644 apps/web/src/app/admin/revenue/revenue-dashboard-status.ts create mode 100644 apps/web/src/lib/revenueKpi.test.ts create mode 100644 apps/web/src/lib/service-fees/kilo-pass-classification-audit.test.ts create mode 100644 apps/web/src/lib/service-fees/kilo-pass-classification-audit.ts create mode 100644 apps/web/src/lib/service-fees/restricted-coupon-audit.test.ts create mode 100644 apps/web/src/lib/service-fees/restricted-coupon-audit.ts create mode 100644 apps/web/src/scripts/service-fees/kilo-pass-classification-audit.ts create mode 100644 apps/web/src/scripts/service-fees/restricted-coupon-audit.ts 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..f22993a424 --- /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. + + + + + + ); + } + + 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.changedAt)} +

+
+ ) : 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); + }} + > + + + + { + 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. + + + +
+ +