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,