From f74c8975ea743821bcea15a662606b1bfab3f616 Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Thu, 25 Jun 2026 14:51:28 +0200 Subject: [PATCH 1/5] feat(web): distribute funds from parent to child organizations Adds an owner/billing_manager-only page that lets a parent organization move available balance to its direct child organizations. Transfers are recorded as non-expiring credit transactions on both the parent and each child within a single transaction, with audit-log entries. The action is disabled entirely while the parent has any expiring credits, avoiding the need to partially move expiring credit buckets through the lazy-expiry engine. --- .../distribute-funds/DistributeFundsPage.tsx | 269 +++++++++++++++++ .../[id]/distribute-funds/page.tsx | 16 + apps/web/src/app/api/organizations/hooks.ts | 30 ++ .../OrganizationChildOrganizationsCard.tsx | 20 +- .../organization-funds-router.test.ts | 243 +++++++++++++++ .../organization-funds-router.ts | 281 ++++++++++++++++++ .../organizations/organization-router.ts | 2 + packages/db/src/schema-types.ts | 1 + 8 files changed, 861 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/app/(app)/organizations/[id]/distribute-funds/DistributeFundsPage.tsx create mode 100644 apps/web/src/app/(app)/organizations/[id]/distribute-funds/page.tsx create mode 100644 apps/web/src/routers/organizations/organization-funds-router.test.ts create mode 100644 apps/web/src/routers/organizations/organization-funds-router.ts 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..e206b3496f --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/distribute-funds/DistributeFundsPage.tsx @@ -0,0 +1,269 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { AlertTriangle } from 'lucide-react'; +import { toast } from 'sonner'; + +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, toMicrodollars } from '@/lib/utils'; + +type Props = { + organizationId: string; +}; + +function parseDollarInput(raw: string): { microdollars: number; error: string | null } { + 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); + if (value === 0) return { microdollars: 0, error: null }; + if (!Number.isFinite(value) || value < 0) { + return { microdollars: 0, error: 'Enter a valid amount' }; + } + return { microdollars: toMicrodollars(value), error: null }; +} + +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. + + + ) : ( + + + 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/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..5477360d84 --- /dev/null +++ b/apps/web/src/routers/organizations/organization-funds-router.test.ts @@ -0,0 +1,243 @@ +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 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('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(); + + 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('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..757e8366aa --- /dev/null +++ b/apps/web/src/routers/organizations/organization-funds-router.ts @@ -0,0 +1,281 @@ +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, + }) + .from(organizations) + .where( + and( + eq(organizations.parent_organization_id, input.organizationId), + isNull(organizations.deleted_at) + ) + ) + .orderBy(asc(organizations.name)); + + return { + parentBalanceMicrodollars: parent.total_microdollars_acquired - parent.microdollars_used, + hasExpiringCredits: parent.next_credit_expiration_at != null, + children: childRows.map(child => ({ + id: child.id, + name: child.name, + balanceMicrodollars: child.total_microdollars_acquired - child.microdollars_used, + })), + }; + }), + + // 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 false because this + // is real balance being moved, not a newly granted free credit. + await tx.insert(credit_transactions).values({ + kilo_user_id: user.id, + created_by_kilo_user_id: user.id, + is_free: false, + 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. + await tx.insert(credit_transactions).values({ + kilo_user_id: user.id, + created_by_kilo_user_id: user.id, + is_free: false, + 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 --- From 5a64d271c4e4dfc741ed48a47a16f543bc7e1045 Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Thu, 25 Jun 2026 15:03:10 +0200 Subject: [PATCH 2/5] fix(web): record parent->child transfers as non-paid credits A transfer is a balance movement, not a purchase. Recording the ledger rows as is_free: false fabricated paid provenance via hasOrganizationEverPaid (which gates deployments and notifications) for children funded by free credits, and for parents funded only by free credits. Mark both the incoming and outgoing transfer rows as is_free: true. --- .../organization-funds-router.test.ts | 18 ++++++++++++++++++ .../organizations/organization-funds-router.ts | 13 +++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/web/src/routers/organizations/organization-funds-router.test.ts b/apps/web/src/routers/organizations/organization-funds-router.test.ts index 5477360d84..f924e1462b 100644 --- a/apps/web/src/routers/organizations/organization-funds-router.test.ts +++ b/apps/web/src/routers/organizations/organization-funds-router.test.ts @@ -9,6 +9,7 @@ import { 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; @@ -165,6 +166,9 @@ describe('organization funds router', () => { 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() @@ -191,6 +195,20 @@ describe('organization funds router', () => { 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); diff --git a/apps/web/src/routers/organizations/organization-funds-router.ts b/apps/web/src/routers/organizations/organization-funds-router.ts index 757e8366aa..271eeac19e 100644 --- a/apps/web/src/routers/organizations/organization-funds-router.ts +++ b/apps/web/src/routers/organizations/organization-funds-router.ts @@ -203,12 +203,14 @@ export const organizationFundsRouter = createTRPCRouter({ 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 false because this - // is real balance being moved, not a newly granted free credit. + // 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: false, + is_free: true, amount_microdollars: allocation.amountMicrodollars, description: `Transfer from parent organization ${parent.name}`, credit_category: 'parent_to_child_transfer_in', @@ -227,10 +229,13 @@ export const organizationFundsRouter = createTRPCRouter({ .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: false, + is_free: true, amount_microdollars: -allocation.amountMicrodollars, description: `Transfer to child organization ${child.name}`, credit_category: 'parent_to_child_transfer_out', From 8726b410ab0dceb4e155483fa140232566df8904 Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Thu, 25 Jun 2026 15:13:50 +0200 Subject: [PATCH 3/5] fix(web): process due credit expirations for child balances childBalances previously read children's cached acquired/used values directly, so a child with a past-due expiry could show an overstated current balance. Process due expirations per child before returning, mirroring the parent path. --- .../organization-funds-router.test.ts | 39 +++++++++++++++++++ .../organization-funds-router.ts | 35 ++++++++++++++--- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/apps/web/src/routers/organizations/organization-funds-router.test.ts b/apps/web/src/routers/organizations/organization-funds-router.test.ts index f924e1462b..d32df25453 100644 --- a/apps/web/src/routers/organizations/organization-funds-router.test.ts +++ b/apps/web/src/routers/organizations/organization-funds-router.test.ts @@ -119,6 +119,45 @@ describe('organization funds router', () => { 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 diff --git a/apps/web/src/routers/organizations/organization-funds-router.ts b/apps/web/src/routers/organizations/organization-funds-router.ts index 271eeac19e..7f78bd02ab 100644 --- a/apps/web/src/routers/organizations/organization-funds-router.ts +++ b/apps/web/src/routers/organizations/organization-funds-router.ts @@ -98,6 +98,7 @@ export const organizationFundsRouter = createTRPCRouter({ 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( @@ -108,14 +109,38 @@ export const organizationFundsRouter = createTRPCRouter({ ) .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: childRows.map(child => ({ - id: child.id, - name: child.name, - balanceMicrodollars: child.total_microdollars_acquired - child.microdollars_used, - })), + children, }; }), From 8d030da20cbcb7e55792ae208f52827f800af24e Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Thu, 25 Jun 2026 15:34:48 +0200 Subject: [PATCH 4/5] fix(web): lock distribute-funds form for read-only organizations The page only checked role membership, but the transfer mutation also requires an active subscription/trial. On an expired-trial (read-only) org, owners/billing managers could open the form and attempt a transfer, hitting FORBIDDEN at submit. Wrap the form in LockableContainer so it renders non-interactive with the standard lock UI, matching other organization settings pages. --- .../distribute-funds/DistributeFundsPage.tsx | 235 +++++++++--------- 1 file changed, 121 insertions(+), 114 deletions(-) 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 index e206b3496f..1673cd8937 100644 --- a/apps/web/src/app/(app)/organizations/[id]/distribute-funds/DistributeFundsPage.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/distribute-funds/DistributeFundsPage.tsx @@ -4,6 +4,7 @@ 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'; @@ -146,123 +147,129 @@ export function DistributeFundsPage({ organizationId }: Props) { ) : ( - - - 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. - - - )} + // 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)} - -
+
+ 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' - )} - /> +
+ + + 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} +

