Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
93689aa
Harden decimal money conversion
11sid11 Aug 19, 2026
44d3179
Reject malformed money input
11sid11 Aug 19, 2026
9bcf07c
Keep invalid money text from saving stale values
11sid11 Aug 19, 2026
c000f8b
Cover strict money parsing
11sid11 Aug 19, 2026
802f8e0
Use strict local calendar dates
11sid11 Aug 19, 2026
f92b9a2
Cover local calendar validation
11sid11 Aug 19, 2026
ecafa5b
Validate Track calendar dates
11sid11 Aug 19, 2026
0bbb574
Use local current month in Track
11sid11 Aug 19, 2026
0e3e7e7
Fix Track current-month timezone handling
11sid11 Aug 19, 2026
41b290f
Validate budget month locally
11sid11 Aug 19, 2026
5f1365f
Fix Track partial transaction updates
11sid11 Aug 19, 2026
e84fcc3
Cover Track date and partial-update regressions
11sid11 Aug 19, 2026
b5ba35f
Prevent duplicate suggested settlements
11sid11 Aug 19, 2026
2291f2f
Preserve previously deleted Lend entries on ledger undo
11sid11 Aug 19, 2026
2a70d86
Cover Lend cascade restore boundaries
11sid11 Aug 19, 2026
8a0562f
Make Split CSV import atomic and identity-safe
11sid11 Aug 19, 2026
9957dee
Map self identity during Splitwise import
11sid11 Aug 19, 2026
a3d23e4
Cover atomic and identity-safe Split imports
11sid11 Aug 19, 2026
ccf8537
Add atomic Split trip creation service
11sid11 Aug 19, 2026
e615f06
Use atomic Split trip creation
11sid11 Aug 19, 2026
e46facd
Cover atomic Split trip creation
11sid11 Aug 19, 2026
5b31b7a
Validate backup financial relationships
11sid11 Aug 19, 2026
be6d0c7
Cover backup relationship validation
11sid11 Aug 19, 2026
6bfc183
Keep Lend on the Main currency
11sid11 Aug 19, 2026
d0dc6ac
Create Lend ledgers only when money is recorded
11sid11 Aug 19, 2026
cdd6d9f
Avoid empty Lend ledgers from people creation
11sid11 Aug 19, 2026
9784ddc
Validate resulting Lend entry updates
11sid11 Aug 19, 2026
6ea740b
Cover Main-currency and Lend update invariants
11sid11 Aug 19, 2026
b90a960
Lock Main currency only after money is recorded
11sid11 Aug 19, 2026
e5501ef
Report amount-bearing Lend history in Settings
11sid11 Aug 19, 2026
8f7a3cf
Match Main currency lock UI to financial records
11sid11 Aug 19, 2026
c7e9358
Cover Main currency lock semantics
11sid11 Aug 19, 2026
cb98ff7
Exclude deleted rows from analysis exports
11sid11 Aug 19, 2026
077c2d3
Cover active-only CSV package exports
11sid11 Aug 19, 2026
71299bd
Type backup fixtures explicitly
11sid11 Aug 19, 2026
13a40b5
Satisfy strict backup relation narrowing
11sid11 Aug 19, 2026
e5ccbb8
Reject invalid canonical CSV dates
11sid11 Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/components/ui/MoneyInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
};

Expand Down
105 changes: 89 additions & 16 deletions src/export/json/backup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] },
Expand All @@ -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([]);
Expand All @@ -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 = {
Expand All @@ -70,9 +82,7 @@ describe('JSON backup', () => {
amountMinor: '15000',
currency: 'INR',
date: '2026-08-13',
createdAt: '',
updatedAt: '',
revision: 1,
...entity,
},
],
},
Expand All @@ -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({
Expand All @@ -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();
Expand All @@ -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');
});
Expand All @@ -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);
});
});
155 changes: 150 additions & 5 deletions src/export/json/backup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -199,8 +200,8 @@ export interface Backup {
export async function exportBackup(): Promise<Backup> {
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,
Expand Down Expand Up @@ -273,15 +274,159 @@ export async function exportBackup(): Promise<Backup> {
};
}

/** 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) {
const issue = result.error.issues[0];
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 = <T extends { id: string }>(path: string, rows: T[]): Set<string> => {
const out = new Set<string>();
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<string, number>();
for (const payer of backup.split.payers) {
payerTotals.set(payer.expenseId, (payerTotals.get(payer.expenseId) ?? 0) + payer.amountMinor);
}
const shareTotals = new Map<string, number>();
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. */
Expand Down
Loading
Loading