Milestone 3: launch (20261008.05)

Backup and restore: restore from Settings (any backup in the folder, or an
uploaded file) or from the command line with scripts/restore.sh. Each restore
checks the file first, saves a copy of the current data so it can be undone,
and keeps the person restoring signed in. Backups are now single
self-contained files. Drilled end to end: last night's backup brings
everything back exactly, including onto an empty new machine.

Speed: a phone's first load is 147 KB compressed (target 150). The ideas box,
account menu and sign-in screens load on demand, a small built-in toast
replaces sonner, the Inter font is trimmed to the characters used, and files
and API answers are served compressed. Every screen's data comes back in
under 100 ms with 50,000 transactions (server/bench).

Accessibility: no WCAG 2.2 AA issues on any screen in either theme. Darker
status text colors, 44 px touch targets on phones, reduced motion honoured,
and focus returns after dialogs close.

Also: an in-app guide ("How Spendlight works"), OFX/QFX/QBO import, and tags
on individual parts of a split purchase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 12:23:45 -04:00
parent 4ecd64132b
commit a68c343647
82 changed files with 3822 additions and 265 deletions

88
server/test/ofx.test.ts Normal file
View File

@@ -0,0 +1,88 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { parseOfx } from '../src/import/ofx';
import { makeApp, setUpAdmin, type Client, type TestApp } from './helpers';
// Made-up OFX files in both flavors banks send: the older SGML style with
// unclosed tags (bank account), and XML (credit card, as in a .qfx).
const SGML = `OFXHEADER:100
DATA:OFXSGML
VERSION:102
SECURITY:NONE
ENCODING:USASCII
CHARSET:1252
<OFX>
<SIGNONMSGSRSV1><SONRS><STATUS><CODE>0<SEVERITY>INFO</STATUS><DTSERVER>20261007120000<LANGUAGE>ENG<FI><ORG>Bank of America<FID>5959</FI></SONRS></SIGNONMSGSRSV1>
<BANKMSGSRSV1><STMTTRNRS><TRNUID>1<STATUS><CODE>0<SEVERITY>INFO</STATUS>
<STMTRS><CURDEF>USD<BANKACCTFROM><BANKID>000000000<ACCTID>000099991234<ACCTTYPE>CHECKING</BANKACCTFROM>
<BANKTRANLIST><DTSTART>20260901<DTEND>20260930
<STMTTRN><TRNTYPE>CREDIT<DTPOSTED>20260905120000.000[-5:EST]<TRNAMT>3000.00<FITID>A1<NAME>ACME CORP PAYROLL<MEMO>PPD ID: 123</STMTTRN>
<STMTTRN><TRNTYPE>DEBIT<DTPOSTED>20260912<TRNAMT>-54.20<FITID>A2<NAME>KROGER #123 CHARLOTTE NC</STMTTRN>
<STMTTRN><TRNTYPE>XFER<DTPOSTED>20260915<TRNAMT>-300.00<FITID>A3<NAME>Online Banking transfer to SAV<MEMO>5678 Confirmation# 1</STMTTRN>
<STMTTRN><TRNTYPE>DEBIT<DTPOSTED>20260920<TRNAMT>-12,50<FITID>A4<NAME>JOES COFFEE &amp; BAGELS</STMTTRN>
</BANKTRANLIST><LEDGERBAL><BALAMT>4633.30<DTASOF>20260930</LEDGERBAL><AVAILBAL><BALAMT>4633.30<DTASOF>20260930</AVAILBAL></STMTRS></STMTTRNRS></BANKMSGSRSV1></OFX>
`;
const XML = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<?OFX OFXHEADER="200" VERSION="220" SECURITY="NONE" OLDFILEUID="NONE" NEWFILEUID="NONE"?>
<OFX><CREDITCARDMSGSRSV1><CCSTMTTRNRS><TRNUID>1</TRNUID><CCSTMTRS><CURDEF>USD</CURDEF>
<CCACCTFROM><ACCTID>4111XXXXXXXX9876</ACCTID></CCACCTFROM>
<BANKTRANLIST><DTSTART>20260901</DTSTART><DTEND>20260930</DTEND>
<STMTTRN><TRNTYPE>DEBIT</TRNTYPE><DTPOSTED>20260903</DTPOSTED><DTUSER>20260902</DTUSER><TRNAMT>-23.99</TRNAMT><FITID>C1</FITID><NAME>AMAZON MKTPL*AB12CD3E4</NAME></STMTTRN>
<STMTTRN><TRNTYPE>CREDIT</TRNTYPE><DTPOSTED>20260907</DTPOSTED><TRNAMT>18.50</TRNAMT><FITID>C2</FITID><NAME>AMAZON MKTPL*QR56ST7U8</NAME><MEMO>Return</MEMO></STMTTRN>
<STMTTRN><TRNTYPE>DEBIT</TRNTYPE><DTPOSTED>20260912</DTPOSTED><TRNAMT>-15.49</TRNAMT><FITID>C3</FITID><NAME>NETFLIX.COM</NAME></STMTTRN>
</BANKTRANLIST><LEDGERBAL><BALAMT>-412.33</BALAMT><DTASOF>20260930</DTASOF></LEDGERBAL></CCSTMTRS></CCSTMTTRNRS></CREDITCARDMSGSRSV1></OFX>
`;
describe('reading OFX', () => {
it('reads the older SGML style', () => {
const s = parseOfx(SGML);
expect(s).toMatchObject({ kind: 'checking', institution: 'Bank of America', accountLast4: '1234', ledger: { cents: 463330, date: '2026-09-30' } });
expect(s.transactions.map((t) => [t.date, t.amountCents, t.name])).toEqual([
['2026-09-05', 300000, 'ACME CORP PAYROLL'],
['2026-09-12', -5420, 'KROGER #123 CHARLOTTE NC'],
['2026-09-15', -30000, 'Online Banking transfer to SAV'],
['2026-09-20', -1250, 'JOES COFFEE & BAGELS'],
]);
});
it('reads XML, using the purchase date when there is one', () => {
const s = parseOfx(XML);
expect(s).toMatchObject({ kind: 'credit_card', accountLast4: '9876' });
expect(s.transactions[0]).toMatchObject({ date: '2026-09-02', postedDate: '2026-09-03', amountCents: -2399 });
expect(s.transactions[1]).toMatchObject({ amountCents: 1850, memo: 'Return' });
});
});
describe('importing OFX', () => {
let t: TestApp;
let admin: Client;
beforeEach(async () => {
t = await makeApp();
admin = await setUpAdmin(t);
});
afterEach(() => t.cleanup());
it('imports with no mapping, matches the account by its last digits, and skips repeats', async () => {
const checking = (await admin.post('/api/v1/accounts', { name: 'Checking', kind: 'checking', institution: 'Bank of America', last4: '1234' })).json.id;
await admin.post('/api/v1/accounts', { name: 'Prime Visa', kind: 'credit_card', institution: 'Chase', last4: '9876' });
const preview = await admin.upload('/api/v1/imports/files', 'export.qfx', SGML);
expect(preview.json).toMatchObject({ layout: { key: 'ofx' }, account: { id: checking, confidence: 'certain' }, counts: { new: 4 } });
const done = await admin.post('/api/v1/imports', { stagingId: preview.json.stagingId, accountId: checking });
expect(done.status).toBe(200);
const page = (await admin.get(`/api/v1/transactions?accountId=${checking}`)).json;
expect(page.items.map((x: { merchant: string }) => x.merchant)).toContain('Kroger');
const account = ((await admin.get('/api/v1/accounts')).json as { id: number; balanceCents: number; balanceConfirmedOn: string }[]).find((a) => a.id === checking)!;
expect(account).toMatchObject({ balanceCents: 463330, balanceConfirmedOn: '2026-09-20' });
const again = await admin.upload('/api/v1/imports/files', 'export.qfx', SGML);
expect(again.json.counts).toMatchObject({ new: 0, duplicates: 4 });
const card = await admin.upload('/api/v1/imports/files', 'Statement.qfx', XML);
expect(card.json.account.confidence).toBe('certain');
expect(card.json.accountHint).toMatchObject({ kind: 'credit_card' });
});
});