+ )}
- {row.error && ( -

- {row.error} -

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

- The total exceeds the available balance. -

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

+ The total exceeds the available balance. +

+ )} + +
+
+ )} ); From ecb5fc8c58cbab166af15fd1e93c6b1eb592cabe Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Thu, 25 Jun 2026 16:46:36 +0200 Subject: [PATCH 5/5] refactor(web): extract parseDollarInput with unit tests Move the dollar-amount parsing logic out of DistributeFundsPage into a co-located, pure parseDollarInput helper and cover it with unit tests (whole/fractional amounts, commas, decimal-place limit, non-numeric and negative input, and non-finite guard). Addresses PR review feedback. --- .../distribute-funds/DistributeFundsPage.tsx | 23 +-------- .../distribute-funds/parseDollarInput.test.ts | 50 +++++++++++++++++++ .../[id]/distribute-funds/parseDollarInput.ts | 32 ++++++++++++ 3 files changed, 84 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/app/(app)/organizations/[id]/distribute-funds/parseDollarInput.test.ts create mode 100644 apps/web/src/app/(app)/organizations/[id]/distribute-funds/parseDollarInput.ts 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 index 1673cd8937..cde77688d8 100644 --- a/apps/web/src/app/(app)/organizations/[id]/distribute-funds/DistributeFundsPage.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/distribute-funds/DistributeFundsPage.tsx @@ -32,32 +32,13 @@ import { useOrganizationChildBalances, } from '@/app/api/organizations/hooks'; import { formatMicrodollars } from '@/lib/admin-utils'; -import { cn, toMicrodollars } from '@/lib/utils'; +import { cn } from '@/lib/utils'; +import { parseDollarInput } from './parseDollarInput'; type Props = { organizationId: string; }; -function parseDollarInput(raw: string): { microdollars: number; error: string | null } { - 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); - if (value === 0) return { microdollars: 0, error: null }; - if (!Number.isFinite(value) || value < 0) { - return { microdollars: 0, error: 'Enter a valid amount' }; - } - return { microdollars: toMicrodollars(value), error: null }; -} - export function DistributeFundsPage({ organizationId }: Props) { const { data, isLoading, error } = useOrganizationChildBalances(organizationId); const distribute = useDistributeFundsToChildren(); 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 }; +}