From 98bc0499e3d8fc0ce33604a8d791ecde01fa0e3a Mon Sep 17 00:00:00 2001 From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:17:07 +0530 Subject: [PATCH 01/28] Harden currency export formatting --- src/shared/money/convert.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/shared/money/convert.ts b/src/shared/money/convert.ts index 9c0f498..8c58357 100644 --- a/src/shared/money/convert.ts +++ b/src/shared/money/convert.ts @@ -42,3 +42,20 @@ export function minorToDecimal(amountMinor: number, currency: CurrencyCode): num const num = Number(`${intPart}.${fracPart}`); return negative ? -num : num; } + +/** + * Render minor units as a locale-neutral decimal string using the currency's + * exact fractional precision. This is intended for CSV/JSON-style exports, + * where values must not be forced to two decimals (for example JPY or KWD). + */ +export function minorToDecimalString(amountMinor: number, currency: CurrencyCode): string { + const decimals = currencyDecimals(currency); + if (decimals === 0) return String(amountMinor); + + const negative = amountMinor < 0; + const abs = Math.abs(amountMinor); + const raw = abs.toString().padStart(decimals + 1, '0'); + const integer = raw.slice(0, -decimals) || '0'; + const fraction = raw.slice(-decimals); + return `${negative ? '-' : ''}${integer}.${fraction}`; +} From 1b8ff5ccaa25f04a3f041adc63cfb2ff53f437c9 Mon Sep 17 00:00:00 2001 From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:17:32 +0530 Subject: [PATCH 02/28] Cover export currency precision --- src/shared/money/money.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/shared/money/money.test.ts b/src/shared/money/money.test.ts index f213e88..386f839 100644 --- a/src/shared/money/money.test.ts +++ b/src/shared/money/money.test.ts @@ -6,6 +6,7 @@ import { decimalToMinor, formatMoney, minorToDecimal, + minorToDecimalString, parseMoney, sumMinor, assertSumsTo, @@ -41,6 +42,15 @@ describe('decimalToMinor / minorToDecimal', () => { }); }); +describe('minorToDecimalString', () => { + it('uses the exact currency precision for export-safe values', () => { + expect(minorToDecimalString(125050, 'INR')).toBe('1250.50'); + expect(minorToDecimalString(123, 'JPY')).toBe('123'); + expect(minorToDecimalString(1234, 'KWD')).toBe('1.234'); + expect(minorToDecimalString(-5, 'KWD')).toBe('-0.005'); + }); +}); + describe('formatMoney', () => { it('formats INR with rupee symbol', () => { const formatted = formatMoney({ amountMinor: 125050, currency: 'INR' }); From ce739f97f4eff78a37d064d92ba9ae0a10ac1773 Mon Sep 17 00:00:00 2001 From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:18:13 +0530 Subject: [PATCH 03/28] Fix CSV currency precision --- src/export/csv/serializer.ts | 91 ++++++++++++++++++------------------ 1 file changed, 45 insertions(+), 46 deletions(-) diff --git a/src/export/csv/serializer.ts b/src/export/csv/serializer.ts index 307c7e7..b9232b6 100644 --- a/src/export/csv/serializer.ts +++ b/src/export/csv/serializer.ts @@ -29,7 +29,7 @@ import type { LendLedger, LendEntry, } from '@db/schema'; -import { minorToDecimal } from '@shared/money'; +import { minorToDecimalString } from '@shared/money'; const BOM = '\uFEFF'; @@ -42,15 +42,15 @@ function escapeField(v: string | number | undefined | null): string { return s; } -function row(values: Array): string { +export function csvRow(values: Array): string { return values.map(escapeField).join(','); } export function csvOfPeople(people: Person[]): string { const lines: string[] = []; - lines.push(row(['person_id', 'name', 'is_self', 'phone', 'email', 'note', 'created_at'])); + lines.push(csvRow(['person_id', 'name', 'is_self', 'phone', 'email', 'note', 'created_at'])); for (const p of people) { - lines.push(row([p.id, p.name, p.isSelf ? 'true' : 'false', p.phone, p.email, p.note, p.createdAt])); + lines.push(csvRow([p.id, p.name, p.isSelf ? 'true' : 'false', p.phone, p.email, p.note, p.createdAt])); } return BOM + lines.join('\r\n') + '\r\n'; } @@ -58,12 +58,11 @@ export function csvOfPeople(people: Person[]): string { export function csvOfTrackTransactions( txs: TrackTransaction[], categories: TrackCategory[], - currency: string, ): string { const catMap = new Map(categories.map((c) => [c.id, c.name])); const lines: string[] = []; lines.push( - row([ + csvRow([ 'transaction_id', 'date', 'type', @@ -79,12 +78,12 @@ export function csvOfTrackTransactions( ); for (const t of txs) { lines.push( - row([ + csvRow([ t.id, t.date, t.type, t.title, - minorToDecimal(t.amountMinor, currency).toFixed(2), + minorToDecimalString(t.amountMinor, t.currency), t.amountMinor, t.currency, t.categoryId, @@ -99,19 +98,19 @@ export function csvOfTrackTransactions( export function csvOfTrackCategories(cats: TrackCategory[]): string { const lines: string[] = []; - lines.push(row(['category_id', 'name', 'type', 'icon', 'archived'])); + lines.push(csvRow(['category_id', 'name', 'type', 'icon', 'archived'])); for (const c of cats) { - lines.push(row([c.id, c.name, c.type, c.icon, c.archived ? 'true' : 'false'])); + lines.push(csvRow([c.id, c.name, c.type, c.icon, c.archived ? 'true' : 'false'])); } return BOM + lines.join('\r\n') + '\r\n'; } export function csvOfTrackBudgets(b: TrackBudget[]): string { const lines: string[] = []; - lines.push(row(['month', 'amount', 'amount_minor', 'currency'])); + lines.push(csvRow(['month', 'amount', 'amount_minor', 'currency'])); for (const x of b) { lines.push( - row([x.month, minorToDecimal(x.amountMinor, x.currency).toFixed(2), x.amountMinor, x.currency]), + csvRow([x.month, minorToDecimalString(x.amountMinor, x.currency), x.amountMinor, x.currency]), ); } return BOM + lines.join('\r\n') + '\r\n'; @@ -119,14 +118,14 @@ export function csvOfTrackBudgets(b: TrackBudget[]): string { export function csvOfTrackRecurring(r: TrackRecurringRule[]): string { const lines: string[] = []; - lines.push(row(['rule_id', 'title', 'amount', 'amount_minor', 'currency', 'frequency', 'next_date', 'enabled'])); + lines.push(csvRow(['rule_id', 'title', 'amount', 'amount_minor', 'currency', 'frequency', 'next_date', 'enabled'])); for (const x of r) { const amountMinor = x.amountMinor ?? 0; lines.push( - row([ + csvRow([ x.id, x.title, - x.amountMinor !== undefined ? minorToDecimal(amountMinor, x.currency).toFixed(2) : '', + x.amountMinor !== undefined ? minorToDecimalString(amountMinor, x.currency) : '', x.amountMinor ?? '', x.currency, x.frequency, @@ -140,9 +139,9 @@ export function csvOfTrackRecurring(r: TrackRecurringRule[]): string { export function csvOfSplitGroups(g: SplitGroup[]): string { const lines: string[] = []; - lines.push(row(['group_id', 'name', 'description', 'currency', 'archived', 'created_at'])); + lines.push(csvRow(['group_id', 'name', 'description', 'currency', 'archived', 'created_at'])); for (const x of g) { - lines.push(row([x.id, x.name, x.description, x.currency, x.archived ? 'true' : 'false', x.createdAt])); + lines.push(csvRow([x.id, x.name, x.description, x.currency, x.archived ? 'true' : 'false', x.createdAt])); } return BOM + lines.join('\r\n') + '\r\n'; } @@ -151,10 +150,10 @@ export function csvOfSplitMembers(m: SplitGroupMember[], people: Person[]): stri const pMap = new Map(people.map((p) => [p.id, p.name])); const lines: string[] = []; lines.push( - row(['member_id', 'group_id', 'person_id', 'person_name', 'active', 'joined_at']), + csvRow(['member_id', 'group_id', 'person_id', 'person_name', 'active', 'joined_at']), ); for (const x of m) { - lines.push(row([x.id, x.groupId, x.personId, pMap.get(x.personId) ?? '', x.active ? 'true' : 'false', x.joinedAt])); + lines.push(csvRow([x.id, x.groupId, x.personId, pMap.get(x.personId) ?? '', x.active ? 'true' : 'false', x.joinedAt])); } return BOM + lines.join('\r\n') + '\r\n'; } @@ -166,7 +165,7 @@ export function csvOfSplitExpenses( const gMap = new Map(groups.map((g) => [g.id, g.name])); const lines: string[] = []; lines.push( - row([ + csvRow([ 'expense_id', 'date', 'group_id', @@ -182,13 +181,13 @@ export function csvOfSplitExpenses( ); for (const x of e) { lines.push( - row([ + csvRow([ x.id, x.date, x.groupId, gMap.get(x.groupId) ?? '', x.title, - minorToDecimal(x.amountMinor, x.currency).toFixed(2), + minorToDecimalString(x.amountMinor, x.currency), x.amountMinor, x.currency, x.category ?? '', @@ -201,11 +200,11 @@ export function csvOfSplitExpenses( } export function csvOfSplitPayers(p: SplitPayer[], expenses: SplitExpense[], people: Person[]): string { - const eMap = new Map(expenses.map((e) => [e.id, e.title])); - const pMap = new Map(people.map((p) => [p.id, p.name])); + const eMap = new Map(expenses.map((e) => [e.id, e])); + const pMap = new Map(people.map((person) => [person.id, person.name])); const lines: string[] = []; lines.push( - row([ + csvRow([ 'expense_id', 'expense_title', 'person_id', @@ -216,16 +215,16 @@ export function csvOfSplitPayers(p: SplitPayer[], expenses: SplitExpense[], peop ]), ); for (const x of p) { - const ex = expenses.find((e) => e.id === x.expenseId); + const expense = eMap.get(x.expenseId); lines.push( - row([ + csvRow([ x.expenseId, - eMap.get(x.expenseId) ?? '', + expense?.title ?? '', x.personId, pMap.get(x.personId) ?? '', - ex ? minorToDecimal(x.amountMinor, ex.currency).toFixed(2) : '', + expense ? minorToDecimalString(x.amountMinor, expense.currency) : '', x.amountMinor, - ex?.currency ?? '', + expense?.currency ?? '', ]), ); } @@ -233,11 +232,11 @@ export function csvOfSplitPayers(p: SplitPayer[], expenses: SplitExpense[], peop } export function csvOfSplitShares(s: SplitShare[], expenses: SplitExpense[], people: Person[]): string { - const eMap = new Map(expenses.map((e) => [e.id, e.title])); - const pMap = new Map(people.map((p) => [p.id, p.name])); + const eMap = new Map(expenses.map((e) => [e.id, e])); + const pMap = new Map(people.map((person) => [person.id, person.name])); const lines: string[] = []; lines.push( - row([ + csvRow([ 'expense_id', 'expense_title', 'person_id', @@ -248,16 +247,16 @@ export function csvOfSplitShares(s: SplitShare[], expenses: SplitExpense[], peop ]), ); for (const x of s) { - const ex = expenses.find((e) => e.id === x.expenseId); + const expense = eMap.get(x.expenseId); lines.push( - row([ + csvRow([ x.expenseId, - eMap.get(x.expenseId) ?? '', + expense?.title ?? '', x.personId, pMap.get(x.personId) ?? '', - ex ? minorToDecimal(x.amountMinor, ex.currency).toFixed(2) : '', + expense ? minorToDecimalString(x.amountMinor, expense.currency) : '', x.amountMinor, - ex?.currency ?? '', + expense?.currency ?? '', ]), ); } @@ -269,7 +268,7 @@ export function csvOfSplitSettlements(s: SplitSettlement[], groups: SplitGroup[] const pMap = new Map(people.map((p) => [p.id, p.name])); const lines: string[] = []; lines.push( - row([ + csvRow([ 'settlement_id', 'date', 'group_id', @@ -286,7 +285,7 @@ export function csvOfSplitSettlements(s: SplitSettlement[], groups: SplitGroup[] ); for (const x of s) { lines.push( - row([ + csvRow([ x.id, x.date, x.groupId, @@ -295,7 +294,7 @@ export function csvOfSplitSettlements(s: SplitSettlement[], groups: SplitGroup[] pMap.get(x.fromPersonId) ?? '', x.toPersonId, pMap.get(x.toPersonId) ?? '', - minorToDecimal(x.amountMinor, x.currency).toFixed(2), + minorToDecimalString(x.amountMinor, x.currency), x.amountMinor, x.currency, x.note, @@ -309,10 +308,10 @@ export function csvOfLendLedgers(l: LendLedger[], people: Person[]): string { const pMap = new Map(people.map((p) => [p.id, p.name])); const lines: string[] = []; lines.push( - row(['ledger_id', 'person_id', 'person_name', 'currency', 'label', 'archived']), + csvRow(['ledger_id', 'person_id', 'person_name', 'currency', 'label', 'archived']), ); for (const x of l) { - lines.push(row([x.id, x.personId, pMap.get(x.personId) ?? '', x.currency, x.label, x.archived ? 'true' : 'false'])); + lines.push(csvRow([x.id, x.personId, pMap.get(x.personId) ?? '', x.currency, x.label, x.archived ? 'true' : 'false'])); } return BOM + lines.join('\r\n') + '\r\n'; } @@ -322,7 +321,7 @@ export function csvOfLendEntries(e: LendEntry[], ledgers: LendLedger[], people: const pMap = new Map(people.map((p) => [p.id, p.name])); const lines: string[] = []; lines.push( - row([ + csvRow([ 'entry_id', 'date', 'ledger_id', @@ -341,14 +340,14 @@ export function csvOfLendEntries(e: LendEntry[], ledgers: LendLedger[], people: const currency = ledger?.currency ?? ''; const personId = ledger?.personId ?? ''; lines.push( - row([ + csvRow([ x.id, x.date, x.ledgerId, personId, pMap.get(personId) ?? '', x.type, - minorToDecimal(x.amountMinor, currency).toFixed(2), + ledger ? minorToDecimalString(x.amountMinor, currency) : '', x.amountMinor, currency, x.dueDate, From 2b9cce143d6be5fa764ee89818ee77fb6ef8651a Mon Sep 17 00:00:00 2001 From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:18:35 +0530 Subject: [PATCH 04/28] Test multi-precision CSV exports --- src/export/csv/csv.test.ts | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/export/csv/csv.test.ts b/src/export/csv/csv.test.ts index d1c2fb4..4783403 100644 --- a/src/export/csv/csv.test.ts +++ b/src/export/csv/csv.test.ts @@ -27,7 +27,7 @@ describe('CSV serializer', () => { expect(csv).toMatch(/"line\r?\nbreak, comma"/); }); - it('uses decimal + minor + currency in track transactions', () => { + it('uses each Track transaction currency and its exact decimal precision', () => { const csv = csvOfTrackTransactions( [ { @@ -42,12 +42,32 @@ describe('CSV serializer', () => { updatedAt: '2026-08-13T00:00:00.000Z', revision: 1, }, + { + id: 't2', + type: 'expense', + title: 'Train', + amountMinor: 1500, + currency: 'JPY', + date: '2026-08-14', + createdAt: '2026-08-14T00:00:00.000Z', + updatedAt: '2026-08-14T00:00:00.000Z', + revision: 1, + }, + { + id: 't3', + type: 'expense', + title: 'Coffee', + amountMinor: 1234, + currency: 'KWD', + date: '2026-08-15', + createdAt: '2026-08-15T00:00:00.000Z', + updatedAt: '2026-08-15T00:00:00.000Z', + revision: 1, + }, ], [{ id: 'c1', name: 'Food', type: 'expense', archived: false, createdAt: '', updatedAt: '', revision: 1 }], - 'INR', ); const lines = csv.split(/\r?\n/); - // header + 1 row + trailing empty expect(lines[0]).toContain('amount'); expect(lines[0]).toContain('amount_minor'); expect(lines[0]).toContain('currency'); @@ -55,6 +75,8 @@ describe('CSV serializer', () => { expect(lines[1]).toContain('125050'); expect(lines[1]).toContain('INR'); expect(lines[1]).toContain('Food'); + expect(lines[2]).toContain('1500,1500,JPY'); + expect(lines[3]).toContain('1.234,1234,KWD'); }); it('uses ISO date and decimal amount for split expenses', () => { From 4c2f658eeb0d8b611c8bd60fc57b2648214ca9a2 Mon Sep 17 00:00:00 2001 From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:20:06 +0530 Subject: [PATCH 05/28] Fix derived ZIP summaries --- src/export/zip/builder.ts | 328 ++++++++++++++++++++++++++------------ 1 file changed, 230 insertions(+), 98 deletions(-) diff --git a/src/export/zip/builder.ts b/src/export/zip/builder.ts index 028a577..cd8b670 100644 --- a/src/export/zip/builder.ts +++ b/src/export/zip/builder.ts @@ -15,10 +15,23 @@ import { zip, strToU8 } from 'fflate'; import { APP_VERSION, SCHEMA_VERSION } from '@app/constants'; -import { toMonthKey } from '@shared/dates'; import { getDB } from '@db/database'; -import { nowISO } from '@shared/dates'; -import { minorToDecimal } from '@shared/money'; +import type { + LendEntry, + LendLedger, + Person, + SplitExpense, + SplitGroup, + SplitGroupMember, + SplitPayer, + SplitSettlement, + SplitShare, + TrackTransaction, +} from '@db/schema'; +import { entryToSignedAmount } from '@modules/lend/domain/signs'; +import { computeMemberBalances } from '@modules/split/domain/balances'; +import { nowISO, toMonthKey } from '@shared/dates'; +import { minorToDecimalString } from '@shared/money'; import { csvOfPeople, csvOfTrackTransactions, @@ -33,14 +46,14 @@ import { csvOfSplitSettlements, csvOfLendLedgers, csvOfLendEntries, + csvRow, } from '../csv/serializer'; -export const README_TEXT = `Finance Utility — Data Export -================================ +export const README_TEXT = `AfterSum — Data Export +====================== -This archive contains the full local database produced by -the Finance Utility app. The format is human-readable CSV -plus a manifest. +This archive contains a snapshot of the local AfterSum database. +The format is human-readable CSV plus a manifest. Module independence ------------------- @@ -53,9 +66,9 @@ transactions. They are informational only. Restoring --------- -This CSV package is a snapshot for inspection and audit. -For a full restore of the local database, use the JSON -backup file from Settings → Data & Backup → Export JSON. +This CSV package is for inspection and audit, not restore. +For a full restore, use the portable AfterSum backup from +Settings → Data & Storage → Portable backup. Schema ------ @@ -93,6 +106,7 @@ export interface FullZipOptions { export async function buildFullZip(opts: FullZipOptions = {}): Promise { const db = getDB(); const [ + settings, people, trackTx, trackCats, @@ -106,22 +120,44 @@ export async function buildFullZip(opts: FullZipOptions = {}): Promise { splitSettlements, lendLedgers, lendEntries, - ] = await Promise.all([ - db.people.toArray(), - db.trackTransactions.toArray(), - db.trackCategories.toArray(), - db.trackBudgets.toArray(), - db.trackRecurringRules.toArray(), - db.splitGroups.toArray(), - db.splitGroupMembers.toArray(), - db.splitExpenses.toArray(), - db.splitPayers.toArray(), - db.splitShares.toArray(), - db.splitSettlements.toArray(), - db.lendLedgers.toArray(), - db.lendEntries.toArray(), - ]); + ] = await db.transaction( + 'r', + [ + db.settings, + db.people, + db.trackTransactions, + db.trackCategories, + db.trackBudgets, + db.trackRecurringRules, + db.splitGroups, + db.splitGroupMembers, + db.splitExpenses, + db.splitPayers, + db.splitShares, + db.splitSettlements, + db.lendLedgers, + db.lendEntries, + ], + async () => + Promise.all([ + db.settings.get('app'), + db.people.toArray(), + db.trackTransactions.toArray(), + db.trackCategories.toArray(), + db.trackBudgets.toArray(), + db.trackRecurringRules.toArray(), + db.splitGroups.toArray(), + db.splitGroupMembers.toArray(), + db.splitExpenses.toArray(), + db.splitPayers.toArray(), + db.splitShares.toArray(), + db.splitSettlements.toArray(), + db.lendLedgers.toArray(), + db.lendEntries.toArray(), + ]), + ); + const defaultCurrency = settings?.defaultCurrency ?? 'INR'; const inputs: Record = { 'README.txt': strToU8(README_TEXT), 'manifest.json': strToU8( @@ -152,7 +188,7 @@ export async function buildFullZip(opts: FullZipOptions = {}): Promise { ), ), 'shared/people.csv': strToU8(csvOfPeople(people)), - 'track/transactions.csv': strToU8(csvOfTrackTransactions(trackTx, trackCats, 'INR')), + 'track/transactions.csv': strToU8(csvOfTrackTransactions(trackTx, trackCats)), 'track/categories.csv': strToU8(csvOfTrackCategories(trackCats)), 'track/budgets.csv': strToU8(csvOfTrackBudgets(trackBudgets)), 'track/recurring.csv': strToU8(csvOfTrackRecurring(trackRecurring)), @@ -167,12 +203,22 @@ export async function buildFullZip(opts: FullZipOptions = {}): Promise { }; if (opts.includeOverview !== false) { - // people-summary: per-person breakdown across lend + split - const peopleSummary = buildPeopleSummary(people, lendLedgers, lendEntries, splitGroups, splitMembers, splitPayers, splitShares, splitSettlements); - // monthly-summary: per-month spent - const monthly = buildMonthlySummary(trackTx); - inputs['overview/people-summary.csv'] = strToU8(peopleSummary); - inputs['overview/monthly-summary.csv'] = strToU8(monthly); + inputs['overview/people-summary.csv'] = strToU8( + buildPeopleSummary({ + people, + ledgers: lendLedgers, + lendEntries, + groups: splitGroups, + members: splitMembers, + expenses: splitExpenses, + payers: splitPayers, + shares: splitShares, + settlements: splitSettlements, + }), + ); + inputs['overview/monthly-summary.csv'] = strToU8( + buildMonthlySummary(trackTx, defaultCurrency), + ); } const out = await new Promise((resolve, reject) => { @@ -184,80 +230,166 @@ export async function buildFullZip(opts: FullZipOptions = {}): Promise { return new Blob([out], { type: 'application/zip' }); } -function buildPeopleSummary( - people: Array<{ id: string; name: string }>, - ledgers: Array<{ id: string; personId: string; currency: string }>, - lendEntries: Array<{ ledgerId: string; type: string; amountMinor: number }>, - groups: Array<{ id: string; name: string; currency: string }>, - members: Array<{ groupId: string; personId: string }>, - payers: Array<{ expenseId: string; personId: string; amountMinor: number }>, - shares: Array<{ expenseId: string; personId: string; amountMinor: number }>, - settlements: Array<{ groupId: string; fromPersonId: string; toPersonId: string; amountMinor: number }>, -): string { - const lines: string[] = []; - lines.push(['person_id', 'person_name', 'lend_balance', 'currency_lend', 'split_balance', 'currency_split'].join(',')); - for (const p of people) { - const personLedgers = ledgers.filter((l) => l.personId === p.id); - const lendByCurrency: Record = {}; - for (const l of personLedgers) { - const e = lendEntries.filter((x) => x.ledgerId === l.id); - let s = 0; - for (const x of e) { - if (x.type === 'lent' || x.type === 'repayment_given' || x.type === 'adjustment') s += x.amountMinor; - else s -= x.amountMinor; - } - lendByCurrency[l.currency] = (lendByCurrency[l.currency] ?? 0) + s; - } - const lendStr = Object.entries(lendByCurrency) - .map(([c, v]) => `${c} ${(v / 100).toFixed(2)}`) - .join(' | '); +interface PeopleSummaryInputs { + people: Person[]; + ledgers: LendLedger[]; + lendEntries: LendEntry[]; + groups: SplitGroup[]; + members: SplitGroupMember[]; + expenses: SplitExpense[]; + payers: SplitPayer[]; + shares: SplitShare[]; + settlements: SplitSettlement[]; +} + +function buildPeopleSummary(inputs: PeopleSummaryInputs): string { + const lendTotals = new Map>(); + const splitTotals = new Map>(); + + const entriesByLedger = new Map(); + for (const entry of inputs.lendEntries) { + if (entry.deletedAt) continue; + pushGrouped(entriesByLedger, entry.ledgerId, entry); + } + + for (const ledger of inputs.ledgers) { + if (ledger.deletedAt || ledger.archived) continue; + const balance = (entriesByLedger.get(ledger.id) ?? []).reduce( + (sum, entry) => sum + entryToSignedAmount(entry), + 0, + ); + addCurrencyTotal(lendTotals, ledger.personId, ledger.currency, balance); + } + + const membersByGroup = new Map(); + const expensesByGroup = new Map(); + const payersByGroup = new Map(); + const sharesByGroup = new Map(); + const settlementsByGroup = new Map(); + const groupIdByExpense = new Map(); + + for (const member of inputs.members) { + if (!member.deletedAt) pushGrouped(membersByGroup, member.groupId, member); + } + for (const expense of inputs.expenses) { + if (expense.deletedAt) continue; + pushGrouped(expensesByGroup, expense.groupId, expense); + groupIdByExpense.set(expense.id, expense.groupId); + } + for (const payer of inputs.payers) { + if (payer.deletedAt) continue; + const groupId = groupIdByExpense.get(payer.expenseId); + if (groupId) pushGrouped(payersByGroup, groupId, payer); + } + for (const share of inputs.shares) { + if (share.deletedAt) continue; + const groupId = groupIdByExpense.get(share.expenseId); + if (groupId) pushGrouped(sharesByGroup, groupId, share); + } + for (const settlement of inputs.settlements) { + if (!settlement.deletedAt) pushGrouped(settlementsByGroup, settlement.groupId, settlement); + } - const personGroups = members.filter((m) => m.personId === p.id); - const splitByCurrency: Record = {}; - for (const m of personGroups) { - const g = groups.find((x) => x.id === m.groupId); - if (!g) continue; - const gExpIds = new Set([]); - // Find expenses in this group via payers or shares (in a real impl we'd query splitExpenses too) - const personPayers = payers.filter((x) => x.personId === p.id); - const personShares = shares.filter((x) => x.personId === p.id); - const personSets = settlements.filter((x) => x.fromPersonId === p.id || x.toPersonId === p.id); - const paid = personPayers.reduce((a, b) => a + b.amountMinor, 0); - const share = personShares.reduce((a, b) => a + b.amountMinor, 0); - const sent = personSets.filter((x) => x.fromPersonId === p.id).reduce((a, b) => a + b.amountMinor, 0); - const received = personSets.filter((x) => x.toPersonId === p.id).reduce((a, b) => a + b.amountMinor, 0); - const bal = paid - share + sent - received; - splitByCurrency[g.currency] = (splitByCurrency[g.currency] ?? 0) + bal; - // mark expenseIds so eslint doesn't flag unused - gExpIds.add(''); + for (const group of inputs.groups) { + if (group.deletedAt) continue; + const balances = computeMemberBalances({ + group, + members: membersByGroup.get(group.id) ?? [], + expenses: expensesByGroup.get(group.id) ?? [], + payers: payersByGroup.get(group.id) ?? [], + shares: sharesByGroup.get(group.id) ?? [], + settlements: settlementsByGroup.get(group.id) ?? [], + }); + for (const [personId, balance] of balances) { + addCurrencyTotal(splitTotals, personId, group.currency, balance); } - const splitStr = Object.entries(splitByCurrency) - .map(([c, v]) => `${c} ${(v / 100).toFixed(2)}`) - .join(' | '); + } - lines.push([p.id, p.name, lendStr, personLedgers[0]?.currency ?? '', splitStr, ''].join(',')); + const lines = [csvRow(['person_id', 'person_name', 'lend_balances', 'split_balances'])]; + for (const person of inputs.people) { + if (person.deletedAt) continue; + lines.push( + csvRow([ + person.id, + person.name, + formatCurrencyTotals(lendTotals.get(person.id)), + formatCurrencyTotals(splitTotals.get(person.id)), + ]), + ); } return '\uFEFF' + lines.join('\r\n') + '\r\n'; } -function buildMonthlySummary(trackTx: Array<{ deletedAt?: string; date: string; type: string; amountMinor: number; currency: string }>): string { - const byMonth: Record = {}; - for (const t of trackTx) { - if (t.deletedAt) continue; - const month = t.date.slice(0, 7); - const cur = (byMonth[month] ??= { spent: 0, income: 0, currency: t.currency }); - if (t.type === 'expense') cur.spent += t.amountMinor; - else cur.income += t.amountMinor; +function buildMonthlySummary(trackTx: TrackTransaction[], defaultCurrency: string): string { + const byMonthCurrency = new Map< + string, + { month: string; spent: number; income: number; currency: string } + >(); + + for (const transaction of trackTx) { + if (transaction.deletedAt) continue; + const month = transaction.date.slice(0, 7); + const key = `${month}\u0000${transaction.currency}`; + const current = byMonthCurrency.get(key) ?? { + month, + spent: 0, + income: 0, + currency: transaction.currency, + }; + if (transaction.type === 'expense') current.spent += transaction.amountMinor; + else current.income += transaction.amountMinor; + byMonthCurrency.set(key, current); } - const lines: string[] = []; - lines.push(['month', 'spent', 'income', 'currency'].join(',')); - for (const [month, v] of Object.entries(byMonth).sort()) { - lines.push([month, minorToDecimal(v.spent, v.currency).toFixed(2), minorToDecimal(v.income, v.currency).toFixed(2), v.currency].join(',')); + + const currentMonth = toMonthKey(); + const currentKey = `${currentMonth}\u0000${defaultCurrency}`; + if (!byMonthCurrency.has(currentKey)) { + byMonthCurrency.set(currentKey, { + month: currentMonth, + spent: 0, + income: 0, + currency: defaultCurrency, + }); } - // Always include the current month row even if zero. - const cur = toMonthKey(); - if (!byMonth[cur]) { - lines.push([cur, '0.00', '0.00', 'INR'].join(',')); + + const rows = [...byMonthCurrency.values()].sort( + (a, b) => a.month.localeCompare(b.month) || a.currency.localeCompare(b.currency), + ); + const lines = [csvRow(['month', 'spent', 'income', 'currency'])]; + for (const row of rows) { + lines.push( + csvRow([ + row.month, + minorToDecimalString(row.spent, row.currency), + minorToDecimalString(row.income, row.currency), + row.currency, + ]), + ); } return '\uFEFF' + lines.join('\r\n') + '\r\n'; } + +function pushGrouped(map: Map, key: K, value: V): void { + const rows = map.get(key) ?? []; + rows.push(value); + map.set(key, rows); +} + +function addCurrencyTotal( + totals: Map>, + personId: string, + currency: string, + amountMinor: number, +): void { + const byCurrency = totals.get(personId) ?? new Map(); + byCurrency.set(currency, (byCurrency.get(currency) ?? 0) + amountMinor); + totals.set(personId, byCurrency); +} + +function formatCurrencyTotals(totals: Map | undefined): string { + if (!totals) return ''; + return [...totals.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([currency, amountMinor]) => `${currency} ${minorToDecimalString(amountMinor, currency)}`) + .join(' | '); +} From 2dd32fe2dcd1ffc3af28204a69bc54db74fdb0fb Mon Sep 17 00:00:00 2001 From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:20:40 +0530 Subject: [PATCH 06/28] Cover ZIP summary correctness --- src/export/zip/builder.test.ts | 135 +++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 src/export/zip/builder.test.ts diff --git a/src/export/zip/builder.test.ts b/src/export/zip/builder.test.ts new file mode 100644 index 0000000..e10a63a --- /dev/null +++ b/src/export/zip/builder.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { strFromU8, unzipSync } from 'fflate'; +import { getDB } from '@db/database'; +import { freshDB, wipeDB } from '@tests/db-test-utils'; +import { buildFullZip, README_TEXT } from './builder'; + +const createdAt = '2026-08-18T00:00:00.000Z'; +const base = { createdAt, updatedAt: createdAt, revision: 1 } as const; + +async function unzipText(blob: Blob, path: string): Promise { + const files = unzipSync(new Uint8Array(await blob.arrayBuffer())); + const file = files[path]; + if (!file) throw new Error(`Missing ZIP entry: ${path}`); + return strFromU8(file); +} + +describe('full ZIP export', () => { + beforeEach(async () => { + await wipeDB(); + freshDB(); + }); + + it('keeps Split group balances scoped and preserves currency precision', async () => { + const db = getDB(); + await db.settings.put({ + id: 'app', + defaultCurrency: 'USD', + theme: 'system', + hideAmounts: false, + onboardingComplete: true, + ...base, + }); + await db.people.bulkPut([ + { id: 'p1', name: 'Rahul, Sr.', ...base }, + { id: 'p2', name: 'Aman', ...base }, + ]); + await db.splitGroups.bulkPut([ + { id: 'g1', name: 'Goa', currency: 'INR', archived: false, ...base }, + { id: 'g2', name: 'Tokyo', currency: 'JPY', archived: false, ...base }, + ]); + await db.splitGroupMembers.bulkPut([ + { id: 'm1', groupId: 'g1', personId: 'p1', active: true, joinedAt: createdAt, ...base }, + { id: 'm2', groupId: 'g1', personId: 'p2', active: true, joinedAt: createdAt, ...base }, + { id: 'm3', groupId: 'g2', personId: 'p1', active: true, joinedAt: createdAt, ...base }, + { id: 'm4', groupId: 'g2', personId: 'p2', active: true, joinedAt: createdAt, ...base }, + ]); + await db.splitExpenses.bulkPut([ + { + id: 'e1', + groupId: 'g1', + title: 'Hotel', + amountMinor: 10000, + currency: 'INR', + date: '2026-08-10', + splitMethod: 'equal', + ...base, + }, + { + id: 'e2', + groupId: 'g2', + title: 'Train', + amountMinor: 1000, + currency: 'JPY', + date: '2026-08-11', + splitMethod: 'equal', + ...base, + }, + ]); + await db.splitPayers.bulkPut([ + { id: 'pay1', expenseId: 'e1', personId: 'p1', amountMinor: 10000, ...base }, + { id: 'pay2', expenseId: 'e2', personId: 'p2', amountMinor: 1000, ...base }, + ]); + await db.splitShares.bulkPut([ + { id: 'share1', expenseId: 'e1', personId: 'p1', amountMinor: 5000, ...base }, + { id: 'share2', expenseId: 'e1', personId: 'p2', amountMinor: 5000, ...base }, + { id: 'share3', expenseId: 'e2', personId: 'p1', amountMinor: 500, ...base }, + { id: 'share4', expenseId: 'e2', personId: 'p2', amountMinor: 500, ...base }, + ]); + await db.lendLedgers.put({ + id: 'l1', + personId: 'p1', + currency: 'KWD', + archived: false, + ...base, + }); + await db.lendEntries.put({ + id: 'le1', + ledgerId: 'l1', + type: 'lent', + amountMinor: 1234, + date: '2026-08-12', + ...base, + }); + await db.trackTransactions.bulkPut([ + { + id: 't1', + type: 'expense', + title: 'Tokyo metro', + amountMinor: 1500, + currency: 'JPY', + date: '2026-08-12', + ...base, + }, + { + id: 't2', + type: 'income', + title: 'Refund', + amountMinor: 1234, + currency: 'KWD', + date: '2026-08-12', + ...base, + }, + ]); + + const zip = await buildFullZip(); + const peopleSummary = await unzipText(zip, 'overview/people-summary.csv'); + const track = await unzipText(zip, 'track/transactions.csv'); + const monthly = await unzipText(zip, 'overview/monthly-summary.csv'); + + expect(peopleSummary).toContain('"Rahul, Sr."'); + expect(peopleSummary).toContain('KWD 1.234'); + expect(peopleSummary).toContain('INR 50.00 | JPY -500'); + expect(peopleSummary).not.toContain('INR -5.00'); + expect(track).toContain('1500,1500,JPY'); + expect(track).toContain('1.234,1234,KWD'); + expect(monthly).toContain('2026-08,1500,0,JPY'); + expect(monthly).toContain('2026-08,0.000,1.234,KWD'); + }); + + it('uses AfterSum terminology in the bundled readme', () => { + expect(README_TEXT).toContain('AfterSum — Data Export'); + expect(README_TEXT).toContain('Settings → Data & Storage → Portable backup'); + expect(README_TEXT).not.toContain('Finance Utility — Data Export'); + }); +}); From e365553374f695decc223622ec1b1a9a0b14f00b Mon Sep 17 00:00:00 2001 From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:21:26 +0530 Subject: [PATCH 07/28] Make backup reads snapshot-consistent --- src/export/json/backup.ts | 89 +++++++++++++++++++++++++++------------ 1 file changed, 63 insertions(+), 26 deletions(-) diff --git a/src/export/json/backup.ts b/src/export/json/backup.ts index 0c37470..bf4fe3c 100644 --- a/src/export/json/backup.ts +++ b/src/export/json/backup.ts @@ -195,31 +195,68 @@ export interface Backup { }; } -/** Build a deep snapshot of the local financial database. */ +/** Build one transactionally-consistent snapshot of the local financial database. */ export async function exportBackup(): Promise { const db = getDB(); - const [settings, people, track, split, lend] = await Promise.all([ - settingsRepository.get(), - db.people.toArray(), - db.trackTransactions.toArray().then(async (transactions) => ({ - transactions, - categories: await db.trackCategories.toArray(), - budgets: await db.trackBudgets.toArray(), - recurringRules: await db.trackRecurringRules.toArray(), - })), - db.splitGroups.toArray().then(async (groups) => ({ - groups, - members: await db.splitGroupMembers.toArray(), - expenses: await db.splitExpenses.toArray(), - payers: await db.splitPayers.toArray(), - shares: await db.splitShares.toArray(), - settlements: await db.splitSettlements.toArray(), - })), - db.lendLedgers.toArray().then(async (ledgers) => ({ - ledgers, - entries: await db.lendEntries.toArray(), - })), - ]); + + // Ensure first-run defaults exist before opening the read-only snapshot. + await settingsRepository.get(); + + const [ + settings, + people, + transactions, + categories, + budgets, + recurringRules, + groups, + members, + expenses, + payers, + shares, + settlements, + ledgers, + entries, + ] = await db.transaction( + 'r', + [ + db.settings, + db.people, + db.trackTransactions, + db.trackCategories, + db.trackBudgets, + db.trackRecurringRules, + db.splitGroups, + db.splitGroupMembers, + db.splitExpenses, + db.splitPayers, + db.splitShares, + db.splitSettlements, + db.lendLedgers, + db.lendEntries, + ], + async () => + Promise.all([ + db.settings.get('app'), + db.people.toArray(), + db.trackTransactions.toArray(), + db.trackCategories.toArray(), + db.trackBudgets.toArray(), + db.trackRecurringRules.toArray(), + db.splitGroups.toArray(), + db.splitGroupMembers.toArray(), + db.splitExpenses.toArray(), + db.splitPayers.toArray(), + db.splitShares.toArray(), + db.splitSettlements.toArray(), + db.lendLedgers.toArray(), + db.lendEntries.toArray(), + ]), + ); + + if (!settings) { + throw new Error('App settings are unavailable, so a complete backup cannot be created.'); + } return { format: BACKUP_FORMAT, @@ -230,9 +267,9 @@ export async function exportBackup(): Promise { people, settings: { defaultCurrency: settings.defaultCurrency }, }, - track, - split, - lend, + track: { transactions, categories, budgets, recurringRules }, + split: { groups, members, expenses, payers, shares, settlements }, + lend: { ledgers, entries }, }; } From f14e43cd0ae602062928ae4cbc064c9971312148 Mon Sep 17 00:00:00 2001 From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:21:42 +0530 Subject: [PATCH 08/28] Make startup failure recovery safer --- src/app/providers.tsx | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 4ec9bc0..bbeb587 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -7,7 +7,7 @@ import { type ReactNode, useEffect, useState } from 'react'; import { ensureFirstLaunch } from '@db/seed'; -import { CelebrationProvider, ToastProvider } from '@components/ui'; +import { Button, CelebrationProvider, ToastProvider } from '@components/ui'; import { ensureDailyRecoverySnapshot } from '@/backup/recovery'; import { ThemeSync } from '@shared/settings/ThemeSync'; import { PwaUpdatePrompt } from './pwa/PwaUpdatePrompt'; @@ -50,13 +50,20 @@ export function AppProviders({ children }: AppProvidersProps) { if (error) { return ( -
-
-

Database error

-

{error}

-

- Try clearing site data in your browser settings. +

+
+

AfterSum could not open local data

+

+ Reload the app first. If the problem continues, do not clear site data unless you already + have a portable backup—clearing site data permanently removes local AfterSum records.

+ +
+ Technical details +

{error}

+
); From eb7a5e752016b593292c133a38e6cb7e526a53d0 Mon Sep 17 00:00:00 2001 From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:22:01 +0530 Subject: [PATCH 09/28] Handle onboarding failures clearly --- src/routes/OnboardingPage.tsx | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/routes/OnboardingPage.tsx b/src/routes/OnboardingPage.tsx index 6b63858..534aeb7 100644 --- a/src/routes/OnboardingPage.tsx +++ b/src/routes/OnboardingPage.tsx @@ -1,6 +1,4 @@ -/** - * Short first-run onboarding. No account or bank connection is required. - */ +/** Short first-run onboarding. No account or bank connection is required. */ import { useRef, useState } from 'react'; import { useNavigate } from '@tanstack/react-router'; @@ -18,25 +16,37 @@ export function OnboardingPage() { const [name, setName] = useState('Me'); const [persist, setPersist] = useState(false); const [busy, setBusy] = useState(false); + const [error, setError] = useState(); const finishStarted = useRef(false); - const next = () => setStep((s) => (s + 1) as Step); - const back = () => setStep((s) => (s - 1) as Step); + const next = () => { + setError(undefined); + setStep((s) => (s + 1) as Step); + }; + const back = () => { + setError(undefined); + setStep((s) => (s - 1) as Step); + }; const finish = async () => { if (finishStarted.current) return; finishStarted.current = true; setBusy(true); + setError(undefined); try { await settingsRepository.update({ defaultCurrency: currency }); await personRepository.update('self', { name: name.trim() }); if (persist) await persistBrowserStorage(); await settingsRepository.setOnboardingComplete(true); await navigate({ to: '/overview', replace: true }); - } catch (error) { + } catch (finishError) { finishStarted.current = false; - throw error; + setError( + finishError instanceof Error + ? finishError.message + : 'Could not finish setup. Your choices are still here; try again.', + ); } finally { setBusy(false); } @@ -106,9 +116,14 @@ export function OnboardingPage() {
+ {error && ( +

+ {error} +

+ )}
- - + +
)} From 164950322d8bcbf7940021b3c8ef04c79ebc3e40 Mon Sep 17 00:00:00 2001 From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:22:14 +0530 Subject: [PATCH 10/28] Keep settings DB reads out of routes --- src/shared/settings/useSettings.ts | 34 +++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/shared/settings/useSettings.ts b/src/shared/settings/useSettings.ts index 47d6728..7c4dd52 100644 --- a/src/shared/settings/useSettings.ts +++ b/src/shared/settings/useSettings.ts @@ -1,13 +1,37 @@ -/** - * Settings queries (live). - */ +/** Settings live queries. */ import { useLiveQuery } from 'dexie-react-hooks'; import { getDB } from '@db/database'; export function useAppSettings() { + return useLiveQuery(async () => getDB().settings.get('app'), []); +} + +export interface SettingsStats { + people: number; + track: number; + groups: number; + lendLedgers: number; + budgets: number; +} + +/** Small settings-screen summary; financial counts include soft-deleted history. */ +export function useSettingsStats(): SettingsStats | undefined { return useLiveQuery(async () => { - const row = await getDB().settings.get('app'); - return row; + const db = getDB(); + const [peopleRows, track, groups, lendLedgers, budgets] = await Promise.all([ + db.people.toArray(), + db.trackTransactions.count(), + db.splitGroups.count(), + db.lendLedgers.count(), + db.trackBudgets.count(), + ]); + return { + people: peopleRows.filter((person) => !person.deletedAt).length, + track, + groups, + lendLedgers, + budgets, + }; }, []); } From e150fdbd87cad4bcdbaf0eeadbc6c04c2bc1c6c9 Mon Sep 17 00:00:00 2001 From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:22:36 +0530 Subject: [PATCH 11/28] Handle settings writes reliably --- src/routes/settings/SettingsPage.tsx | 39 ++++++++++------------------ 1 file changed, 14 insertions(+), 25 deletions(-) diff --git a/src/routes/settings/SettingsPage.tsx b/src/routes/settings/SettingsPage.tsx index 76df60d..31ba954 100644 --- a/src/routes/settings/SettingsPage.tsx +++ b/src/routes/settings/SettingsPage.tsx @@ -2,35 +2,16 @@ import { Link } from '@tanstack/react-router'; import { Card, Toggle, Spinner, CurrencyPicker, useToast } from '@components/ui'; -import { useAppSettings } from '@shared/settings/useSettings'; +import { useAppSettings, useSettingsStats } from '@shared/settings/useSettings'; import { settingsRepository } from '@shared/settings/repository'; import { Users, Database, ChevronRight, Eye, EyeOff, Sun, Moon, Monitor } from 'lucide-react'; import clsx from 'clsx'; -import { useLiveQuery } from 'dexie-react-hooks'; -import { getDB } from '@db/database'; export function SettingsPage() { const settings = useAppSettings(); + const stats = useSettingsStats(); const toast = useToast(); - const stats = useLiveQuery(async () => { - const db = getDB(); - const [peopleRows, track, groups, lendLedgers, budgets] = await Promise.all([ - db.people.toArray(), - db.trackTransactions.count(), - db.splitGroups.count(), - db.lendLedgers.count(), - db.trackBudgets.count(), - ]); - return { - people: peopleRows.filter((person) => !person.deletedAt).length, - track, - groups, - lendLedgers, - budgets, - }; - }, []); - if (!settings) return ; const hasFinancialData = !!stats && stats.track + stats.groups + stats.lendLedgers + stats.budgets > 0; @@ -40,6 +21,17 @@ export function SettingsPage() { }); }; + const setPrivacyMode = async (value: boolean) => { + try { + await settingsRepository.setHideAmounts(value); + toast.show(value ? 'Privacy mode on' : 'Privacy mode off'); + } catch (error) { + toast.show(error instanceof Error ? error.message : 'Could not change privacy mode', { + variant: 'error', + }); + } + }; + const themeButton = (mode: 'system' | 'light' | 'dark', icon: React.ReactNode, label: string) => (

- This portable format is readable JSON. Keep the file somewhere private. + This backup is readable JSON and contains your financial records. Keep it private.

@@ -330,7 +323,7 @@ export function BackupCenter() {

Automatic recovery

- Keeps one rolling daily checkpoint, replacing the previous automatic copy, plus up to three pre-restore safety checkpoints. These help with mistakes but cannot recover a lost or reset device. + Keeps one rolling daily checkpoint plus up to three pre-restore safety checkpoints. These help undo local mistakes but cannot recover a lost or reset device.

@@ -364,7 +357,7 @@ export function BackupCenter() {

Restore portable backup

- Choose an AfterSum backup file. Restore is explicit and creates a local safety checkpoint first. + Choose an AfterSum backup file. Restore replaces current financial records and creates a local safety checkpoint first.