Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c687680
feat(db): add service fee assessment schema and billing ADR
jeanduplessis Aug 12, 2026
40d83ea
docs(billing): keep service fee ops procedure out of this repo
jeanduplessis Aug 12, 2026
b0ba59f
docs(billing): add service fee requirements and validation plan
jeanduplessis Aug 12, 2026
9ae488e
Update docs/adr/0004-stripe-service-fee-assessment.md
jeanduplessis Aug 13, 2026
3d96091
Update .plans/service-fees/SPEC.md
jeanduplessis Aug 13, 2026
28e1b30
refactor(db): reduce service fee schema
jeanduplessis Aug 18, 2026
244056c
docs(billing): normalize service fee tables
jeanduplessis Aug 18, 2026
31bd5da
docs(billing): align plans with reduced schema
jeanduplessis Aug 18, 2026
f5731b3
chore(db): regenerate service fee migration
jeanduplessis Aug 19, 2026
c53d656
feat(billing): add unused service fee calculation and assessment library
jeanduplessis Aug 12, 2026
7e5856d
refactor(billing): simplify service fee persistence
jeanduplessis Aug 18, 2026
c38220b
fix(billing): order exemption changes monotonically
jeanduplessis Aug 18, 2026
68aaf4a
fix(billing): terminate fee preparation fallbacks
jeanduplessis Aug 19, 2026
90ae367
feat(billing): grant credits from trusted product principal
jeanduplessis Aug 12, 2026
484f4d4
fix(billing): harden Kilo Pass settlement
jeanduplessis Aug 19, 2026
9f91364
feat(billing): charge service fees on credit top-ups
jeanduplessis Aug 12, 2026
878935f
test(billing): use unified exemption log
jeanduplessis Aug 18, 2026
10131df
feat(billing): charge service fees on Kilo Pass invoices
jeanduplessis Aug 12, 2026
c2d309a
test(billing): use exemption log identity
jeanduplessis Aug 18, 2026
f7b5df8
fix(billing): prevent duplicate Kilo Pass fees
jeanduplessis Aug 19, 2026
ba157ed
feat(admin): add service fee exemptions, revenue split, and audits
jeanduplessis Aug 12, 2026
e564ec7
refactor(admin): read unified exemption history
jeanduplessis Aug 18, 2026
4394f10
test(admin): assert appended exemption identity
jeanduplessis Aug 18, 2026
9983757
fix(admin): scale revenue and classification audits
jeanduplessis Aug 19, 2026
6acac19
fix(admin): report service fees as a narrow sidecar
jeanduplessis Aug 19, 2026
647b7f5
fix(billing): move service fee cutover to November
jeanduplessis Aug 19, 2026
ed191b7
test(billing): align exemption fixtures with cutover
jeanduplessis Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
356 changes: 356 additions & 0 deletions .plans/service-fees/GOAL.md

Large diffs are not rendered by default.

1,253 changes: 1,253 additions & 0 deletions .plans/service-fees/SPEC.md

Large diffs are not rendered by default.

1,806 changes: 1,806 additions & 0 deletions .plans/service-fees/VALIDATION.md

Large diffs are not rendered by default.

14 changes: 11 additions & 3 deletions .specs/impact-affiliate-tracking.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ BCP 14 [RFC 2119] [RFC 8174] keywords apply only when they appear in all capital
governed by KiloClaw billing. Zero-dollar periods, fully comped periods, organization-scoped KiloClaw activity, and
admin-only interventions are excluded.
- **Affiliate-eligible Kilo Pass invoice settlement**: Kilo Pass Stripe invoice settlement for an attributed user with
positive paid amount and resolvable Kilo Pass tier and cadence. Initial purchases and renewals can qualify.
a positive settled eligible product amount and resolvable Kilo Pass tier and cadence. The eligible product amount
excludes any service-fee line. Initial purchases and renewals can qualify.
- **Reported amount**: Monetary amount represented in the payment currency's major units from the authoritative
monetized amount for the eligible event. Catalog/list price and Kilo Pass credit issuance amounts are not substitutes.
- **Kilo Pass tier**: Eligible package level `19`, `49`, or `199`.
Expand Down Expand Up @@ -148,8 +149,10 @@ after the winning attribution is established.
payment period or invoice they represent.

17. SALE events MUST report the eligible event's reported amount and payment currency. KiloClaw SALE amounts MUST use
the monetized KiloClaw payment-period amount. Kilo Pass SALE amounts MUST use the positive settled invoice paid
amount, not catalog price or credit issuance value.
the monetized KiloClaw payment-period amount. Kilo Pass SALE amounts MUST use the settled eligible product amount,
excluding any service-fee line. That product amount is the service-fee assessment's `settled_product_minor`
converted to major units, not the gross settled invoice paid amount, catalog price, or credit issuance value. A
zero settled product amount MUST NOT produce a SALE.

18. The reported amount MUST be normalized to the payment currency's major units without changing the authoritative
settled or monetized value. Any rounding needed by a provider integration must preserve that business amount.
Expand Down Expand Up @@ -252,6 +255,11 @@ after the winning attribution is established.

## Changelog

### 2026-08-11 -- Kilo Pass SALE amount excludes the service fee

Amended rule 17 so Kilo Pass SALE amounts use the assessment's settled eligible product amount
(`settled_product_minor`), not the gross settled invoice paid amount. A zero product amount suppresses SALE.

### 2026-05-28 -- Enforced EFW refund reversals

Expanded adverse SALE reversal to enforced Stripe Early Fraud Warning refunds so proactive refunds can reverse a full eligible affiliate commission without waiting for a dispute, while preserving reversal identity and deduplication requirements.
Expand Down
30 changes: 30 additions & 0 deletions apps/web/src/app/admin/api/organizations/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -66,6 +67,7 @@ export function OrganizationAdminDashboard({ organizationId }: { organizationId:
<OrganizationAdminCreatedBy organizationId={organizationId} />
<OrganizationAdminCreditGrant organizationId={organizationId} />
<OrganizationAdminCreditNullify organizationId={organizationId} />
<OrganizationAdminServiceFeeExemption organizationId={organizationId} />
<OrganizationWorkOSCard organizationId={organizationId} />
</div>
<div className="space-y-8 lg:col-span-2">
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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
);
}
Loading
Loading