From 93689aac71be9f681621a59ab4ef28d9887d9265 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 11:54:14 +0530
Subject: [PATCH 01/37] Harden decimal money conversion
---
src/shared/money/convert.ts | 22 +++++++++++++++-------
1 file changed, 15 insertions(+), 7 deletions(-)
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;
}
From 44d3179373a9c3c9cc429e9cf72f3c44f0ba6474 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 11:54:27 +0530
Subject: [PATCH 02/37] Reject malformed money input
---
src/shared/money/format.ts | 15 +++++++++------
1 file changed, 9 insertions(+), 6 deletions(-)
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);
From 9bcf07c7958b2e6dd1a0b5a32a5f1db2a63f4e21 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 11:54:42 +0530
Subject: [PATCH 03/37] Keep invalid money text from saving stale values
---
src/components/ui/MoneyInput.tsx | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
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);
}
};
From c000f8bb7e13a1bb7e1855eb529acc48d709a8f1 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 11:55:05 +0530
Subject: [PATCH 04/37] Cover strict money parsing
---
src/shared/money/money.test.ts | 21 +++++++++++++++++----
1 file changed, 17 insertions(+), 4 deletions(-)
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();
});
});
From 802f8e01d3541432334d2f69e55f91ed3355cf9b Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 11:55:24 +0530
Subject: [PATCH 05/37] Use strict local calendar dates
---
src/shared/dates/index.ts | 24 ++++++++++++++++++++++--
1 file changed, 22 insertions(+), 2 deletions(-)
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',
From f92b9a233697c4367d3adda179b74a0ddac50d99 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 11:55:38 +0530
Subject: [PATCH 06/37] Cover local calendar validation
---
src/shared/dates/dates.test.ts | 29 ++++++++++++++++++++++++++++-
1 file changed, 28 insertions(+), 1 deletion(-)
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);
});
});
From ecafa5b0b4882c45fa02234ace35b8a6ec63c3be Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 11:56:03 +0530
Subject: [PATCH 07/37] Validate Track calendar dates
---
src/modules/track/domain/validation.ts | 16 ++++++----------
1 file changed, 6 insertions(+), 10 deletions(-)
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. */
From 0bbb57459c5ab7286c482b3f38176185719c07dd Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 11:56:10 +0530
Subject: [PATCH 08/37] Use local current month in Track
---
src/routes/track/TrackPage.tsx | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
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 ;
}
From 0e3e7e78ffe33bb21a450eaaa230b48af3aab41a Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 11:56:47 +0530
Subject: [PATCH 09/37] Fix Track current-month timezone handling
---
src/modules/track/components/TrackMonthView.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
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;
From 41b290f3fbab96def00d9c4c8ee10fa242ce76c7 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 11:57:08 +0530
Subject: [PATCH 10/37] Validate budget month locally
---
src/routes/track/TrackBudgetPage.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
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 (
From 5f1365f0629a8be960c597ce06a9a66bad5b1b59 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 11:57:35 +0530
Subject: [PATCH 11/37] Fix Track partial transaction updates
---
.../repositories/trackTransactionRepository.ts | 14 ++++++--------
1 file changed, 6 insertions(+), 8 deletions(-)
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 {
From e84fcc31fa27ece29ad6f463a9c3c6d102e8f2c6 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 11:58:08 +0530
Subject: [PATCH 12/37] Cover Track date and partial-update regressions
---
.../track/repositories/repositories.test.ts | 35 +++++++++++++++++++
1 file changed, 35 insertions(+)
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');
From b5ba35f4947a3d0134a2ebefb6429618358db9bd Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 11:58:47 +0530
Subject: [PATCH 13/37] Prevent duplicate suggested settlements
---
.../split/components/TripBalancesPanel.tsx | 25 +++++++++++++++++--
1 file changed, 23 insertions(+), 2 deletions(-)
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({
-
+
From 2291f2fa0829a110fd16911ce9c71db3722d865a Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 11:59:18 +0530
Subject: [PATCH 14/37] Preserve previously deleted Lend entries on ledger undo
---
.../lend/repositories/lendLedgerRepository.ts | 36 ++++++++++++++-----
1 file changed, 27 insertions(+), 9 deletions(-)
diff --git a/src/modules/lend/repositories/lendLedgerRepository.ts b/src/modules/lend/repositories/lendLedgerRepository.ts
index b228b37..38ac1f2 100644
--- a/src/modules/lend/repositories/lendLedgerRepository.ts
+++ b/src/modules/lend/repositories/lendLedgerRepository.ts
@@ -15,10 +15,10 @@ import { runTransaction } from '@db/transaction';
import {
repoCreate,
repoUpdate,
- repoSoftDelete,
repoRestore,
type CreateInput,
} from '@db/repositories/base';
+import { nowISO } from '@shared/dates';
import { LendLedgerInputSchema, type LendLedgerInput } from '../domain/validation';
import type { LendLedger, LendEntry } from '@db/schema';
@@ -94,7 +94,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 +106,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) {
From 2a70d8620e13b1b2099ca04dc7e8a211fdaad2ec Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 11:59:35 +0530
Subject: [PATCH 15/37] Cover Lend cascade restore boundaries
---
.../repositories/lendLedgerAtomicity.test.ts | 26 +++++++++++++++++++
1 file changed, 26 insertions(+)
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);
+ });
});
From 8a0562f38c60249921d14b3e5772183621154fce Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 12:01:03 +0530
Subject: [PATCH 16/37] Make Split CSV import atomic and identity-safe
---
src/modules/split/services/importCsv.ts | 252 +++++++++++++++---------
1 file changed, 156 insertions(+), 96 deletions(-)
diff --git a/src/modules/split/services/importCsv.ts b/src/modules/split/services/importCsv.ts
index 4e2b7a6..3d46095 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;
}
+}
- return { imported, peopleAdded, skippedDuplicates };
+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;
+ }
+
+ 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,19 @@ 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 (isValidDateOnly(raw)) return raw;
+
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 {
From 9957deef7fde890f13717f0ddc11c26ec296fbf2 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 12:01:32 +0530
Subject: [PATCH 17/37] Map self identity during Splitwise import
---
.../split/components/SplitCsvImportCard.tsx | 65 +++++++++++++++++--
1 file changed, 58 insertions(+), 7 deletions(-)
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.
-
-
+
+
)}
From a3d23e4e894352e33a5346ffba4e6036101a6a4e Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 12:02:06 +0530
Subject: [PATCH 18/37] Cover atomic and identity-safe Split imports
---
src/modules/split/services/importCsv.test.ts | 93 +++++++++++++++++++-
1 file changed, 91 insertions(+), 2 deletions(-)
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);
+ });
});
From ccf853773f4673bc513e875f9e7e2a3f88e136c0 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 12:02:25 +0530
Subject: [PATCH 19/37] Add atomic Split trip creation service
---
src/modules/split/services/createTrip.ts | 43 ++++++++++++++++++++++++
1 file changed, 43 insertions(+)
create mode 100644 src/modules/split/services/createTrip.ts
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;
+ },
+ );
+}
From e615f063d6fc5b760e14de948243c4c2ac78e9c1 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 12:05:20 +0530
Subject: [PATCH 20/37] Use atomic Split trip creation
---
src/routes/split/SplitPage.tsx | 34 +++++++++++++++++++++-------------
1 file changed, 21 insertions(+), 13 deletions(-)
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) {
From e46facdbd22733523e835158d96016b1360a93ff Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 12:05:33 +0530
Subject: [PATCH 21/37] Cover atomic Split trip creation
---
src/modules/split/services/createTrip.test.ts | 53 +++++++++++++++++++
1 file changed, 53 insertions(+)
create mode 100644 src/modules/split/services/createTrip.test.ts
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);
+ });
+});
From 5b31b7a720b33cab16e5d8d0354a4d3762df1b31 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 12:06:53 +0530
Subject: [PATCH 22/37] Validate backup financial relationships
---
src/export/json/backup.ts | 155 ++++++++++++++++++++++++++++++++++++--
1 file changed, 150 insertions(+), 5 deletions(-)
diff --git a/src/export/json/backup.ts b/src/export/json/backup.ts
index bf4fe3c..a29fa1f 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. */
From be6d0c76ddeda4aaf5d2fba8c65d96fc4254da18 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 12:07:30 +0530
Subject: [PATCH 23/37] Cover backup relationship validation
---
src/export/json/backup.test.ts | 102 ++++++++++++++++++++++++++++-----
1 file changed, 87 insertions(+), 15 deletions(-)
diff --git a/src/export/json/backup.test.ts b/src/export/json/backup.test.ts
index 3562002..58f166c 100644
--- a/src/export/json/backup.test.ts
+++ b/src/export/json/backup.test.ts
@@ -10,15 +10,19 @@ import {
} from './backup';
import { getDB } from '@db/database';
import { settingsRepository } from '@shared/settings/repository';
+import { SELF_PERSON_ID } from '@db/seed';
+
+const timestamp = '2026-08-17T00:00:00.000Z';
+const entity = { createdAt: timestamp, updatedAt: timestamp, revision: 1 };
function validEmptyBackup() {
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 +37,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 +57,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 +81,7 @@ describe('JSON backup', () => {
amountMinor: '15000',
currency: 'INR',
date: '2026-08-13',
- createdAt: '',
- updatedAt: '',
- revision: 1,
+ ...entity,
},
],
},
@@ -81,14 +90,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 +170,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 +187,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 +197,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);
});
});
From 6bfc1838f2396e21e5269a217a49e1e3eff7cfe5 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 12:08:15 +0530
Subject: [PATCH 24/37] Keep Lend on the Main currency
---
.../lend/repositories/lendLedgerRepository.ts | 33 +++++++++++++------
1 file changed, 23 insertions(+), 10 deletions(-)
diff --git a/src/modules/lend/repositories/lendLedgerRepository.ts b/src/modules/lend/repositories/lendLedgerRepository.ts
index 38ac1f2..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';
@@ -19,6 +17,7 @@ import {
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));
},
From d0dc6ac0cd2a86d2f8557d9cab5282d1bcd6993b Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 12:08:34 +0530
Subject: [PATCH 25/37] Create Lend ledgers only when money is recorded
---
src/modules/lend/components/AddLendPersonModal.tsx | 14 ++------------
1 file changed, 2 insertions(+), 12 deletions(-)
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' },
From cdd6d9f2eee0103151e0e6d7203cd615b42825de Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 12:08:53 +0530
Subject: [PATCH 26/37] Avoid empty Lend ledgers from people creation
---
src/routes/lend/LendPage.tsx | 1 -
1 file changed, 1 deletion(-)
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 } })}
/>
From 9784ddc3b9757fc07dd2beb8c4e083cd7485a82b Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 12:09:30 +0530
Subject: [PATCH 27/37] Validate resulting Lend entry updates
---
.../lend/repositories/lendEntryRepository.ts | 78 ++++++-------------
1 file changed, 25 insertions(+), 53 deletions(-)
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);
},
From 6ea740b30c090c84aa8889047461174a5861a881 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 12:10:11 +0530
Subject: [PATCH 28/37] Cover Main-currency and Lend update invariants
---
.../lend/repositories/repositories.test.ts | 71 +++++++++++--------
1 file changed, 40 insertions(+), 31 deletions(-)
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,
);
From b90a960932fb4e2a6eaa9d103dd63399a098f541 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 12:10:35 +0530
Subject: [PATCH 29/37] Lock Main currency only after money is recorded
---
src/shared/settings/repository.ts | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
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 });
From e5501efbdb267cb7f16efa7616c75badf23dfd8b Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 12:10:50 +0530
Subject: [PATCH 30/37] Report amount-bearing Lend history in Settings
---
src/shared/settings/useSettings.ts | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
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,
};
}, []);
From 8f7a3cf6d17eebc96352de2da820b35065c5085a Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 12:11:19 +0530
Subject: [PATCH 31/37] Match Main currency lock UI to financial records
---
src/routes/settings/SettingsPage.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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) => {
From c7e9358b85a9a452a848659f9d253cd4f34c7160 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 12:11:33 +0530
Subject: [PATCH 32/37] Cover Main currency lock semantics
---
src/shared/settings/repository.test.ts | 39 ++++++++++++++++++++++++++
1 file changed, 39 insertions(+)
create mode 100644 src/shared/settings/repository.test.ts
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');
+ });
+});
From cb98ff7d6634c12105486385dd998fcdea81c188 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 12:13:07 +0530
Subject: [PATCH 33/37] Exclude deleted rows from analysis exports
---
src/export/zip/builder.ts | 96 ++++++++++++++++++++++++---------------
1 file changed, 60 insertions(+), 36 deletions(-)
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),
);
}
From 077c2d37f506f0169b3bfd6859c3ecffa95294e1 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 12:13:26 +0530
Subject: [PATCH 34/37] Cover active-only CSV package exports
---
src/export/zip/builderDeletedRows.test.ts | 94 +++++++++++++++++++++++
1 file changed, 94 insertions(+)
create mode 100644 src/export/zip/builderDeletedRows.test.ts
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);
+ });
+});
From 71299bdc3ef85ec0cceb5faf3a970dbdf779547b Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 12:14:08 +0530
Subject: [PATCH 35/37] Type backup fixtures explicitly
---
src/export/json/backup.test.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/export/json/backup.test.ts b/src/export/json/backup.test.ts
index 58f166c..bf1c966 100644
--- a/src/export/json/backup.test.ts
+++ b/src/export/json/backup.test.ts
@@ -7,6 +7,7 @@ import {
restoreBackup,
summarizeBackup,
validateBackup,
+ type Backup,
} from './backup';
import { getDB } from '@db/database';
import { settingsRepository } from '@shared/settings/repository';
@@ -15,7 +16,7 @@ import { SELF_PERSON_ID } from '@db/seed';
const timestamp = '2026-08-17T00:00:00.000Z';
const entity = { createdAt: timestamp, updatedAt: timestamp, revision: 1 };
-function validEmptyBackup() {
+function validEmptyBackup(): Backup {
return {
format: BACKUP_FORMAT,
schemaVersion: BACKUP_SCHEMA_VERSION,
From 13a40b59aa49c0fe44f383b7ca14ec00a1ca61e9 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 12:17:07 +0530
Subject: [PATCH 36/37] Satisfy strict backup relation narrowing
---
src/export/json/backup.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/export/json/backup.ts b/src/export/json/backup.ts
index a29fa1f..1da84fe 100644
--- a/src/export/json/backup.ts
+++ b/src/export/json/backup.ts
@@ -356,7 +356,7 @@ function validateBackupRelations(backup: Backup): void {
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) {
+ 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');
@@ -402,7 +402,7 @@ function validateBackupRelations(backup: Backup): void {
if (settlement.fromPersonId === settlement.toPersonId) {
fail(`split.settlements.${index}`, 'payer and receiver must be different people');
}
- if (settlement.currency !== group.currency) {
+ if (settlement.currency !== group!.currency) {
fail(`split.settlements.${index}.currency`, 'currency does not match its group');
}
if (!isValidDateOnly(settlement.date)) {
From e5ccbb829fb6e87af2b0987ade5d8c473b47e353 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Wed, 19 Aug 2026 12:20:04 +0530
Subject: [PATCH 37/37] Reject invalid canonical CSV dates
---
src/modules/split/services/importCsv.ts | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/modules/split/services/importCsv.ts b/src/modules/split/services/importCsv.ts
index 3d46095..95ede38 100644
--- a/src/modules/split/services/importCsv.ts
+++ b/src/modules/split/services/importCsv.ts
@@ -388,7 +388,9 @@ function parseOptionalMinor(value: string, currency: string): number {
function normalizeDate(value: string): string | undefined {
const raw = value.trim();
- if (isValidDateOnly(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) {