diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b096b6..3237bc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,3 +24,12 @@ jobs: run: pnpm typecheck - name: Test run: pnpm test + - name: Domain unit tests + coverage + run: pnpm --filter @tithe/domain test:coverage + - name: Upload domain coverage + if: always() + uses: actions/upload-artifact@v4 + with: + name: domain-coverage + path: packages/domain/coverage/ + retention-days: 14 diff --git a/packages/domain/package.json b/packages/domain/package.json index a8c1d07..5b93272 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -8,7 +8,10 @@ "build": "tsc -p tsconfig.json", "dev": "tsc -w -p tsconfig.json", "typecheck": "tsc --noEmit -p tsconfig.json", - "lint": "biome check src" + "lint": "biome check src", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@tithe/contracts": "workspace:*", @@ -16,5 +19,9 @@ "drizzle-orm": "^0.41.0", "rrule": "^2.8.1", "zod": "^3.24.2" + }, + "devDependencies": { + "@vitest/coverage-v8": "^3.0.8", + "vitest": "^3.0.8" } } diff --git a/packages/domain/src/services/__tests__/expenses-logic.test.ts b/packages/domain/src/services/__tests__/expenses-logic.test.ts new file mode 100644 index 0000000..ffb20bd --- /dev/null +++ b/packages/domain/src/services/__tests__/expenses-logic.test.ts @@ -0,0 +1,566 @@ +import { describe, expect, it } from 'vitest'; + +import { AppError } from '../../errors.js'; +import { + assertPositiveAmountMinor, + computeRecoverableMinor, + deriveExpenseKind, + deriveReimbursementStatus, + enrichExpensesWithReimbursements, + isTransferKind, + normalizeCounterpartyType, + normalizeExpenseKind, + normalizeTransferDirection, + resolveReimbursableDefaults, + validateAndResolveMyShareMinor, +} from '../expenses-logic.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +const makeCategory = ( + overrides: Partial<{ + id: string; + kind: 'expense' | 'income' | 'transfer'; + reimbursementMode: 'none' | 'optional' | 'always'; + }> = {}, +) => ({ + id: overrides.id ?? 'cat-1', + kind: overrides.kind ?? 'expense', + reimbursementMode: overrides.reimbursementMode ?? 'none', +}); + +const makeExpense = ( + overrides: Partial<{ + id: string; + kind: string; + reimbursementStatus: string; + amountMinor: number; + myShareMinor: number | null; + closedOutstandingMinor: number | null; + }> = {}, +) => ({ + id: overrides.id ?? 'exp-1', + kind: (overrides.kind ?? 'expense') as + | 'expense' + | 'income' + | 'transfer_internal' + | 'transfer_external', + reimbursementStatus: (overrides.reimbursementStatus ?? 'expected') as + | 'none' + | 'expected' + | 'partial' + | 'settled' + | 'written_off', + money: { + amountMinor: overrides.amountMinor ?? 10000, + currency: 'GBP', + amountBaseMinor: undefined, + fxRate: undefined, + }, + myShareMinor: overrides.myShareMinor === undefined ? 2000 : overrides.myShareMinor, + closedOutstandingMinor: overrides.closedOutstandingMinor ?? null, +}); + +// ── normalizeTransferDirection ───────────────────────────────────────────── + +describe('normalizeTransferDirection', () => { + it('returns "in" for "in"', () => expect(normalizeTransferDirection('in')).toBe('in')); + it('returns "out" for "out"', () => expect(normalizeTransferDirection('out')).toBe('out')); + it('returns null for null', () => expect(normalizeTransferDirection(null)).toBeNull()); + it('returns null for undefined', () => expect(normalizeTransferDirection(undefined)).toBeNull()); +}); + +// ── normalizeExpenseKind ─────────────────────────────────────────────────── + +describe('normalizeExpenseKind', () => { + it.each(['expense', 'income', 'transfer_internal', 'transfer_external'] as const)( + 'accepts "%s"', + (kind) => expect(normalizeExpenseKind(kind)).toBe(kind), + ); + it('returns null for undefined', () => expect(normalizeExpenseKind(undefined)).toBeNull()); + it('returns null for null', () => expect(normalizeExpenseKind(null)).toBeNull()); + it('returns null for invalid string', () => + expect(normalizeExpenseKind('bogus' as never)).toBeNull()); +}); + +// ── normalizeCounterpartyType ────────────────────────────────────────────── + +describe('normalizeCounterpartyType', () => { + it.each(['self', 'partner', 'team', 'other'] as const)('accepts "%s"', (type) => + expect(normalizeCounterpartyType(type)).toBe(type), + ); + it('returns null for null', () => expect(normalizeCounterpartyType(null)).toBeNull()); + it('returns null for undefined', () => expect(normalizeCounterpartyType(undefined)).toBeNull()); +}); + +// ── assertPositiveAmountMinor ────────────────────────────────────────────── + +describe('assertPositiveAmountMinor', () => { + it('accepts positive integers', () => expect(assertPositiveAmountMinor(100)).toBe(100)); + it('accepts 1', () => expect(assertPositiveAmountMinor(1)).toBe(1)); + + it('rejects 0', () => { + expect(() => assertPositiveAmountMinor(0)).toThrow(AppError); + }); + it('rejects negative', () => { + expect(() => assertPositiveAmountMinor(-5)).toThrow(AppError); + }); + it('rejects floats', () => { + expect(() => assertPositiveAmountMinor(1.5)).toThrow(AppError); + }); + it('uses custom field name in error', () => { + try { + assertPositiveAmountMinor(0, 'myField'); + } catch (error) { + expect((error as AppError).message).toContain('myField'); + } + }); +}); + +// ── isTransferKind ───────────────────────────────────────────────────────── + +describe('isTransferKind', () => { + it('true for transfer_internal', () => expect(isTransferKind('transfer_internal')).toBe(true)); + it('true for transfer_external', () => expect(isTransferKind('transfer_external')).toBe(true)); + it('false for expense', () => expect(isTransferKind('expense')).toBe(false)); + it('false for income', () => expect(isTransferKind('income')).toBe(false)); +}); + +// ── deriveExpenseKind ────────────────────────────────────────────────────── + +describe('deriveExpenseKind', () => { + describe('expense category', () => { + const category = makeCategory({ kind: 'expense' }); + + it('derives "expense" with no requested kind', () => { + expect(deriveExpenseKind({ category, requestedKind: null, transferDirection: null })).toBe( + 'expense', + ); + }); + + it('accepts matching requested kind', () => { + expect( + deriveExpenseKind({ category, requestedKind: 'expense', transferDirection: null }), + ).toBe('expense'); + }); + + it('rejects mismatched kind', () => { + expect(() => + deriveExpenseKind({ category, requestedKind: 'income', transferDirection: null }), + ).toThrow('kind does not match category kind'); + }); + + it('rejects transfer kind on expense category', () => { + expect(() => + deriveExpenseKind({ + category, + requestedKind: 'transfer_external', + transferDirection: null, + }), + ).toThrow('transfer kinds require a transfer category'); + }); + }); + + describe('income category', () => { + const category = makeCategory({ kind: 'income' }); + + it('derives "income" with no requested kind', () => { + expect(deriveExpenseKind({ category, requestedKind: null, transferDirection: null })).toBe( + 'income', + ); + }); + }); + + describe('transfer category', () => { + const category = makeCategory({ kind: 'transfer' }); + + it('requires transferDirection', () => { + expect(() => + deriveExpenseKind({ category, requestedKind: null, transferDirection: null }), + ).toThrow('transferDirection is required'); + }); + + it('defaults to transfer_external when no kind requested', () => { + expect(deriveExpenseKind({ category, requestedKind: null, transferDirection: 'out' })).toBe( + 'transfer_external', + ); + }); + + it('accepts transfer_internal', () => { + expect( + deriveExpenseKind({ + category, + requestedKind: 'transfer_internal', + transferDirection: 'out', + }), + ).toBe('transfer_internal'); + }); + + it('accepts transfer_external', () => { + expect( + deriveExpenseKind({ + category, + requestedKind: 'transfer_external', + transferDirection: 'in', + }), + ).toBe('transfer_external'); + }); + + it('rejects non-transfer kind on transfer category', () => { + expect(() => + deriveExpenseKind({ category, requestedKind: 'expense', transferDirection: 'out' }), + ).toThrow('kind must be transfer_internal or transfer_external'); + }); + }); +}); + +// ── computeRecoverableMinor ──────────────────────────────────────────────── + +describe('computeRecoverableMinor', () => { + it('returns 0 for non-expense kinds', () => { + expect(computeRecoverableMinor(makeExpense({ kind: 'income' }))).toBe(0); + }); + + it('returns 0 when reimbursementStatus is none', () => { + expect(computeRecoverableMinor(makeExpense({ reimbursementStatus: 'none' }))).toBe(0); + }); + + it('returns amountMinor - myShareMinor for reimbursable expense', () => { + expect(computeRecoverableMinor(makeExpense({ amountMinor: 10000, myShareMinor: 3000 }))).toBe( + 7000, + ); + }); + + it('returns full amount when myShareMinor is 0', () => { + expect(computeRecoverableMinor(makeExpense({ amountMinor: 5000, myShareMinor: 0 }))).toBe(5000); + }); + + it('returns full amount when myShareMinor is null', () => { + expect(computeRecoverableMinor(makeExpense({ amountMinor: 5000, myShareMinor: null }))).toBe( + 5000, + ); + }); + + it('clamps to 0 when myShareMinor exceeds amount', () => { + expect(computeRecoverableMinor(makeExpense({ amountMinor: 1000, myShareMinor: 2000 }))).toBe(0); + }); +}); + +// ── deriveReimbursementStatus ────────────────────────────────────────────── + +describe('deriveReimbursementStatus', () => { + it('returns "none" for non-expense kinds', () => { + expect( + deriveReimbursementStatus({ expense: makeExpense({ kind: 'income' }), recoveredMinor: 0 }), + ).toBe('none'); + }); + + it('returns "none" when not reimbursable (status=none + myShare=null)', () => { + expect( + deriveReimbursementStatus({ + expense: makeExpense({ reimbursementStatus: 'none', myShareMinor: null }), + recoveredMinor: 0, + }), + ).toBe('none'); + }); + + it('returns "expected" when nothing recovered', () => { + expect( + deriveReimbursementStatus({ + expense: makeExpense({ amountMinor: 10000, myShareMinor: 2000 }), + recoveredMinor: 0, + }), + ).toBe('expected'); + }); + + it('returns "partial" when partially recovered', () => { + expect( + deriveReimbursementStatus({ + expense: makeExpense({ amountMinor: 10000, myShareMinor: 2000 }), + recoveredMinor: 3000, + }), + ).toBe('partial'); + }); + + it('returns "settled" when fully recovered', () => { + expect( + deriveReimbursementStatus({ + expense: makeExpense({ amountMinor: 10000, myShareMinor: 2000 }), + recoveredMinor: 8000, + }), + ).toBe('settled'); + }); + + it('returns "written_off" when closedOutstandingMinor > 0', () => { + expect( + deriveReimbursementStatus({ + expense: makeExpense({ + amountMinor: 10000, + myShareMinor: 2000, + closedOutstandingMinor: 3000, + }), + recoveredMinor: 5000, + }), + ).toBe('written_off'); + }); + + it('returns "settled" when recoverableMinor is 0 (myShare == amount)', () => { + expect( + deriveReimbursementStatus({ + expense: makeExpense({ amountMinor: 5000, myShareMinor: 5000 }), + recoveredMinor: 0, + }), + ).toBe('settled'); + }); +}); + +// ── enrichExpensesWithReimbursements ─────────────────────────────────────── + +describe('enrichExpensesWithReimbursements', () => { + it('enriches expense items with reimbursement fields', () => { + const items = [makeExpense({ id: 'e1', amountMinor: 10000, myShareMinor: 2000 })] as never[]; + const recoveredByOutId = new Map([['e1', 3000]]); + + const result = enrichExpensesWithReimbursements({ items, recoveredByOutId }); + + expect(result[0]).toMatchObject({ + recoverableMinor: 8000, + recoveredMinor: 3000, + outstandingMinor: 5000, + reimbursementStatus: 'partial', + }); + }); + + it('handles items with no recovery data', () => { + const items = [makeExpense({ id: 'e2', amountMinor: 5000, myShareMinor: 1000 })] as never[]; + const recoveredByOutId = new Map(); + + const result = enrichExpensesWithReimbursements({ items, recoveredByOutId }); + + expect(result[0]).toMatchObject({ + recoverableMinor: 4000, + recoveredMinor: 0, + outstandingMinor: 4000, + reimbursementStatus: 'expected', + }); + }); +}); + +// ── resolveReimbursableDefaults ──────────────────────────────────────────── + +describe('resolveReimbursableDefaults', () => { + it('returns false for income categories', () => { + expect( + resolveReimbursableDefaults({ + category: makeCategory({ kind: 'income' }), + kind: 'income', + requestedReimbursable: undefined, + }), + ).toBe(false); + }); + + it('throws if reimbursable requested on income', () => { + expect(() => + resolveReimbursableDefaults({ + category: makeCategory({ kind: 'income' }), + kind: 'income', + requestedReimbursable: true, + }), + ).toThrow('Only expense rows can be reimbursable'); + }); + + it('returns false for category with reimbursementMode=none', () => { + expect( + resolveReimbursableDefaults({ + category: makeCategory({ kind: 'expense', reimbursementMode: 'none' }), + kind: 'expense', + requestedReimbursable: undefined, + }), + ).toBe(false); + }); + + it('throws if reimbursable requested on mode=none category', () => { + expect(() => + resolveReimbursableDefaults({ + category: makeCategory({ kind: 'expense', reimbursementMode: 'none' }), + kind: 'expense', + requestedReimbursable: true, + }), + ).toThrow('Category does not allow reimbursement tracking'); + }); + + it('returns true for category with reimbursementMode=always', () => { + expect( + resolveReimbursableDefaults({ + category: makeCategory({ kind: 'expense', reimbursementMode: 'always' }), + kind: 'expense', + requestedReimbursable: undefined, + }), + ).toBe(true); + }); + + it('throws if reimbursable=false on mode=always category', () => { + expect(() => + resolveReimbursableDefaults({ + category: makeCategory({ kind: 'expense', reimbursementMode: 'always' }), + kind: 'expense', + requestedReimbursable: false, + }), + ).toThrow('cannot be disabled per-row'); + }); + + it('respects explicit request for mode=optional', () => { + expect( + resolveReimbursableDefaults({ + category: makeCategory({ kind: 'expense', reimbursementMode: 'optional' }), + kind: 'expense', + requestedReimbursable: false, + }), + ).toBe(false); + }); + + it('defaults to true for mode=optional with no request and no existing', () => { + expect( + resolveReimbursableDefaults({ + category: makeCategory({ kind: 'expense', reimbursementMode: 'optional' }), + kind: 'expense', + requestedReimbursable: undefined, + }), + ).toBe(true); + }); + + it('preserves existing reimbursable state for mode=optional', () => { + expect( + resolveReimbursableDefaults({ + category: makeCategory({ kind: 'expense', reimbursementMode: 'optional' }), + kind: 'expense', + requestedReimbursable: undefined, + existing: { reimbursementStatus: 'none' as const, myShareMinor: null }, + }), + ).toBe(false); + + expect( + resolveReimbursableDefaults({ + category: makeCategory({ kind: 'expense', reimbursementMode: 'optional' }), + kind: 'expense', + requestedReimbursable: undefined, + existing: { reimbursementStatus: 'expected' as const, myShareMinor: 2000 }, + }), + ).toBe(true); + }); +}); + +// ── validateAndResolveMyShareMinor ───────────────────────────────────────── + +describe('validateAndResolveMyShareMinor', () => { + it('returns null when not reimbursable', () => { + expect( + validateAndResolveMyShareMinor({ + amountMinor: 1000, + reimbursable: false, + requestedMyShareMinor: undefined, + }), + ).toBeNull(); + }); + + it('throws when myShareMinor provided but not reimbursable', () => { + expect(() => + validateAndResolveMyShareMinor({ + amountMinor: 1000, + reimbursable: false, + requestedMyShareMinor: 500, + }), + ).toThrow('myShareMinor is only valid for reimbursable expenses'); + }); + + it('returns 0 when reimbursable and null explicitly passed', () => { + expect( + validateAndResolveMyShareMinor({ + amountMinor: 1000, + reimbursable: true, + requestedMyShareMinor: null, + }), + ).toBe(0); + }); + + it('returns requested value when valid', () => { + expect( + validateAndResolveMyShareMinor({ + amountMinor: 5000, + reimbursable: true, + requestedMyShareMinor: 2000, + }), + ).toBe(2000); + }); + + it('accepts 0 as myShareMinor', () => { + expect( + validateAndResolveMyShareMinor({ + amountMinor: 5000, + reimbursable: true, + requestedMyShareMinor: 0, + }), + ).toBe(0); + }); + + it('accepts myShareMinor equal to amountMinor', () => { + expect( + validateAndResolveMyShareMinor({ + amountMinor: 5000, + reimbursable: true, + requestedMyShareMinor: 5000, + }), + ).toBe(5000); + }); + + it('throws when myShareMinor exceeds amountMinor', () => { + expect(() => + validateAndResolveMyShareMinor({ + amountMinor: 5000, + reimbursable: true, + requestedMyShareMinor: 6000, + }), + ).toThrow('myShareMinor must be an integer between 0 and amountMinor'); + }); + + it('throws for negative myShareMinor', () => { + expect(() => + validateAndResolveMyShareMinor({ + amountMinor: 5000, + reimbursable: true, + requestedMyShareMinor: -1, + }), + ).toThrow(AppError); + }); + + it('throws for non-integer myShareMinor', () => { + expect(() => + validateAndResolveMyShareMinor({ + amountMinor: 5000, + reimbursable: true, + requestedMyShareMinor: 1.5, + }), + ).toThrow(AppError); + }); + + it('falls back to existing myShareMinor when undefined', () => { + expect( + validateAndResolveMyShareMinor({ + amountMinor: 5000, + reimbursable: true, + requestedMyShareMinor: undefined, + existing: { myShareMinor: 3000 }, + }), + ).toBe(3000); + }); + + it('falls back to 0 when undefined and no existing', () => { + expect( + validateAndResolveMyShareMinor({ + amountMinor: 5000, + reimbursable: true, + requestedMyShareMinor: undefined, + }), + ).toBe(0); + }); +}); diff --git a/packages/domain/src/services/expenses-logic.ts b/packages/domain/src/services/expenses-logic.ts new file mode 100644 index 0000000..03c7c42 --- /dev/null +++ b/packages/domain/src/services/expenses-logic.ts @@ -0,0 +1,264 @@ +/** + * Pure business logic functions extracted from expenses.service.ts for testability. + * + * These functions contain no I/O or database access — they are purely computational. + */ + +import { AppError } from '../errors.js'; +import type { CategoryDto } from '../repositories/categories.repository.js'; +import type { ExpenseDto } from '../repositories/expenses.repository.js'; +import type { ExpenseKind, ReimbursementStatus } from '../types.js'; + +// ── Normalisation ────────────────────────────────────────────────────────── + +type TransferDirection = 'in' | 'out' | null; + +export const normalizeTransferDirection = ( + value: 'in' | 'out' | null | undefined, +): TransferDirection => { + if (value === 'in' || value === 'out') return value; + return null; +}; + +export const normalizeExpenseKind = (value: ExpenseKind | null | undefined): ExpenseKind | null => { + if ( + value === 'expense' || + value === 'income' || + value === 'transfer_internal' || + value === 'transfer_external' + ) { + return value; + } + return null; +}; + +export const normalizeCounterpartyType = ( + value: 'self' | 'partner' | 'team' | 'other' | null | undefined, +): 'self' | 'partner' | 'team' | 'other' | null => { + if (value === 'self' || value === 'partner' || value === 'team' || value === 'other') + return value; + return null; +}; + +// ── Validation ───────────────────────────────────────────────────────────── + +export const assertPositiveAmountMinor = (value: number, field = 'amountMinor'): number => { + if (!Number.isInteger(value) || value <= 0) { + throw new AppError('VALIDATION_ERROR', `${field} must be a positive integer`, 400, { + field, + value, + }); + } + return value; +}; + +export const isTransferKind = (kind: ExpenseKind): boolean => + kind === 'transfer_internal' || kind === 'transfer_external'; + +// ── Kind derivation ──────────────────────────────────────────────────────── + +export const deriveExpenseKind = ({ + category, + requestedKind, + transferDirection, +}: { + category: Pick; + requestedKind: ExpenseKind | null; + transferDirection: TransferDirection; +}): ExpenseKind => { + if (category.kind === 'transfer') { + if (!transferDirection) { + throw new AppError( + 'VALIDATION_ERROR', + 'transferDirection is required when category kind is transfer', + 400, + { categoryId: category.id }, + ); + } + + if (!requestedKind) return 'transfer_external'; + + if (!isTransferKind(requestedKind)) { + throw new AppError( + 'VALIDATION_ERROR', + 'kind must be transfer_internal or transfer_external for transfer categories', + 400, + { categoryId: category.id, kind: requestedKind }, + ); + } + + return requestedKind; + } + + if (requestedKind && isTransferKind(requestedKind)) { + throw new AppError('VALIDATION_ERROR', 'transfer kinds require a transfer category', 400, { + categoryId: category.id, + kind: requestedKind, + }); + } + + const expectedKind: ExpenseKind = category.kind === 'income' ? 'income' : 'expense'; + + if (requestedKind && requestedKind !== expectedKind) { + throw new AppError('VALIDATION_ERROR', 'kind does not match category kind', 400, { + categoryId: category.id, + kind: requestedKind, + categoryKind: category.kind, + }); + } + + return expectedKind; +}; + +// ── Reimbursement logic ──────────────────────────────────────────────────── + +export const computeRecoverableMinor = ( + expense: Pick, +): number => { + if (expense.kind !== 'expense' || expense.reimbursementStatus === 'none') return 0; + const myShareMinor = expense.myShareMinor ?? 0; + return Math.max(expense.money.amountMinor - myShareMinor, 0); +}; + +export const deriveReimbursementStatus = ({ + expense, + recoveredMinor, +}: { + expense: Pick< + ExpenseDto, + 'kind' | 'reimbursementStatus' | 'money' | 'myShareMinor' | 'closedOutstandingMinor' + >; + recoveredMinor: number; +}): ReimbursementStatus => { + if (expense.kind !== 'expense') return 'none'; + + const isReimbursable = expense.reimbursementStatus !== 'none' || expense.myShareMinor !== null; + if (!isReimbursable) return 'none'; + + const recoverableMinor = computeRecoverableMinor(expense); + const writtenOffMinor = Math.max(expense.closedOutstandingMinor ?? 0, 0); + const outstandingMinor = Math.max(recoverableMinor - recoveredMinor - writtenOffMinor, 0); + + if (writtenOffMinor > 0) return 'written_off'; + if (recoverableMinor === 0 || outstandingMinor === 0) return 'settled'; + if (recoveredMinor > 0) return 'partial'; + return 'expected'; +}; + +export const enrichExpensesWithReimbursements = ({ + items, + recoveredByOutId, +}: { + items: ExpenseDto[]; + recoveredByOutId: ReadonlyMap; +}): ExpenseDto[] => + items.map((item) => { + const recoveredMinor = recoveredByOutId.get(item.id) ?? 0; + const recoverableMinor = computeRecoverableMinor(item); + const outstandingMinor = Math.max( + recoverableMinor - recoveredMinor - Math.max(item.closedOutstandingMinor ?? 0, 0), + 0, + ); + const reimbursementStatus = deriveReimbursementStatus({ expense: item, recoveredMinor }); + + return { + ...item, + reimbursementStatus, + recoverableMinor, + recoveredMinor, + outstandingMinor, + }; + }); + +// ── Reimbursable defaults ────────────────────────────────────────────────── + +export const resolveReimbursableDefaults = ({ + category, + kind, + requestedReimbursable, + existing, +}: { + category: Pick; + kind: ExpenseKind; + requestedReimbursable: boolean | undefined; + existing?: Pick; +}): boolean => { + if (kind !== 'expense' || category.kind !== 'expense') { + if (requestedReimbursable === true) { + throw new AppError('VALIDATION_ERROR', 'Only expense rows can be reimbursable', 400, { + categoryId: category.id, + kind, + }); + } + return false; + } + + if (category.reimbursementMode === 'none') { + if (requestedReimbursable === true) { + throw new AppError( + 'VALIDATION_ERROR', + 'Category does not allow reimbursement tracking', + 400, + { categoryId: category.id }, + ); + } + return false; + } + + if (category.reimbursementMode === 'always') { + if (requestedReimbursable === false) { + throw new AppError( + 'VALIDATION_ERROR', + 'Category reimbursement mode is always and cannot be disabled per-row', + 400, + { categoryId: category.id }, + ); + } + return true; + } + + if (requestedReimbursable !== undefined) return requestedReimbursable; + if (existing) return existing.reimbursementStatus !== 'none' || existing.myShareMinor !== null; + return true; +}; + +// ── My share validation ──────────────────────────────────────────────────── + +export const validateAndResolveMyShareMinor = ({ + amountMinor, + reimbursable, + requestedMyShareMinor, + existing, +}: { + amountMinor: number; + reimbursable: boolean; + requestedMyShareMinor: number | null | undefined; + existing?: Pick; +}): number | null => { + if (!reimbursable) { + if (requestedMyShareMinor !== undefined && requestedMyShareMinor !== null) { + throw new AppError( + 'VALIDATION_ERROR', + 'myShareMinor is only valid for reimbursable expenses', + 400, + ); + } + return null; + } + + const resolved = + requestedMyShareMinor === undefined ? (existing?.myShareMinor ?? 0) : requestedMyShareMinor; + + if (resolved === null) return 0; + + if (!Number.isInteger(resolved) || resolved < 0 || resolved > amountMinor) { + throw new AppError( + 'VALIDATION_ERROR', + 'myShareMinor must be an integer between 0 and amountMinor', + 400, + { myShareMinor: resolved, amountMinor }, + ); + } + + return resolved; +}; diff --git a/packages/domain/src/services/expenses.service.ts b/packages/domain/src/services/expenses.service.ts index d5e8e82..5ade397 100644 --- a/packages/domain/src/services/expenses.service.ts +++ b/packages/domain/src/services/expenses.service.ts @@ -1,7 +1,6 @@ import crypto from 'node:crypto'; import { AppError } from '../errors.js'; -import type { CategoryDto } from '../repositories/categories.repository.js'; import { SqliteCategoriesRepository } from '../repositories/categories.repository.js'; import { SqliteCommitmentsRepository } from '../repositories/commitments.repository.js'; import type { ExpenseDto } from '../repositories/expenses.repository.js'; @@ -11,11 +10,22 @@ import { type RepositoryDb, withTransaction } from '../repositories/shared.js'; import type { ActorContext, CreateExpenseInput, - ExpenseKind, ListExpensesInput, ReimbursementStatus, UpdateExpenseInput, } from '../types.js'; +import { + assertPositiveAmountMinor, + deriveExpenseKind, + deriveReimbursementStatus, + enrichExpensesWithReimbursements, + isTransferKind, + normalizeCounterpartyType, + normalizeExpenseKind, + normalizeTransferDirection, + resolveReimbursableDefaults, + validateAndResolveMyShareMinor, +} from './expenses-logic.js'; import type { ApprovalToken } from './shared/approval-service.js'; import type { ApprovalService } from './shared/approval-service.js'; import type { AuditService } from './shared/audit-service.js'; @@ -37,274 +47,6 @@ interface ExpenseServiceDeps { audit: AuditService; } -type TransferDirection = 'in' | 'out' | null; - -const normalizeTransferDirection = (value: 'in' | 'out' | null | undefined): TransferDirection => { - if (value === 'in' || value === 'out') { - return value; - } - return null; -}; - -const normalizeExpenseKind = (value: ExpenseKind | null | undefined): ExpenseKind | null => { - if ( - value === 'expense' || - value === 'income' || - value === 'transfer_internal' || - value === 'transfer_external' - ) { - return value; - } - return null; -}; - -const isTransferKind = (kind: ExpenseKind): boolean => - kind === 'transfer_internal' || kind === 'transfer_external'; - -const assertPositiveAmountMinor = (value: number, field = 'amountMinor'): number => { - if (!Number.isInteger(value) || value <= 0) { - throw new AppError('VALIDATION_ERROR', `${field} must be a positive integer`, 400, { - field, - value, - }); - } - return value; -}; - -const deriveExpenseKind = ({ - category, - requestedKind, - transferDirection, -}: { - category: CategoryDto; - requestedKind: ExpenseKind | null; - transferDirection: TransferDirection; -}): ExpenseKind => { - if (category.kind === 'transfer') { - if (!transferDirection) { - throw new AppError( - 'VALIDATION_ERROR', - 'transferDirection is required when category kind is transfer', - 400, - { categoryId: category.id }, - ); - } - - if (!requestedKind) { - return 'transfer_external'; - } - - if (!isTransferKind(requestedKind)) { - throw new AppError( - 'VALIDATION_ERROR', - 'kind must be transfer_internal or transfer_external for transfer categories', - 400, - { categoryId: category.id, kind: requestedKind }, - ); - } - - return requestedKind; - } - - if (requestedKind && isTransferKind(requestedKind)) { - throw new AppError('VALIDATION_ERROR', 'transfer kinds require a transfer category', 400, { - categoryId: category.id, - kind: requestedKind, - }); - } - - const expectedKind: ExpenseKind = category.kind === 'income' ? 'income' : 'expense'; - - if (requestedKind && requestedKind !== expectedKind) { - throw new AppError('VALIDATION_ERROR', 'kind does not match category kind', 400, { - categoryId: category.id, - kind: requestedKind, - categoryKind: category.kind, - }); - } - - return expectedKind; -}; - -const normalizeCounterpartyType = ( - value: 'self' | 'partner' | 'team' | 'other' | null | undefined, -): 'self' | 'partner' | 'team' | 'other' | null => { - if (value === 'self' || value === 'partner' || value === 'team' || value === 'other') { - return value; - } - return null; -}; - -const computeRecoverableMinor = ( - expense: Pick, -): number => { - if (expense.kind !== 'expense' || expense.reimbursementStatus === 'none') { - return 0; - } - - const myShareMinor = expense.myShareMinor ?? 0; - return Math.max(expense.money.amountMinor - myShareMinor, 0); -}; - -const deriveReimbursementStatus = ({ - expense, - recoveredMinor, -}: { - expense: ExpenseDto; - recoveredMinor: number; -}): ReimbursementStatus => { - if (expense.kind !== 'expense') { - return 'none'; - } - - const isReimbursable = expense.reimbursementStatus !== 'none' || expense.myShareMinor !== null; - if (!isReimbursable) { - return 'none'; - } - - const recoverableMinor = computeRecoverableMinor(expense); - const writtenOffMinor = Math.max(expense.closedOutstandingMinor ?? 0, 0); - const outstandingMinor = Math.max(recoverableMinor - recoveredMinor - writtenOffMinor, 0); - - if (writtenOffMinor > 0) { - return 'written_off'; - } - if (recoverableMinor === 0 || outstandingMinor === 0) { - return 'settled'; - } - if (recoveredMinor > 0) { - return 'partial'; - } - return 'expected'; -}; - -const enrichExpensesWithReimbursements = ({ - items, - recoveredByOutId, -}: { - items: ExpenseDto[]; - recoveredByOutId: ReadonlyMap; -}): ExpenseDto[] => - items.map((item) => { - const recoveredMinor = recoveredByOutId.get(item.id) ?? 0; - const recoverableMinor = computeRecoverableMinor(item); - const outstandingMinor = Math.max( - recoverableMinor - recoveredMinor - Math.max(item.closedOutstandingMinor ?? 0, 0), - 0, - ); - const reimbursementStatus = deriveReimbursementStatus({ expense: item, recoveredMinor }); - - return { - ...item, - reimbursementStatus, - recoverableMinor, - recoveredMinor, - outstandingMinor, - }; - }); - -const resolveReimbursableDefaults = ({ - category, - kind, - requestedReimbursable, - existing, -}: { - category: CategoryDto; - kind: ExpenseKind; - requestedReimbursable: boolean | undefined; - existing?: ExpenseDto; -}): boolean => { - if (kind !== 'expense' || category.kind !== 'expense') { - if (requestedReimbursable === true) { - throw new AppError('VALIDATION_ERROR', 'Only expense rows can be reimbursable', 400, { - categoryId: category.id, - kind, - }); - } - return false; - } - - if (category.reimbursementMode === 'none') { - if (requestedReimbursable === true) { - throw new AppError( - 'VALIDATION_ERROR', - 'Category does not allow reimbursement tracking', - 400, - { - categoryId: category.id, - }, - ); - } - return false; - } - - if (category.reimbursementMode === 'always') { - if (requestedReimbursable === false) { - throw new AppError( - 'VALIDATION_ERROR', - 'Category reimbursement mode is always and cannot be disabled per-row', - 400, - { categoryId: category.id }, - ); - } - return true; - } - - if (requestedReimbursable !== undefined) { - return requestedReimbursable; - } - - if (existing) { - return existing.reimbursementStatus !== 'none' || existing.myShareMinor !== null; - } - - return true; -}; - -const validateAndResolveMyShareMinor = ({ - amountMinor, - reimbursable, - requestedMyShareMinor, - existing, -}: { - amountMinor: number; - reimbursable: boolean; - requestedMyShareMinor: number | null | undefined; - existing?: ExpenseDto; -}): number | null => { - if (!reimbursable) { - if (requestedMyShareMinor !== undefined && requestedMyShareMinor !== null) { - throw new AppError( - 'VALIDATION_ERROR', - 'myShareMinor is only valid for reimbursable expenses', - 400, - ); - } - return null; - } - - const resolved = - requestedMyShareMinor === undefined ? (existing?.myShareMinor ?? 0) : requestedMyShareMinor; - - if (resolved === null) { - return 0; - } - - if (!Number.isInteger(resolved) || resolved < 0 || resolved > amountMinor) { - throw new AppError( - 'VALIDATION_ERROR', - 'myShareMinor must be an integer between 0 and amountMinor', - 400, - { - myShareMinor: resolved, - amountMinor, - }, - ); - } - - return resolved; -}; - export const createExpensesService = ({ runtime, approvals, diff --git a/packages/domain/vitest.config.ts b/packages/domain/vitest.config.ts new file mode 100644 index 0000000..982ef44 --- /dev/null +++ b/packages/domain/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + include: ['src/**/__tests__/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'text-summary', 'lcov'], + reportsDirectory: 'coverage', + include: ['src/**/*.ts'], + exclude: ['src/**/index.ts', 'src/**/__tests__/**'], + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f6b0d0..10bff75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,6 +176,13 @@ importers: zod: specifier: ^3.24.2 version: 3.25.76 + devDependencies: + '@vitest/coverage-v8': + specifier: ^3.0.8 + version: 3.2.4(vitest@3.2.4(@types/node@22.19.11)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + vitest: + specifier: ^3.0.8 + version: 3.2.4(@types/node@22.19.11)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) packages/integrations-monzo: dependencies: @@ -216,6 +223,10 @@ importers: packages: + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@apideck/better-ajv-errors@0.3.6': resolution: {integrity: sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==} engines: {node: '>=10'} @@ -729,6 +740,10 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@biomejs/biome@1.9.4': resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==} engines: {node: '>=14.21.3'} @@ -1184,10 +1199,18 @@ packages: '@fastify/swagger@9.7.0': resolution: {integrity: sha512-Vp1SC1GC2Hrkd3faFILv86BzUNyFz5N4/xdExqtCgkGASOzn/x+eMe4qXIGq7cdT6wif/P/oa6r1Ruqx19paZA==} + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@isaacs/cliui@9.0.0': resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} engines: {node: '>=18'} + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1312,6 +1335,10 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@playwright/test@1.58.2': resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} engines: {node: '>=18'} @@ -1581,6 +1608,15 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/coverage-v8@3.2.4': + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + peerDependencies: + '@vitest/browser': 3.2.4 + vitest: 3.2.4 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -1633,10 +1669,18 @@ packages: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-regex@6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -1656,6 +1700,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.11: + resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -1796,6 +1843,13 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} @@ -2020,6 +2074,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} engines: {node: '>=0.10.0'} @@ -2031,6 +2088,12 @@ packages: emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -2253,6 +2316,11 @@ packages: github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} @@ -2278,6 +2346,10 @@ packages: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + has-property-descriptors@1.0.2: resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} @@ -2300,6 +2372,9 @@ packages: hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -2376,6 +2451,10 @@ packages: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-fullwidth-code-point@4.0.0: resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} engines: {node: '>=12'} @@ -2465,6 +2544,25 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jackspeak@4.2.3: resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} engines: {node: 20 || >=22} @@ -2474,6 +2572,9 @@ packages: engines: {node: '>=10'} hasBin: true + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2556,6 +2657,9 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.6: resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} engines: {node: 20 || >=22} @@ -2569,6 +2673,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2622,6 +2733,10 @@ packages: resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} engines: {node: '>=10'} + minimatch@9.0.8: + resolution: {integrity: sha512-reYkDYtj/b19TeqbNZCV4q9t+Yxylf/rYBsLb42SXJatTv4/ylq5lEiAmhA/IToxO7NI2UzNMghHoHuaqDkAjw==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -2720,6 +2835,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -3099,6 +3218,14 @@ packages: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -3126,6 +3253,10 @@ packages: resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==} engines: {node: '>=4'} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + strip-ansi@7.1.2: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} @@ -3156,6 +3287,10 @@ packages: resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} engines: {node: '>=14.18.0'} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -3180,6 +3315,10 @@ packages: engines: {node: '>=10'} hasBin: true + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} + thread-stream@4.0.0: resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} engines: {node: '>=20'} @@ -3501,6 +3640,14 @@ packages: workbox-window@7.4.0: resolution: {integrity: sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} @@ -3525,6 +3672,11 @@ packages: snapshots: + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@apideck/better-ajv-errors@0.3.6(ajv@8.18.0)': dependencies: ajv: 8.18.0 @@ -4196,6 +4348,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@1.0.2': {} + '@biomejs/biome@1.9.4': optionalDependencies: '@biomejs/cli-darwin-arm64': 1.9.4 @@ -4535,8 +4689,19 @@ snapshots: transitivePeerDependencies: - supports-color + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/cliui@9.0.0': {} + '@istanbuljs/schema@0.1.3': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4658,6 +4823,9 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@pkgjs/parseargs@0.11.0': + optional: true + '@playwright/test@1.58.2': dependencies: playwright: 1.58.2 @@ -4893,6 +5061,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@22.19.11)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.11 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/node@22.19.11)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - supports-color + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -4954,8 +5141,14 @@ snapshots: dependencies: environment: 1.1.0 + ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + ansi-styles@6.2.3: {} array-buffer-byte-length@1.0.2: @@ -4977,6 +5170,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@0.3.11: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + async-function@1.0.0: {} async@3.2.6: {} @@ -5124,6 +5323,12 @@ snapshots: clsx@2.1.1: {} + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + colorette@2.0.20: {} combined-stream@1.0.8: @@ -5247,6 +5452,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + ejs@3.1.10: dependencies: jake: 10.9.4 @@ -5255,6 +5462,10 @@ snapshots: emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -5597,6 +5808,15 @@ snapshots: github-from-package@0.0.0: {} + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.8 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@11.1.0: dependencies: foreground-child: 3.3.1 @@ -5623,6 +5843,8 @@ snapshots: has-bigints@1.1.0: {} + has-flag@4.0.0: {} + has-property-descriptors@1.0.2: dependencies: es-define-property: 1.0.1 @@ -5645,6 +5867,8 @@ snapshots: dependencies: react-is: 16.13.1 + html-escaper@2.0.2: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -5724,6 +5948,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-fullwidth-code-point@3.0.0: {} + is-fullwidth-code-point@4.0.0: {} is-fullwidth-code-point@5.1.0: @@ -5802,6 +6028,33 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jackspeak@4.2.3: dependencies: '@isaacs/cliui': 9.0.0 @@ -5812,6 +6065,8 @@ snapshots: filelist: 1.0.4 picocolors: 1.1.1 + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -5902,6 +6157,8 @@ snapshots: loupe@3.2.1: {} + lru-cache@10.4.3: {} + lru-cache@11.2.6: {} lru-cache@5.1.1: @@ -5916,6 +6173,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + math-intrinsics@1.1.0: {} merge-stream@2.0.0: {} @@ -5951,6 +6218,10 @@ snapshots: dependencies: brace-expansion: 2.0.2 + minimatch@9.0.8: + dependencies: + brace-expansion: 5.0.2 + minimist@1.2.8: {} minipass@7.1.3: {} @@ -6035,6 +6306,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + path-scurry@2.0.2: dependencies: lru-cache: 11.2.6 @@ -6449,6 +6725,18 @@ snapshots: string-argv@0.3.2: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -6504,6 +6792,10 @@ snapshots: is-obj: 1.0.1 is-regexp: 1.0.0 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + strip-ansi@7.1.2: dependencies: ansi-regex: 6.2.2 @@ -6542,6 +6834,10 @@ snapshots: transitivePeerDependencies: - supports-color + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + supports-preserve-symlinks-flag@1.0.0: {} tar-fs@2.1.4: @@ -6575,6 +6871,12 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + test-exclude@7.0.2: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 10.5.0 + minimatch: 10.2.2 + thread-stream@4.0.0: dependencies: real-require: 0.2.0 @@ -6978,6 +7280,18 @@ snapshots: '@types/trusted-types': 2.0.7 workbox-core: 7.4.0 + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3