diff --git a/src/components/ui/MoneyInput.tsx b/src/components/ui/MoneyInput.tsx index 81e7bcb..f635248 100644 --- a/src/components/ui/MoneyInput.tsx +++ b/src/components/ui/MoneyInput.tsx @@ -24,7 +24,7 @@ interface MoneyInputProps { } function editableValue(value: number | undefined, currency: CurrencyCode): string { - if (value === undefined) return ''; + if (value === undefined || !Number.isFinite(value)) return ''; const decimal = minorToDecimal(value, currency); if (value === 0) return ''; return String(decimal); @@ -64,7 +64,10 @@ export function MoneyInput({ lastExternal.current = `${currency}:${parsed.amountMinor}`; onChange(parsed.amountMinor); } catch { - // Intermediate values such as "12." remain editable. + // Never keep a previously valid amount behind invalid visible text. + // Zero is already rejected by every financial form that requires money. + lastExternal.current = `${currency}:0`; + onChange(0); } }; diff --git a/src/export/json/backup.test.ts b/src/export/json/backup.test.ts index 3562002..bf1c966 100644 --- a/src/export/json/backup.test.ts +++ b/src/export/json/backup.test.ts @@ -7,18 +7,23 @@ import { restoreBackup, summarizeBackup, validateBackup, + type Backup, } from './backup'; import { getDB } from '@db/database'; import { settingsRepository } from '@shared/settings/repository'; +import { SELF_PERSON_ID } from '@db/seed'; -function validEmptyBackup() { +const timestamp = '2026-08-17T00:00:00.000Z'; +const entity = { createdAt: timestamp, updatedAt: timestamp, revision: 1 }; + +function validEmptyBackup(): Backup { return { format: BACKUP_FORMAT, schemaVersion: BACKUP_SCHEMA_VERSION, - exportedAt: '2026-08-17T00:00:00.000Z', + exportedAt: timestamp, appVersion: 'test', shared: { - people: [], + people: [{ id: SELF_PERSON_ID, name: 'Me', isSelf: true, ...entity }], settings: { defaultCurrency: 'INR' }, }, track: { transactions: [], categories: [], budgets: [], recurringRules: [] }, @@ -33,11 +38,12 @@ describe('JSON backup', () => { freshDB(); }); - it('exports an empty backup with the right shape and financial settings', async () => { + it('exports an empty financial backup with the required self identity', async () => { const backup = await exportBackup(); expect(backup.format).toBe(BACKUP_FORMAT); expect(backup.schemaVersion).toBe(BACKUP_SCHEMA_VERSION); - expect(backup.shared.people).toEqual([]); + expect(backup.shared.people).toHaveLength(1); + expect(backup.shared.people[0]).toMatchObject({ id: SELF_PERSON_ID, isSelf: true }); expect(backup.shared.settings.defaultCurrency).toBe('INR'); expect(backup.track.transactions).toEqual([]); expect(backup.split.expenses).toEqual([]); @@ -52,10 +58,16 @@ describe('JSON backup', () => { expect(() => validateBackup({ ...validEmptyBackup(), schemaVersion: 999 })).toThrow(); }); - it('validateBackup accepts a well-formed empty backup', () => { + it('validateBackup accepts a well-formed empty financial backup', () => { expect(() => validateBackup(validEmptyBackup())).not.toThrow(); }); + it('validateBackup requires exactly one active self person', () => { + const backup = validEmptyBackup(); + backup.shared.people = []; + expect(() => validateBackup(backup)).toThrow(/self person/); + }); + it('validateBackup rejects malformed financial rows with a useful path', () => { const backup = validEmptyBackup(); const malformed = { @@ -70,9 +82,7 @@ describe('JSON backup', () => { amountMinor: '15000', currency: 'INR', date: '2026-08-13', - createdAt: '', - updatedAt: '', - revision: 1, + ...entity, }, ], }, @@ -81,14 +91,77 @@ describe('JSON backup', () => { expect(() => validateBackup(malformed)).toThrow('track.transactions.0.amountMinor'); }); + it('validateBackup rejects impossible calendar dates', () => { + const backup = validEmptyBackup(); + backup.track.transactions.push({ + id: 't1', + type: 'expense', + title: 'Coffee', + amountMinor: 15000, + currency: 'INR', + date: '2026-02-30', + ...entity, + }); + expect(() => validateBackup(backup)).toThrow(/calendar date/); + }); + + it('validateBackup rejects dangling Lend entries', () => { + const backup = validEmptyBackup(); + backup.lend.entries.push({ + id: 'e1', + ledgerId: 'missing-ledger', + type: 'lent', + amountMinor: 5000, + date: '2026-08-17', + ...entity, + }); + expect(() => validateBackup(backup)).toThrow(/ledger does not exist/); + }); + + it('validateBackup rejects Split expenses whose payer totals do not match', () => { + const backup = validEmptyBackup(); + backup.split.groups.push({ + id: 'g1', + name: 'Trip', + currency: 'INR', + archived: false, + ...entity, + }); + backup.split.expenses.push({ + id: 'x1', + groupId: 'g1', + title: 'Dinner', + amountMinor: 10000, + currency: 'INR', + date: '2026-08-17', + splitMethod: 'exact', + ...entity, + }); + backup.split.payers.push({ + id: 'pay1', + expenseId: 'x1', + personId: SELF_PERSON_ID, + amountMinor: 5000, + ...entity, + }); + backup.split.shares.push({ + id: 'share1', + expenseId: 'x1', + personId: SELF_PERSON_ID, + amountMinor: 10000, + ...entity, + }); + expect(() => validateBackup(backup)).toThrow(/payer totals/); + }); + it('round-trips financial records and the default currency', async () => { const db = getDB(); await settingsRepository.update({ defaultCurrency: 'USD' }); await db.people.put({ id: 'p1', name: 'Rahul', - createdAt: '2026-08-13T00:00:00.000Z', - updatedAt: '2026-08-13T00:00:00.000Z', + createdAt: timestamp, + updatedAt: timestamp, revision: 1, }); await db.trackTransactions.put({ @@ -98,13 +171,13 @@ describe('JSON backup', () => { amountMinor: 15000, currency: 'USD', date: '2026-08-13', - createdAt: '2026-08-13T00:00:00.000Z', - updatedAt: '2026-08-13T00:00:00.000Z', + createdAt: timestamp, + updatedAt: timestamp, revision: 1, }); const backup = await exportBackup(); - expect(backup.shared.people).toHaveLength(1); + expect(backup.shared.people).toHaveLength(2); expect(backup.shared.settings.defaultCurrency).toBe('USD'); await db.people.clear(); @@ -115,7 +188,7 @@ describe('JSON backup', () => { await restoreBackup(validateBackup(backup)); - expect(await db.people.toArray()).toHaveLength(1); + expect(await db.people.toArray()).toHaveLength(2); expect(await db.trackTransactions.toArray()).toHaveLength(1); expect((await db.settings.get('app'))?.defaultCurrency).toBe('USD'); }); @@ -125,6 +198,6 @@ describe('JSON backup', () => { await db.people.put({ id: 'p1', name: 'A', createdAt: '', updatedAt: '', revision: 1 }); const backup = await exportBackup(); const summary = summarizeBackup(backup); - expect(summary.people).toBe(1); + expect(summary.people).toBe(2); }); }); diff --git a/src/export/json/backup.ts b/src/export/json/backup.ts index bf4fe3c..1da84fe 100644 --- a/src/export/json/backup.ts +++ b/src/export/json/backup.ts @@ -9,7 +9,8 @@ import { z } from 'zod'; import { getDB } from '@db/database'; import { APP_VERSION } from '@app/constants'; -import { nowISO } from '@shared/dates'; +import { isValidDateOnly, isValidMonthKey, nowISO } from '@shared/dates'; +import { personRepository } from '@shared/people/repository'; import { settingsRepository } from '@shared/settings/repository'; import type { Person, @@ -199,8 +200,8 @@ export interface Backup { export async function exportBackup(): Promise { const db = getDB(); - // Ensure first-run defaults exist before opening the read-only snapshot. - await settingsRepository.get(); + // Ensure first-run identity/settings exist before opening the read-only snapshot. + await Promise.all([settingsRepository.get(), personRepository.ensureSelf()]); const [ settings, @@ -273,7 +274,7 @@ export async function exportBackup(): Promise { }; } -/** Validate a parsed backup object. Throws with the first invalid path. */ +/** Validate a parsed backup object and its cross-table financial relationships. */ export function validateBackup(input: unknown): Backup { const result = backupSchema.safeParse(input); if (!result.success) { @@ -281,7 +282,151 @@ export function validateBackup(input: unknown): Backup { const path = issue?.path.join('.'); throw new Error(`Invalid backup${path ? ` at ${path}` : ''}: ${issue?.message ?? 'unknown validation error'}`); } - return result.data as Backup; + const backup = result.data as Backup; + validateBackupRelations(backup); + return backup; +} + +function validateBackupRelations(backup: Backup): void { + const fail = (path: string, message: string): never => { + throw new Error(`Invalid backup at ${path}: ${message}`); + }; + const ids = (path: string, rows: T[]): Set => { + const out = new Set(); + for (const [index, row] of rows.entries()) { + if (out.has(row.id)) fail(`${path}.${index}.id`, `duplicate id ${row.id}`); + out.add(row.id); + } + return out; + }; + const assertSafeAmount = (path: string, amount: number) => { + if (!Number.isSafeInteger(amount)) fail(path, 'amount must be a safe integer'); + }; + + const peopleIds = ids('shared.people', backup.shared.people); + const categoryIds = ids('track.categories', backup.track.categories); + ids('track.transactions', backup.track.transactions); + ids('track.budgets', backup.track.budgets); + ids('track.recurringRules', backup.track.recurringRules); + const groupIds = ids('split.groups', backup.split.groups); + ids('split.members', backup.split.members); + const expenseIds = ids('split.expenses', backup.split.expenses); + ids('split.payers', backup.split.payers); + ids('split.shares', backup.split.shares); + ids('split.settlements', backup.split.settlements); + const ledgerIds = ids('lend.ledgers', backup.lend.ledgers); + ids('lend.entries', backup.lend.entries); + + const activeSelf = backup.shared.people.filter((person) => person.isSelf && !person.deletedAt); + if (activeSelf.length !== 1) { + fail('shared.people', 'exactly one active self person is required'); + } + + for (const [index, transaction] of backup.track.transactions.entries()) { + assertSafeAmount(`track.transactions.${index}.amountMinor`, transaction.amountMinor); + if (!isValidDateOnly(transaction.date)) { + fail(`track.transactions.${index}.date`, 'invalid calendar date'); + } + if (transaction.categoryId && !categoryIds.has(transaction.categoryId)) { + fail(`track.transactions.${index}.categoryId`, 'category does not exist'); + } + } + for (const [index, budget] of backup.track.budgets.entries()) { + assertSafeAmount(`track.budgets.${index}.amountMinor`, budget.amountMinor); + if (!isValidMonthKey(budget.month)) fail(`track.budgets.${index}.month`, 'invalid calendar month'); + } + for (const [index, rule] of backup.track.recurringRules.entries()) { + if (rule.amountMinor !== undefined) { + assertSafeAmount(`track.recurringRules.${index}.amountMinor`, rule.amountMinor); + } + if (!isValidDateOnly(rule.nextDate)) { + fail(`track.recurringRules.${index}.nextDate`, 'invalid calendar date'); + } + if (rule.categoryId && !categoryIds.has(rule.categoryId)) { + fail(`track.recurringRules.${index}.categoryId`, 'category does not exist'); + } + } + + const groupsById = new Map(backup.split.groups.map((group) => [group.id, group])); + for (const [index, member] of backup.split.members.entries()) { + if (!groupIds.has(member.groupId)) fail(`split.members.${index}.groupId`, 'group does not exist'); + if (!peopleIds.has(member.personId)) fail(`split.members.${index}.personId`, 'person does not exist'); + } + for (const [index, expense] of backup.split.expenses.entries()) { + assertSafeAmount(`split.expenses.${index}.amountMinor`, expense.amountMinor); + const group = groupsById.get(expense.groupId); + if (!group) fail(`split.expenses.${index}.groupId`, 'group does not exist'); + if (expense.currency !== group!.currency) { + fail(`split.expenses.${index}.currency`, 'currency does not match its group'); + } + if (!isValidDateOnly(expense.date)) fail(`split.expenses.${index}.date`, 'invalid calendar date'); + } + for (const [index, payer] of backup.split.payers.entries()) { + assertSafeAmount(`split.payers.${index}.amountMinor`, payer.amountMinor); + if (!expenseIds.has(payer.expenseId)) fail(`split.payers.${index}.expenseId`, 'expense does not exist'); + if (!peopleIds.has(payer.personId)) fail(`split.payers.${index}.personId`, 'person does not exist'); + } + for (const [index, share] of backup.split.shares.entries()) { + assertSafeAmount(`split.shares.${index}.amountMinor`, share.amountMinor); + if (!expenseIds.has(share.expenseId)) fail(`split.shares.${index}.expenseId`, 'expense does not exist'); + if (!peopleIds.has(share.personId)) fail(`split.shares.${index}.personId`, 'person does not exist'); + } + + const payerTotals = new Map(); + for (const payer of backup.split.payers) { + payerTotals.set(payer.expenseId, (payerTotals.get(payer.expenseId) ?? 0) + payer.amountMinor); + } + const shareTotals = new Map(); + for (const share of backup.split.shares) { + shareTotals.set(share.expenseId, (shareTotals.get(share.expenseId) ?? 0) + share.amountMinor); + } + for (const [index, expense] of backup.split.expenses.entries()) { + if ((payerTotals.get(expense.id) ?? 0) !== expense.amountMinor) { + fail(`split.expenses.${index}`, 'payer totals do not match expense amount'); + } + if ((shareTotals.get(expense.id) ?? 0) !== expense.amountMinor) { + fail(`split.expenses.${index}`, 'share totals do not match expense amount'); + } + } + + for (const [index, settlement] of backup.split.settlements.entries()) { + assertSafeAmount(`split.settlements.${index}.amountMinor`, settlement.amountMinor); + const group = groupsById.get(settlement.groupId); + if (!group) fail(`split.settlements.${index}.groupId`, 'group does not exist'); + if (!peopleIds.has(settlement.fromPersonId)) { + fail(`split.settlements.${index}.fromPersonId`, 'person does not exist'); + } + if (!peopleIds.has(settlement.toPersonId)) { + fail(`split.settlements.${index}.toPersonId`, 'person does not exist'); + } + if (settlement.fromPersonId === settlement.toPersonId) { + fail(`split.settlements.${index}`, 'payer and receiver must be different people'); + } + if (settlement.currency !== group!.currency) { + fail(`split.settlements.${index}.currency`, 'currency does not match its group'); + } + if (!isValidDateOnly(settlement.date)) { + fail(`split.settlements.${index}.date`, 'invalid calendar date'); + } + } + + for (const [index, ledger] of backup.lend.ledgers.entries()) { + if (!peopleIds.has(ledger.personId)) fail(`lend.ledgers.${index}.personId`, 'person does not exist'); + } + for (const [index, entry] of backup.lend.entries.entries()) { + assertSafeAmount(`lend.entries.${index}.amountMinor`, entry.amountMinor); + if (!ledgerIds.has(entry.ledgerId)) fail(`lend.entries.${index}.ledgerId`, 'ledger does not exist'); + if (!isValidDateOnly(entry.date)) fail(`lend.entries.${index}.date`, 'invalid calendar date'); + if (entry.dueDate && !isValidDateOnly(entry.dueDate)) { + fail(`lend.entries.${index}.dueDate`, 'invalid calendar date'); + } + if (entry.type !== 'adjustment' && entry.amountMinor <= 0) { + fail(`lend.entries.${index}.amountMinor`, 'non-adjustment amount must be positive'); + } + if (entry.type === 'adjustment' && entry.amountMinor === 0) { + fail(`lend.entries.${index}.amountMinor`, 'adjustment amount must not be zero'); + } + } } /** Restore a backup into the local database atomically. */ diff --git a/src/export/zip/builder.ts b/src/export/zip/builder.ts index cd8b670..602a832 100644 --- a/src/export/zip/builder.ts +++ b/src/export/zip/builder.ts @@ -103,6 +103,10 @@ export interface FullZipOptions { includeOverview?: boolean; } +function activeRows(rows: T[]): T[] { + return rows.filter((row) => !row.deletedAt); +} + export async function buildFullZip(opts: FullZipOptions = {}): Promise { const db = getDB(); const [ @@ -157,6 +161,26 @@ export async function buildFullZip(opts: FullZipOptions = {}): Promise { ]), ); + // The CSV package is an analysis/export view, not a restore format. Export + // only rows that are active in normal app behavior, and keep child tables + // scoped to active parents so the package never contains dangling rows. + const exportPeople = activeRows(people); + const exportTrackTx = activeRows(trackTx); + const exportTrackCats = activeRows(trackCats); + const exportTrackBudgets = activeRows(trackBudgets); + const exportTrackRecurring = activeRows(trackRecurring); + const exportSplitGroups = activeRows(splitGroups); + const activeGroupIds = new Set(exportSplitGroups.map((group) => group.id)); + const exportSplitMembers = activeRows(splitMembers).filter((member) => activeGroupIds.has(member.groupId)); + const exportSplitExpenses = activeRows(splitExpenses).filter((expense) => activeGroupIds.has(expense.groupId)); + const activeExpenseIds = new Set(exportSplitExpenses.map((expense) => expense.id)); + const exportSplitPayers = activeRows(splitPayers).filter((payer) => activeExpenseIds.has(payer.expenseId)); + const exportSplitShares = activeRows(splitShares).filter((share) => activeExpenseIds.has(share.expenseId)); + const exportSplitSettlements = activeRows(splitSettlements).filter((settlement) => activeGroupIds.has(settlement.groupId)); + const exportLendLedgers = activeRows(lendLedgers); + const activeLedgerIds = new Set(exportLendLedgers.map((ledger) => ledger.id)); + const exportLendEntries = activeRows(lendEntries).filter((entry) => activeLedgerIds.has(entry.ledgerId)); + const defaultCurrency = settings?.defaultCurrency ?? 'INR'; const inputs: Record = { 'README.txt': strToU8(README_TEXT), @@ -168,56 +192,56 @@ export async function buildFullZip(opts: FullZipOptions = {}): Promise { exportedAt: nowISO(), appVersion: APP_VERSION, counts: { - people: people.length, - trackTransactions: trackTx.length, - trackCategories: trackCats.length, - trackBudgets: trackBudgets.length, - trackRecurring: trackRecurring.length, - splitGroups: splitGroups.length, - splitMembers: splitMembers.length, - splitExpenses: splitExpenses.length, - splitPayers: splitPayers.length, - splitShares: splitShares.length, - splitSettlements: splitSettlements.length, - lendLedgers: lendLedgers.length, - lendEntries: lendEntries.length, + people: exportPeople.length, + trackTransactions: exportTrackTx.length, + trackCategories: exportTrackCats.length, + trackBudgets: exportTrackBudgets.length, + trackRecurring: exportTrackRecurring.length, + splitGroups: exportSplitGroups.length, + splitMembers: exportSplitMembers.length, + splitExpenses: exportSplitExpenses.length, + splitPayers: exportSplitPayers.length, + splitShares: exportSplitShares.length, + splitSettlements: exportSplitSettlements.length, + lendLedgers: exportLendLedgers.length, + lendEntries: exportLendEntries.length, }, } satisfies Manifest, null, 2, ), ), - 'shared/people.csv': strToU8(csvOfPeople(people)), - 'track/transactions.csv': strToU8(csvOfTrackTransactions(trackTx, trackCats)), - 'track/categories.csv': strToU8(csvOfTrackCategories(trackCats)), - 'track/budgets.csv': strToU8(csvOfTrackBudgets(trackBudgets)), - 'track/recurring.csv': strToU8(csvOfTrackRecurring(trackRecurring)), - 'split/groups.csv': strToU8(csvOfSplitGroups(splitGroups)), - 'split/members.csv': strToU8(csvOfSplitMembers(splitMembers, people)), - 'split/expenses.csv': strToU8(csvOfSplitExpenses(splitExpenses, splitGroups)), - 'split/payers.csv': strToU8(csvOfSplitPayers(splitPayers, splitExpenses, people)), - 'split/shares.csv': strToU8(csvOfSplitShares(splitShares, splitExpenses, people)), - 'split/settlements.csv': strToU8(csvOfSplitSettlements(splitSettlements, splitGroups, people)), - 'lend/ledgers.csv': strToU8(csvOfLendLedgers(lendLedgers, people)), - 'lend/entries.csv': strToU8(csvOfLendEntries(lendEntries, lendLedgers, people)), + 'shared/people.csv': strToU8(csvOfPeople(exportPeople)), + 'track/transactions.csv': strToU8(csvOfTrackTransactions(exportTrackTx, exportTrackCats)), + 'track/categories.csv': strToU8(csvOfTrackCategories(exportTrackCats)), + 'track/budgets.csv': strToU8(csvOfTrackBudgets(exportTrackBudgets)), + 'track/recurring.csv': strToU8(csvOfTrackRecurring(exportTrackRecurring)), + 'split/groups.csv': strToU8(csvOfSplitGroups(exportSplitGroups)), + 'split/members.csv': strToU8(csvOfSplitMembers(exportSplitMembers, exportPeople)), + 'split/expenses.csv': strToU8(csvOfSplitExpenses(exportSplitExpenses, exportSplitGroups)), + 'split/payers.csv': strToU8(csvOfSplitPayers(exportSplitPayers, exportSplitExpenses, exportPeople)), + 'split/shares.csv': strToU8(csvOfSplitShares(exportSplitShares, exportSplitExpenses, exportPeople)), + 'split/settlements.csv': strToU8(csvOfSplitSettlements(exportSplitSettlements, exportSplitGroups, exportPeople)), + 'lend/ledgers.csv': strToU8(csvOfLendLedgers(exportLendLedgers, exportPeople)), + 'lend/entries.csv': strToU8(csvOfLendEntries(exportLendEntries, exportLendLedgers, exportPeople)), }; if (opts.includeOverview !== false) { inputs['overview/people-summary.csv'] = strToU8( buildPeopleSummary({ - people, - ledgers: lendLedgers, - lendEntries, - groups: splitGroups, - members: splitMembers, - expenses: splitExpenses, - payers: splitPayers, - shares: splitShares, - settlements: splitSettlements, + people: exportPeople, + ledgers: exportLendLedgers, + lendEntries: exportLendEntries, + groups: exportSplitGroups, + members: exportSplitMembers, + expenses: exportSplitExpenses, + payers: exportSplitPayers, + shares: exportSplitShares, + settlements: exportSplitSettlements, }), ); inputs['overview/monthly-summary.csv'] = strToU8( - buildMonthlySummary(trackTx, defaultCurrency), + buildMonthlySummary(exportTrackTx, defaultCurrency), ); } diff --git a/src/export/zip/builderDeletedRows.test.ts b/src/export/zip/builderDeletedRows.test.ts new file mode 100644 index 0000000..36cb150 --- /dev/null +++ b/src/export/zip/builderDeletedRows.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { strFromU8, unzipSync } from 'fflate'; +import { getDB } from '@db/database'; +import { freshDB, wipeDB } from '@tests/db-test-utils'; +import { buildFullZip } from './builder'; + +const timestamp = '2026-08-19T00:00:00.000Z'; +const base = { createdAt: timestamp, updatedAt: timestamp, revision: 1 } as const; + +async function unzipFiles(blob: Blob) { + return unzipSync(new Uint8Array(await blob.arrayBuffer())); +} + +describe('full ZIP active-row policy', () => { + beforeEach(async () => { + await wipeDB(); + freshDB(); + }); + + it('omits soft-deleted financial rows and reports active manifest counts', async () => { + const db = getDB(); + await db.settings.put({ + id: 'app', + defaultCurrency: 'INR', + theme: 'system', + hideAmounts: false, + onboardingComplete: true, + ...base, + }); + await db.trackTransactions.bulkPut([ + { + id: 'active', + type: 'expense', + title: 'Visible coffee', + amountMinor: 10000, + currency: 'INR', + date: '2026-08-19', + ...base, + }, + { + id: 'deleted', + type: 'expense', + title: 'Deleted dinner', + amountMinor: 50000, + currency: 'INR', + date: '2026-08-19', + deletedAt: timestamp, + ...base, + }, + ]); + + const files = await unzipFiles(await buildFullZip({ includeOverview: false })); + const transactions = strFromU8(files['track/transactions.csv']!); + const manifest = JSON.parse(strFromU8(files['manifest.json']!)) as { + counts: { trackTransactions: number }; + }; + + expect(transactions).toContain('Visible coffee'); + expect(transactions).not.toContain('Deleted dinner'); + expect(manifest.counts.trackTransactions).toBe(1); + }); + + it('does not export child Split rows when their trip is deleted', async () => { + const db = getDB(); + await db.splitGroups.put({ + id: 'g1', + name: 'Deleted trip', + currency: 'INR', + archived: false, + deletedAt: timestamp, + ...base, + }); + await db.splitExpenses.put({ + id: 'e1', + groupId: 'g1', + title: 'Old dinner', + amountMinor: 10000, + currency: 'INR', + date: '2026-08-19', + splitMethod: 'equal', + ...base, + }); + + const files = await unzipFiles(await buildFullZip({ includeOverview: false })); + const expenses = strFromU8(files['split/expenses.csv']!); + const manifest = JSON.parse(strFromU8(files['manifest.json']!)) as { + counts: { splitGroups: number; splitExpenses: number }; + }; + + expect(expenses).not.toContain('Old dinner'); + expect(manifest.counts.splitGroups).toBe(0); + expect(manifest.counts.splitExpenses).toBe(0); + }); +}); diff --git a/src/modules/lend/components/AddLendPersonModal.tsx b/src/modules/lend/components/AddLendPersonModal.tsx index a9cf948..8ccac58 100644 --- a/src/modules/lend/components/AddLendPersonModal.tsx +++ b/src/modules/lend/components/AddLendPersonModal.tsx @@ -4,24 +4,19 @@ import { Button, Input, Modal, useToast } from '@components/ui'; import { usePeople } from '@shared/people/queries'; import { personNameKey } from '@shared/people/domain'; import { personRepository } from '@shared/people/repository'; -import type { CurrencyCode } from '@shared/money'; -import { lendLedgerRepository } from '../repositories/lendLedgerRepository'; interface AddLendPersonModalProps { open: boolean; - currency: CurrencyCode; onClose: () => void; onOpenPerson: (personId: string) => void; } /** - * Adds a person to the Lend surface without introducing a second identity - * system. Existing shared People are reused and an empty Lend ledger is only - * created when the person does not already have one. + * Adds a shared person to the Lend surface without creating financial data. + * The person's Lend ledger is created lazily with the first actual entry. */ export function AddLendPersonModal({ open, - currency, onClose, onOpenPerson, }: AddLendPersonModalProps) { @@ -67,11 +62,6 @@ export function AddLendPersonModal({ setError(undefined); try { const person = existingPerson ?? (await personRepository.create({ name: candidate })); - const existingLedgers = await lendLedgerRepository.listForPerson(person.id); - if (existingLedgers.length === 0) { - await lendLedgerRepository.getOrCreate(person.id, currency); - } - toast.show( existingPerson ? `${person.name} already exists — opening their Lend history` : `${person.name} added`, { variant: 'success' }, diff --git a/src/modules/lend/repositories/lendEntryRepository.ts b/src/modules/lend/repositories/lendEntryRepository.ts index a35ed86..ca2ccaf 100644 --- a/src/modules/lend/repositories/lendEntryRepository.ts +++ b/src/modules/lend/repositories/lendEntryRepository.ts @@ -3,11 +3,6 @@ * * Owns the Dexie `lendEntries` table. The Lend module is * the ONLY writer of this table. - * - * No "dueDate overdue" computation lives here in V1 — the - * Lend module deliberately stays decoupled from any - * scheduling logic. The schema accepts a `dueDate` for - * future use. */ import { getDB } from '@db/database'; @@ -18,14 +13,15 @@ import { repoRestore, type CreateInput, } from '@db/repositories/base'; +import { isValidDateOnly } from '@shared/dates'; import { LendEntryInputSchema, LendEntryTypeSchema, type LendEntryInput } from '../domain/validation'; import { magnitudeToStoredAmount } from '../domain/signs'; import type { LendEntry } from '@db/schema'; function clean(input: Partial): Partial { const out: Partial = { ...input }; - if (typeof out.note === 'string' && out.note === '') delete out.note; - if (typeof out.dueDate === 'string' && out.dueDate === '') delete out.dueDate; + if (typeof out.note === 'string' && out.note === '') out.note = undefined; + if (typeof out.dueDate === 'string' && out.dueDate === '') out.dueDate = undefined; return out; } @@ -67,12 +63,7 @@ export const lendEntryRepository = { return getDB().lendEntries.get(id); }, - /** - * Create a new entry. The schema enforces the magnitude / - * sign rules. The stored `amountMinor` is the magnitude - * for non-adjustment types; for `adjustment` the stored - * value is the user-supplied signed amount. - */ + /** Create a new validated Lend event. */ async create(input: LendEntryInput): Promise { const parsed = LendEntryInputSchema.parse(input); const cleaned = clean(parsed) as LendEntryInput; @@ -88,14 +79,11 @@ export const lendEntryRepository = { return repoCreate(getDB().lendEntries, toCreate); }, - /** - * Update an entry. The same magnitude/sign rules apply - * to the patch. - */ + /** Update an entry while validating the resulting event, not only the patch. */ async update(id: string, patch: Partial): Promise { - // We can't call .partial() on the refined schema - // directly (superRefine blocks it), so we do a - // shape-level validation per field instead. + const current = await this.get(id); + if (!current) throw new Error(`Lend entry not found: ${id}`); + const cleaned = clean(patch); if (typeof cleaned.ledgerId === 'string' && cleaned.ledgerId.length === 0) { throw new Error('Ledger is required'); @@ -103,48 +91,32 @@ export const lendEntryRepository = { if (typeof cleaned.type === 'string' && !LendEntryTypeSchema.safeParse(cleaned.type).success) { throw new Error('Invalid entry type'); } - if (cleaned.date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(cleaned.date)) { - throw new Error('Date must be YYYY-MM-DD'); + if (cleaned.date !== undefined && !isValidDateOnly(cleaned.date)) { + throw new Error('Invalid calendar date'); } - if ( - cleaned.dueDate !== undefined && - typeof cleaned.dueDate === 'string' && - cleaned.dueDate !== '' && - !/^\d{4}-\d{2}-\d{2}$/.test(cleaned.dueDate) - ) { - throw new Error('Due date must be YYYY-MM-DD'); + if (cleaned.dueDate !== undefined && !isValidDateOnly(cleaned.dueDate)) { + throw new Error('Invalid due date'); } - if (cleaned.amountMinor !== undefined) { - if (!Number.isInteger(cleaned.amountMinor)) { - throw new Error('Amount must be an integer (minor units)'); - } - if (cleaned.amountMinor === 0) { - throw new Error('Amount must not be zero'); - } - if (cleaned.type !== 'adjustment' && cleaned.amountMinor < 0) { - throw new Error('Amount must be positive for this entry type'); - } + + const resultingType = cleaned.type ?? current.type; + const resultingAmount = cleaned.amountMinor ?? current.amountMinor; + if (!Number.isSafeInteger(resultingAmount) || resultingAmount === 0) { + throw new Error('Amount must be a non-zero safe integer in minor units'); + } + if (resultingType !== 'adjustment' && resultingAmount < 0) { + throw new Error('Amount must be positive for this entry type'); } const patchWithStored: Partial = {}; if (cleaned.ledgerId !== undefined) patchWithStored.ledgerId = cleaned.ledgerId; if (cleaned.type !== undefined) patchWithStored.type = cleaned.type; if (cleaned.date !== undefined) patchWithStored.date = cleaned.date; - if (cleaned.dueDate !== undefined) - patchWithStored.dueDate = cleaned.dueDate || undefined; - if (cleaned.note !== undefined) patchWithStored.note = cleaned.note || undefined; - if (cleaned.amountMinor !== undefined && cleaned.type !== undefined) { - patchWithStored.amountMinor = magnitudeToStoredAmount(cleaned.type, cleaned.amountMinor); - } else if (cleaned.amountMinor !== undefined) { - // type not provided in patch: we must re-normalize - // using the existing type. - const cur = await this.get(id); - if (cur) { - patchWithStored.amountMinor = magnitudeToStoredAmount(cur.type, cleaned.amountMinor); - } else { - patchWithStored.amountMinor = cleaned.amountMinor; - } + if (Object.hasOwn(patch, 'dueDate')) patchWithStored.dueDate = cleaned.dueDate || undefined; + if (Object.hasOwn(patch, 'note')) patchWithStored.note = cleaned.note || undefined; + if (cleaned.amountMinor !== undefined) { + patchWithStored.amountMinor = magnitudeToStoredAmount(resultingType, cleaned.amountMinor); } + return repoUpdate(getDB().lendEntries, id, patchWithStored); }, diff --git a/src/modules/lend/repositories/lendLedgerAtomicity.test.ts b/src/modules/lend/repositories/lendLedgerAtomicity.test.ts index f7b569a..3220e07 100644 --- a/src/modules/lend/repositories/lendLedgerAtomicity.test.ts +++ b/src/modules/lend/repositories/lendLedgerAtomicity.test.ts @@ -35,4 +35,30 @@ describe('Lend ledger cascade atomicity', () => { expect((await db.lendLedgers.get(ledger.id))?.deletedAt).toBeUndefined(); expect((await db.lendEntries.get(entry.id))?.deletedAt).toBeUndefined(); }); + + it('does not resurrect an entry deleted before the ledger was deleted', async () => { + const person = await personRepository.create({ name: 'Rahul' }); + const ledger = await lendLedgerRepository.create({ personId: person.id, currency: 'INR' }); + const activeEntry = await lendEntryRepository.create({ + ledgerId: ledger.id, + type: 'lent', + amountMinor: 5000, + date: '2026-08-18', + }); + const previouslyDeleted = await lendEntryRepository.create({ + ledgerId: ledger.id, + type: 'lent', + amountMinor: 1000, + date: '2026-08-17', + }); + + await lendEntryRepository.softDelete(previouslyDeleted.id); + const originalDeletedAt = (await getDB().lendEntries.get(previouslyDeleted.id))?.deletedAt; + + await lendLedgerRepository.softDelete(ledger.id); + await lendLedgerRepository.restore(ledger.id); + + expect((await getDB().lendEntries.get(activeEntry.id))?.deletedAt).toBeUndefined(); + expect((await getDB().lendEntries.get(previouslyDeleted.id))?.deletedAt).toBe(originalDeletedAt); + }); }); diff --git a/src/modules/lend/repositories/lendLedgerRepository.ts b/src/modules/lend/repositories/lendLedgerRepository.ts index b228b37..ed4e8eb 100644 --- a/src/modules/lend/repositories/lendLedgerRepository.ts +++ b/src/modules/lend/repositories/lendLedgerRepository.ts @@ -4,10 +4,8 @@ * Owns the Dexie `lendLedgers` table. The Lend module is the * ONLY writer of this table. * - * Important: `getOrCreate(personId, currency)` is idempotent - * — it returns the existing active ledger for the pair, or - * creates a fresh one. This is the canonical way the UI - * resolves a ledger to write an entry into. + * Lend uses the app's Main currency. Split groups remain free + * to use their own independent currencies. */ import { getDB } from '@db/database'; @@ -15,10 +13,11 @@ import { runTransaction } from '@db/transaction'; import { repoCreate, repoUpdate, - repoSoftDelete, repoRestore, type CreateInput, } from '@db/repositories/base'; +import { nowISO } from '@shared/dates'; +import { settingsRepository } from '@shared/settings/repository'; import { LendLedgerInputSchema, type LendLedgerInput } from '../domain/validation'; import type { LendLedger, LendEntry } from '@db/schema'; @@ -29,6 +28,13 @@ function clean(input: Partial): Partial { }; } +async function assertMainCurrency(currency: string): Promise { + const settings = await settingsRepository.get(); + if (currency !== settings.defaultCurrency) { + throw new Error(`Lend uses your Main currency (${settings.defaultCurrency}).`); + } +} + export const lendLedgerRepository = { /** All active, non-archived ledgers, sorted by createdAt asc. */ async list(): Promise { @@ -52,12 +58,12 @@ export const lendLedgerRepository = { }, /** - * Get the active ledger for (person, currency), or create - * one if it doesn't exist. The lookup and creation share a - * write transaction so concurrent callers cannot create two - * active ledgers for the same pair. + * Get the active Main-currency ledger for a person, or create one. + * The lookup and creation share a write transaction so concurrent callers + * cannot create two active ledgers for the same pair. */ async getOrCreate(personId: string, currency: string): Promise { + await assertMainCurrency(currency); const db = getDB(); return db.transaction('rw', db.lendLedgers, async () => { const existing = await db.lendLedgers @@ -72,16 +78,23 @@ export const lendLedgerRepository = { }); }, - /** Create a new ledger. */ + /** Create a new Main-currency ledger. */ async create(input: LendLedgerInput): Promise { const parsed = LendLedgerInputSchema.parse(input); + await assertMainCurrency(parsed.currency); const cleaned = clean(parsed); return repoCreate(getDB().lendLedgers, cleaned as CreateInput); }, - /** Update an existing ledger. */ + /** Update non-financial ledger metadata. Currency changes are not allowed. */ async update(id: string, patch: Partial): Promise { const parsed = LendLedgerInputSchema.partial().parse(patch); + if (parsed.currency !== undefined) { + const current = await this.get(id); + if (current && parsed.currency !== current.currency) { + throw new Error('Lend ledger currency cannot be changed after creation.'); + } + } return repoUpdate(getDB().lendLedgers, id, clean(parsed)); }, @@ -94,7 +107,11 @@ export const lendLedgerRepository = { return this.update(id, { archived: true }); }, - /** Soft-delete a ledger and all currently-active entries atomically. */ + /** + * Soft-delete a ledger and all entries that are active at that moment. + * One deletion timestamp is shared by the cascade so Undo can restore only + * rows deleted by this operation, never entries the user deleted earlier. + */ async softDelete(id: string): Promise<{ ledgerId: string; entryIds: string[] }> { const db = getDB(); return db.transaction('rw', [db.lendLedgers, db.lendEntries], async () => { @@ -102,24 +119,38 @@ export const lendLedgerRepository = { if (!ledger) return { ledgerId: id, entryIds: [] }; const entries = await db.lendEntries.where('ledgerId').equals(id).toArray(); const activeEntries = entries.filter((entry) => !entry.deletedAt); - - await repoSoftDelete(db.lendLedgers, id); + const deletedAt = nowISO(); + + await db.lendLedgers.put({ + ...ledger, + deletedAt, + updatedAt: deletedAt, + revision: ledger.revision + 1, + }); for (const entry of activeEntries) { - await repoSoftDelete(db.lendEntries, entry.id); + await db.lendEntries.put({ + ...entry, + deletedAt, + updatedAt: deletedAt, + revision: entry.revision + 1, + }); } return { ledgerId: id, entryIds: activeEntries.map((entry) => entry.id) }; }); }, - /** Restore a soft-deleted ledger and its soft-deleted entries atomically. */ + /** Restore a soft-deleted ledger and only entries deleted with that ledger. */ async restore(id: string): Promise<{ ledgerId: string; entryIds: string[] }> { const db = getDB(); return db.transaction('rw', [db.lendLedgers, db.lendEntries], async () => { const ledger = await db.lendLedgers.get(id); if (!ledger) return { ledgerId: id, entryIds: [] }; - const entries = (await db.lendEntries.where('ledgerId').equals(id).toArray()).filter( - (entry) => !!entry.deletedAt, - ); + const deletedAt = ledger.deletedAt; + const entries = deletedAt + ? (await db.lendEntries.where('ledgerId').equals(id).toArray()).filter( + (entry) => entry.deletedAt === deletedAt, + ) + : []; await repoRestore(db.lendLedgers, id); for (const entry of entries) { diff --git a/src/modules/lend/repositories/repositories.test.ts b/src/modules/lend/repositories/repositories.test.ts index 2549539..0569c2f 100644 --- a/src/modules/lend/repositories/repositories.test.ts +++ b/src/modules/lend/repositories/repositories.test.ts @@ -20,36 +20,31 @@ import { ledgerBalance, personBalanceFromLedgers } from '../domain/balance'; import { SELF_PERSON_ID } from '@db/seed'; async function seedPerson(name: string) { - const p = await personRepository.create({ name }); - return p; + return personRepository.create({ name }); } beforeEach(async () => { await wipeDB(); freshDB(); - // The repositories touch markDirty which reads the sync - // metadata row; ensure the settings exist so test runs - // are realistic. await settingsRepository.get(); }); describe('LendLedgerRepository', () => { it('list returns active ledgers sorted by createdAt', async () => { - const person = await seedPerson('Rahul'); - const a = await lendLedgerRepository.create({ personId: person.id, currency: 'INR' }); - const b = await lendLedgerRepository.create({ personId: person.id, currency: 'USD' }); + const rahul = await seedPerson('Rahul'); + const priya = await seedPerson('Priya'); + const a = await lendLedgerRepository.create({ personId: rahul.id, currency: 'INR' }); + const b = await lendLedgerRepository.create({ personId: priya.id, currency: 'INR' }); const list = await lendLedgerRepository.list(); - expect(list.map((l) => l.id)).toEqual([a.id, b.id]); + expect(list.map((ledger) => ledger.id)).toEqual([a.id, b.id]); }); - it('getOrCreate is idempotent for (person, currency)', async () => { + it('getOrCreate is idempotent and rejects non-Main currencies', async () => { const person = await seedPerson('Rahul'); const first = await lendLedgerRepository.getOrCreate(person.id, 'INR'); const second = await lendLedgerRepository.getOrCreate(person.id, 'INR'); expect(first.id).toBe(second.id); - // Different currency -> different ledger. - const usd = await lendLedgerRepository.getOrCreate(person.id, 'USD'); - expect(usd.id).not.toBe(first.id); + await expect(lendLedgerRepository.getOrCreate(person.id, 'USD')).rejects.toThrow(/Main currency/); }); it('softDelete then restore round-trips', async () => { @@ -134,6 +129,37 @@ describe('LendEntryRepository', () => { ).rejects.toThrow(); }); + it('rejects an update that would leave a negative non-adjustment amount', async () => { + const person = await seedPerson('Rahul'); + const ledger = await lendLedgerRepository.getOrCreate(person.id, 'INR'); + const entry = await lendEntryRepository.create({ + ledgerId: ledger.id, + type: 'adjustment', + amountMinor: -5000, + date: '2024-01-01', + }); + + await expect(lendEntryRepository.update(entry.id, { type: 'lent' })).rejects.toThrow(/positive/); + expect((await lendEntryRepository.get(entry.id))?.type).toBe('adjustment'); + }); + + it('can explicitly clear note and due date', async () => { + const person = await seedPerson('Rahul'); + const ledger = await lendLedgerRepository.getOrCreate(person.id, 'INR'); + const entry = await lendEntryRepository.create({ + ledgerId: ledger.id, + type: 'lent', + amountMinor: 5000, + date: '2024-01-01', + dueDate: '2024-02-01', + note: 'Reminder', + }); + + const updated = await lendEntryRepository.update(entry.id, { dueDate: '', note: '' }); + expect(updated.dueDate).toBeUndefined(); + expect(updated.note).toBeUndefined(); + }); + it('softDelete then restore round-trips an entry', async () => { const person = await seedPerson('Rahul'); const ledger = await lendLedgerRepository.getOrCreate(person.id, 'INR'); @@ -154,11 +180,9 @@ describe('LendEntryRepository', () => { describe('Critical isolation: Split settlement must not alter Lend (work.md §85)', () => { it('keeps Lend balances intact after a Split settlement', async () => { - // Seed two real people and a Self person. const rahul = await personRepository.create({ name: 'Rahul' }); const me = await personRepository.ensureSelf(); - // Lend: Rahul owes me 5,000. const lendLedger = await lendLedgerRepository.getOrCreate(rahul.id, 'INR'); await lendEntryRepository.create({ ledgerId: lendLedger.id, @@ -167,7 +191,6 @@ describe('Critical isolation: Split settlement must not alter Lend (work.md §85 date: '2024-01-01', }); - // Split: Goa group where Rahul owes me 1,200. const db = getDB(); const goaId = 'goa-group'; const now = new Date().toISOString(); @@ -180,7 +203,6 @@ describe('Critical isolation: Split settlement must not alter Lend (work.md §85 updatedAt: now, revision: 1, }); - // Make sure both people are in the group. for (const pid of [me.id, rahul.id]) { await db.splitGroupMembers.put({ id: `m-${pid}-${goaId}`, @@ -193,12 +215,6 @@ describe('Critical isolation: Split settlement must not alter Lend (work.md §85 revision: 1, }); } - // Goa expense: I paid 1,200; shared equally between me and Rahul. - // Net: Rahul owes me 600 (Goa shows +600, NOT +1,200). - // The spec's "+1,200" is the *gross share*, but the - // balance after settlement is what we measure. We - // verify that AFTER a settlement, the Lend balance - // remains +5,000 regardless of what happens in Split. await db.splitExpenses.put({ id: 'goa-exp-1', groupId: goaId, @@ -242,15 +258,13 @@ describe('Critical isolation: Split settlement must not alter Lend (work.md §85 revision: 1, }, ]); - void SELF_PERSON_ID; // ensureSelf uses this internally + void SELF_PERSON_ID; - // Snapshot Lend state. const lendLedgers = await lendLedgerRepository.list(); const lendEntries = await lendEntryRepository.list(); const lendBalanceBefore = personBalanceFromLedgers(lendLedgers, lendEntries, rahul.id); expect(lendBalanceBefore).toBe(500000); - // Perform a Split settlement: Rahul pays me 600. await db.splitSettlements.put({ id: 'settle-1', groupId: goaId, @@ -264,7 +278,6 @@ describe('Critical isolation: Split settlement must not alter Lend (work.md §85 revision: 1, }); - // Snapshot Lend state again. const lendLedgersAfter = await lendLedgerRepository.list(); const lendEntriesAfter = await lendEntryRepository.list(); const lendBalanceAfter = personBalanceFromLedgers( @@ -273,14 +286,10 @@ describe('Critical isolation: Split settlement must not alter Lend (work.md §85 rahul.id, ); expect(lendBalanceAfter).toBe(500000); - - // The lend ledger's balance should be unchanged. expect(ledgerBalance(lendEntriesAfter)).toBe(500000); - // The Split settlement must be a Split record, NOT a Lend record. const splitSettlements = await db.splitSettlements.toArray(); expect(splitSettlements).toHaveLength(1); - // And there should be no lend entry created from the settlement. const lendSettlementLike = lendEntriesAfter.filter( (e) => e.note?.toLowerCase().includes('settle') ?? false, ); diff --git a/src/modules/split/components/SplitCsvImportCard.tsx b/src/modules/split/components/SplitCsvImportCard.tsx index 46d90f9..2bd9ccc 100644 --- a/src/modules/split/components/SplitCsvImportCard.tsx +++ b/src/modules/split/components/SplitCsvImportCard.tsx @@ -1,33 +1,54 @@ import { useRef, useState } from 'react'; import { FileSpreadsheet, Upload } from 'lucide-react'; import { Button, Card, CardHeader, CardTitle, useToast } from '@components/ui'; +import { useSelf } from '@shared/people/queries'; +import { personNameKey } from '@shared/people/domain'; import { executeSplitCsvImport, previewSplitCsv, type SplitCsvPreview, } from '../services/importCsv'; +const SELF_NOT_LISTED = '__self_not_listed__'; + export function SplitCsvImportCard({ groupId, currency }: { groupId: string; currency: string }) { const inputRef = useRef(null); + const self = useSelf(); const [preview, setPreview] = useState(); const [filename, setFilename] = useState(''); + const [selfParticipant, setSelfParticipant] = useState(''); const [busy, setBusy] = useState(false); const toast = useToast(); + const clearPreview = () => { + setPreview(undefined); + setFilename(''); + setSelfParticipant(''); + }; + const readFile = async (file: File) => { try { const next = previewSplitCsv(await file.text(), currency); + const matchingSelf = + next.kind === 'splitwise' && self + ? next.participantNames.find((name) => personNameKey(name) === personNameKey(self.name)) + : undefined; setPreview(next); setFilename(file.name); + setSelfParticipant(matchingSelf ?? ''); } catch (error) { - setPreview(undefined); - setFilename(''); + clearPreview(); toast.show(error instanceof Error ? error.message : 'Could not read CSV', { variant: 'error' }); } }; const runImport = async () => { if (!preview) return; + if (preview.kind === 'splitwise' && !selfParticipant) { + toast.show('Choose which Splitwise participant is you before importing.', { variant: 'error' }); + return; + } + const confirmed = window.confirm( `Import ${preview.rows.length} expense${preview.rows.length === 1 ? '' : 's'} into this trip? Existing expenses will not be changed.`, ); @@ -35,7 +56,14 @@ export function SplitCsvImportCard({ groupId, currency }: { groupId: string; cur setBusy(true); try { - const result = await executeSplitCsvImport(groupId, preview); + const result = await executeSplitCsvImport(groupId, preview, { + selfParticipantName: + preview.kind !== 'splitwise' + ? undefined + : selfParticipant === SELF_NOT_LISTED + ? null + : selfParticipant, + }); const duplicateNote = result.skippedDuplicates ? ` ${result.skippedDuplicates} already-imported ${result.skippedDuplicates === 1 ? 'row was' : 'rows were'} skipped.` : ''; @@ -43,8 +71,7 @@ export function SplitCsvImportCard({ groupId, currency }: { groupId: string; cur `Imported ${result.imported} expense${result.imported === 1 ? '' : 's'}${result.peopleAdded ? ` and added ${result.peopleAdded} new ${result.peopleAdded === 1 ? 'person' : 'people'}` : ''}.${duplicateNote}`, { variant: 'success' }, ); - setPreview(undefined); - setFilename(''); + clearPreview(); if (inputRef.current) inputRef.current.value = ''; } catch (error) { toast.show(error instanceof Error ? error.message : 'Import failed', { variant: 'error' }); @@ -91,6 +118,25 @@ export function SplitCsvImportCard({ groupId, currency }: { groupId: string; cur People found: {preview.participantNames.join(', ')}. Existing matching names are reused.

)} + {preview.kind === 'splitwise' && ( +
+ + +

+ This prevents your Splitwise rows from being imported as a second person. +

+
+ )} {preview.warnings.length > 0 && (
{preview.warnings.length} import note{preview.warnings.length === 1 ? '' : 's'} @@ -104,8 +150,13 @@ export function SplitCsvImportCard({ groupId, currency }: { groupId: string; cur Re-importing the same CSV is safe: rows AfterSum already imported from that file are skipped.

- - + +
)} diff --git a/src/modules/split/components/TripBalancesPanel.tsx b/src/modules/split/components/TripBalancesPanel.tsx index 7c1145f..c167a17 100644 --- a/src/modules/split/components/TripBalancesPanel.tsx +++ b/src/modules/split/components/TripBalancesPanel.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { useNavigate } from '@tanstack/react-router'; import { ArrowRight, CheckCircle2, Share2, WalletCards } from 'lucide-react'; import { Button, Card, Money, useCelebration, useToast } from '@components/ui'; @@ -22,6 +22,10 @@ interface TripBalancesPanelProps { hideAmounts: boolean; } +function transferKey(transfer: { fromPersonId: string; toPersonId: string; amountMinor: number }): string { + return `${transfer.fromPersonId}:${transfer.toPersonId}:${transfer.amountMinor}`; +} + export function TripBalancesPanel({ groupId, groupName, @@ -38,12 +42,19 @@ export function TripBalancesPanel({ const toast = useToast(); const { celebrate } = useCelebration(); const [pendingUndoId, setPendingUndoId] = useState(null); + const [savingPaymentKey, setSavingPaymentKey] = useState(null); + const savingPaymentRef = useRef(null); const personMap = new Map(people.map((person) => [person.id, person])); const memberPeople = members .map((member) => personMap.get(member.personId)) .filter((person): person is Person => Boolean(person)); const markPaid = async (transfer: { fromPersonId: string; toPersonId: string; amountMinor: number }) => { + const key = transferKey(transfer); + if (savingPaymentRef.current) return; + savingPaymentRef.current = key; + setSavingPaymentKey(key); + try { await splitSettlementRepository.create({ groupId, @@ -59,6 +70,9 @@ export function TripBalancesPanel({ celebrate({ kind: closesTrip ? 'settled' : 'added', message: closesTrip ? 'Trip settled' : 'Payment recorded' }); } catch (error) { toast.show(error instanceof Error ? error.message : 'Could not record payment', { variant: 'error' }); + } finally { + savingPaymentRef.current = null; + setSavingPaymentKey(null); } }; @@ -126,6 +140,8 @@ export function TripBalancesPanel({ {transfers.map((transfer, index) => { const from = personMap.get(transfer.fromPersonId); const to = personMap.get(transfer.toPersonId); + const key = transferKey(transfer); + const saving = savingPaymentKey === key; return (
@@ -149,7 +165,12 @@ export function TripBalancesPanel({ - +
diff --git a/src/modules/split/services/createTrip.test.ts b/src/modules/split/services/createTrip.test.ts new file mode 100644 index 0000000..61f73e1 --- /dev/null +++ b/src/modules/split/services/createTrip.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { freshDB, wipeDB } from '@tests/db-test-utils'; +import { getDB } from '@db/database'; +import { personRepository } from '@shared/people/repository'; +import { createSplitTrip } from './createTrip'; + +beforeEach(async () => { + await wipeDB(); + freshDB(); +}); + +describe('createSplitTrip', () => { + it('creates the trip and all memberships atomically', async () => { + const self = await personRepository.ensureSelf(); + const saved = await personRepository.create({ name: 'Rahul' }); + + const group = await createSplitTrip({ + name: 'Goa Trip', + currency: 'INR', + selfPersonId: self.id, + memberPersonIds: [saved.id], + newPersonNames: ['Priya'], + }); + + expect(await getDB().splitGroups.count()).toBe(1); + const members = await getDB().splitGroupMembers.where('groupId').equals(group.id).toArray(); + expect(members).toHaveLength(3); + expect((await getDB().people.toArray()).map((person) => person.name).sort()).toEqual([ + 'Me', + 'Priya', + 'Rahul', + ]); + }); + + it('rolls back the group and memberships when a new person cannot be created', async () => { + const self = await personRepository.ensureSelf(); + await personRepository.create({ name: 'Rahul' }); + + await expect( + createSplitTrip({ + name: 'Goa Trip', + currency: 'INR', + selfPersonId: self.id, + memberPersonIds: [], + newPersonNames: ['Rahul'], + }), + ).rejects.toThrow(/already exists/i); + + expect(await getDB().splitGroups.count()).toBe(0); + expect(await getDB().splitGroupMembers.count()).toBe(0); + expect(await getDB().people.count()).toBe(2); + }); +}); diff --git a/src/modules/split/services/createTrip.ts b/src/modules/split/services/createTrip.ts new file mode 100644 index 0000000..9c4b021 --- /dev/null +++ b/src/modules/split/services/createTrip.ts @@ -0,0 +1,43 @@ +import { getDB } from '@db/database'; +import type { SplitGroup } from '@db/schema'; +import { personRepository } from '@shared/people/repository'; +import { splitGroupMemberRepository } from '../repositories/splitGroupMemberRepository'; +import { splitGroupRepository } from '../repositories/splitGroupRepository'; + +export interface CreateSplitTripInput { + name: string; + description?: string; + currency: string; + selfPersonId: string; + memberPersonIds: string[]; + newPersonNames: string[]; +} + +/** Create a trip, any new people, and all memberships as one user operation. */ +export async function createSplitTrip(input: CreateSplitTripInput): Promise { + const db = getDB(); + return db.transaction( + 'rw', + [db.people, db.splitGroups, db.splitGroupMembers], + async () => { + const group = await splitGroupRepository.create({ + name: input.name, + description: input.description, + currency: input.currency, + }); + + await splitGroupMemberRepository.getOrCreate(group.id, input.selfPersonId); + for (const personId of new Set(input.memberPersonIds)) { + if (personId !== input.selfPersonId) { + await splitGroupMemberRepository.getOrCreate(group.id, personId); + } + } + for (const personName of input.newPersonNames) { + const person = await personRepository.create({ name: personName }); + await splitGroupMemberRepository.getOrCreate(group.id, person.id); + } + + return group; + }, + ); +} diff --git a/src/modules/split/services/importCsv.test.ts b/src/modules/split/services/importCsv.test.ts index 526b480..22adc56 100644 --- a/src/modules/split/services/importCsv.test.ts +++ b/src/modules/split/services/importCsv.test.ts @@ -1,5 +1,15 @@ -import { describe, expect, it } from 'vitest'; -import { previewSplitCsv } from './importCsv'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { freshDB, wipeDB } from '@tests/db-test-utils'; +import { getDB } from '@db/database'; +import { personRepository } from '@shared/people/repository'; +import { splitGroupRepository } from '../repositories/splitGroupRepository'; +import { splitGroupMemberRepository } from '../repositories/splitGroupMemberRepository'; +import { executeSplitCsvImport, previewSplitCsv, type SplitCsvPreview } from './importCsv'; + +beforeEach(async () => { + await wipeDB(); + freshDB(); +}); describe('previewSplitCsv', () => { it('detects Splitwise paid/owed columns and preserves exact amounts', () => { @@ -54,4 +64,83 @@ describe('previewSplitCsv', () => { expect(preview.skippedRows).toBe(1); expect(preview.warnings[0]).toMatch(/skipped EUR expense/); }); + + it('rejects impossible ISO-looking calendar dates during preview', () => { + const csv = [ + 'Date,Description,Amount', + '2026-02-30,Dinner,100', + ].join('\n'); + + expect(() => previewSplitCsv(csv, 'INR')).toThrow(/invalid or missing date/i); + }); +}); + +describe('executeSplitCsvImport', () => { + async function seedTrip() { + const self = await personRepository.ensureSelf(); + const group = await splitGroupRepository.create({ name: 'Trip', currency: 'INR' }); + await splitGroupMemberRepository.getOrCreate(group.id, self.id); + return { self, group }; + } + + it('maps the chosen Splitwise participant to self instead of creating a duplicate person', async () => { + const { self, group } = await seedTrip(); + const preview = previewSplitCsv([ + 'Date,Description,Cost,Currency,Siddharth paid,Siddharth owed,Rahul paid,Rahul owed', + '2026-08-14,Dinner,100.00,INR,100.00,50.00,0.00,50.00', + ].join('\n'), 'INR'); + + const result = await executeSplitCsvImport(group.id, preview, { + selfParticipantName: 'Siddharth', + }); + + expect(result.imported).toBe(1); + expect(result.peopleAdded).toBe(1); + const people = await getDB().people.toArray(); + expect(people.filter((person) => !person.isSelf).map((person) => person.name)).toEqual(['Rahul']); + + const expense = (await getDB().splitExpenses.toArray())[0]!; + const payers = await getDB().splitPayers.where('expenseId').equals(expense.id).toArray(); + const shares = await getDB().splitShares.where('expenseId').equals(expense.id).toArray(); + expect(payers.find((payer) => payer.personId === self.id)?.amountMinor).toBe(10000); + expect(shares.find((share) => share.personId === self.id)?.amountMinor).toBe(5000); + }); + + it('rolls back people, memberships and earlier expenses when a later row fails', async () => { + const { group } = await seedTrip(); + const preview: SplitCsvPreview = { + kind: 'splitwise', + participantNames: ['Rahul'], + warnings: [], + skippedRows: 0, + rows: [ + { + rowNumber: 2, + sourceKey: 'row-1', + date: '2026-08-14', + title: 'Valid', + amountMinor: 10000, + payerAmountsByName: { Rahul: 10000 }, + shareAmountsByName: { Rahul: 10000 }, + }, + { + rowNumber: 3, + sourceKey: 'row-2', + date: '2026-08-15', + title: 'Invalid payer total', + amountMinor: 10000, + payerAmountsByName: { Rahul: 5000 }, + shareAmountsByName: { Rahul: 10000 }, + }, + ], + }; + + await expect( + executeSplitCsvImport(group.id, preview, { selfParticipantName: null }), + ).rejects.toThrow(/Payer totals/); + + expect(await getDB().splitExpenses.count()).toBe(0); + expect((await getDB().people.toArray()).filter((person) => !person.isSelf)).toHaveLength(0); + expect(await getDB().splitGroupMembers.where('groupId').equals(group.id).count()).toBe(1); + }); }); diff --git a/src/modules/split/services/importCsv.ts b/src/modules/split/services/importCsv.ts index 4e2b7a6..95ede38 100644 --- a/src/modules/split/services/importCsv.ts +++ b/src/modules/split/services/importCsv.ts @@ -1,6 +1,7 @@ import { getDB } from '@db/database'; import type { SplitExpenseCategory } from '@db/schema'; import { decimalToMinor } from '@shared/money'; +import { isValidDateOnly, toDateOnly } from '@shared/dates'; import { personRepository } from '@shared/people/repository'; import { splitExpenseRepository } from '../repositories/splitExpenseRepository'; import { splitGroupMemberRepository } from '../repositories/splitGroupMemberRepository'; @@ -33,6 +34,11 @@ export interface SplitCsvImportResult { skippedDuplicates: number; } +export interface SplitCsvImportOptions { + /** CSV participant explicitly mapped to the app's self person. Null means self is not listed. */ + selfParticipantName?: string | null; +} + /** Parse a CSV locally. Nothing is written until executeSplitCsvImport is called. */ export function previewSplitCsv(text: string, groupCurrency: string): SplitCsvPreview { const table = parseCsv(text); @@ -151,7 +157,7 @@ export function previewSplitCsv(text: string, groupCurrency: string): SplitCsvPr } /** - * Import a validated preview into one trip. + * Import a validated preview into one trip atomically. * Splitwise paid/owed columns are preserved as exact payer/share values. * Generic CSV rows use the trip's saved split (or You + everyone equally). * Rows imported previously from the same CSV position/content are skipped. @@ -159,106 +165,159 @@ export function previewSplitCsv(text: string, groupCurrency: string): SplitCsvPr export async function executeSplitCsvImport( groupId: string, preview: SplitCsvPreview, + options: SplitCsvImportOptions = {}, ): Promise { + validatePreview(preview); const db = getDB(); - const group = await db.splitGroups.get(groupId); - if (!group || group.deletedAt || group.archived) throw new Error('Trip is not available for import.'); - - const existingExpenses = await db.splitExpenses.where('groupId').equals(groupId).toArray(); - const existingKeys = new Set(existingExpenses.map((expense) => expense.importSourceKey).filter(Boolean)); - const pendingRows = preview.rows.filter((row) => !existingKeys.has(row.sourceKey)); - const skippedDuplicates = preview.rows.length - pendingRows.length; - if (pendingRows.length === 0) return { imported: 0, peopleAdded: 0, skippedDuplicates }; - - const people = (await db.people.toArray()).filter((person) => !person.deletedAt); - const self = people.find((person) => person.isSelf); - if (!self) throw new Error('Your profile is missing.'); - - const peopleByName = new Map(people.map((person) => [normalizeName(person.name), person])); - let peopleAdded = 0; - - const namesNeeded = new Set(); - for (const row of pendingRows) { - for (const name of Object.keys(row.payerAmountsByName ?? {})) namesNeeded.add(name); - for (const name of Object.keys(row.shareAmountsByName ?? {})) namesNeeded.add(name); - } - for (const name of namesNeeded) { - const key = normalizeName(name); - if (peopleByName.has(key)) continue; - const person = await personRepository.create({ name: name.trim() }); - peopleByName.set(key, person); - peopleAdded += 1; - } + return db.transaction( + 'rw', + [ + db.people, + db.splitGroups, + db.splitGroupMembers, + db.splitExpenses, + db.splitPayers, + db.splitShares, + ], + async () => { + const group = await db.splitGroups.get(groupId); + if (!group || group.deletedAt || group.archived) throw new Error('Trip is not available for import.'); + + const existingExpenses = await db.splitExpenses.where('groupId').equals(groupId).toArray(); + const existingKeys = new Set(existingExpenses.map((expense) => expense.importSourceKey).filter(Boolean)); + const pendingRows = preview.rows.filter((row) => !existingKeys.has(row.sourceKey)); + const skippedDuplicates = preview.rows.length - pendingRows.length; + if (pendingRows.length === 0) return { imported: 0, peopleAdded: 0, skippedDuplicates }; + + const people = (await db.people.toArray()).filter((person) => !person.deletedAt); + const self = people.find((person) => person.isSelf); + if (!self) throw new Error('Your profile is missing.'); + + const peopleByName = new Map(people.map((person) => [normalizeName(person.name), person])); + const selfParticipantName = resolveSelfParticipantName(preview, self.name, options.selfParticipantName); + if (selfParticipantName) { + peopleByName.set(normalizeName(selfParticipantName), self); + } + let peopleAdded = 0; - for (const name of namesNeeded) { - const person = peopleByName.get(normalizeName(name)); - if (person) await splitGroupMemberRepository.getOrCreate(groupId, person.id); - } + const namesNeeded = new Set(); + for (const row of pendingRows) { + for (const name of Object.keys(row.payerAmountsByName ?? {})) namesNeeded.add(name); + for (const name of Object.keys(row.shareAmountsByName ?? {})) namesNeeded.add(name); + } - const members = await db.splitGroupMembers.where('groupId').equals(groupId).toArray(); - const activePersonIds = members - .filter((member) => !member.deletedAt && member.active) - .map((member) => member.personId); - const fallback = resolveTripDefaultSplit({ - saved: group.defaultSplit, - activePersonIds, - preferredPayerId: self.id, - }); - if (!fallback.payerPersonId || fallback.participantIds.length === 0) { - throw new Error('Add at least one participant before importing expenses.'); - } + for (const name of namesNeeded) { + const key = normalizeName(name); + if (peopleByName.has(key)) continue; + const person = await personRepository.create({ name: name.trim() }); + peopleByName.set(key, person); + peopleAdded += 1; + } - let imported = 0; - for (const row of pendingRows) { - if (preview.kind === 'splitwise' && row.payerAmountsByName && row.shareAmountsByName) { - const payers = Object.entries(row.payerAmountsByName).map(([name, amountMinor]) => { - const person = peopleByName.get(normalizeName(name)); - if (!person) throw new Error(`Could not resolve payer ${name}.`); - return { personId: person.id, amountMinor }; - }); - const shareEntries = Object.entries(row.shareAmountsByName).map(([name, amountMinor]) => { + for (const name of namesNeeded) { const person = peopleByName.get(normalizeName(name)); - if (!person) throw new Error(`Could not resolve participant ${name}.`); - return [person.id, amountMinor] as const; - }); - const participantIds = shareEntries.map(([personId]) => personId); - await splitExpenseRepository.createAtomic({ - groupId, - title: row.title, - amountMinor: row.amountMinor, - currency: group.currency, - date: row.date, - splitMethod: 'exact', - category: row.category, - payers, - participantIds, - allocation: { method: 'exact', amountsByPersonId: Object.fromEntries(shareEntries) }, - importSourceKey: row.sourceKey, - }); - } else { - await splitExpenseRepository.createAtomic({ - groupId, - title: row.title, - amountMinor: row.amountMinor, - currency: group.currency, - date: row.date, - splitMethod: fallback.splitMethod, - category: row.category, - payers: [{ personId: fallback.payerPersonId, amountMinor: row.amountMinor }], - participantIds: fallback.participantIds, - allocation: allocationSnapshotToInput( - fallback.splitMethod, - fallback.participantIds, - fallback.allocation, - ), - importSourceKey: row.sourceKey, + if (person) await splitGroupMemberRepository.getOrCreate(groupId, person.id); + } + + const members = await db.splitGroupMembers.where('groupId').equals(groupId).toArray(); + const activePersonIds = members + .filter((member) => !member.deletedAt && member.active) + .map((member) => member.personId); + const fallback = resolveTripDefaultSplit({ + saved: group.defaultSplit, + activePersonIds, + preferredPayerId: self.id, }); + if (!fallback.payerPersonId || fallback.participantIds.length === 0) { + throw new Error('Add at least one participant before importing expenses.'); + } + + let imported = 0; + for (const row of pendingRows) { + if (preview.kind === 'splitwise' && row.payerAmountsByName && row.shareAmountsByName) { + const payers = Object.entries(row.payerAmountsByName).map(([name, amountMinor]) => { + const person = peopleByName.get(normalizeName(name)); + if (!person) throw new Error(`Could not resolve payer ${name}.`); + return { personId: person.id, amountMinor }; + }); + const shareEntries = Object.entries(row.shareAmountsByName).map(([name, amountMinor]) => { + const person = peopleByName.get(normalizeName(name)); + if (!person) throw new Error(`Could not resolve participant ${name}.`); + return [person.id, amountMinor] as const; + }); + const participantIds = shareEntries.map(([personId]) => personId); + await splitExpenseRepository.createAtomic({ + groupId, + title: row.title, + amountMinor: row.amountMinor, + currency: group.currency, + date: row.date, + splitMethod: 'exact', + category: row.category, + payers, + participantIds, + allocation: { method: 'exact', amountsByPersonId: Object.fromEntries(shareEntries) }, + importSourceKey: row.sourceKey, + }); + } else { + await splitExpenseRepository.createAtomic({ + groupId, + title: row.title, + amountMinor: row.amountMinor, + currency: group.currency, + date: row.date, + splitMethod: fallback.splitMethod, + category: row.category, + payers: [{ personId: fallback.payerPersonId, amountMinor: row.amountMinor }], + participantIds: fallback.participantIds, + allocation: allocationSnapshotToInput( + fallback.splitMethod, + fallback.participantIds, + fallback.allocation, + ), + importSourceKey: row.sourceKey, + }); + } + imported += 1; + } + + return { imported, peopleAdded, skippedDuplicates }; + }, + ); +} + +function validatePreview(preview: SplitCsvPreview): void { + if (preview.rows.length === 0) throw new Error('No importable expenses found.'); + for (const row of preview.rows) { + if (!isValidDateOnly(row.date)) throw new Error(`Row ${row.rowNumber}: invalid date.`); + if (!row.title.trim()) throw new Error(`Row ${row.rowNumber}: expense title is required.`); + if (!Number.isSafeInteger(row.amountMinor) || row.amountMinor <= 0) { + throw new Error(`Row ${row.rowNumber}: invalid amount.`); } - imported += 1; + } +} + +function resolveSelfParticipantName( + preview: SplitCsvPreview, + selfName: string, + selected: string | null | undefined, +): string | undefined { + if (preview.kind !== 'splitwise') return undefined; + if (selected === null) return undefined; + + const participants = new Map( + preview.participantNames.map((name) => [normalizeName(name), name] as const), + ); + if (selected !== undefined) { + const resolved = participants.get(normalizeName(selected)); + if (!resolved) throw new Error('Choose a Splitwise participant from this file.'); + return resolved; } - return { imported, peopleAdded, skippedDuplicates }; + const exactNameMatch = participants.get(normalizeName(selfName)); + if (exactNameMatch) return exactNameMatch; + throw new Error('Choose which Splitwise participant is you before importing.'); } function parseCsv(text: string): string[][] { @@ -329,18 +388,21 @@ function parseOptionalMinor(value: string, currency: string): number { function normalizeDate(value: string): string | undefined { const raw = value.trim(); - if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw; + if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) { + return isValidDateOnly(raw) ? raw : undefined; + } + const us = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(raw); if (us) { const [, month, day, year] = us; - return `${year}-${month!.padStart(2, '0')}-${day!.padStart(2, '0')}`; + const candidate = `${year}-${month!.padStart(2, '0')}-${day!.padStart(2, '0')}`; + return isValidDateOnly(candidate) ? candidate : undefined; } + const date = new Date(raw); if (Number.isNaN(date.getTime())) return undefined; - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; + const candidate = toDateOnly(date); + return isValidDateOnly(candidate) ? candidate : undefined; } function mapCategory(value: string): SplitExpenseCategory | undefined { diff --git a/src/modules/track/components/TrackMonthView.tsx b/src/modules/track/components/TrackMonthView.tsx index b03d9b8..2d7f64f 100644 --- a/src/modules/track/components/TrackMonthView.tsx +++ b/src/modules/track/components/TrackMonthView.tsx @@ -6,7 +6,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Card, Button, Money, EmptyState, Spinner } from '@components/ui'; import { useAppSettings } from '@shared/settings/useSettings'; import { useTrackMonthlySummary, useTrackTransactionsForMonth } from '../queries'; -import { todayDateOnly, toMonthKey } from '@shared/dates'; +import { toMonthKey } from '@shared/dates'; import { MonthNavigator } from './MonthNavigator'; import { TransactionListItem } from './TransactionListItem'; import { CategoryBreakdown } from './CategoryBreakdown'; @@ -41,7 +41,7 @@ export function TrackMonthView({ month, showFilters = true }: TrackMonthViewProp : queriedMonthTxs; }, [queriedMonthTxs, selectedCategoryId]); - const currentMonth = toMonthKey(new Date(todayDateOnly())); + const currentMonth = toMonthKey(); const isCurrentMonth = month === currentMonth; const hide = settings?.hideAmounts ?? false; diff --git a/src/modules/track/domain/validation.ts b/src/modules/track/domain/validation.ts index e59d1db..da2fa40 100644 --- a/src/modules/track/domain/validation.ts +++ b/src/modules/track/domain/validation.ts @@ -13,12 +13,6 @@ * to indicate direction; amountMinor is the magnitude) * - reject blank titles * - reject malformed dates - * - * Note: schemas are kept simple (no `.preprocess` / `.refine`) - * so they remain StandardSchemaV1-compatible with TanStack - * Form's strict type expectations. Trimming and date sanity - * are done in the repository layer (`cleanTransactionInput` - * + `assertTransactionInvariants`). */ import { z } from 'zod'; @@ -32,18 +26,21 @@ import type { TrackBudget, TrackRecurringRule, } from '@db/schema'; +import { isValidDateOnly, isValidMonthKey } from '@shared/dates'; -/** YYYY-MM-DD date string. */ +/** YYYY-MM-DD date string representing a real calendar date. */ const dateOnly = z .string() .min(10, 'Date is required') - .regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'); + .regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD') + .refine(isValidDateOnly, 'Invalid calendar date'); /** YYYY-MM month key. */ const monthKey = z .string() .min(7, 'Month is required') - .regex(/^\d{4}-\d{2}$/, 'Month must be YYYY-MM'); + .regex(/^\d{4}-\d{2}$/, 'Month must be YYYY-MM') + .refine(isValidMonthKey, 'Invalid calendar month'); /** Currency code (3-letter typical, max 8 to be safe). */ const currencyCode = z @@ -141,7 +138,6 @@ export function assertTransactionInvariants(input: TrackTransactionInput): void if (!Number.isFinite(input.amountMinor) || input.amountMinor <= 0) { throw new Error('Amount must be a positive integer in minor units'); } - // date shape was already checked by the regex; no further check. } /** Full entity schema. */ diff --git a/src/modules/track/repositories/repositories.test.ts b/src/modules/track/repositories/repositories.test.ts index 824edc2..513cb04 100644 --- a/src/modules/track/repositories/repositories.test.ts +++ b/src/modules/track/repositories/repositories.test.ts @@ -97,6 +97,19 @@ describe('trackTransactionRepository', () => { expect(updated.revision).toBe(before + 1); }); + it('supports partial updates that do not include title', async () => { + const t = await trackTransactionRepository.create({ + type: 'expense', + title: 'Lunch', + amountMinor: 1000, + currency: 'INR', + date: '2026-08-01', + }); + const updated = await trackTransactionRepository.update(t.id, { amountMinor: 1250 }); + expect(updated.title).toBe('Lunch'); + expect(updated.amountMinor).toBe(1250); + }); + it('softDelete + restore round-trips', async () => { const t = await trackTransactionRepository.create({ type: 'expense', @@ -173,6 +186,18 @@ describe('trackTransactionRepository', () => { }), ).rejects.toThrow(); }); + + it('rejects impossible calendar dates', async () => { + await expect( + trackTransactionRepository.create({ + type: 'expense', + title: 'bad date', + amountMinor: 100, + currency: 'INR', + date: '2026-02-30', + }), + ).rejects.toThrow(/calendar date/); + }); }); describe('trackBudgetRepository', () => { @@ -198,6 +223,16 @@ describe('trackBudgetRepository', () => { expect(list).toHaveLength(1); }); + it('rejects impossible month keys', async () => { + await expect( + trackBudgetRepository.setForMonth({ + month: '2026-13', + amountMinor: 30000, + currency: 'INR', + }), + ).rejects.toThrow(/calendar month/); + }); + it('deleteByMonth removes the row', async () => { await trackBudgetRepository.setForMonth({ month: '2026-08', amountMinor: 30000, currency: 'INR' }); await trackBudgetRepository.deleteByMonth('2026-08'); diff --git a/src/modules/track/repositories/trackTransactionRepository.ts b/src/modules/track/repositories/trackTransactionRepository.ts index 6fe9619..48075cd 100644 --- a/src/modules/track/repositories/trackTransactionRepository.ts +++ b/src/modules/track/repositories/trackTransactionRepository.ts @@ -19,20 +19,18 @@ import { } from '@db/repositories/base'; import { TrackTransactionInputSchema, - cleanTransactionInput, type TrackTransactionInput, } from '../domain/validation'; import { monthDateRange } from '@shared/dates'; import type { TrackTransaction } from '@db/schema'; function clean(input: Partial): Partial { - const merged = { - ...input, - categoryId: input.categoryId || undefined, - paymentMethod: input.paymentMethod || undefined, - note: input.note || undefined, - }; - return cleanTransactionInput(merged as TrackTransactionInput); + const cleaned: Partial = { ...input }; + if (input.title !== undefined) cleaned.title = input.title.trim(); + if (Object.hasOwn(input, 'categoryId')) cleaned.categoryId = input.categoryId || undefined; + if (Object.hasOwn(input, 'paymentMethod')) cleaned.paymentMethod = input.paymentMethod || undefined; + if (Object.hasOwn(input, 'note')) cleaned.note = input.note || undefined; + return cleaned; } function newestDateFirst(a: TrackTransaction, b: TrackTransaction): number { diff --git a/src/routes/lend/LendPage.tsx b/src/routes/lend/LendPage.tsx index 101e600..181f7c8 100644 --- a/src/routes/lend/LendPage.tsx +++ b/src/routes/lend/LendPage.tsx @@ -90,7 +90,6 @@ export function LendPage() { setAddPersonOpen(false)} onOpenPerson={(personId) => navigate({ to: '/lend/person/$personId', params: { personId } })} /> diff --git a/src/routes/settings/SettingsPage.tsx b/src/routes/settings/SettingsPage.tsx index 31ba954..6f85f88 100644 --- a/src/routes/settings/SettingsPage.tsx +++ b/src/routes/settings/SettingsPage.tsx @@ -13,7 +13,7 @@ export function SettingsPage() { const toast = useToast(); if (!settings) return ; - const hasFinancialData = !!stats && stats.track + stats.groups + stats.lendLedgers + stats.budgets > 0; + const hasFinancialData = !!stats && stats.track + stats.lendEntries + stats.budgets > 0; const setTheme = (mode: 'system' | 'light' | 'dark') => { void settingsRepository.setTheme(mode).catch((error) => { diff --git a/src/routes/split/SplitPage.tsx b/src/routes/split/SplitPage.tsx index d5cf0aa..2bbbd48 100644 --- a/src/routes/split/SplitPage.tsx +++ b/src/routes/split/SplitPage.tsx @@ -19,10 +19,9 @@ import { useArchivedSplitGroups } from '@modules/split/queries/useArchivedSplitG import { GroupCard } from '@modules/split/components/GroupCard'; import { MemberSelector } from '@modules/split/components/MemberSelector'; import { splitGroupRepository } from '@modules/split/repositories/splitGroupRepository'; -import { splitGroupMemberRepository } from '@modules/split/repositories/splitGroupMemberRepository'; +import { createSplitTrip } from '@modules/split/services/createTrip'; import { normalizePersonName, personNameKey } from '@shared/people/domain'; import { usePeople, useSelf } from '@shared/people/queries'; -import { personRepository } from '@shared/people/repository'; import { useAppSettings } from '@shared/settings/useSettings'; export function SplitPage() { @@ -112,14 +111,22 @@ function CreateTripModal({ defaultCurrency, selfPersonId, onClose, onCreated }: const [error, setError] = useState(); const selectablePeople = useMemo(() => (people ?? []).filter((person) => person.id !== selfPersonId && !person.isSelf), [people, selfPersonId]); + const findExistingPerson = (candidate: string) => { + const candidateKey = personNameKey(candidate); + return (people ?? []).find((person) => personNameKey(person.name) === candidateKey); + }; + const duplicateMessage = (existing: { id: string; name: string }) => + existing.id === selfPersonId + ? `${existing.name} is your own profile. Choose another name.` + : `${existing.name} is already saved. Select them above instead.`; const addPendingPerson = () => { const candidate = normalizePersonName(newPersonName); if (!candidate) return; const candidateKey = personNameKey(candidate); - const existing = selectablePeople.find((person) => personNameKey(person.name) === candidateKey); + const existing = findExistingPerson(candidate); if (existing) { - setError(`${existing.name} is already saved. Select them above instead.`); + setError(duplicateMessage(existing)); return; } if (pendingPeople.some((personName) => personNameKey(personName) === candidateKey)) { @@ -139,9 +146,9 @@ function CreateTripModal({ defaultCurrency, selfPersonId, onClose, onCreated }: const inlineName = normalizePersonName(newPersonName); if (inlineName) { const inlineKey = personNameKey(inlineName); - const existing = selectablePeople.find((person) => personNameKey(person.name) === inlineKey); + const existing = findExistingPerson(inlineName); if (existing) { - setError(`${existing.name} is already saved. Select them above instead.`); + setError(duplicateMessage(existing)); return; } if (!peopleToCreate.some((personName) => personNameKey(personName) === inlineKey)) { @@ -152,13 +159,14 @@ function CreateTripModal({ defaultCurrency, selfPersonId, onClose, onCreated }: setSubmitting(true); setError(undefined); try { - const group = await splitGroupRepository.create({ name: name.trim(), description: description.trim() || undefined, currency }); - await splitGroupMemberRepository.getOrCreate(group.id, selfPersonId); - for (const personId of selectedMemberIds) await splitGroupMemberRepository.getOrCreate(group.id, personId); - for (const personName of peopleToCreate) { - const person = await personRepository.create({ name: personName }); - await splitGroupMemberRepository.getOrCreate(group.id, person.id); - } + const group = await createSplitTrip({ + name: name.trim(), + description: description.trim() || undefined, + currency, + selfPersonId, + memberPersonIds: selectedMemberIds, + newPersonNames: peopleToCreate, + }); celebrate({ kind: 'added', message: 'Trip created' }); onCreated(group.id); } catch (err) { diff --git a/src/routes/track/TrackBudgetPage.tsx b/src/routes/track/TrackBudgetPage.tsx index b233e82..8c4da9c 100644 --- a/src/routes/track/TrackBudgetPage.tsx +++ b/src/routes/track/TrackBudgetPage.tsx @@ -8,14 +8,14 @@ import { useAppSettings } from '@shared/settings/useSettings'; import { useTrackMonthlySummary } from '@modules/track/queries'; import { trackBudgetRepository } from '@modules/track/repositories/trackBudgetRepository'; import { getDB } from '@db/database'; -import { toMonthKey, todayDateOnly } from '@shared/dates'; +import { isValidMonthKey, toMonthKey } from '@shared/dates'; import { TrackBudgetInputSchema } from '@modules/track/domain/validation'; import type { TrackBudget } from '@db/schema'; export function TrackBudgetPage() { const search = useSearch({ strict: false }) as { month?: string }; const settings = useAppSettings(); - const requested = search.month && /^\d{4}-\d{2}$/.test(search.month) ? search.month : toMonthKey(); + const requested = search.month && isValidMonthKey(search.month) ? search.month : toMonthKey(); const budget = useLiveQuery(async () => { const rows = await getDB().trackBudgets.toArray(); return rows.find((row) => !row.deletedAt && row.month === requested) ?? null; @@ -72,7 +72,7 @@ function TrackBudgetForm({ month, budget, currency, hideAmounts }: { month: stri } }; - const isCurrent = month === toMonthKey(new Date(todayDateOnly())); + const isCurrent = month === toMonthKey(); return (
diff --git a/src/routes/track/TrackPage.tsx b/src/routes/track/TrackPage.tsx index ac3c585..c6d83b7 100644 --- a/src/routes/track/TrackPage.tsx +++ b/src/routes/track/TrackPage.tsx @@ -2,10 +2,9 @@ * Track landing page — renders the current month. */ -import { todayDateOnly, toMonthKey } from '@shared/dates'; +import { toMonthKey } from '@shared/dates'; import { TrackMonthView } from '@modules/track/components/TrackMonthView'; export function TrackPage() { - const month = toMonthKey(new Date(todayDateOnly())); - return ; + return ; } diff --git a/src/shared/dates/dates.test.ts b/src/shared/dates/dates.test.ts index 78c05ef..ff4c37d 100644 --- a/src/shared/dates/dates.test.ts +++ b/src/shared/dates/dates.test.ts @@ -7,6 +7,9 @@ import { monthDateRange, isInMonth, todayDateOnly, + isValidDateOnly, + isValidMonthKey, + formatHumanDate, } from './index'; describe('dates', () => { @@ -14,6 +17,21 @@ describe('dates', () => { expect(toDateOnly(new Date(2026, 7, 13))).toBe('2026-08-13'); }); + it('validates real calendar dates', () => { + expect(isValidDateOnly('2026-02-28')).toBe(true); + expect(isValidDateOnly('2024-02-29')).toBe(true); + expect(isValidDateOnly('2026-02-30')).toBe(false); + expect(isValidDateOnly('2026-04-31')).toBe(false); + expect(isValidDateOnly('2026-13-01')).toBe(false); + }); + + it('validates month keys', () => { + expect(isValidMonthKey('2026-01')).toBe(true); + expect(isValidMonthKey('2026-12')).toBe(true); + expect(isValidMonthKey('2026-00')).toBe(false); + expect(isValidMonthKey('2026-13')).toBe(false); + }); + it('toMonthKey / fromMonthKey round-trip', () => { expect(toMonthKey(new Date(2026, 0, 31))).toBe('2026-01'); expect(toMonthKey(fromMonthKey('2026-01'))).toBe('2026-01'); @@ -40,8 +58,17 @@ describe('dates', () => { expect(isInMonth('2026-08-13T00:00:00.000Z', '2026-09')).toBe(false); }); + it('formats date-only values as local calendar dates', () => { + const expected = new Date(2026, 7, 13).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + expect(formatHumanDate('2026-08-13')).toBe(expected); + }); + it('todayDateOnly returns a valid YYYY-MM-DD', () => { const t = todayDateOnly(); - expect(t).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(isValidDateOnly(t)).toBe(true); }); }); diff --git a/src/shared/dates/index.ts b/src/shared/dates/index.ts index e0ab28d..1d23222 100644 --- a/src/shared/dates/index.ts +++ b/src/shared/dates/index.ts @@ -27,6 +27,26 @@ export function toDateOnly(d: Date): string { return `${y}-${m}-${day}`; } +/** True when a value is a real calendar date in canonical YYYY-MM-DD form. */ +export function isValidDateOnly(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const [year, month, day] = value.split('-').map(Number); + if (!year || !month || !day || month < 1 || month > 12) return false; + const probe = new Date(year, month - 1, day); + return ( + probe.getFullYear() === year && + probe.getMonth() === month - 1 && + probe.getDate() === day + ); +} + +/** True when a value is a canonical YYYY-MM month key. */ +export function isValidMonthKey(value: string): boolean { + if (!/^\d{4}-\d{2}$/.test(value)) return false; + const month = Number(value.slice(5, 7)); + return month >= 1 && month <= 12; +} + /** Parse a YYYY-MM-DD string as a Date in local timezone. */ export function fromDateOnly(s: string): Date { const [y, m, d] = s.split('-').map(Number); @@ -87,9 +107,9 @@ export function nextDays(count: number, from: Date = new Date()): string[] { return out; } -/** Format an ISO timestamp for human display in the user's locale. */ +/** Format a persisted date/date-time for human display in the user's locale. */ export function formatHumanDate(iso: string): string { - const d = new Date(iso); + const d = isValidDateOnly(iso) ? fromDateOnly(iso) : new Date(iso); return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', diff --git a/src/shared/money/convert.ts b/src/shared/money/convert.ts index 8c58357..bc94b83 100644 --- a/src/shared/money/convert.ts +++ b/src/shared/money/convert.ts @@ -5,24 +5,32 @@ import type { CurrencyCode } from './types'; * Convert a decimal amount (e.g. `12.50`) to integer minor units * (e.g. `1250`) for the given currency. * - * Uses banker-safe integer math. Truncates the input to the - * currency's number of decimals first to avoid float surprises. + * String inputs must use no more fractional digits than the currency supports. + * This avoids silently changing what a user typed. Numeric inputs are rounded + * to the currency precision before conversion because their original textual + * precision is no longer available. */ export function decimalToMinor(amount: number | string, currency: CurrencyCode): number { const decimals = currencyDecimals(currency); const str = typeof amount === 'number' ? amount.toFixed(decimals) : amount.trim(); - // Reject any non-numeric characters - if (!/^-?\d+(\.\d+)?$/.test(str)) { + if (!/^-?\d+(?:\.\d*)?$/.test(str)) { throw new Error(`decimalToMinor: invalid decimal amount "${amount}"`); } + const negative = str.startsWith('-'); const body = negative ? str.slice(1) : str; const [intPart, fracPart = ''] = body.split('.'); - const paddedFrac = (fracPart + '0'.repeat(decimals)).slice(0, decimals); + if (fracPart.length > decimals) { + throw new Error( + `decimalToMinor: ${currency} supports at most ${decimals} fractional digit${decimals === 1 ? '' : 's'}`, + ); + } + + const paddedFrac = fracPart.padEnd(decimals, '0'); const combined = `${intPart}${paddedFrac}`.replace(/^0+(?=\d)/, ''); const minor = Number(combined); - if (!Number.isFinite(minor)) { - throw new Error(`decimalToMinor: failed to convert "${amount}"`); + if (!Number.isSafeInteger(minor)) { + throw new Error(`decimalToMinor: amount is outside the safe integer range: "${amount}"`); } return negative ? -minor : minor; } diff --git a/src/shared/money/format.ts b/src/shared/money/format.ts index 03ab0e5..5876c48 100644 --- a/src/shared/money/format.ts +++ b/src/shared/money/format.ts @@ -32,14 +32,17 @@ export function formatMoney(money: Money, locale?: string): string { * Parse a user-entered string (e.g. "1,250.50" or "₹1,250.50") * into a Money value. * - * Strips currency symbols and thousands separators, then - * delegates to `decimalToMinor`. + * Whitespace/thousands separators and a leading currency label/symbol are + * accepted. Characters inside the numeric portion are not stripped: malformed + * values such as `1e3` must fail instead of being silently rewritten. */ export function parseMoney(input: string, currency: CurrencyCode): Money { - const cleaned = input - .replace(/[\s,]/g, '') - .replace(/[^0-9.-]/g, ''); - if (cleaned === '' || cleaned === '-' || cleaned === '.') { + let cleaned = input.trim().replace(/[\s,]/g, ''); + if (cleaned.toUpperCase().startsWith(currency.toUpperCase())) { + cleaned = cleaned.slice(currency.length); + } + cleaned = cleaned.replace(/^[^0-9+-]+/, ''); + if (cleaned === '' || cleaned === '-' || cleaned === '+') { throw new Error('parseMoney: empty amount'); } const amountMinor = decimalToMinor(cleaned, currency); diff --git a/src/shared/money/money.test.ts b/src/shared/money/money.test.ts index 386f839..896538c 100644 --- a/src/shared/money/money.test.ts +++ b/src/shared/money/money.test.ts @@ -31,11 +31,19 @@ describe('decimalToMinor / minorToDecimal', () => { it('handles JPY (zero decimal)', () => { expect(decimalToMinor('100', 'JPY')).toBe(100); - // JPY has 0 decimal places — fraction is discarded (truncated). - expect(decimalToMinor('100.5', 'JPY')).toBe(100); + expect(() => decimalToMinor('100.5', 'JPY')).toThrow(/fractional digit/); expect(currencyDecimals('JPY')).toBe(0); }); + it('rejects excess precision instead of truncating user input', () => { + expect(() => decimalToMinor('12.999', 'USD')).toThrow(/fractional digit/); + expect(() => decimalToMinor('1.2345', 'KWD')).toThrow(/fractional digit/); + }); + + it('accepts a trailing decimal point while a user is typing', () => { + expect(decimalToMinor('12.', 'USD')).toBe(1200); + }); + it('throws on invalid input', () => { expect(() => decimalToMinor('abc', 'INR')).toThrow(); expect(() => decimalToMinor('1.2.3', 'INR')).toThrow(); @@ -64,10 +72,15 @@ describe('formatMoney', () => { }); describe('parseMoney', () => { - it('strips thousands separators and currency symbols', () => { + it('accepts thousands separators and leading currency labels', () => { expect(parseMoney('₹1,250.50', 'INR').amountMinor).toBe(125050); expect(parseMoney('1,250.50', 'USD').amountMinor).toBe(125050); - expect(parseMoney(' 99.99 ', 'EUR').amountMinor).toBe(9999); + expect(parseMoney('USD 99.99', 'USD').amountMinor).toBe(9999); + }); + + it('rejects malformed numeric text instead of stripping internal characters', () => { + expect(() => parseMoney('1e3', 'USD')).toThrow(); + expect(() => parseMoney('12abc34', 'USD')).toThrow(); }); }); diff --git a/src/shared/settings/repository.test.ts b/src/shared/settings/repository.test.ts new file mode 100644 index 0000000..c529c3a --- /dev/null +++ b/src/shared/settings/repository.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { freshDB, wipeDB } from '@tests/db-test-utils'; +import { getDB } from '@db/database'; +import { personRepository } from '@shared/people/repository'; +import { lendEntryRepository } from '@modules/lend/repositories/lendEntryRepository'; +import { lendLedgerRepository } from '@modules/lend/repositories/lendLedgerRepository'; +import { splitGroupRepository } from '@modules/split/repositories/splitGroupRepository'; +import { settingsRepository } from './repository'; + +beforeEach(async () => { + await wipeDB(); + freshDB(); + await settingsRepository.get(); +}); + +describe('settingsRepository.setDefaultCurrency', () => { + it('does not lock Main currency for empty Split groups or empty Lend ledgers', async () => { + const person = await personRepository.create({ name: 'Rahul' }); + await splitGroupRepository.create({ name: 'Trip', currency: 'EUR' }); + await lendLedgerRepository.create({ personId: person.id, currency: 'INR' }); + + const updated = await settingsRepository.setDefaultCurrency('USD'); + expect(updated.defaultCurrency).toBe('USD'); + }); + + it('locks Main currency after a Lend entry is recorded', async () => { + const person = await personRepository.create({ name: 'Rahul' }); + const ledger = await lendLedgerRepository.getOrCreate(person.id, 'INR'); + await lendEntryRepository.create({ + ledgerId: ledger.id, + type: 'lent', + amountMinor: 1000, + date: '2026-08-19', + }); + + await expect(settingsRepository.setDefaultCurrency('USD')).rejects.toThrow(/locked/); + expect((await getDB().settings.get('app'))?.defaultCurrency).toBe('INR'); + }); +}); diff --git a/src/shared/settings/repository.ts b/src/shared/settings/repository.ts index a8119e4..5f78a9e 100644 --- a/src/shared/settings/repository.ts +++ b/src/shared/settings/repository.ts @@ -54,14 +54,14 @@ export const settingsRepository = { const cur = await settingsRepository.get(); if (currency === cur.defaultCurrency) return cur; - const [trackTransactions, trackBudgets, splitGroups, lendLedgers] = await Promise.all([ + // Only amount-bearing Main-currency records lock this setting. Split groups + // have their own currency, and empty Lend ledgers contain no financial data. + const [trackTransactions, trackBudgets, lendEntries] = await Promise.all([ db.trackTransactions.count(), db.trackBudgets.count(), - db.splitGroups.count(), - db.lendLedgers.count(), + db.lendEntries.count(), ]); - const hasFinancialData = trackTransactions + trackBudgets + splitGroups + lendLedgers > 0; - if (hasFinancialData) { + if (trackTransactions + trackBudgets + lendEntries > 0) { throw new Error('Default currency is locked after financial data has been recorded.'); } return settingsRepository.update({ defaultCurrency: currency }); diff --git a/src/shared/settings/useSettings.ts b/src/shared/settings/useSettings.ts index 7c4dd52..c2d9aa5 100644 --- a/src/shared/settings/useSettings.ts +++ b/src/shared/settings/useSettings.ts @@ -12,6 +12,7 @@ export interface SettingsStats { track: number; groups: number; lendLedgers: number; + lendEntries: number; budgets: number; } @@ -19,11 +20,12 @@ export interface SettingsStats { export function useSettingsStats(): SettingsStats | undefined { return useLiveQuery(async () => { const db = getDB(); - const [peopleRows, track, groups, lendLedgers, budgets] = await Promise.all([ + const [peopleRows, track, groups, lendLedgers, lendEntries, budgets] = await Promise.all([ db.people.toArray(), db.trackTransactions.count(), db.splitGroups.count(), db.lendLedgers.count(), + db.lendEntries.count(), db.trackBudgets.count(), ]); return { @@ -31,6 +33,7 @@ export function useSettingsStats(): SettingsStats | undefined { track, groups, lendLedgers, + lendEntries, budgets, }; }, []);