diff --git a/apps/web/src/app/(app)/organizations/[id]/distribute-funds/DistributeFundsPage.tsx b/apps/web/src/app/(app)/organizations/[id]/distribute-funds/DistributeFundsPage.tsx new file mode 100644 index 0000000000..cde77688d8 --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/distribute-funds/DistributeFundsPage.tsx @@ -0,0 +1,257 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { AlertTriangle } from 'lucide-react'; +import { toast } from 'sonner'; + +import { LockableContainer } from '@/components/organizations/LockableContainer'; +import { OrganizationPageHeader } from '@/components/organizations/OrganizationPageHeader'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { + Table, + TableBody, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + useDistributeFundsToChildren, + useOrganizationChildBalances, +} from '@/app/api/organizations/hooks'; +import { formatMicrodollars } from '@/lib/admin-utils'; +import { cn } from '@/lib/utils'; +import { parseDollarInput } from './parseDollarInput'; + +type Props = { + organizationId: string; +}; + +export function DistributeFundsPage({ organizationId }: Props) { + const { data, isLoading, error } = useOrganizationChildBalances(organizationId); + const distribute = useDistributeFundsToChildren(); + const [amounts, setAmounts] = useState>({}); + + const parentBalance = data?.parentBalanceMicrodollars ?? 0; + const hasExpiringCredits = data?.hasExpiringCredits ?? false; + const children = useMemo(() => data?.children ?? [], [data]); + + const rows = useMemo( + () => + children.map(child => { + const raw = amounts[child.id] ?? ''; + return { child, raw, ...parseDollarInput(raw) }; + }), + [children, amounts] + ); + + const totalMicrodollars = rows.reduce((sum, row) => sum + row.microdollars, 0); + const remainingMicrodollars = parentBalance - totalMicrodollars; + const overBudget = remainingMicrodollars < 0; + const hasFieldError = rows.some(row => row.error != null); + const canSubmit = + !hasExpiringCredits && + !hasFieldError && + !overBudget && + totalMicrodollars > 0 && + !distribute.isPending; + + const handleSubmit = () => { + const allocations = rows + .filter(row => row.microdollars > 0) + .map(row => ({ childOrganizationId: row.child.id, amountMicrodollars: row.microdollars })); + if (allocations.length === 0) return; + + distribute.mutate( + { organizationId, allocations }, + { + onSuccess: result => { + toast.success( + `Distributed ${formatMicrodollars(result.totalMovedMicrodollars)} to ${result.childCount} child organization${result.childCount === 1 ? '' : 's'}.` + ); + setAmounts({}); + }, + onError: mutationError => { + toast.error( + mutationError instanceof Error ? mutationError.message : 'Failed to distribute funds.' + ); + }, + } + ); + }; + + return ( +
+ + + {error ? ( + + + + Failed to load child organizations:{' '} + {error instanceof Error ? error.message : 'Unknown error'} + + + ) : isLoading ? ( + + + + + + + + + + + + ) : children.length === 0 ? ( + + + + This organization has no child organizations to distribute funds to. + + + ) : ( + // Locked (non-interactive) for read-only orgs whose trial has expired, + // matching the lock UI used across organization settings. The transfer + // mutation also enforces an active subscription/trial server-side. + + + + Distribute funds to child organizations + + Move available balance to child organizations. The total you distribute can't + exceed the available balance. + + + + {hasExpiringCredits && ( + + + + Distributing funds isn't available while this organization has expiring + credits. + + + )} + +
+ Available to distribute + + {formatMicrodollars(parentBalance)} + +
+ + + + + Organization + Current balance + Amount to move + + + + {rows.map(row => { + const errorId = `${row.child.id}-amount-error`; + return ( + + {row.child.name} + + {formatMicrodollars(row.child.balanceMicrodollars)} + + +
+
+ + $ + + + setAmounts(previous => ({ + ...previous, + [row.child.id]: event.target.value, + })) + } + className={cn( + 'pl-7 text-right tabular-nums', + row.error && + 'border-destructive focus-visible:ring-destructive/40' + )} + /> +
+ {row.error && ( +

+ {row.error} +

+ )} +
+
+
+ ); + })} +
+ + + Total to distribute + + + {formatMicrodollars(totalMicrodollars)} + + + + Remaining balance + + + {formatMicrodollars(remainingMicrodollars)} + + + +
+
+ + {overBudget && ( +

+ The total exceeds the available balance. +

+ )} + +
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/distribute-funds/page.tsx b/apps/web/src/app/(app)/organizations/[id]/distribute-funds/page.tsx new file mode 100644 index 0000000000..bb3f389045 --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/distribute-funds/page.tsx @@ -0,0 +1,16 @@ +import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; +import { DistributeFundsPage } from './DistributeFundsPage'; + +export default async function OrganizationDistributeFundsPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + return ( + } + /> + ); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/distribute-funds/parseDollarInput.test.ts b/apps/web/src/app/(app)/organizations/[id]/distribute-funds/parseDollarInput.test.ts new file mode 100644 index 0000000000..76f213f9ac --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/distribute-funds/parseDollarInput.test.ts @@ -0,0 +1,50 @@ +import { parseDollarInput } from './parseDollarInput'; + +describe('parseDollarInput', () => { + it('treats empty input as no allocation', () => { + expect(parseDollarInput('')).toEqual({ microdollars: 0, error: null }); + expect(parseDollarInput(' ')).toEqual({ microdollars: 0, error: null }); + }); + + it('treats an explicit zero as no allocation', () => { + expect(parseDollarInput('0')).toEqual({ microdollars: 0, error: null }); + expect(parseDollarInput('0.00')).toEqual({ microdollars: 0, error: null }); + }); + + it('parses whole and fractional dollar amounts to microdollars', () => { + expect(parseDollarInput('10')).toEqual({ microdollars: 10_000_000, error: null }); + expect(parseDollarInput('10.50')).toEqual({ microdollars: 10_500_000, error: null }); + expect(parseDollarInput('0.01')).toEqual({ microdollars: 10_000, error: null }); + expect(parseDollarInput('.5')).toEqual({ microdollars: 500_000, error: null }); + expect(parseDollarInput('5.')).toEqual({ microdollars: 5_000_000, error: null }); + }); + + it('tolerates commas as thousands separators', () => { + expect(parseDollarInput('1,000')).toEqual({ microdollars: 1_000_000_000, error: null }); + expect(parseDollarInput('1,234.56')).toEqual({ microdollars: 1_234_560_000, error: null }); + }); + + it('rejects more than two decimal places', () => { + expect(parseDollarInput('1.005')).toEqual({ + microdollars: 0, + error: 'Use at most 2 decimal places', + }); + }); + + it('rejects non-numeric and negative input', () => { + expect(parseDollarInput('abc')).toEqual({ microdollars: 0, error: 'Enter a valid amount' }); + expect(parseDollarInput('-5')).toEqual({ microdollars: 0, error: 'Enter a valid amount' }); + expect(parseDollarInput('.')).toEqual({ microdollars: 0, error: 'Enter a valid amount' }); + expect(parseDollarInput('1.2.3')).toEqual({ + microdollars: 0, + error: 'Enter a valid amount', + }); + }); + + it('rejects pathological input that parses to a non-finite number', () => { + expect(parseDollarInput('9'.repeat(400))).toEqual({ + microdollars: 0, + error: 'Enter a valid amount', + }); + }); +}); diff --git a/apps/web/src/app/(app)/organizations/[id]/distribute-funds/parseDollarInput.ts b/apps/web/src/app/(app)/organizations/[id]/distribute-funds/parseDollarInput.ts new file mode 100644 index 0000000000..c1cf9e4cf4 --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/distribute-funds/parseDollarInput.ts @@ -0,0 +1,32 @@ +import { toMicrodollars } from '@/lib/utils'; + +export type ParsedDollarInput = { microdollars: number; error: string | null }; + +/** + * Parses a user-entered dollar amount into microdollars. + * + * An empty string (and an explicit "0") is treated as "no allocation" rather + * than an error. Commas are tolerated as thousands separators. Anything that + * isn't a non-negative decimal with at most two fraction digits is rejected. + */ +export function parseDollarInput(raw: string): ParsedDollarInput { + const trimmed = raw.trim(); + if (trimmed === '') return { microdollars: 0, error: null }; + + const normalized = trimmed.replace(/,/g, ''); + if (!/^\d*\.?\d*$/.test(normalized) || normalized === '.') { + return { microdollars: 0, error: 'Enter a valid amount' }; + } + if (/\.\d{3,}$/.test(normalized)) { + return { microdollars: 0, error: 'Use at most 2 decimal places' }; + } + + const value = Number(normalized); + // Guards against pathological inputs (e.g. a very long digit string parsing + // to Infinity); the regex above already excludes negatives and non-numerics. + if (!Number.isFinite(value)) { + return { microdollars: 0, error: 'Enter a valid amount' }; + } + if (value === 0) return { microdollars: 0, error: null }; + return { microdollars: toMicrodollars(value), error: null }; +} diff --git a/apps/web/src/app/api/organizations/hooks.ts b/apps/web/src/app/api/organizations/hooks.ts index ae28e1c4eb..e74f42cd7c 100644 --- a/apps/web/src/app/api/organizations/hooks.ts +++ b/apps/web/src/app/api/organizations/hooks.ts @@ -39,6 +39,36 @@ export function useOrganizationChildren(id: string) { ); } +export function useOrganizationChildBalances(id: string) { + const trpc = useTRPC(); + return useQuery( + trpc.organizations.funds.childBalances.queryOptions( + { organizationId: id }, + { + trpc: { + context: { + skipBatch: true, + }, + }, + } + ) + ); +} + +export function useDistributeFundsToChildren() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + return useMutation( + trpc.organizations.funds.distribute.mutationOptions({ + onSuccess: async () => { + // Balances change on both the parent and the children, so refresh all + // organization-scoped queries. + await queryClient.invalidateQueries({ queryKey: trpc.organizations.pathKey() }); + }, + }) + ); +} + const useInvalidateOrganizationAndMembers = () => { const trpc = useTRPC(); const queryClient = useQueryClient(); diff --git a/apps/web/src/components/organizations/OrganizationChildOrganizationsCard.tsx b/apps/web/src/components/organizations/OrganizationChildOrganizationsCard.tsx index 11632bcc05..a80177c39f 100644 --- a/apps/web/src/components/organizations/OrganizationChildOrganizationsCard.tsx +++ b/apps/web/src/components/organizations/OrganizationChildOrganizationsCard.tsx @@ -2,7 +2,15 @@ import Link from 'next/link'; import { Building2, ChevronRight } from 'lucide-react'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; import { useOrganizationChildren } from '@/app/api/organizations/hooks'; type Props = { @@ -47,6 +55,16 @@ export function OrganizationChildOrganizationsCard({ organizationId }: Props) { ))} + + + ); } diff --git a/apps/web/src/routers/organizations/organization-funds-router.test.ts b/apps/web/src/routers/organizations/organization-funds-router.test.ts new file mode 100644 index 0000000000..d32df25453 --- /dev/null +++ b/apps/web/src/routers/organizations/organization-funds-router.test.ts @@ -0,0 +1,300 @@ +import { createCallerForUser } from '@/routers/test-utils'; +import { db } from '@/lib/drizzle'; +import { + organizations, + credit_transactions, + organization_audit_logs, + organization_memberships, +} from '@kilocode/db/schema'; +import { eq, and, inArray } from 'drizzle-orm'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { createOrganization } from '@/lib/organizations/organizations'; +import { hasOrganizationEverPaid } from '@/lib/creditTransactions'; +import type { User, Organization } from '@kilocode/db/schema'; + +let ownerUser: User; +let parentOrg: Organization; +let childA: Organization; +let childB: Organization; +let unrelatedOrg: Organization; + +async function setChildOf(childId: string, parentId: string) { + await db + .update(organizations) + .set({ parent_organization_id: parentId }) + .where(eq(organizations.id, childId)); +} + +async function setBalance(organizationId: string, acquired: number, used: number) { + await db + .update(organizations) + .set({ + total_microdollars_acquired: acquired, + microdollars_used: used, + microdollars_balance: acquired - used, + next_credit_expiration_at: null, + }) + .where(eq(organizations.id, organizationId)); +} + +function balanceOf(org: { total_microdollars_acquired: number; microdollars_used: number }) { + return org.total_microdollars_acquired - org.microdollars_used; +} + +async function getOrg(organizationId: string) { + const [org] = await db + .select({ + total_microdollars_acquired: organizations.total_microdollars_acquired, + microdollars_used: organizations.microdollars_used, + }) + .from(organizations) + .where(eq(organizations.id, organizationId)); + return org; +} + +describe('organization funds router', () => { + beforeAll(async () => { + ownerUser = await insertTestUser({ + google_user_email: 'funds-owner@example.com', + google_user_name: 'Funds Owner', + is_admin: false, + }); + + parentOrg = await createOrganization('Funds Parent Org', ownerUser.id); + childA = await createOrganization('Funds Child A', ownerUser.id); + childB = await createOrganization('Funds Child B', ownerUser.id); + unrelatedOrg = await createOrganization('Funds Unrelated Org', ownerUser.id); + + await setChildOf(childA.id, parentOrg.id); + await setChildOf(childB.id, parentOrg.id); + }); + + afterAll(async () => { + const orgIds = [parentOrg.id, childA.id, childB.id, unrelatedOrg.id]; + await db + .delete(credit_transactions) + .where(inArray(credit_transactions.organization_id, orgIds)); + await db + .delete(organization_audit_logs) + .where(inArray(organization_audit_logs.organization_id, orgIds)); + await db + .delete(organization_memberships) + .where(inArray(organization_memberships.organization_id, orgIds)); + // Children must be removed before the parent (FK onDelete: restrict). + await db.delete(organizations).where(inArray(organizations.id, [childA.id, childB.id])); + await db + .delete(organizations) + .where(inArray(organizations.id, [parentOrg.id, unrelatedOrg.id])); + }); + + beforeEach(async () => { + await setBalance(parentOrg.id, 5_000_000, 0); + await setBalance(childA.id, 0, 0); + await setBalance(childB.id, 0, 0); + await setBalance(unrelatedOrg.id, 0, 0); + const orgIds = [parentOrg.id, childA.id, childB.id, unrelatedOrg.id]; + await db + .delete(credit_transactions) + .where(inArray(credit_transactions.organization_id, orgIds)); + await db + .delete(organization_audit_logs) + .where(inArray(organization_audit_logs.organization_id, orgIds)); + }); + + describe('childBalances', () => { + it('returns parent balance, child balances, and the expiring-credits flag', async () => { + await setBalance(childA.id, 3_000_000, 1_000_000); + const caller = await createCallerForUser(ownerUser.id); + + const result = await caller.organizations.funds.childBalances({ + organizationId: parentOrg.id, + }); + + expect(result.parentBalanceMicrodollars).toBe(5_000_000); + expect(result.hasExpiringCredits).toBe(false); + const childAResult = result.children.find(child => child.id === childA.id); + const childBResult = result.children.find(child => child.id === childB.id); + expect(childAResult?.balanceMicrodollars).toBe(2_000_000); + expect(childBResult?.balanceMicrodollars).toBe(0); + expect(result.children.some(child => child.id === unrelatedOrg.id)).toBe(false); + }); + + it('processes due expirations for children before returning their balance', async () => { + const past = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + await db + .update(organizations) + .set({ + total_microdollars_acquired: 2_000_000, + microdollars_used: 0, + microdollars_balance: 2_000_000, + next_credit_expiration_at: past, + }) + .where(eq(organizations.id, childA.id)); + await db.insert(credit_transactions).values({ + kilo_user_id: 'system', + is_free: true, + amount_microdollars: 2_000_000, + description: 'Expiring grant', + credit_category: 'organization_custom', + expiry_date: past, + organization_id: childA.id, + original_baseline_microdollars_used: 0, + expiration_baseline_microdollars_used: 0, + }); + + const caller = await createCallerForUser(ownerUser.id); + const result = await caller.organizations.funds.childBalances({ + organizationId: parentOrg.id, + }); + + const childAResult = result.children.find(child => child.id === childA.id); + expect(childAResult?.balanceMicrodollars).toBe(0); + + // Expiry was actually processed: the child's expiry hint is cleared. + const [updated] = await db + .select({ next: organizations.next_credit_expiration_at }) + .from(organizations) + .where(eq(organizations.id, childA.id)); + expect(updated.next).toBeNull(); + }); + + it('reports hasExpiringCredits when the parent has a future expiry date', async () => { + const future = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); + await db + .update(organizations) + .set({ next_credit_expiration_at: future }) + .where(eq(organizations.id, parentOrg.id)); + + const caller = await createCallerForUser(ownerUser.id); + const result = await caller.organizations.funds.childBalances({ + organizationId: parentOrg.id, + }); + + expect(result.hasExpiringCredits).toBe(true); + }); + }); + + describe('distribute', () => { + it('moves funds from parent to children and records ledger transactions', async () => { + const caller = await createCallerForUser(ownerUser.id); + + const result = await caller.organizations.funds.distribute({ + organizationId: parentOrg.id, + allocations: [ + { childOrganizationId: childA.id, amountMicrodollars: 2_000_000 }, + { childOrganizationId: childB.id, amountMicrodollars: 1_000_000 }, + ], + }); + + expect(result.totalMovedMicrodollars).toBe(3_000_000); + expect(result.childCount).toBe(2); + + expect(balanceOf(await getOrg(parentOrg.id))).toBe(2_000_000); + expect(balanceOf(await getOrg(childA.id))).toBe(2_000_000); + expect(balanceOf(await getOrg(childB.id))).toBe(1_000_000); + + const childAIncoming = await db + .select() + .from(credit_transactions) + .where( + and( + eq(credit_transactions.organization_id, childA.id), + eq(credit_transactions.credit_category, 'parent_to_child_transfer_in') + ) + ); + expect(childAIncoming).toHaveLength(1); + expect(childAIncoming[0].amount_microdollars).toBe(2_000_000); + expect(childAIncoming[0].expiry_date).toBeNull(); + // A transfer is a balance movement, not a purchase: it must not be + // recorded as paid, or it would fabricate paid provenance downstream. + expect(childAIncoming[0].is_free).toBe(true); + + const parentOutgoing = await db + .select() + .from(credit_transactions) + .where( + and( + eq(credit_transactions.organization_id, parentOrg.id), + eq(credit_transactions.credit_category, 'parent_to_child_transfer_out') + ) + ); + expect(parentOutgoing).toHaveLength(2); + const total = parentOutgoing.reduce((sum, tx) => sum + tx.amount_microdollars, 0); + expect(total).toBe(-3_000_000); + + const parentAuditLogs = await db + .select() + .from(organization_audit_logs) + .where( + and( + eq(organization_audit_logs.organization_id, parentOrg.id), + eq(organization_audit_logs.action, 'organization.funds.distribute_to_children') + ) + ); + expect(parentAuditLogs).toHaveLength(1); + }); + + it('does not mark the child or parent as having ever paid', async () => { + const caller = await createCallerForUser(ownerUser.id); + + await caller.organizations.funds.distribute({ + organizationId: parentOrg.id, + allocations: [{ childOrganizationId: childA.id, amountMicrodollars: 1_000_000 }], + }); + + // Neither org made a purchase; the transfer must not flip the paid gate + // that gates deployments and notifications. + expect(await hasOrganizationEverPaid(childA.id)).toBe(false); + expect(await hasOrganizationEverPaid(parentOrg.id)).toBe(false); + }); + + it('rejects when the total exceeds the available balance', async () => { + const caller = await createCallerForUser(ownerUser.id); + + await expect( + caller.organizations.funds.distribute({ + organizationId: parentOrg.id, + allocations: [{ childOrganizationId: childA.id, amountMicrodollars: 6_000_000 }], + }) + ).rejects.toThrow('exceeds the available balance'); + + // Nothing should have moved. + expect(balanceOf(await getOrg(parentOrg.id))).toBe(5_000_000); + expect(balanceOf(await getOrg(childA.id))).toBe(0); + }); + + it('rejects when a target is not a direct child organization', async () => { + const caller = await createCallerForUser(ownerUser.id); + + await expect( + caller.organizations.funds.distribute({ + organizationId: parentOrg.id, + allocations: [{ childOrganizationId: unrelatedOrg.id, amountMicrodollars: 1_000_000 }], + }) + ).rejects.toThrow('must be direct child organizations'); + + expect(balanceOf(await getOrg(parentOrg.id))).toBe(5_000_000); + expect(balanceOf(await getOrg(unrelatedOrg.id))).toBe(0); + }); + + it('rejects while the parent has expiring credits', async () => { + const future = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); + await db + .update(organizations) + .set({ next_credit_expiration_at: future }) + .where(eq(organizations.id, parentOrg.id)); + + const caller = await createCallerForUser(ownerUser.id); + + await expect( + caller.organizations.funds.distribute({ + organizationId: parentOrg.id, + allocations: [{ childOrganizationId: childA.id, amountMicrodollars: 1_000_000 }], + }) + ).rejects.toThrow('expiring credits'); + + expect(balanceOf(await getOrg(parentOrg.id))).toBe(5_000_000); + expect(balanceOf(await getOrg(childA.id))).toBe(0); + }); + }); +}); diff --git a/apps/web/src/routers/organizations/organization-funds-router.ts b/apps/web/src/routers/organizations/organization-funds-router.ts new file mode 100644 index 0000000000..7f78bd02ab --- /dev/null +++ b/apps/web/src/routers/organizations/organization-funds-router.ts @@ -0,0 +1,311 @@ +import { credit_transactions, organizations } from '@kilocode/db/schema'; +import { db } from '@/lib/drizzle'; +import { createTRPCRouter } from '@/lib/trpc/init'; +import { + OrganizationIdInputSchema, + organizationBillingProcedure, + organizationBillingMutationProcedure, +} from '@/routers/organizations/utils'; +import { getOrganizationById } from '@/lib/organizations/organizations'; +import { processOrganizationExpirations } from '@/lib/creditExpiration'; +import { createAuditLog } from '@/lib/organizations/organization-audit-logs'; +import { formatMicrodollars } from '@/lib/admin-utils'; +import { TRPCError } from '@trpc/server'; +import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm'; +import * as z from 'zod'; + +const ChildBalancesOutputSchema = z.object({ + /** Spendable balance of the parent organization, in microdollars. */ + parentBalanceMicrodollars: z.number(), + /** + * True when the parent still has active expiring credits. Distribution is + * disabled entirely in that case, because partially moving an expiring credit + * bucket cannot be done without changing the lazy-expiry engine. + */ + hasExpiringCredits: z.boolean(), + children: z.array( + z.object({ + id: z.string(), + name: z.string(), + balanceMicrodollars: z.number(), + }) + ), +}); + +const DistributeFundsInputSchema = OrganizationIdInputSchema.extend({ + allocations: z + .array( + z.object({ + childOrganizationId: z.uuid(), + amountMicrodollars: z.number().int().positive(), + }) + ) + .min(1) + .refine( + allocations => + new Set(allocations.map(allocation => allocation.childOrganizationId)).size === + allocations.length, + { message: 'Each child organization can appear at most once' } + ), +}); + +const DistributeFundsOutputSchema = z.object({ + totalMovedMicrodollars: z.number(), + childCount: z.number(), +}); + +/** + * Fetches the organization, processing any due credit expirations first so the + * returned balance and `next_credit_expiration_at` flag are current. Mirrors the + * lazy-expiry pattern used by `organizations.withMembers`. + */ +async function getOrganizationWithExpiryProcessed(organizationId: string) { + let organization = await getOrganizationById(organizationId); + if (!organization) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Organization not found' }); + } + if ( + organization.next_credit_expiration_at && + new Date() >= new Date(organization.next_credit_expiration_at) + ) { + const expiryResult = await processOrganizationExpirations( + { + id: organizationId, + microdollars_used: organization.microdollars_used, + next_credit_expiration_at: organization.next_credit_expiration_at, + total_microdollars_acquired: organization.total_microdollars_acquired, + }, + new Date() + ); + if (expiryResult) { + organization = (await getOrganizationById(organizationId)) ?? organization; + } + } + return organization; +} + +export const organizationFundsRouter = createTRPCRouter({ + // Per-child balances plus the parent balance and eligibility flag, used to + // render the fund-distribution table. Restricted to owner/billing_manager. + childBalances: organizationBillingProcedure + .output(ChildBalancesOutputSchema) + .query(async ({ input }) => { + const parent = await getOrganizationWithExpiryProcessed(input.organizationId); + + const childRows = await db + .select({ + id: organizations.id, + name: organizations.name, + total_microdollars_acquired: organizations.total_microdollars_acquired, + microdollars_used: organizations.microdollars_used, + next_credit_expiration_at: organizations.next_credit_expiration_at, + }) + .from(organizations) + .where( + and( + eq(organizations.parent_organization_id, input.organizationId), + isNull(organizations.deleted_at) + ) + ) + .orderBy(asc(organizations.name)); + + // Process any due expirations per child so displayed balances aren't + // overstated, mirroring the parent path. The hierarchy is single-level, + // so this is a small, bounded set of direct children. + const now = new Date(); + const children: { id: string; name: string; balanceMicrodollars: number }[] = []; + for (const child of childRows) { + let totalAcquired = child.total_microdollars_acquired; + if (child.next_credit_expiration_at && now >= new Date(child.next_credit_expiration_at)) { + const expiryResult = await processOrganizationExpirations( + { + id: child.id, + microdollars_used: child.microdollars_used, + next_credit_expiration_at: child.next_credit_expiration_at, + total_microdollars_acquired: child.total_microdollars_acquired, + }, + now + ); + if (expiryResult) { + totalAcquired = expiryResult.total_microdollars_acquired; + } + } + children.push({ + id: child.id, + name: child.name, + balanceMicrodollars: totalAcquired - child.microdollars_used, + }); + } + + return { + parentBalanceMicrodollars: parent.total_microdollars_acquired - parent.microdollars_used, + hasExpiringCredits: parent.next_credit_expiration_at != null, + children, + }; + }), + + // Moves funds from the parent organization to one or more direct children as + // non-expiring credit transactions. The whole action is rejected while the + // parent has expiring credits. + distribute: organizationBillingMutationProcedure + .input(DistributeFundsInputSchema) + .output(DistributeFundsOutputSchema) + .mutation(async ({ input, ctx }) => { + const { user } = ctx; + const { organizationId, allocations } = input; + + // Process any due expirations before locking so the eligibility check and + // balance comparison below are accurate. + await getOrganizationWithExpiryProcessed(organizationId); + + const totalToMoveMicrodollars = allocations.reduce( + (sum, allocation) => sum + allocation.amountMicrodollars, + 0 + ); + + const childCount = await db.transaction(async tx => { + const [parent] = await tx + .select({ + name: organizations.name, + total_microdollars_acquired: organizations.total_microdollars_acquired, + microdollars_used: organizations.microdollars_used, + next_credit_expiration_at: organizations.next_credit_expiration_at, + }) + .from(organizations) + .where(eq(organizations.id, organizationId)) + .for('update'); + + if (!parent) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Organization not found' }); + } + + // Expiring credits make the source pool ambiguous, so the action is + // disabled entirely while any exist. Re-checked under the row lock to + // guard against a credit being granted between page load and submit. + if (parent.next_credit_expiration_at != null) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'Funds cannot be distributed while this organization has expiring credits.', + }); + } + + const parentBalance = parent.total_microdollars_acquired - parent.microdollars_used; + if (totalToMoveMicrodollars > parentBalance) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'The total amount to distribute exceeds the available balance.', + }); + } + + // Validate every target is a direct, non-deleted child and lock the rows. + const childIds = allocations.map(allocation => allocation.childOrganizationId); + const childRows = await tx + .select({ + id: organizations.id, + name: organizations.name, + microdollars_used: organizations.microdollars_used, + }) + .from(organizations) + .where( + and( + eq(organizations.parent_organization_id, organizationId), + inArray(organizations.id, childIds), + isNull(organizations.deleted_at) + ) + ) + .for('update'); + + const childById = new Map(childRows.map(child => [child.id, child])); + if (childById.size !== childIds.length) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'All recipients must be direct child organizations.', + }); + } + + for (const allocation of allocations) { + const child = childById.get(allocation.childOrganizationId); + if (!child) continue; // Unreachable given the size check above. + + // Credit the child. Transfers are non-expiring in v1, so there is no + // expiry date or expiration baseline. `is_free` is true because a + // transfer is a balance movement, not a purchase: marking it as paid + // would fabricate `hasOrganizationEverPaid` for a child that never + // paid (and could move free credits in as paid-looking ones). + await tx.insert(credit_transactions).values({ + kilo_user_id: user.id, + created_by_kilo_user_id: user.id, + is_free: true, + amount_microdollars: allocation.amountMicrodollars, + description: `Transfer from parent organization ${parent.name}`, + credit_category: 'parent_to_child_transfer_in', + expiry_date: null, + organization_id: child.id, + original_baseline_microdollars_used: child.microdollars_used, + expiration_baseline_microdollars_used: null, + }); + + await tx + .update(organizations) + .set({ + total_microdollars_acquired: sql`${organizations.total_microdollars_acquired} + ${allocation.amountMicrodollars}`, + microdollars_balance: sql`${organizations.microdollars_balance} + ${allocation.amountMicrodollars}`, + }) + .where(eq(organizations.id, child.id)); + + // Matching deduction recorded on the parent for a symmetric ledger. + // Also `is_free: true` so the movement never fabricates a paid signal + // on the parent either (a parent funded only by free credits must not + // look like it paid just because it distributed funds). + await tx.insert(credit_transactions).values({ + kilo_user_id: user.id, + created_by_kilo_user_id: user.id, + is_free: true, + amount_microdollars: -allocation.amountMicrodollars, + description: `Transfer to child organization ${child.name}`, + credit_category: 'parent_to_child_transfer_out', + expiry_date: null, + organization_id: organizationId, + original_baseline_microdollars_used: parent.microdollars_used, + expiration_baseline_microdollars_used: null, + }); + + await createAuditLog({ + tx, + action: 'organization.funds.distribute_to_children', + actor_email: user.google_user_email, + actor_id: user.id, + actor_name: user.google_user_name, + message: `Received ${formatMicrodollars(allocation.amountMicrodollars)} from parent organization ${parent.name}`, + organization_id: child.id, + }); + } + + // Single rollup deduction on the parent for the full distributed total. + await tx + .update(organizations) + .set({ + total_microdollars_acquired: sql`${organizations.total_microdollars_acquired} - ${totalToMoveMicrodollars}`, + microdollars_balance: sql`${organizations.microdollars_balance} - ${totalToMoveMicrodollars}`, + }) + .where(eq(organizations.id, organizationId)); + + await createAuditLog({ + tx, + action: 'organization.funds.distribute_to_children', + actor_email: user.google_user_email, + actor_id: user.id, + actor_name: user.google_user_name, + message: `Distributed ${formatMicrodollars(totalToMoveMicrodollars)} to ${allocations.length} child organization${allocations.length === 1 ? '' : 's'}`, + organization_id: organizationId, + }); + + return allocations.length; + }); + + return { + totalMovedMicrodollars: totalToMoveMicrodollars, + childCount, + }; + }), +}); diff --git a/apps/web/src/routers/organizations/organization-router.ts b/apps/web/src/routers/organizations/organization-router.ts index 253d2295d8..77795c46ee 100644 --- a/apps/web/src/routers/organizations/organization-router.ts +++ b/apps/web/src/routers/organizations/organization-router.ts @@ -64,6 +64,7 @@ import { organizationAutoFixRouter } from '@/routers/organizations/organization- import { organizationAutoTopUpRouter } from '@/routers/organizations/organization-auto-top-up-router'; import { organizationKiloclawRouter } from '@/routers/organizations/organization-kiloclaw-router'; import { organizationBitbucketRouter } from '@/routers/organizations/organization-bitbucket-router'; +import { organizationFundsRouter } from '@/routers/organizations/organization-funds-router'; const OrganizationUpdateSchema = OrganizationIdInputSchema.extend({ name: OrganizationNameSchema, @@ -120,6 +121,7 @@ export const organizationsRouter = createTRPCRouter({ autoTopUp: organizationAutoTopUpRouter, kiloclaw: organizationKiloclawRouter, bitbucket: organizationBitbucketRouter, + funds: organizationFundsRouter, list: baseProcedure.query(async opts => { const { user } = opts.ctx; diff --git a/packages/db/src/schema-types.ts b/packages/db/src/schema-types.ts index a91588c0e5..635352e271 100644 --- a/packages/db/src/schema-types.ts +++ b/packages/db/src/schema-types.ts @@ -923,6 +923,7 @@ export const AuditLogAction = z.enum([ 'organization.mode.delete', // ✅ 'organization.created', // ✅ 'organization.token.generate', // ✅ + 'organization.funds.distribute_to_children', // ✅ ]); // --- EncryptedData ---