diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..538a840
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,31 @@
+name: CI
+
+on:
+ pull_request:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 9
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm typecheck
+ - run: pnpm lint
+ - run: pnpm test
+ - run: pnpm build
diff --git a/src/app/router.tsx b/src/app/router.tsx
index 6f92eb8..55826c5 100644
--- a/src/app/router.tsx
+++ b/src/app/router.tsx
@@ -17,34 +17,103 @@ import {
createRootRoute,
createRoute,
createRouter,
+ lazyRouteComponent,
Outlet,
redirect,
} from '@tanstack/react-router';
import { RootLayout } from './layout/RootLayout';
-import { OverviewPage } from '@/routes/overview/OverviewPage';
-import { TrackPage } from '@/routes/track/TrackPage';
-import { TrackMonthPage } from '@/routes/track/TrackMonthPage';
-import { TrackAddPage } from '@/routes/track/TrackAddPage';
-import { TrackTransactionPage } from '@/routes/track/TrackTransactionPage';
-import { TrackCategoriesPage } from '@/routes/track/TrackCategoriesPage';
-import { TrackBudgetPage } from '@/routes/track/TrackBudgetPage';
-import { TrackRecurringPage } from '@/routes/track/TrackRecurringPage';
-import { SplitPage } from '@/routes/split/SplitPage';
-import { SplitGroupPage } from '@/routes/split/SplitGroupPage';
-import { SplitGroupAddPage } from '@/routes/split/SplitGroupAddPage';
-import { SplitGroupBalancesPage } from '@/routes/split/SplitGroupBalancesPage';
-import { SplitGroupActivityPage } from '@/routes/split/SplitGroupActivityPage';
-import { SplitGroupSettlePage } from '@/routes/split/SplitGroupSettlePage';
-import { SplitGroupSettingsPage } from '@/routes/split/SplitGroupSettingsPage';
-import { LendPage } from '@/routes/lend/LendPage';
-import { LendPersonPage } from '@/routes/lend/LendPersonPage';
-import { LendLedgerPage } from '@/routes/lend/LendLedgerPage';
-import { LendAddPage } from '@/routes/lend/LendAddPage';
-import { SettingsPage } from '@/routes/settings/SettingsPage';
-import { PeoplePage } from '@/routes/settings/PeoplePage';
-import { BackupPage } from '@/routes/settings/BackupPage';
-import { SearchPage } from '@/routes/overview/SearchPage';
-import { OnboardingPage } from '@/routes/OnboardingPage';
+
+const OverviewPage = lazyRouteComponent(
+ () => import('@/routes/overview/OverviewPage'),
+ 'OverviewPage',
+);
+const SearchPage = lazyRouteComponent(
+ () => import('@/routes/overview/SearchPage'),
+ 'SearchPage',
+);
+const OnboardingPage = lazyRouteComponent(
+ () => import('@/routes/OnboardingPage'),
+ 'OnboardingPage',
+);
+
+const TrackPage = lazyRouteComponent(() => import('@/routes/track/TrackPage'), 'TrackPage');
+const TrackMonthPage = lazyRouteComponent(
+ () => import('@/routes/track/TrackMonthPage'),
+ 'TrackMonthPage',
+);
+const TrackAddPage = lazyRouteComponent(
+ () => import('@/routes/track/TrackAddPage'),
+ 'TrackAddPage',
+);
+const TrackTransactionPage = lazyRouteComponent(
+ () => import('@/routes/track/TrackTransactionPage'),
+ 'TrackTransactionPage',
+);
+const TrackCategoriesPage = lazyRouteComponent(
+ () => import('@/routes/track/TrackCategoriesPage'),
+ 'TrackCategoriesPage',
+);
+const TrackBudgetPage = lazyRouteComponent(
+ () => import('@/routes/track/TrackBudgetPage'),
+ 'TrackBudgetPage',
+);
+const TrackRecurringPage = lazyRouteComponent(
+ () => import('@/routes/track/TrackRecurringPage'),
+ 'TrackRecurringPage',
+);
+
+const SplitPage = lazyRouteComponent(() => import('@/routes/split/SplitPage'), 'SplitPage');
+const SplitGroupPage = lazyRouteComponent(
+ () => import('@/routes/split/SplitGroupPage'),
+ 'SplitGroupPage',
+);
+const SplitGroupAddPage = lazyRouteComponent(
+ () => import('@/routes/split/SplitGroupAddPage'),
+ 'SplitGroupAddPage',
+);
+const SplitGroupBalancesPage = lazyRouteComponent(
+ () => import('@/routes/split/SplitGroupBalancesPage'),
+ 'SplitGroupBalancesPage',
+);
+const SplitGroupActivityPage = lazyRouteComponent(
+ () => import('@/routes/split/SplitGroupActivityPage'),
+ 'SplitGroupActivityPage',
+);
+const SplitGroupSettlePage = lazyRouteComponent(
+ () => import('@/routes/split/SplitGroupSettlePage'),
+ 'SplitGroupSettlePage',
+);
+const SplitGroupSettingsPage = lazyRouteComponent(
+ () => import('@/routes/split/SplitGroupSettingsPage'),
+ 'SplitGroupSettingsPage',
+);
+
+const LendPage = lazyRouteComponent(() => import('@/routes/lend/LendPage'), 'LendPage');
+const LendPersonPage = lazyRouteComponent(
+ () => import('@/routes/lend/LendPersonPage'),
+ 'LendPersonPage',
+);
+const LendLedgerPage = lazyRouteComponent(
+ () => import('@/routes/lend/LendLedgerPage'),
+ 'LendLedgerPage',
+);
+const LendAddPage = lazyRouteComponent(
+ () => import('@/routes/lend/LendAddPage'),
+ 'LendAddPage',
+);
+
+const SettingsPage = lazyRouteComponent(
+ () => import('@/routes/settings/SettingsPage'),
+ 'SettingsPage',
+);
+const PeoplePage = lazyRouteComponent(
+ () => import('@/routes/settings/PeoplePage'),
+ 'PeoplePage',
+);
+const BackupPage = lazyRouteComponent(
+ () => import('@/routes/settings/BackupPage'),
+ 'BackupPage',
+);
const rootRoute = createRootRoute({
component: () => (
@@ -62,17 +131,37 @@ const indexRoute = createRoute({
},
});
-const overviewRoute = createRoute({ getParentRoute: () => rootRoute, path: '/overview', component: OverviewPage });
-const searchRoute = createRoute({ getParentRoute: () => rootRoute, path: '/search', component: SearchPage });
-const onboardingRoute = createRoute({ getParentRoute: () => rootRoute, path: '/onboarding', component: OnboardingPage });
+const overviewRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/overview',
+ component: OverviewPage,
+});
+const searchRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/search',
+ component: SearchPage,
+});
+const onboardingRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/onboarding',
+ component: OnboardingPage,
+});
-const trackRoute = createRoute({ getParentRoute: () => rootRoute, path: '/track', component: TrackPage });
+const trackRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/track',
+ component: TrackPage,
+});
const trackMonthRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/track/month/$year/$month',
component: TrackMonthPage,
});
-const trackAddRoute = createRoute({ getParentRoute: () => rootRoute, path: '/track/add', component: TrackAddPage });
+const trackAddRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/track/add',
+ component: TrackAddPage,
+});
const trackTransactionRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/track/transaction/$transactionId',
@@ -83,14 +172,22 @@ const trackCategoriesRoute = createRoute({
path: '/track/categories',
component: TrackCategoriesPage,
});
-const trackBudgetRoute = createRoute({ getParentRoute: () => rootRoute, path: '/track/budget', component: TrackBudgetPage });
+const trackBudgetRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/track/budget',
+ component: TrackBudgetPage,
+});
const trackRecurringRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/track/recurring',
component: TrackRecurringPage,
});
-const splitRoute = createRoute({ getParentRoute: () => rootRoute, path: '/split', component: SplitPage });
+const splitRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/split',
+ component: SplitPage,
+});
const splitGroupRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/split/group/$groupId',
@@ -122,7 +219,11 @@ const splitGroupSettingsRoute = createRoute({
component: SplitGroupSettingsPage,
});
-const lendRoute = createRoute({ getParentRoute: () => rootRoute, path: '/lend', component: LendPage });
+const lendRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/lend',
+ component: LendPage,
+});
const lendPersonRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/lend/person/$personId',
@@ -133,11 +234,27 @@ const lendLedgerRoute = createRoute({
path: '/lend/ledger/$ledgerId',
component: LendLedgerPage,
});
-const lendAddRoute = createRoute({ getParentRoute: () => rootRoute, path: '/lend/add', component: LendAddPage });
+const lendAddRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/lend/add',
+ component: LendAddPage,
+});
-const settingsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/settings', component: SettingsPage });
-const peopleRoute = createRoute({ getParentRoute: () => rootRoute, path: '/settings/people', component: PeoplePage });
-const backupRoute = createRoute({ getParentRoute: () => rootRoute, path: '/settings/backup', component: BackupPage });
+const settingsRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/settings',
+ component: SettingsPage,
+});
+const peopleRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/settings/people',
+ component: PeoplePage,
+});
+const backupRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/settings/backup',
+ component: BackupPage,
+});
const routeTree = rootRoute.addChildren([
indexRoute,
diff --git a/src/components/ui/Spinner.tsx b/src/components/ui/Spinner.tsx
new file mode 100644
index 0000000..1aba6c3
--- /dev/null
+++ b/src/components/ui/Spinner.tsx
@@ -0,0 +1,14 @@
+import clsx from 'clsx';
+
+export function Spinner({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/src/components/ui/Tabs.tsx b/src/components/ui/Tabs.tsx
index 1aba6c3..7df721c 100644
--- a/src/components/ui/Tabs.tsx
+++ b/src/components/ui/Tabs.tsx
@@ -1,14 +1,5 @@
-import clsx from 'clsx';
-
-export function Spinner({ className }: { className?: string }) {
- return (
-
- );
-}
+/**
+ * Compatibility re-export for older direct imports.
+ * New code should import Spinner from `./Spinner` or the UI barrel.
+ */
+export { Spinner } from './Spinner';
diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts
index 93bbb87..88dfed3 100644
--- a/src/components/ui/index.ts
+++ b/src/components/ui/index.ts
@@ -8,7 +8,7 @@ export { MoneyInput } from './MoneyInput';
export { DateInput } from './DateInput';
export { CategoryPicker, PaymentMethodPicker, CurrencyPicker } from './Picker';
export { Toggle } from './Toggle';
-export { Spinner } from './Tabs';
+export { Spinner } from './Spinner';
export { ToastProvider, useToast } from './Toast';
export { CelebrationProvider, useCelebration } from './Celebration';
export { Modal } from './Modal';
diff --git a/src/db/repositories/base.ts b/src/db/repositories/base.ts
index 9c596b3..9ead932 100644
--- a/src/db/repositories/base.ts
+++ b/src/db/repositories/base.ts
@@ -1,9 +1,9 @@
/**
* Base repository helpers.
*
- * Repositories are the only place that touch Dexie. Components
- * and services call `xxxRepository.create()` etc. They never
- * reach into `db.table.add(...)` directly.
+ * Repositories own persistence writes. Query/projection modules may read
+ * Dexie directly when they need reactive or aggregate data, but UI components
+ * should not operate on database tables themselves.
*
* Every write:
* - sets `createdAt` / `updatedAt`
diff --git a/src/export/json/backup-e2e.test.ts b/src/export/json/backup-e2e.test.ts
index 8faa8ff..9218044 100644
--- a/src/export/json/backup-e2e.test.ts
+++ b/src/export/json/backup-e2e.test.ts
@@ -1,5 +1,5 @@
/**
- * End-to-end backup E2E (work.md section 89).
+ * End-to-end backup E2E.
*
* create sample data
* ↓
@@ -19,6 +19,7 @@ import { freshDB, wipeDB } from '../../tests/db-test-utils';
import { exportBackup, validateBackup, restoreBackup } from './backup';
import { getDB } from '@db/database';
import { SELF_PERSON_ID } from '@db/seed';
+import { entryToSignedAmount } from '@modules/lend/domain/signs';
describe('backup E2E', () => {
beforeEach(async () => {
@@ -29,11 +30,9 @@ describe('backup E2E', () => {
it('preserves all data + calculated balances after restore', async () => {
const db = getDB();
- // Seed self + Rahul
await db.people.put({ id: SELF_PERSON_ID, name: 'Me', isSelf: true, createdAt: '', updatedAt: '', revision: 1 });
await db.people.put({ id: 'rahul', name: 'Rahul', createdAt: '', updatedAt: '', revision: 1 });
- // Lend: lent 5000 to Rahul
await db.lendLedgers.put({
id: 'l1',
personId: 'rahul',
@@ -54,7 +53,6 @@ describe('backup E2E', () => {
revision: 1,
});
- // Track: Coffee 150
await db.trackTransactions.put({
id: 't1',
type: 'expense',
@@ -67,42 +65,34 @@ describe('backup E2E', () => {
revision: 1,
});
- // Export
const backup = await exportBackup();
expect(backup.shared.people).toHaveLength(2);
+ expect(backup.shared.settings.defaultCurrency).toBe('INR');
expect(backup.lend.entries).toHaveLength(1);
expect(backup.track.transactions).toHaveLength(1);
- // Wipe
await db.people.clear();
await db.lendLedgers.clear();
await db.lendEntries.clear();
await db.trackTransactions.clear();
expect(await db.people.toArray()).toHaveLength(0);
- // Restore
await restoreBackup(validateBackup(backup));
- // Verify exact records
const people = await db.people.toArray();
expect(people).toHaveLength(2);
- expect(people.find((p) => p.id === 'rahul')?.name).toBe('Rahul');
- expect(people.find((p) => p.id === SELF_PERSON_ID)?.isSelf).toBe(true);
+ expect(people.find((person) => person.id === 'rahul')?.name).toBe('Rahul');
+ expect(people.find((person) => person.id === SELF_PERSON_ID)?.isSelf).toBe(true);
const entries = await db.lendEntries.toArray();
expect(entries).toHaveLength(1);
expect(entries[0]!.amountMinor).toBe(500000);
- const txs = await db.trackTransactions.toArray();
- expect(txs).toHaveLength(1);
- expect(txs[0]!.title).toBe('Coffee');
+ const transactions = await db.trackTransactions.toArray();
+ expect(transactions).toHaveLength(1);
+ expect(transactions[0]!.title).toBe('Coffee');
- // Verify calculated balance: Rahul owes 5000
- const sum = entries.reduce(
- (a, e) =>
- a + (e.type === 'lent' || e.type === 'repayment_given' ? e.amountMinor : -e.amountMinor),
- 0,
- );
- expect(sum).toBe(500000);
+ const balance = entries.reduce((sum, entry) => sum + entryToSignedAmount(entry), 0);
+ expect(balance).toBe(500000);
});
});
diff --git a/src/export/json/backup.test.ts b/src/export/json/backup.test.ts
index 3523652..3562002 100644
--- a/src/export/json/backup.test.ts
+++ b/src/export/json/backup.test.ts
@@ -1,7 +1,31 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { freshDB, wipeDB } from '../../tests/db-test-utils';
-import { exportBackup, validateBackup, restoreBackup, BACKUP_FORMAT, summarizeBackup } from './backup';
+import {
+ BACKUP_FORMAT,
+ BACKUP_SCHEMA_VERSION,
+ exportBackup,
+ restoreBackup,
+ summarizeBackup,
+ validateBackup,
+} from './backup';
import { getDB } from '@db/database';
+import { settingsRepository } from '@shared/settings/repository';
+
+function validEmptyBackup() {
+ return {
+ format: BACKUP_FORMAT,
+ schemaVersion: BACKUP_SCHEMA_VERSION,
+ exportedAt: '2026-08-17T00:00:00.000Z',
+ appVersion: 'test',
+ shared: {
+ people: [],
+ settings: { defaultCurrency: 'INR' },
+ },
+ track: { transactions: [], categories: [], budgets: [], recurringRules: [] },
+ split: { groups: [], members: [], expenses: [], payers: [], shares: [], settlements: [] },
+ lend: { ledgers: [], entries: [] },
+ };
+}
describe('JSON backup', () => {
beforeEach(async () => {
@@ -9,47 +33,57 @@ describe('JSON backup', () => {
freshDB();
});
- it('exports an empty backup with the right shape', async () => {
- const b = await exportBackup();
- expect(b.format).toBe(BACKUP_FORMAT);
- expect(b.schemaVersion).toBe(1);
- expect(b.shared.people).toEqual([]);
- expect(b.track.transactions).toEqual([]);
- expect(b.split.expenses).toEqual([]);
- expect(b.lend.entries).toEqual([]);
+ it('exports an empty backup with the right shape and financial settings', 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.settings.defaultCurrency).toBe('INR');
+ expect(backup.track.transactions).toEqual([]);
+ expect(backup.split.expenses).toEqual([]);
+ expect(backup.lend.entries).toEqual([]);
});
it('validateBackup rejects wrong format', () => {
- expect(() => validateBackup({ format: 'wrong', schemaVersion: 1 })).toThrow();
+ expect(() => validateBackup({ ...validEmptyBackup(), format: 'wrong' })).toThrow();
});
it('validateBackup rejects wrong schemaVersion', () => {
- expect(() =>
- validateBackup({
- format: BACKUP_FORMAT,
- schemaVersion: 999,
- shared: { people: [] },
- track: { transactions: [], categories: [], budgets: [], recurringRules: [] },
- split: { groups: [], members: [], expenses: [], payers: [], shares: [], settlements: [] },
- lend: { ledgers: [], entries: [] },
- }),
- ).toThrow();
+ expect(() => validateBackup({ ...validEmptyBackup(), schemaVersion: 999 })).toThrow();
});
it('validateBackup accepts a well-formed empty backup', () => {
- const b = {
- format: BACKUP_FORMAT,
- schemaVersion: 1,
- shared: { people: [] },
- track: { transactions: [], categories: [], budgets: [], recurringRules: [] },
- split: { groups: [], members: [], expenses: [], payers: [], shares: [], settlements: [] },
- lend: { ledgers: [], entries: [] },
+ expect(() => validateBackup(validEmptyBackup())).not.toThrow();
+ });
+
+ it('validateBackup rejects malformed financial rows with a useful path', () => {
+ const backup = validEmptyBackup();
+ const malformed = {
+ ...backup,
+ track: {
+ ...backup.track,
+ transactions: [
+ {
+ id: 't1',
+ type: 'expense',
+ title: 'Coffee',
+ amountMinor: '15000',
+ currency: 'INR',
+ date: '2026-08-13',
+ createdAt: '',
+ updatedAt: '',
+ revision: 1,
+ },
+ ],
+ },
};
- expect(() => validateBackup(b)).not.toThrow();
+
+ expect(() => validateBackup(malformed)).toThrow('track.transactions.0.amountMinor');
});
- it('round-trips: export then restore', async () => {
+ 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',
@@ -62,33 +96,35 @@ describe('JSON backup', () => {
type: 'expense',
title: 'Coffee',
amountMinor: 15000,
- currency: 'INR',
+ currency: 'USD',
date: '2026-08-13',
createdAt: '2026-08-13T00:00:00.000Z',
updatedAt: '2026-08-13T00:00:00.000Z',
revision: 1,
});
- const b = await exportBackup();
- expect(b.shared.people).toHaveLength(1);
- expect(b.track.transactions).toHaveLength(1);
+ const backup = await exportBackup();
+ expect(backup.shared.people).toHaveLength(1);
+ expect(backup.shared.settings.defaultCurrency).toBe('USD');
- // Wipe
await db.people.clear();
await db.trackTransactions.clear();
- expect(await db.people.toArray()).toHaveLength(0);
+ await db.settings.clear();
+ await settingsRepository.get();
+ expect((await db.settings.get('app'))?.defaultCurrency).toBe('INR');
+
+ await restoreBackup(validateBackup(backup));
- // Restore
- await restoreBackup(validateBackup(b));
expect(await db.people.toArray()).toHaveLength(1);
expect(await db.trackTransactions.toArray()).toHaveLength(1);
+ expect((await db.settings.get('app'))?.defaultCurrency).toBe('USD');
});
it('summarizeBackup reports counts', async () => {
const db = getDB();
await db.people.put({ id: 'p1', name: 'A', createdAt: '', updatedAt: '', revision: 1 });
- const b = await exportBackup();
- const s = summarizeBackup(b);
- expect(s.people).toBe(1);
+ const backup = await exportBackup();
+ const summary = summarizeBackup(backup);
+ expect(summary.people).toBe(1);
});
});
diff --git a/src/export/json/backup.ts b/src/export/json/backup.ts
index 35b9e9d..0c37470 100644
--- a/src/export/json/backup.ts
+++ b/src/export/json/backup.ts
@@ -1,15 +1,16 @@
/**
- * JSON backup format (work.md section 54).
+ * JSON backup format.
*
- * { format, schemaVersion, exportedAt, shared, track, split, lend }
- *
- * This is the EXACT restore format. Restore replaces all
- * data inside an atomic DB transaction.
+ * Restoreable backups contain financial identity/settings plus every
+ * Track, Split, and Lend record. Restore replaces financial data inside
+ * one atomic database transaction while preserving device-only UI settings.
*/
+import { z } from 'zod';
import { getDB } from '@db/database';
-import { APP_VERSION, SCHEMA_VERSION } from '@app/constants';
+import { APP_VERSION } from '@app/constants';
import { nowISO } from '@shared/dates';
+import { settingsRepository } from '@shared/settings/repository';
import type {
Person,
TrackTransaction,
@@ -27,14 +28,152 @@ import type {
} from '@db/schema';
export const BACKUP_FORMAT = 'finance-utility-backup';
+export const BACKUP_SCHEMA_VERSION = 2;
+
+const idSchema = z.string().min(1);
+const currencySchema = z.string().trim().min(1);
+const minorAmountSchema = z.number().int().nonnegative();
+const baseEntitySchema = z.object({
+ id: idSchema,
+ createdAt: z.string(),
+ updatedAt: z.string(),
+ deletedAt: z.string().optional(),
+ revision: z.number().int().nonnegative(),
+});
+
+const personSchema = baseEntitySchema.extend({
+ name: z.string(),
+ isSelf: z.boolean().optional(),
+}).passthrough();
+
+const trackTransactionSchema = baseEntitySchema.extend({
+ type: z.enum(['expense', 'income']),
+ title: z.string(),
+ amountMinor: minorAmountSchema,
+ currency: currencySchema,
+ date: z.string(),
+}).passthrough();
+
+const trackCategorySchema = baseEntitySchema.extend({
+ name: z.string(),
+ type: z.enum(['expense', 'income']),
+ archived: z.boolean(),
+}).passthrough();
+
+const trackBudgetSchema = baseEntitySchema.extend({
+ month: z.string(),
+ amountMinor: minorAmountSchema,
+ currency: currencySchema,
+}).passthrough();
+
+const trackRecurringRuleSchema = baseEntitySchema.extend({
+ title: z.string(),
+ amountMinor: minorAmountSchema.optional(),
+ currency: currencySchema,
+ frequency: z.enum(['weekly', 'monthly', 'yearly']),
+ nextDate: z.string(),
+ enabled: z.boolean(),
+}).passthrough();
+
+const splitGroupSchema = baseEntitySchema.extend({
+ name: z.string(),
+ currency: currencySchema,
+ archived: z.boolean(),
+}).passthrough();
+
+const splitGroupMemberSchema = baseEntitySchema.extend({
+ groupId: idSchema,
+ personId: idSchema,
+ active: z.boolean(),
+ joinedAt: z.string(),
+}).passthrough();
+
+const splitExpenseSchema = baseEntitySchema.extend({
+ groupId: idSchema,
+ title: z.string(),
+ amountMinor: minorAmountSchema,
+ currency: currencySchema,
+ date: z.string(),
+ splitMethod: z.enum(['equal', 'exact', 'percentage', 'shares']),
+}).passthrough();
+
+const splitPayerSchema = baseEntitySchema.extend({
+ expenseId: idSchema,
+ personId: idSchema,
+ amountMinor: minorAmountSchema,
+}).passthrough();
+
+const splitShareSchema = baseEntitySchema.extend({
+ expenseId: idSchema,
+ personId: idSchema,
+ amountMinor: minorAmountSchema,
+}).passthrough();
+
+const splitSettlementSchema = baseEntitySchema.extend({
+ groupId: idSchema,
+ fromPersonId: idSchema,
+ toPersonId: idSchema,
+ amountMinor: minorAmountSchema,
+ currency: currencySchema,
+ date: z.string(),
+}).passthrough();
+
+const lendLedgerSchema = baseEntitySchema.extend({
+ personId: idSchema,
+ currency: currencySchema,
+ archived: z.boolean(),
+}).passthrough();
+
+const lendEntrySchema = baseEntitySchema.extend({
+ ledgerId: idSchema,
+ type: z.enum(['lent', 'borrowed', 'repayment_received', 'repayment_given', 'adjustment']),
+ amountMinor: z.number().int(),
+ date: z.string(),
+}).passthrough();
+
+const backupSchema = z.object({
+ format: z.literal(BACKUP_FORMAT),
+ schemaVersion: z.literal(BACKUP_SCHEMA_VERSION),
+ exportedAt: z.string().min(1),
+ appVersion: z.string().min(1),
+ shared: z.object({
+ people: z.array(personSchema),
+ settings: z.object({
+ defaultCurrency: currencySchema,
+ }).strict(),
+ }).strict(),
+ track: z.object({
+ transactions: z.array(trackTransactionSchema),
+ categories: z.array(trackCategorySchema),
+ budgets: z.array(trackBudgetSchema),
+ recurringRules: z.array(trackRecurringRuleSchema),
+ }).strict(),
+ split: z.object({
+ groups: z.array(splitGroupSchema),
+ members: z.array(splitGroupMemberSchema),
+ expenses: z.array(splitExpenseSchema),
+ payers: z.array(splitPayerSchema),
+ shares: z.array(splitShareSchema),
+ settlements: z.array(splitSettlementSchema),
+ }).strict(),
+ lend: z.object({
+ ledgers: z.array(lendLedgerSchema),
+ entries: z.array(lendEntrySchema),
+ }).strict(),
+}).strict();
+
+export interface BackupSettings {
+ defaultCurrency: string;
+}
export interface Backup {
format: typeof BACKUP_FORMAT;
- schemaVersion: number;
+ schemaVersion: typeof BACKUP_SCHEMA_VERSION;
exportedAt: string;
appVersion: string;
shared: {
people: Person[];
+ settings: BackupSettings;
};
track: {
transactions: TrackTransaction[];
@@ -56,10 +195,11 @@ export interface Backup {
};
}
-/** Build a deep snapshot of the local database. */
+/** Build a deep snapshot of the local financial database. */
export async function exportBackup(): Promise {
const db = getDB();
- const [people, track, split, lend] = await Promise.all([
+ const [settings, people, track, split, lend] = await Promise.all([
+ settingsRepository.get(),
db.people.toArray(),
db.trackTransactions.toArray().then(async (transactions) => ({
transactions,
@@ -80,65 +220,48 @@ export async function exportBackup(): Promise {
entries: await db.lendEntries.toArray(),
})),
]);
+
return {
format: BACKUP_FORMAT,
- schemaVersion: SCHEMA_VERSION,
+ schemaVersion: BACKUP_SCHEMA_VERSION,
exportedAt: nowISO(),
appVersion: APP_VERSION,
- shared: { people },
+ shared: {
+ people,
+ settings: { defaultCurrency: settings.defaultCurrency },
+ },
track,
split,
lend,
};
}
-/** Validate a parsed backup object. Throws on shape errors. */
+/** Validate a parsed backup object. Throws with the first invalid path. */
export function validateBackup(input: unknown): Backup {
- if (!input || typeof input !== 'object') throw new Error('Backup is not an object');
- const b = input as Partial;
- if (b.format !== BACKUP_FORMAT) {
- throw new Error(`Invalid backup format: ${b.format}`);
- }
- if (typeof b.schemaVersion !== 'number' || b.schemaVersion !== SCHEMA_VERSION) {
- throw new Error(
- `Unsupported schema version: ${b.schemaVersion}. Expected ${SCHEMA_VERSION}.`,
- );
- }
- for (const path of [
- ['shared', 'people'],
- ['track', 'transactions'],
- ['track', 'categories'],
- ['track', 'budgets'],
- ['track', 'recurringRules'],
- ['split', 'groups'],
- ['split', 'members'],
- ['split', 'expenses'],
- ['split', 'payers'],
- ['split', 'shares'],
- ['split', 'settlements'],
- ['lend', 'ledgers'],
- ['lend', 'entries'],
- ]) {
- let cur: unknown = b;
- for (const k of path) {
- if (!cur || typeof cur !== 'object' || !(k in (cur as object))) {
- throw new Error(`Backup missing ${path.join('.')}`);
- }
- cur = (cur as Record)[k];
- }
- if (!Array.isArray(cur)) {
- throw new Error(`Backup ${path.join('.')} is not an array`);
- }
+ const result = backupSchema.safeParse(input);
+ if (!result.success) {
+ const issue = result.error.issues[0];
+ const path = issue?.path.join('.');
+ throw new Error(`Invalid backup${path ? ` at ${path}` : ''}: ${issue?.message ?? 'unknown validation error'}`);
}
- return b as Backup;
+ return result.data as Backup;
}
/** Restore a backup into the local database atomically. */
export async function restoreBackup(backup: Backup): Promise {
const db = getDB();
+ const currentSettings = await settingsRepository.get();
+ const restoredSettings = {
+ ...currentSettings,
+ defaultCurrency: backup.shared.settings.defaultCurrency,
+ updatedAt: nowISO(),
+ revision: currentSettings.revision + 1,
+ };
+
await db.transaction(
'rw',
[
+ db.settings,
db.people,
db.trackTransactions,
db.trackCategories,
@@ -155,6 +278,7 @@ export async function restoreBackup(backup: Backup): Promise {
],
async () => {
await Promise.all([
+ db.settings.clear(),
db.people.clear(),
db.trackTransactions.clear(),
db.trackCategories.clear(),
@@ -169,20 +293,21 @@ export async function restoreBackup(backup: Backup): Promise {
db.lendLedgers.clear(),
db.lendEntries.clear(),
]);
- if (backup.shared.people.length)
- await db.people.bulkPut(backup.shared.people);
- await db.trackTransactions.bulkPut(backup.track.transactions);
- await db.trackCategories.bulkPut(backup.track.categories);
- await db.trackBudgets.bulkPut(backup.track.budgets);
- await db.trackRecurringRules.bulkPut(backup.track.recurringRules);
- await db.splitGroups.bulkPut(backup.split.groups);
- await db.splitGroupMembers.bulkPut(backup.split.members);
- await db.splitExpenses.bulkPut(backup.split.expenses);
- await db.splitPayers.bulkPut(backup.split.payers);
- await db.splitShares.bulkPut(backup.split.shares);
- await db.splitSettlements.bulkPut(backup.split.settlements);
- await db.lendLedgers.bulkPut(backup.lend.ledgers);
- await db.lendEntries.bulkPut(backup.lend.entries);
+
+ await db.settings.put(restoredSettings);
+ if (backup.shared.people.length > 0) await db.people.bulkPut(backup.shared.people);
+ if (backup.track.transactions.length > 0) await db.trackTransactions.bulkPut(backup.track.transactions);
+ if (backup.track.categories.length > 0) await db.trackCategories.bulkPut(backup.track.categories);
+ if (backup.track.budgets.length > 0) await db.trackBudgets.bulkPut(backup.track.budgets);
+ if (backup.track.recurringRules.length > 0) await db.trackRecurringRules.bulkPut(backup.track.recurringRules);
+ if (backup.split.groups.length > 0) await db.splitGroups.bulkPut(backup.split.groups);
+ if (backup.split.members.length > 0) await db.splitGroupMembers.bulkPut(backup.split.members);
+ if (backup.split.expenses.length > 0) await db.splitExpenses.bulkPut(backup.split.expenses);
+ if (backup.split.payers.length > 0) await db.splitPayers.bulkPut(backup.split.payers);
+ if (backup.split.shares.length > 0) await db.splitShares.bulkPut(backup.split.shares);
+ if (backup.split.settlements.length > 0) await db.splitSettlements.bulkPut(backup.split.settlements);
+ if (backup.lend.ledgers.length > 0) await db.lendLedgers.bulkPut(backup.lend.ledgers);
+ if (backup.lend.entries.length > 0) await db.lendEntries.bulkPut(backup.lend.entries);
},
);
}
diff --git a/src/modules/lend/queries/index.ts b/src/modules/lend/queries/index.ts
index bd4cae2..7a766ec 100644
--- a/src/modules/lend/queries/index.ts
+++ b/src/modules/lend/queries/index.ts
@@ -31,16 +31,30 @@ import type { LendEntry, LendLedger } from '@db/schema';
// Raw table queries
// --------------------------------------------------------------------
-/** All active ledgers, sorted by createdAt asc. */
+/** All active, non-archived ledgers, sorted by createdAt asc. */
export function useLendLedgers(): LendLedger[] | undefined {
return useLiveQuery(async () => {
const all = await getDB().lendLedgers.toArray();
return all
- .filter((l) => !l.deletedAt)
+ .filter((ledger) => !ledger.deletedAt && !ledger.archived)
.sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1));
}, []);
}
+/** All active, non-archived ledgers for one person, sorted by createdAt asc. */
+export function useLendLedgersForPerson(personId: string | undefined): LendLedger[] | undefined {
+ return useLiveQuery(
+ async () => {
+ if (!personId) return [];
+ const rows = await getDB().lendLedgers.where('personId').equals(personId).toArray();
+ return rows
+ .filter((ledger) => !ledger.deletedAt && !ledger.archived)
+ .sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1));
+ },
+ [personId],
+ );
+}
+
/** A single ledger by id. */
export function useLendLedger(id: string | undefined): LendLedger | undefined {
return useLiveQuery(
@@ -49,7 +63,7 @@ export function useLendLedger(id: string | undefined): LendLedger | undefined {
);
}
-/** The (single, V1) active ledger for a (person, currency) pair, if any. */
+/** The active ledger for a (person, currency) pair, if any. */
export function useLendLedgerForPerson(
personId: string | undefined,
currency: CurrencyCode,
@@ -57,10 +71,11 @@ export function useLendLedgerForPerson(
return useLiveQuery(
async () => {
if (!personId) return undefined;
- const all = await getDB().lendLedgers.toArray();
- return all.find(
- (l) => l.personId === personId && l.currency === currency && !l.deletedAt,
- );
+ return getDB()
+ .lendLedgers.where('[personId+currency]')
+ .equals([personId, currency])
+ .filter((ledger) => !ledger.deletedAt && !ledger.archived)
+ .first();
},
[personId, currency],
);
@@ -73,30 +88,31 @@ export function useLendEntriesForLedger(ledgerId: string | undefined): LendEntry
if (!ledgerId) return [];
const all = await getDB().lendEntries.where('ledgerId').equals(ledgerId).toArray();
return all
- .filter((e) => !e.deletedAt)
+ .filter((entry) => !entry.deletedAt)
.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : a.id < b.id ? 1 : -1));
},
[ledgerId],
);
}
-/** All active entries across all of a person's ledgers. */
+/** All active entries across all of a person's active ledgers. */
export function useLendEntriesForPerson(personId: string | undefined): LendEntry[] | undefined {
return useLiveQuery(
async () => {
if (!personId) return [];
const db = getDB();
- const ledgers = (await db.lendLedgers.toArray()).filter(
- (l) => l.personId === personId && !l.deletedAt,
+ const ledgers = (await db.lendLedgers.where('personId').equals(personId).toArray()).filter(
+ (ledger) => !ledger.deletedAt && !ledger.archived,
);
if (ledgers.length === 0) return [];
- const out: LendEntry[] = [];
- for (const l of ledgers) {
- const entries = await db.lendEntries.where('ledgerId').equals(l.id).toArray();
- for (const e of entries) if (!e.deletedAt) out.push(e);
- }
- out.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : a.id < b.id ? 1 : -1));
- return out;
+
+ const entryBatches = await Promise.all(
+ ledgers.map((ledger) => db.lendEntries.where('ledgerId').equals(ledger.id).toArray()),
+ );
+ return entryBatches
+ .flat()
+ .filter((entry) => !entry.deletedAt)
+ .sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : a.id < b.id ? 1 : -1));
},
[personId],
);
@@ -112,7 +128,7 @@ export function useLendDashboard(): DashboardSummary | undefined {
const ledgers = useLendLedgers();
const entries = useLiveQuery(async () => {
const all = await getDB().lendEntries.toArray();
- return all.filter((e) => !e.deletedAt);
+ return all.filter((entry) => !entry.deletedAt);
}, []);
return useMemo(() => {
@@ -134,27 +150,18 @@ export interface LendPersonDetail {
* dashboard shows the per-currency numbers side by side.
*/
export function useLendPersonDetail(personId: string | undefined): LendPersonDetail | undefined {
- const ledgersAll = useLendLedgers();
- const entriesAll = useLiveQuery(async () => {
- const all = await getDB().lendEntries.toArray();
- return all.filter((e) => !e.deletedAt);
- }, []);
+ const ledgers = useLendLedgersForPerson(personId);
+ const entries = useLendEntriesForPerson(personId);
return useMemo(() => {
- if (!personId || !ledgersAll || !entriesAll) return undefined;
- const ledgers = ledgersAll.filter((l) => l.personId === personId);
- const ledgerIds = new Set(ledgers.map((l) => l.id));
- const entries = entriesAll
- .filter((e) => ledgerIds.has(e.ledgerId))
- .sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : a.id < b.id ? 1 : -1));
- const totalBalance = personBalanceFromLedgers(ledgersAll, entriesAll, personId);
+ if (!personId || !ledgers || !entries) return undefined;
return {
ledgers,
- totalBalance,
+ totalBalance: personBalanceFromLedgers(ledgers, entries, personId),
entries,
currency: ledgers[0]?.currency,
};
- }, [personId, ledgersAll, entriesAll]);
+ }, [personId, ledgers, entries]);
}
/** Recent activity across all ledgers (top N). */
@@ -178,12 +185,9 @@ export function useRecentLendEntries(limit: number = 20): LendEntry[] | undefine
* app's defaultCurrency if the person has no ledgers yet.
*/
export function useDefaultLendCurrency(personId: string | undefined): CurrencyCode {
- const ledger = useLendLedgers();
+ const ledgers = useLendLedgersForPerson(personId);
const settings = useAppSettings();
- if (personId && ledger) {
- const found = ledger.find((l) => l.personId === personId);
- if (found) return found.currency;
- }
+ if (ledgers?.[0]) return ledgers[0].currency;
return settings?.defaultCurrency ?? 'INR';
}
@@ -197,7 +201,7 @@ export function useLendPeople(): PersonSummary[] | undefined {
const ledgers = useLendLedgers();
const entries = useLiveQuery(async () => {
const all = await getDB().lendEntries.toArray();
- return all.filter((e) => !e.deletedAt);
+ return all.filter((entry) => !entry.deletedAt);
}, []);
return useMemo(() => {
diff --git a/src/modules/lend/repositories/lendLedgerArchive.test.ts b/src/modules/lend/repositories/lendLedgerArchive.test.ts
new file mode 100644
index 0000000..0b324c7
--- /dev/null
+++ b/src/modules/lend/repositories/lendLedgerArchive.test.ts
@@ -0,0 +1,27 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { freshDB, wipeDB } from '@/tests/db-test-utils';
+import { personRepository } from '@shared/people/repository';
+import { settingsRepository } from '@shared/settings/repository';
+import { lendLedgerRepository } from './lendLedgerRepository';
+
+describe('Lend archived ledgers', () => {
+ beforeEach(async () => {
+ await wipeDB();
+ freshDB();
+ await settingsRepository.get();
+ });
+
+ it('excludes archived ledgers from active lists and creates a fresh quick-entry ledger', async () => {
+ const person = await personRepository.create({ name: 'Rahul' });
+ const archived = await lendLedgerRepository.getOrCreate(person.id, 'INR');
+
+ await lendLedgerRepository.archive(archived.id);
+
+ expect(await lendLedgerRepository.list()).toEqual([]);
+ expect(await lendLedgerRepository.listForPerson(person.id)).toEqual([]);
+
+ const replacement = await lendLedgerRepository.getOrCreate(person.id, 'INR');
+ expect(replacement.id).not.toBe(archived.id);
+ expect(replacement.archived).toBe(false);
+ });
+});
diff --git a/src/modules/lend/repositories/lendLedgerRepository.ts b/src/modules/lend/repositories/lendLedgerRepository.ts
index 570c2b8..cb08243 100644
--- a/src/modules/lend/repositories/lendLedgerRepository.ts
+++ b/src/modules/lend/repositories/lendLedgerRepository.ts
@@ -30,19 +30,19 @@ function clean(input: Partial): Partial {
}
export const lendLedgerRepository = {
- /** All active ledgers, sorted by createdAt asc. */
+ /** All active, non-archived ledgers, sorted by createdAt asc. */
async list(): Promise {
const all = await getDB().lendLedgers.toArray();
return all
- .filter((l) => !l.deletedAt)
+ .filter((l) => !l.deletedAt && !l.archived)
.sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1));
},
- /** All active ledgers for a person. */
+ /** All active, non-archived ledgers for a person. */
async listForPerson(personId: string): Promise {
- const all = await getDB().lendLedgers.toArray();
+ const all = await getDB().lendLedgers.where('personId').equals(personId).toArray();
return all
- .filter((l) => !l.deletedAt && l.personId === personId)
+ .filter((l) => !l.deletedAt && !l.archived)
.sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1));
},
@@ -53,16 +53,23 @@ export const lendLedgerRepository = {
/**
* Get the active ledger for (person, currency), or create
- * one if it doesn't exist. Idempotent.
+ * 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.
*/
async getOrCreate(personId: string, currency: string): Promise {
- const existing = await getDB()
- .lendLedgers.where('[personId+currency]')
- .equals([personId, currency])
- .filter((l) => !l.deletedAt)
- .first();
- if (existing) return existing;
- return this.create({ personId, currency, archived: false });
+ const db = getDB();
+ return db.transaction('rw', db.lendLedgers, async () => {
+ const existing = await db.lendLedgers
+ .where('[personId+currency]')
+ .equals([personId, currency])
+ .filter((ledger) => !ledger.deletedAt && !ledger.archived)
+ .first();
+ if (existing) return existing;
+
+ const parsed = LendLedgerInputSchema.parse({ personId, currency, archived: false });
+ return repoCreate(db.lendLedgers, clean(parsed) as CreateInput);
+ });
},
/** Create a new ledger. */
@@ -81,7 +88,7 @@ export const lendLedgerRepository = {
/**
* Archive a ledger (logical flag). The ledger and its
* entries remain queryable but are excluded from
- * dashboard summaries.
+ * dashboard summaries and future quick-entry resolution.
*/
async archive(id: string): Promise {
return this.update(id, { archived: true });
diff --git a/src/modules/split/queries/index.ts b/src/modules/split/queries/index.ts
index 192b901..bd35f38 100644
--- a/src/modules/split/queries/index.ts
+++ b/src/modules/split/queries/index.ts
@@ -11,6 +11,7 @@
* They never read Track or Lend.
*/
+import { useMemo } from 'react';
import { useLiveQuery } from 'dexie-react-hooks';
import { getDB } from '@db/database';
import { findSelf } from '@shared/people/domain';
@@ -61,11 +62,10 @@ export function useSplitGroupMembers(
async () => {
if (!groupId) return undefined;
const all = await getDB().splitGroupMembers.where('groupId').equals(groupId).toArray();
- const filtered = all
+ return all
.filter((m) => !m.deletedAt)
.filter((m) => (includeInactive ? true : m.active))
.sort((a, b) => (a.joinedAt < b.joinedAt ? -1 : 1));
- return filtered;
},
[groupId, includeInactive],
);
@@ -99,6 +99,32 @@ export function useSplitGroupSettlements(groupId: string | undefined): SplitSett
);
}
+interface SplitExpenseRelations {
+ payers: SplitPayer[];
+ shares: SplitShare[];
+}
+
+/** Load payer/share rows only for the supplied expenses using their indexed expenseId. */
+function useSplitExpenseRelations(
+ expenses: SplitExpense[] | undefined,
+): SplitExpenseRelations | undefined {
+ return useLiveQuery(
+ async () => {
+ if (!expenses) return undefined;
+ const expenseIds = expenses.map((expense) => expense.id);
+ if (expenseIds.length === 0) return { payers: [], shares: [] };
+
+ const db = getDB();
+ const [payers, shares] = await Promise.all([
+ db.splitPayers.where('expenseId').anyOf(expenseIds).toArray(),
+ db.splitShares.where('expenseId').anyOf(expenseIds).toArray(),
+ ]);
+ return { payers, shares };
+ },
+ [expenses],
+ );
+}
+
// ---------------------------------------------------------------------------
// Composite hooks
// ---------------------------------------------------------------------------
@@ -114,94 +140,60 @@ export interface GroupBalancesResult {
* transfers once loading is done.
*/
export function useSplitGroupBalances(groupId: string | undefined): GroupBalancesResult | undefined {
- const groups = useLiveQuery(async () => (groupId ? getDB().splitGroups.get(groupId) : undefined), [groupId]);
- const members = useSplitGroupMembers(groupId, true);
- const expenses = useSplitGroupExpenses(groupId);
- const payers = useLiveQuery(
- async () => {
- if (!groupId) return undefined;
- const all = await getDB().splitExpenses.where('groupId').equals(groupId).toArray();
- const ids = new Set(all.map((e) => e.id));
- if (ids.size === 0) return [];
- const list = await getDB().splitPayers.toArray();
- return list.filter((p) => ids.has(p.expenseId));
- },
- [groupId],
- );
- const shares = useLiveQuery(
- async () => {
- if (!groupId) return undefined;
- const all = await getDB().splitExpenses.where('groupId').equals(groupId).toArray();
- const ids = new Set(all.map((e) => e.id));
- if (ids.size === 0) return [];
- const list = await getDB().splitShares.toArray();
- return list.filter((s) => ids.has(s.expenseId));
- },
+ const group = useLiveQuery(
+ async () => (groupId ? getDB().splitGroups.get(groupId) : undefined),
[groupId],
);
+ const members = useSplitGroupMembers(groupId, true);
+ const expenses = useSplitGroupExpenses(groupId);
+ const relations = useSplitExpenseRelations(expenses);
const settlements = useSplitGroupSettlements(groupId);
- return useLiveQuery(() => {
- if (!groupId) return undefined;
- if (!groups || !members || !expenses || !payers || !shares || !settlements) return undefined;
+ return useMemo(() => {
+ if (!groupId || !group || !members || !expenses || !relations || !settlements) {
+ return undefined;
+ }
const balances = computeGroupBalances({
- group: groups,
+ group,
members,
expenses,
- payers,
- shares,
+ payers: relations.payers,
+ shares: relations.shares,
settlements,
});
return { balances, transfers: simplifyDebts(balances) };
- }, [groupId, groups, members, expenses, payers, shares, settlements]);
+ }, [groupId, group, members, expenses, relations, settlements]);
}
/** Group summary used by the landing screen. */
export function useSplitGroupSummary(groupId: string | undefined): GroupSummary | undefined {
const self = useSelf();
- const group = useLiveQuery(async () => (groupId ? getDB().splitGroups.get(groupId) : undefined), [groupId]);
- const members = useSplitGroupMembers(groupId, true);
- const expenses = useSplitGroupExpenses(groupId);
- const payers = useLiveQuery(
- async () => {
- if (!groupId) return undefined;
- const all = await getDB().splitExpenses.where('groupId').equals(groupId).toArray();
- const ids = new Set(all.map((e) => e.id));
- if (ids.size === 0) return [];
- const list = await getDB().splitPayers.toArray();
- return list.filter((p) => ids.has(p.expenseId));
- },
- [groupId],
- );
- const shares = useLiveQuery(
- async () => {
- if (!groupId) return undefined;
- const all = await getDB().splitExpenses.where('groupId').equals(groupId).toArray();
- const ids = new Set(all.map((e) => e.id));
- if (ids.size === 0) return [];
- const list = await getDB().splitShares.toArray();
- return list.filter((s) => ids.has(s.expenseId));
- },
+ const group = useLiveQuery(
+ async () => (groupId ? getDB().splitGroups.get(groupId) : undefined),
[groupId],
);
+ const members = useSplitGroupMembers(groupId, true);
+ const expenses = useSplitGroupExpenses(groupId);
+ const relations = useSplitExpenseRelations(expenses);
const settlements = useSplitGroupSettlements(groupId);
- return useLiveQuery(() => {
- if (!groupId || !self) return undefined;
- if (!group || !members || !expenses || !payers || !shares || !settlements) return undefined;
+ return useMemo(() => {
+ if (!groupId || !self || !group || !members || !expenses || !relations || !settlements) {
+ return undefined;
+ }
return buildGroupSummary(
{
group,
members,
expenses,
- payers,
- shares,
+ payers: relations.payers,
+ shares: relations.shares,
settlements,
selfPersonId: self.id,
},
{ selfPersonId: self.id },
);
- }, [groupId, self, group, members, expenses, payers, shares, settlements]);
+ }, [groupId, self, group, members, expenses, relations, settlements]);
}
// ---------------------------------------------------------------------------
@@ -288,27 +280,21 @@ export function useSplitGroupRaw(groupId: string | undefined) {
async () => {
if (!groupId) return undefined;
const db = getDB();
- const [g, ms, es, ps, ss, sts] = await Promise.all([
+ const [group, members, expenses, settlements] = await Promise.all([
db.splitGroups.get(groupId),
db.splitGroupMembers.where('groupId').equals(groupId).toArray(),
db.splitExpenses.where('groupId').equals(groupId).toArray(),
- (async () => {
- const list = await db.splitExpenses.where('groupId').equals(groupId).toArray();
- const ids = new Set(list.map((e) => e.id));
- if (ids.size === 0) return [] as SplitPayer[];
- const all = await db.splitPayers.toArray();
- return all.filter((p) => ids.has(p.expenseId));
- })(),
- (async () => {
- const list = await db.splitExpenses.where('groupId').equals(groupId).toArray();
- const ids = new Set(list.map((e) => e.id));
- if (ids.size === 0) return [] as SplitShare[];
- const all = await db.splitShares.toArray();
- return all.filter((s) => ids.has(s.expenseId));
- })(),
db.splitSettlements.where('groupId').equals(groupId).toArray(),
]);
- return { group: g, members: ms ?? [], expenses: es ?? [], payers: ps ?? [], shares: ss ?? [], settlements: sts ?? [] };
+ const expenseIds = expenses.map((expense) => expense.id);
+ const [payers, shares] = expenseIds.length
+ ? await Promise.all([
+ db.splitPayers.where('expenseId').anyOf(expenseIds).toArray(),
+ db.splitShares.where('expenseId').anyOf(expenseIds).toArray(),
+ ])
+ : [[], []];
+
+ return { group, members, expenses, payers, shares, settlements };
},
[groupId],
);
diff --git a/src/modules/track/repositories/trackBudgetRepository.ts b/src/modules/track/repositories/trackBudgetRepository.ts
index 201f0b4..ba3b5a3 100644
--- a/src/modules/track/repositories/trackBudgetRepository.ts
+++ b/src/modules/track/repositories/trackBudgetRepository.ts
@@ -19,8 +19,11 @@ import type { TrackBudget } from '@db/schema';
export const trackBudgetRepository = {
/** The active budget for a given month, or undefined. */
async getByMonth(month: string): Promise {
- const all = await getDB().trackBudgets.toArray();
- return all.find((b) => !b.deletedAt && b.month === month);
+ return getDB()
+ .trackBudgets.where('month')
+ .equals(month)
+ .filter((budget) => !budget.deletedAt)
+ .first();
},
/** All active budgets (across months), sorted by month desc. */
diff --git a/src/overview/queries/index.test.tsx b/src/overview/queries/index.test.tsx
new file mode 100644
index 0000000..2d37b40
--- /dev/null
+++ b/src/overview/queries/index.test.tsx
@@ -0,0 +1,84 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { renderHook, waitFor } from '@testing-library/react';
+import { freshDB, wipeDB } from '@/tests/db-test-utils';
+import { getDB } from '@db/database';
+import { settingsRepository } from '@shared/settings/repository';
+import { useOverviewSummary } from './index';
+
+describe('useOverviewSummary', () => {
+ beforeEach(async () => {
+ await wipeDB();
+ freshDB();
+ await settingsRepository.get();
+ });
+
+ it('loads a monthly budget through the month index rather than treating month as the primary key', async () => {
+ const now = new Date().toISOString();
+ await getDB().trackBudgets.put({
+ id: 'budget-generated-id',
+ month: '2026-08',
+ amountMinor: 500_000,
+ currency: 'INR',
+ createdAt: now,
+ updatedAt: now,
+ revision: 1,
+ });
+
+ const { result } = renderHook(() => useOverviewSummary('2026-08'));
+
+ await waitFor(() => expect(result.current?.track.budgetMinor).toBe(500_000));
+ expect(result.current?.track.budgetRemainingMinor).toBe(500_000);
+ });
+
+ it('excludes archived Lend ledgers from current Overview balances', async () => {
+ const db = getDB();
+ const now = new Date().toISOString();
+ await db.lendLedgers.bulkPut([
+ {
+ id: 'active-ledger',
+ personId: 'person-active',
+ currency: 'INR',
+ archived: false,
+ createdAt: now,
+ updatedAt: now,
+ revision: 1,
+ },
+ {
+ id: 'archived-ledger',
+ personId: 'person-archived',
+ currency: 'INR',
+ archived: true,
+ createdAt: now,
+ updatedAt: now,
+ revision: 1,
+ },
+ ]);
+ await db.lendEntries.bulkPut([
+ {
+ id: 'active-entry',
+ ledgerId: 'active-ledger',
+ type: 'lent',
+ amountMinor: 10_000,
+ date: '2026-08-01',
+ createdAt: now,
+ updatedAt: now,
+ revision: 1,
+ },
+ {
+ id: 'archived-entry',
+ ledgerId: 'archived-ledger',
+ type: 'lent',
+ amountMinor: 99_000,
+ date: '2026-08-01',
+ createdAt: now,
+ updatedAt: now,
+ revision: 1,
+ },
+ ]);
+
+ const { result } = renderHook(() => useOverviewSummary('2026-08'));
+
+ await waitFor(() => expect(result.current?.lend.youWillReceiveMinor).toBe(10_000));
+ expect(result.current?.lend.youOweMinor).toBe(0);
+ });
+});
diff --git a/src/overview/queries/index.ts b/src/overview/queries/index.ts
index 75210df..8ffe9d9 100644
--- a/src/overview/queries/index.ts
+++ b/src/overview/queries/index.ts
@@ -7,6 +7,9 @@ import { getDB } from '@db/database';
import { SELF_PERSON_ID } from '@db/seed';
import { toMonthKey, isInMonth } from '@shared/dates';
import { settingsRepository } from '@shared/settings/repository';
+import { trackBudgetRepository } from '@modules/track/repositories/trackBudgetRepository';
+import { computeMemberBalances } from '@modules/split/domain/balances';
+import { entryToSignedAmount } from '@modules/lend/domain/signs';
import {
trackToActivity,
splitToActivity,
@@ -16,15 +19,10 @@ import {
import { calculateSplitPersonalShareForMonth } from '../projections/calculations';
import type { ActivityItem, OverviewSummary, PersonExposure } from '../projections/types';
import type { CurrencyCode } from '@shared/money';
+import type { LendEntry } from '@db/schema';
-function computeSignedLendSum(types: string[], entries: Array<{ type: string; amountMinor: number }>): number {
- let s = 0;
- for (const e of entries) {
- if (!types.includes(e.type)) continue;
- if (e.type === 'lent' || e.type === 'repayment_given' || e.type === 'adjustment') s += e.amountMinor;
- else s -= e.amountMinor;
- }
- return s;
+function sumLendEntries(entries: Array>): number {
+ return entries.reduce((sum, entry) => sum + entryToSignedAmount(entry), 0);
}
export function useOverviewSummary(month: string = toMonthKey()): OverviewSummary | undefined {
@@ -32,20 +30,40 @@ export function useOverviewSummary(month: string = toMonthKey()): OverviewSummar
const settings = await settingsRepository.get();
const currency = settings.defaultCurrency;
const db = getDB();
- const trackAll = await db.trackTransactions.toArray();
- const trackActive = trackAll.filter((t) => !t.deletedAt);
- const monthTrack = trackActive.filter((t) => isInMonth(t.date, month) && t.currency === currency);
- const spentMinor = monthTrack.filter((t) => t.type === 'expense').reduce((a, b) => a + b.amountMinor, 0);
- const incomeMinor = monthTrack.filter((t) => t.type === 'income').reduce((a, b) => a + b.amountMinor, 0);
- const budget = await db.trackBudgets.get(month);
+
+ const [trackAll, budget] = await Promise.all([
+ db.trackTransactions.toArray(),
+ trackBudgetRepository.getByMonth(month),
+ ]);
+ const trackActive = trackAll.filter((transaction) => !transaction.deletedAt);
+ const monthTrack = trackActive.filter(
+ (transaction) => isInMonth(transaction.date, month) && transaction.currency === currency,
+ );
+ const spentMinor = monthTrack
+ .filter((transaction) => transaction.type === 'expense')
+ .reduce((sum, transaction) => sum + transaction.amountMinor, 0);
+ const incomeMinor = monthTrack
+ .filter((transaction) => transaction.type === 'income')
+ .reduce((sum, transaction) => sum + transaction.amountMinor, 0);
const budgetMinor = budget && budget.currency === currency ? budget.amountMinor : undefined;
const budgetRemainingMinor = budgetMinor !== undefined ? budgetMinor - spentMinor : undefined;
- const groups = (await db.splitGroups.toArray()).filter((g) => !g.deletedAt);
- const expenses = (await db.splitExpenses.toArray()).filter((e) => !e.deletedAt);
- const payers = (await db.splitPayers.toArray()).filter((p) => !p.deletedAt);
- const shares = (await db.splitShares.toArray()).filter((s) => !s.deletedAt);
- const settlements = (await db.splitSettlements.toArray()).filter((s) => !s.deletedAt);
+ const [groupsAll, membersAll, expensesAll, payersAll, sharesAll, settlementsAll] =
+ await Promise.all([
+ db.splitGroups.toArray(),
+ db.splitGroupMembers.toArray(),
+ db.splitExpenses.toArray(),
+ db.splitPayers.toArray(),
+ db.splitShares.toArray(),
+ db.splitSettlements.toArray(),
+ ]);
+ const groups = groupsAll.filter((group) => !group.deletedAt);
+ const members = membersAll.filter((member) => !member.deletedAt);
+ const expenses = expensesAll.filter((expense) => !expense.deletedAt);
+ const payers = payersAll.filter((payer) => !payer.deletedAt);
+ const shares = sharesAll.filter((share) => !share.deletedAt);
+ const settlements = settlementsAll.filter((settlement) => !settlement.deletedAt);
+
const splitShareMinor = calculateSplitPersonalShareForMonth({
month,
currency,
@@ -57,31 +75,35 @@ export function useOverviewSummary(month: string = toMonthKey()): OverviewSummar
let youAreOwedSplit = 0;
let youOweSplit = 0;
- for (const g of groups) {
- if (g.currency !== currency) continue;
- const gExp = expenses.filter((e) => e.groupId === g.id);
- const expenseIds = new Set(gExp.map((e) => e.id));
- const gPay = payers.filter((p) => expenseIds.has(p.expenseId));
- const gSha = shares.filter((s) => expenseIds.has(s.expenseId));
- const gSet = settlements.filter((s) => s.groupId === g.id);
- const myPaid = gPay.filter((p) => p.personId === SELF_PERSON_ID).reduce((a, b) => a + b.amountMinor, 0);
- const myShare = gSha.filter((s) => s.personId === SELF_PERSON_ID).reduce((a, b) => a + b.amountMinor, 0);
- const mySent = gSet.filter((s) => s.fromPersonId === SELF_PERSON_ID).reduce((a, b) => a + b.amountMinor, 0);
- const myReceived = gSet.filter((s) => s.toPersonId === SELF_PERSON_ID).reduce((a, b) => a + b.amountMinor, 0);
- const bal = myPaid - myShare + mySent - myReceived;
- if (bal > 0) youAreOwedSplit += bal;
- else youOweSplit += -bal;
+ for (const group of groups) {
+ if (group.currency !== currency) continue;
+ const groupExpenses = expenses.filter((expense) => expense.groupId === group.id);
+ const expenseIds = new Set(groupExpenses.map((expense) => expense.id));
+ const balances = computeMemberBalances({
+ group,
+ members: members.filter((member) => member.groupId === group.id),
+ expenses: groupExpenses,
+ payers: payers.filter((payer) => expenseIds.has(payer.expenseId)),
+ shares: shares.filter((share) => expenseIds.has(share.expenseId)),
+ settlements: settlements.filter((settlement) => settlement.groupId === group.id),
+ });
+ const balance = balances.get(SELF_PERSON_ID) ?? 0;
+ if (balance > 0) youAreOwedSplit += balance;
+ else youOweSplit += -balance;
}
- const ledgers = (await db.lendLedgers.toArray()).filter((l) => !l.deletedAt && l.currency === currency);
- const lendEntries = (await db.lendEntries.toArray()).filter((e) => !e.deletedAt);
+ const ledgers = (await db.lendLedgers.toArray()).filter(
+ (ledger) => !ledger.deletedAt && !ledger.archived && ledger.currency === currency,
+ );
+ const lendEntries = (await db.lendEntries.toArray()).filter((entry) => !entry.deletedAt);
let youWillReceiveLend = 0;
let youOweLend = 0;
- for (const l of ledgers) {
- const e = lendEntries.filter((x) => x.ledgerId === l.id);
- const sum = computeSignedLendSum(['lent', 'borrowed', 'repayment_received', 'repayment_given', 'adjustment'], e);
- if (sum > 0) youWillReceiveLend += sum;
- else youOweLend += -sum;
+ for (const ledger of ledgers) {
+ const balance = sumLendEntries(
+ lendEntries.filter((entry) => entry.ledgerId === ledger.id),
+ );
+ if (balance > 0) youWillReceiveLend += balance;
+ else youOweLend += -balance;
}
return {
@@ -111,35 +133,39 @@ export function useGlobalActivity(limit = 20): ActivityItem[] | undefined {
db.lendEntries.toArray(),
db.people.toArray(),
]);
- const groupMap = new Map(groups.filter((g) => !g.deletedAt).map((g) => [g.id, g]));
- const peopleMap = new Map(people.filter((p) => !p.deletedAt).map((p) => [p.id, p]));
- const ledgerMap = new Map(ledgers.filter((l) => !l.deletedAt).map((l) => [l.id, l]));
+ const groupMap = new Map(groups.filter((group) => !group.deletedAt).map((group) => [group.id, group]));
+ const peopleMap = new Map(people.filter((person) => !person.deletedAt).map((person) => [person.id, person]));
+ const ledgerMap = new Map(
+ ledgers
+ .filter((ledger) => !ledger.deletedAt && !ledger.archived)
+ .map((ledger) => [ledger.id, ledger]),
+ );
const items: ActivityItem[] = [];
- for (const t of trackAll) {
- if (t.deletedAt) continue;
- items.push(trackToActivity(t));
+ for (const transaction of trackAll) {
+ if (transaction.deletedAt) continue;
+ items.push(trackToActivity(transaction));
}
- for (const e of expenses) {
- if (e.deletedAt) continue;
- const g = groupMap.get(e.groupId);
- if (!g) continue;
- items.push(splitToActivity(e, g.name, g.currency));
+ for (const expense of expenses) {
+ if (expense.deletedAt) continue;
+ const group = groupMap.get(expense.groupId);
+ if (!group) continue;
+ items.push(splitToActivity(expense, group.name, group.currency));
}
- for (const s of settlements) {
- if (s.deletedAt) continue;
- const g = groupMap.get(s.groupId);
- if (!g) continue;
- items.push(splitSettlementToActivity(s, g.name));
+ for (const settlement of settlements) {
+ if (settlement.deletedAt) continue;
+ const group = groupMap.get(settlement.groupId);
+ if (!group) continue;
+ items.push(splitSettlementToActivity(settlement, group.name));
}
for (const entry of lendEntries) {
if (entry.deletedAt) continue;
- const l = ledgerMap.get(entry.ledgerId);
- if (!l) continue;
- const person = peopleMap.get(l.personId);
+ const ledger = ledgerMap.get(entry.ledgerId);
+ if (!ledger) continue;
+ const person = peopleMap.get(ledger.personId);
const name = person?.name ?? 'Someone';
const item = lendToActivity(entry, name);
- item.currency = l.currency as CurrencyCode;
+ item.currency = ledger.currency as CurrencyCode;
items.push(item);
}
return items.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)).slice(0, limit);
@@ -154,53 +180,71 @@ export function usePersonExposure(personId: string): PersonExposure | null | und
if (!person || person.deletedAt) return null;
const contexts: PersonExposure['contexts'] = [];
- const ledgers = (await db.lendLedgers.toArray()).filter((l) => !l.deletedAt && l.personId === personId);
- const lendEntries = (await db.lendEntries.toArray()).filter((e) => !e.deletedAt);
- for (const l of ledgers) {
- const e = lendEntries.filter((x) => x.ledgerId === l.id);
- const balance = computeSignedLendSum(['lent', 'borrowed', 'repayment_received', 'repayment_given', 'adjustment'], e);
+ const ledgers = (await db.lendLedgers.where('personId').equals(personId).toArray()).filter(
+ (ledger) => !ledger.deletedAt && !ledger.archived,
+ );
+ const ledgerIds = new Set(ledgers.map((ledger) => ledger.id));
+ const lendEntries = ledgerIds.size
+ ? (await db.lendEntries.where('ledgerId').anyOf([...ledgerIds]).toArray()).filter(
+ (entry) => !entry.deletedAt,
+ )
+ : [];
+ for (const ledger of ledgers) {
+ const balance = sumLendEntries(
+ lendEntries.filter((entry) => entry.ledgerId === ledger.id),
+ );
contexts.push({
module: 'lend',
- contextId: l.id,
- contextName: l.label || 'Personal lending',
+ contextId: ledger.id,
+ contextName: ledger.label || 'Personal lending',
balanceMinor: balance,
- currency: l.currency as CurrencyCode,
+ currency: ledger.currency as CurrencyCode,
});
}
- const members = (await db.splitGroupMembers.toArray()).filter((m) => !m.deletedAt && m.personId === personId);
- const groups = (await db.splitGroups.toArray()).filter((g) => !g.deletedAt);
- const expenses = (await db.splitExpenses.toArray()).filter((e) => !e.deletedAt);
- const payers = (await db.splitPayers.toArray()).filter((p) => !p.deletedAt);
- const sShares = (await db.splitShares.toArray()).filter((s) => !s.deletedAt);
- const settlements = (await db.splitSettlements.toArray()).filter((s) => !s.deletedAt);
- for (const m of members) {
- const g = groups.find((x) => x.id === m.groupId);
- if (!g) continue;
- const gExp = expenses.filter((e) => e.groupId === g.id);
- const expenseIds = new Set(gExp.map((e) => e.id));
- const gPay = payers.filter((p) => expenseIds.has(p.expenseId));
- const gSha = sShares.filter((s) => expenseIds.has(s.expenseId));
- const gSet = settlements.filter((s) => s.groupId === g.id);
- const theirPaid = gPay.filter((p) => p.personId === personId).reduce((a, b) => a + b.amountMinor, 0);
- const theirShare = gSha.filter((s) => s.personId === personId).reduce((a, b) => a + b.amountMinor, 0);
- const theirSent = gSet.filter((s) => s.fromPersonId === personId).reduce((a, b) => a + b.amountMinor, 0);
- const theirReceived = gSet.filter((s) => s.toPersonId === personId).reduce((a, b) => a + b.amountMinor, 0);
- const theirBalance = theirPaid - theirShare + theirSent - theirReceived;
+ const allMembers = (await db.splitGroupMembers.toArray()).filter((member) => !member.deletedAt);
+ const memberships = allMembers.filter((member) => member.personId === personId);
+ const [groupsAll, expensesAll, payersAll, sharesAll, settlementsAll] = await Promise.all([
+ db.splitGroups.toArray(),
+ db.splitExpenses.toArray(),
+ db.splitPayers.toArray(),
+ db.splitShares.toArray(),
+ db.splitSettlements.toArray(),
+ ]);
+ const groups = groupsAll.filter((group) => !group.deletedAt);
+ const expenses = expensesAll.filter((expense) => !expense.deletedAt);
+ const payers = payersAll.filter((payer) => !payer.deletedAt);
+ const shares = sharesAll.filter((share) => !share.deletedAt);
+ const settlements = settlementsAll.filter((settlement) => !settlement.deletedAt);
+
+ for (const membership of memberships) {
+ const group = groups.find((candidate) => candidate.id === membership.groupId);
+ if (!group) continue;
+ const groupExpenses = expenses.filter((expense) => expense.groupId === group.id);
+ const expenseIds = new Set(groupExpenses.map((expense) => expense.id));
+ const balances = computeMemberBalances({
+ group,
+ members: allMembers.filter((member) => member.groupId === group.id),
+ expenses: groupExpenses,
+ payers: payers.filter((payer) => expenseIds.has(payer.expenseId)),
+ shares: shares.filter((share) => expenseIds.has(share.expenseId)),
+ settlements: settlements.filter((settlement) => settlement.groupId === group.id),
+ });
contexts.push({
module: 'split',
- contextId: g.id,
- contextName: g.name,
- balanceMinor: theirBalance,
- currency: g.currency as CurrencyCode,
+ contextId: group.id,
+ contextName: group.name,
+ balanceMinor: balances.get(personId) ?? 0,
+ currency: group.currency as CurrencyCode,
});
}
- const currencies = new Set(contexts.map((c) => c.currency));
- let informationalNetMinor: number | undefined;
- if (currencies.size === 1) {
- informationalNetMinor = contexts.reduce((a, c) => a + c.balanceMinor, 0);
- }
+ const currencies = new Set(contexts.map((context) => context.currency));
+ const informationalNetMinor =
+ currencies.size === 1
+ ? contexts.reduce((sum, context) => sum + context.balanceMinor, 0)
+ : undefined;
+
return {
personId,
personName: person.name,