From b6d719a4cd2f85a382876e62d27cdcc9ee293b9e Mon Sep 17 00:00:00 2001 From: Nicholas Hart Date: Mon, 18 May 2026 23:10:23 -0700 Subject: [PATCH 1/4] Auto-redact PII from receipt text before Claude API calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Credit cards (Luhn-validated), SSNs, routing numbers, and account numbers are now stripped from extracted text before sending to the API. Image/PDF vision paths send binary and cannot be text-redacted — this limitation is documented. Includes a PII fixture PDF for visual verification and a preview script (scripts/preview-redaction.ts). Co-Authored-By: Claude Opus 4.6 --- PRIVACY.md | 9 +- README.md | 1 + ROADMAP.md | 3 +- scripts/generate-fixtures.ts | 63 +++++ scripts/preview-redaction.ts | 69 ++++++ src/core/parse.ts | 4 +- src/utils/redact.ts | 86 +++++++ test/core/parse.test.ts | 16 ++ .../receipts/capital-purchase-spa.pdf | Bin 12359 -> 12359 bytes .../receipts/ecommerce-order-amazon.pdf | Bin 6084 -> 6084 bytes test/fixtures/receipts/empty.pdf | Bin 1334 -> 1334 bytes .../not-an-invoice-service-agreement.pdf | Bin 8914 -> 8914 bytes .../payment-processor-pest-control.pdf | Bin 7947 -> 7947 bytes .../receipts/pii-plumbing-invoice.pdf | Bin 0 -> 4958 bytes .../receipts/service-invoice-landscaping.pdf | Bin 5758 -> 5758 bytes test/utils/redact.test.ts | 228 ++++++++++++++++++ 16 files changed, 476 insertions(+), 3 deletions(-) create mode 100644 scripts/preview-redaction.ts create mode 100644 src/utils/redact.ts create mode 100644 test/fixtures/receipts/pii-plumbing-invoice.pdf create mode 100644 test/utils/redact.test.ts diff --git a/PRIVACY.md b/PRIVACY.md index 7c6a1fc..4708940 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -37,7 +37,14 @@ receipt2sheet is designed with privacy as a core principle. Your financial data | Your spreadsheet contents | Never leaves your machine | | Historical expenses | Never leaves your machine | -> **Planned:** Automatic redaction of credit card numbers, bank accounts, SSNs, and routing numbers before API calls is on the [roadmap](./ROADMAP.md) for a future release. In v0.1.0, receipt text is sent to the Claude API without redaction. +**Automatic PII Redaction:** Text content extracted from receipts is automatically scrubbed before being sent to the Claude API. The following PII types are detected and redacted: + +- **Credit card numbers** — 13-19 digit sequences validated with Luhn checksum, replaced with `[REDACTED-CC-XXXX]` (last 4 preserved) +- **Social Security Numbers** — `XXX-XX-XXXX` format, replaced with `[REDACTED-SSN]` +- **Bank routing numbers** — 9-digit numbers preceded by keywords like "routing" or "ABA", replaced with `[REDACTED-ROUTING]` +- **Bank account numbers** — 6-17 digit numbers preceded by "account" or "acct", replaced with `[REDACTED-ACCT]` + +> **Limitation:** Image and PDF vision paths send binary data directly to the Claude API and cannot be text-redacted. If your scanned receipts contain visible PII (e.g., printed credit card numbers), that data will be transmitted as-is. See the [roadmap](./ROADMAP.md) for planned image-level redaction. --- diff --git a/README.md b/README.md index cf0cced..029c40f 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Processing 4 receipt(s)... Your financial data is sensitive. We treat it that way. - **All spreadsheets stay local** — Nothing synced to the cloud +- **Automatic PII redaction** — Credit cards, SSNs, and bank details are scrubbed from text before API calls - **No telemetry** — Zero analytics, tracking, or phone-home - **Open source** — Audit the code yourself diff --git a/ROADMAP.md b/ROADMAP.md index ec8cdcb..ec03609 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,7 +20,8 @@ ### Security/Privacy -- [ ] redact PII, account numbers, etc... (see PRIVACY.md) +- [x] Redact PII from text content before API calls (credit cards, SSNs, routing/account numbers) +- [ ] Image/PDF pixel-level PII redaction (OCR pre-processing) ### Confidence Scores - [ ] Add `confidence` field (0-1) to Claude's parse response diff --git a/scripts/generate-fixtures.ts b/scripts/generate-fixtures.ts index 9725ef2..1036596 100644 --- a/scripts/generate-fixtures.ts +++ b/scripts/generate-fixtures.ts @@ -547,6 +547,68 @@ async function generateEmptyPdf(): Promise { console.log(` ${filename} (${buf.length} bytes)`); } +/** + * Fixture 9: Receipt with embedded PII for redaction testing + * Has: full CC number, SSN, routing number, account number — all fake + * Also has legitimate numbers that should NOT be redacted + */ +async function generatePiiReceipt(): Promise { + const buf = await pdfToBuffer((doc) => { + doc.fontSize(16).font('Helvetica-Bold').text('MOUNTAIN PLUMBING & HEATING'); + doc.fontSize(9).font('Helvetica'); + doc.text('847 Elk Valley Road, Silverton, CO 81433'); + doc.text('Phone: (970) 555-0147'); + doc.text('Tax ID: 84-1234567'); + doc.moveDown(); + + doc.font('Helvetica-Bold').text('INVOICE'); + doc.font('Helvetica'); + doc.text('Invoice #: 01-0583921'); + doc.text('Date: 03/15/2026'); + doc.text('Customer ID: 4829103'); + doc.text('Service Order: 2026-031587'); + doc.moveDown(); + + doc.font('Helvetica-Bold').text('Bill To:'); + doc.font('Helvetica'); + doc.text('Jane Smith'); + doc.text('456 Alpine Drive'); + doc.text('Ouray, CO 81427-9725'); + doc.moveDown(); + + doc.font('Helvetica-Bold').text('Description'); + doc.font('Helvetica'); + doc.text('Emergency water heater replacement — 50 gal Bradford White'); + doc.text(' Parts: $1,247.00'); + doc.text(' Labor (4 hrs @ $125/hr): $500.00'); + doc.text(' Disposal fee: $75.00'); + doc.moveDown(); + + doc.text('Subtotal: $1,822.00'); + doc.text('Sales Tax (8.1%): $147.58'); + doc.font('Helvetica-Bold').text('Total Due: $1,969.58'); + doc.font('Helvetica'); + doc.moveDown(); + + // PII section — this is what should get redacted + doc.font('Helvetica-Bold').text('Payment Information'); + doc.font('Helvetica'); + doc.text('Credit Card: 4532015112830366'); + doc.text('Cardholder SSN: 287-65-4321'); + doc.text('Routing: 021000021'); + doc.text('Account: 9876543210'); + doc.text('Approval Code: 847291'); + doc.text('Transaction ID: 8392017456'); + doc.moveDown(); + + doc.text('Thank you for your business!'); + }); + + const filename = 'pii-plumbing-invoice.pdf'; + fs.writeFileSync(path.join(FIXTURES_DIR, filename), buf); + console.log(` ${filename} (${buf.length} bytes)`); +} + async function main() { console.log('Generating test fixtures...\n'); @@ -558,6 +620,7 @@ async function main() { generateMinimalPng('receipt-photo.png'); generateMinimalJpg('receipt-photo.jpg'); await generateEmptyPdf(); + await generatePiiReceipt(); console.log('\nDone! Fixtures written to test/fixtures/receipts/'); } diff --git a/scripts/preview-redaction.ts b/scripts/preview-redaction.ts new file mode 100644 index 0000000..deaf4c5 --- /dev/null +++ b/scripts/preview-redaction.ts @@ -0,0 +1,69 @@ +#!/usr/bin/env npx tsx +/** + * Preview what redaction does to your real receipts. + * + * Usage: + * npx tsx scripts/preview-redaction.ts inbox/*.pdf + * npx tsx scripts/preview-redaction.ts path/to/receipt.pdf + * + * For each PDF with extractable text, prints: + * 1. The raw extracted text + * 2. The redacted version (what would be sent to the API) + * 3. A summary of what was redacted + * + * No API calls are made. Nothing leaves your machine. + */ + +import { extractText } from '../src/core/extract.js'; +import { redactPII } from '../src/utils/redact.js'; +import path from 'path'; + +const files = process.argv.slice(2); + +if (files.length === 0) { + console.error('Usage: npx tsx scripts/preview-redaction.ts [file...]'); + process.exit(1); +} + +for (const file of files) { + const name = path.basename(file); + console.log(`\n${'='.repeat(60)}`); + console.log(`FILE: ${name}`); + console.log('='.repeat(60)); + + try { + const { text, needsVision } = await extractText(file); + + if (!text) { + console.log(` [No extractable text — would use ${needsVision ? 'vision/PDF' : 'unknown'} path]`); + console.log(' ⚠ Image/PDF vision paths send binary data and cannot be text-redacted.'); + continue; + } + + const { text: redacted, redactions } = redactPII(text); + + if (redactions.total === 0) { + console.log('\n--- EXTRACTED TEXT (no PII detected) ---'); + console.log(text); + console.log('\n✓ No redactions needed'); + } else { + console.log('\n--- ORIGINAL TEXT ---'); + console.log(text); + console.log('\n--- REDACTED TEXT (what gets sent to API) ---'); + console.log(redacted); + console.log('\n--- REDACTION SUMMARY ---'); + if (redactions.creditCards > 0) console.log(` Credit cards: ${redactions.creditCards}`); + if (redactions.ssns > 0) console.log(` SSNs: ${redactions.ssns}`); + if (redactions.routingNumbers > 0) console.log(` Routing numbers: ${redactions.routingNumbers}`); + if (redactions.accountNumbers > 0) console.log(` Account numbers: ${redactions.accountNumbers}`); + console.log(` Total redactions: ${redactions.total}`); + } + + if (needsVision) { + console.log('\n ⚠ Text extraction was partial — vision fallback would also be used.'); + console.log(' The vision path sends the PDF as binary and cannot be text-redacted.'); + } + } catch (err) { + console.error(` Error: ${err instanceof Error ? err.message : err}`); + } +} diff --git a/src/core/parse.ts b/src/core/parse.ts index 854318b..f0e66bb 100644 --- a/src/core/parse.ts +++ b/src/core/parse.ts @@ -7,6 +7,7 @@ import type { } from '@anthropic-ai/sdk/resources/messages.js'; import { ReceiptParseResultSchema, type ReceiptParseResult } from '../schemas/receipt.js'; import type { Vendor } from '../schemas/config.js'; +import { redactPII } from '../utils/redact.js'; const MODEL_ALIASES: Record = { small: 'claude-haiku-4-5-20251001', @@ -81,7 +82,8 @@ export async function parseReceipt( let userContent: Array; if (content.type === 'text') { - userContent = [{ type: 'text', text: content.text + vendorHint }]; + const { text: redactedText } = redactPII(content.text); + userContent = [{ type: 'text', text: redactedText + vendorHint }]; } else if (content.type === 'image') { userContent = [ { diff --git a/src/utils/redact.ts b/src/utils/redact.ts new file mode 100644 index 0000000..d5050f0 --- /dev/null +++ b/src/utils/redact.ts @@ -0,0 +1,86 @@ +export interface RedactionSummary { + creditCards: number; + ssns: number; + routingNumbers: number; + accountNumbers: number; + total: number; +} + +export interface RedactionResult { + text: string; + redactions: RedactionSummary; +} + +export function luhnCheck(digits: string): boolean { + if (!/^\d+$/.test(digits)) return false; + let sum = 0; + let alternate = false; + for (let i = digits.length - 1; i >= 0; i--) { + let n = parseInt(digits[i], 10); + if (alternate) { + n *= 2; + if (n > 9) n -= 9; + } + sum += n; + alternate = !alternate; + } + return sum % 10 === 0; +} + +// Match 13-19 digit sequences with optional space/dash separators +const CC_PATTERN = /\b(\d[ -]?){13,19}\b/g; + +// SSN: exactly 3-2-4 with hyphens +const SSN_PATTERN = /\b\d{3}-\d{2}-\d{4}\b/g; + +// Routing numbers: keyword + 9 digits +const ROUTING_PATTERN = /(?:routing|aba|transit)[\s#:]*(\d{9})\b/gi; + +// Account numbers: keyword + 6-17 digits +const ACCOUNT_PATTERN = /(?:account|acct)[\s#:]*(\d{6,17})\b/gi; + +export function redactPII(text: string): RedactionResult { + const redactions: RedactionSummary = { + creditCards: 0, + ssns: 0, + routingNumbers: 0, + accountNumbers: 0, + total: 0, + }; + + // 1. SSNs (most specific format) + let result = text.replace(SSN_PATTERN, () => { + redactions.ssns++; + return '[REDACTED-SSN]'; + }); + + // 2. Credit cards — find digit sequences, validate with Luhn + result = result.replace(CC_PATTERN, (match) => { + const digits = match.replace(/[ -]/g, ''); + if (digits.length < 13 || digits.length > 19) return match; + if (!luhnCheck(digits)) return match; + const last4 = digits.slice(-4); + redactions.creditCards++; + return `[REDACTED-CC-${last4}]`; + }); + + // 3. Routing numbers (context-dependent) + result = result.replace(ROUTING_PATTERN, (match, digits) => { + redactions.routingNumbers++; + return match.replace(digits, '[REDACTED-ROUTING]'); + }); + + // 4. Account numbers (context-dependent) + result = result.replace(ACCOUNT_PATTERN, (match, digits) => { + redactions.accountNumbers++; + return match.replace(digits, '[REDACTED-ACCT]'); + }); + + redactions.total = + redactions.creditCards + + redactions.ssns + + redactions.routingNumbers + + redactions.accountNumbers; + + return { text: result, redactions }; +} diff --git a/test/core/parse.test.ts b/test/core/parse.test.ts index e8ebc0b..f3fbf23 100644 --- a/test/core/parse.test.ts +++ b/test/core/parse.test.ts @@ -92,6 +92,22 @@ describe('parseReceipt', () => { await expect(parseReceipt({ type: 'text', text: 'test' }, {})).rejects.toThrow(); }); + it('redacts credit card numbers from text before API call', async () => { + mockCreate.mockResolvedValueOnce({ + content: [{ type: 'text', text: validResponse }], + }); + + await parseReceipt( + { type: 'text', text: 'Payment: Visa 4111111111111111\nTotal: $45.99' }, + {}, + ); + + const callArgs = mockCreate.mock.calls[0][0]; + const textContent = callArgs.messages[0].content[0].text; + expect(textContent).toContain('[REDACTED-CC-1111]'); + expect(textContent).not.toContain('4111111111111111'); + }); + it('throws on Zod validation failure', async () => { mockCreate.mockResolvedValueOnce({ content: [{ type: 'text', text: JSON.stringify({ vendor: '' }) }], diff --git a/test/fixtures/receipts/capital-purchase-spa.pdf b/test/fixtures/receipts/capital-purchase-spa.pdf index 4ab8be9430cc33ec79521972d982176bc1cb08a0..b2373eb6a66174e76a04d195fc9b89bab6194ea4 100644 GIT binary patch delta 90 zcmX?}a6DndM{QO!19KCz&EK>?F*{qDq?#C-7$=)qnpvh8nkJeU8 delta 90 zcmX?}a6DndM{QOULo-u@&EK>?F*_TlCR(N>CR-$$Sr{278JHv+r8W~uaC0m-O QCK=i(*bq`OS=&Gc09^SSWB>pF diff --git a/test/fixtures/receipts/ecommerce-order-amazon.pdf b/test/fixtures/receipts/ecommerce-order-amazon.pdf index e8d84bdf476daa439477be0064282de76e1632dd..89b7d51c3ecb48e1ce99b91a56049fffb82d0ff3 100644 GIT binary patch delta 90 zcmX@2e?)(Si3qEifw_s|u>b%7 diff --git a/test/fixtures/receipts/empty.pdf b/test/fixtures/receipts/empty.pdf index c4c5eea8286aed2e5dbb412cf2b5545ca7143e28..8c1fe745660af965e6b7e5a26d53d283889d530a 100644 GIT binary patch delta 90 zcmdnSwT)|oG83zrfw_sobzDJJHomMIp=W|l@4 QMkaO&HiVQ+mSL3w00$=(=>Px# delta 90 zcmdnSwT)|oG83zbp_!?{W(}r|OwQ(sX^BSWX$Gm5Y37DW21Y3+X~st8mdVM+2F7Vd Q2BvljHiVQ+mSL3w0A^zt3IG5A diff --git a/test/fixtures/receipts/not-an-invoice-service-agreement.pdf b/test/fixtures/receipts/not-an-invoice-service-agreement.pdf index 35146f1f626e04925c6be1daa57d6c67824a6381..d22f1ccd262b9754ef083c18f0eb46f513263a3b 100644 GIT binary patch delta 113 zcmccQddYQzlRT@Lfw_s$claEzN3XU~Xczxj|Z&+1cDO)zHMm(8MCu(A+S|Fg4BG+$h;H&D7jH)xyZc PDAi8EhLDoUALOI~Tt*m( delta 90 zcmeCS>$claEzN3TXl81#xj|Z&+1V^9(ZVP(DLL8PBH1j_Jki7?CCxb1!pzXpAkEAq P$=puChLDoUALOI~uC5wI diff --git a/test/fixtures/receipts/pii-plumbing-invoice.pdf b/test/fixtures/receipts/pii-plumbing-invoice.pdf new file mode 100644 index 0000000000000000000000000000000000000000..58987e073fbac855947cd0f66c627c70d2ee127e GIT binary patch literal 4958 zcmbVQO>g5i5WVYH@UlRAh{TT}DHef&CfT6rZrg2=MS%u;uw^QBk=R>XDYpId?Hfw6 z9C?eb;vjKEQJUeKH*bb~_3q;GMCB%X^$$Lo7gB5%zh|eXS#kT<$0&+-^ZS_L?>4R* zp>X>;D}IQ})%cIHiRT`a@QnZ`KW_?SyUTUB}(#X_;A*JpLgF^uy+Z2f?d7TqHHZ(DiP9u zct8Ak*lce;&a23?Fftfr4|}6;KjGf(f zgGyLTG%~k<j9ospZYDgwox*74>`Z7} zVad{JS7VfPh5wBUuJV|xE#_LUXX1o-B4JYGDP*YD5=Iov;4{@{>2eupM1pZ= zCL8sXm*t)%U356m91D&K)wnW*SWF7LbCfIJy?DPI3NCg*?Za0in8sN^X%1Ef2osPN zYczmZ8?bPi1J>l;jd@1|An7oIh?dbcK(zR)aitAhn`VkkZrqqI;J5WAadOEdJ&GP- zZ5nG+?7~!&M&|)3{hTboF(rfHWN0H?(4r$I( zhhzkUB^+WZm(1jv?N0+(s+{PEOn6P38l00!7nUvn@N(koZBRMHVuWU!QHUDE$V)6H z86ij`v7%%s(y;7GjXtF&QgU=DshadnJQj zp=P$PhRZ_<@&_`e2{Kk8$+^}=q&Xg#M0~5u+{iIA*)}mOD2faeiV$FNrPtoz4(_sH zn1!VAs18HM;|`z$crS8C`8>!mZIe473&c*sA=AcW9|e1;wlMGE)=P|2@F>b-Vp*uH z)2y=i1sR^Ll)-0>)h{zxIuzuw>Y&PUcY;LOcP-CrG@?<$p3-)NJNF_?18D3-8jyFn zE;}0R({&KkcIgN`0gTk0FymVq0D^#=4lgroEvk4I@WC)>oDvKg)i!8Da&m>|&?jN$ z1YD+seuT#iZC zTTT67)z((7Kh{t@@I-Slg+{y76>=jwy^SGlax8Zwr`!&sRG z%H$yK$Q()_LkE+rzz606uaMUV)YIxFD;(4+FKMiBq)>mj0Mu=X*caMX?%@i9e&9&j zPGIfmrY7bVG6TKESkv}>_d#KaN!qxhOKb-D#@%?^sWS0=qTy7s8L!Y590eO44Kye6 zKFOmU=u!(E&t79w892O`sws+6N>488seOS$8~q$0-uZUB%x({?c$>-l(XX)YeY0x5 z?l;G%f&X#C^fxZPf#^7CRgjJwh+l_?c?a9?aomk}d4w&#}#IHn?UJ{q<_I zzL+;rD1QC*!G@W4)%~m5lL6j+XT{lk7t=nx_%1&D8Jkr#&x)I;MUzesc&5sV*XwGt zT&?d#@ylxc?RvNB2M!GI2L1~ToSbbQmM^nHgO%Yhpoele^*En5^M}nnAAH>7_@X=a z_;LP-ix#fo={|JB)9E2Tet6@_sZX z(NvD+K}CV~^JfWJdNd~8(fdsB$M3_dj-SPG#NI};ov$9^Hl1GGto{azDJ!lwn?|&3 zF0R(~hCBVl_p1wWcZw~f)LM}V=vr#3eCZZyVPm { + it('validates known test card numbers', () => { + expect(luhnCheck('4111111111111111')).toBe(true); // Visa test + expect(luhnCheck('5500000000000004')).toBe(true); // Mastercard test + expect(luhnCheck('340000000000009')).toBe(true); // Amex test + expect(luhnCheck('6011000000000004')).toBe(true); // Discover test + }); + + it('rejects arbitrary digit strings', () => { + expect(luhnCheck('1234567890123')).toBe(false); + expect(luhnCheck('0000000000000')).toBe(true); // all zeros technically pass Luhn + expect(luhnCheck('1111111111111')).toBe(false); + expect(luhnCheck('9999999999999')).toBe(false); + }); + + it('rejects non-digit strings', () => { + expect(luhnCheck('abcdefghijklm')).toBe(false); + expect(luhnCheck('4111-1111-1111-1111')).toBe(false); // must be stripped first + }); +}); + +describe('redactPII', () => { + describe('credit cards', () => { + it('redacts a valid Visa number', () => { + const { text, redactions } = redactPII('Card: 4111111111111111'); + expect(text).toBe('Card: [REDACTED-CC-1111]'); + expect(redactions.creditCards).toBe(1); + expect(redactions.total).toBe(1); + }); + + it('redacts a card with dashes', () => { + const { text } = redactPII('Card: 4111-1111-1111-1111'); + expect(text).toBe('Card: [REDACTED-CC-1111]'); + }); + + it('redacts a card with spaces', () => { + const { text } = redactPII('Card: 4111 1111 1111 1111'); + expect(text).toBe('Card: [REDACTED-CC-1111]'); + }); + + it('redacts multiple cards', () => { + const { text, redactions } = redactPII( + 'Cards: 4111111111111111 and 5500000000000004', + ); + expect(text).toContain('[REDACTED-CC-1111]'); + expect(text).toContain('[REDACTED-CC-0004]'); + expect(redactions.creditCards).toBe(2); + }); + + it('does not redact numbers that fail Luhn', () => { + const input = 'Number: 1234567890123'; + const { text, redactions } = redactPII(input); + expect(text).toBe(input); + expect(redactions.creditCards).toBe(0); + }); + }); + + describe('SSNs', () => { + it('redacts SSN format', () => { + const { text, redactions } = redactPII('SSN: 123-45-6789'); + expect(text).toBe('SSN: [REDACTED-SSN]'); + expect(redactions.ssns).toBe(1); + }); + + it('redacts multiple SSNs', () => { + const { text, redactions } = redactPII('SSN: 123-45-6789 and 987-65-4321'); + expect(text).toContain('[REDACTED-SSN]'); + expect(redactions.ssns).toBe(2); + }); + }); + + describe('routing numbers', () => { + it('redacts routing number with keyword', () => { + const { text, redactions } = redactPII('Routing: 021000021'); + expect(text).toBe('Routing: [REDACTED-ROUTING]'); + expect(redactions.routingNumbers).toBe(1); + }); + + it('redacts ABA number', () => { + const { text } = redactPII('ABA# 021000021'); + expect(text).toBe('ABA# [REDACTED-ROUTING]'); + }); + + it('redacts transit number', () => { + const { text } = redactPII('Transit: 021000021'); + expect(text).toBe('Transit: [REDACTED-ROUTING]'); + }); + + it('does not redact bare 9-digit numbers', () => { + const input = 'Invoice 021000021'; + const { text, redactions } = redactPII(input); + expect(text).toBe(input); + expect(redactions.routingNumbers).toBe(0); + }); + }); + + describe('account numbers', () => { + it('redacts account number with keyword', () => { + const { text, redactions } = redactPII('Account: 12345678901'); + expect(text).toBe('Account: [REDACTED-ACCT]'); + expect(redactions.accountNumbers).toBe(1); + }); + + it('redacts acct abbreviation', () => { + const { text } = redactPII('Acct# 12345678901'); + expect(text).toBe('Acct# [REDACTED-ACCT]'); + }); + + it('does not redact without keyword', () => { + const input = 'Customer ID: 12345678901'; + const { text } = redactPII(input); + expect(text).toBe(input); + }); + }); + + describe('false negatives (things that should NOT be redacted)', () => { + it('does not redact dollar amounts', () => { + const input = '$8,350.73'; + expect(redactPII(input).text).toBe(input); + }); + + it('does not redact dates', () => { + const input = '02/19/2024'; + expect(redactPII(input).text).toBe(input); + }); + + it('does not redact invoice numbers', () => { + const input = 'Invoice: 01-0479685'; + expect(redactPII(input).text).toBe(input); + }); + + it('does not redact order numbers', () => { + const input = 'Order: 111-2725430-3019452'; + expect(redactPII(input).text).toBe(input); + }); + + it('does not redact zip codes', () => { + const input = 'ZIP: 98034'; + expect(redactPII(input).text).toBe(input); + }); + + it('does not redact phone numbers', () => { + const input = 'Phone: (425) 555-1234'; + expect(redactPII(input).text).toBe(input); + }); + + it('does not redact masked cards', () => { + const input = '41**********4415'; + expect(redactPII(input).text).toBe(input); + }); + + it('does not redact "Visa ending in" format', () => { + const input = 'Visa ending in 5098'; + expect(redactPII(input).text).toBe(input); + }); + + it('does not redact customer IDs', () => { + const input = 'Customer ID: 8847291'; + expect(redactPII(input).text).toBe(input); + }); + + it('does not redact transaction IDs', () => { + const input = 'Transaction: 7829103845'; + expect(redactPII(input).text).toBe(input); + }); + + it('does not redact approval codes', () => { + const input = 'Approval Code: 082947'; + expect(redactPII(input).text).toBe(input); + }); + }); + + describe('integration: full receipt text', () => { + it('redacts PII without damaging receipt content', () => { + const receiptText = ` +AQUA QUIP +12345 Main Street, Bellevue, WA 98005 +Phone: (425) 555-0199 + +Invoice: 01-0479685 +Date: 05/26/2025 +Customer ID: 8847291 + +Hot Tub - Model XR500 +Subtotal: $7,850.00 +Tax: $500.73 +Total: $8,350.73 + +Payment: Visa 4111111111111111 +Approval Code: 082947 +Transaction: 7829103845 + +Cardholder SSN: 123-45-6789 +Routing: 021000021 +Account: 9876543210 +`.trim(); + + const { text, redactions } = redactPII(receiptText); + + // PII should be redacted + expect(text).toContain('[REDACTED-CC-1111]'); + expect(text).toContain('[REDACTED-SSN]'); + expect(text).toContain('[REDACTED-ROUTING]'); + expect(text).toContain('[REDACTED-ACCT]'); + expect(redactions.total).toBe(4); + + // Non-PII should be preserved + expect(text).toContain('AQUA QUIP'); + expect(text).toContain('$8,350.73'); + expect(text).toContain('01-0479685'); + expect(text).toContain('8847291'); + expect(text).toContain('082947'); + expect(text).toContain('7829103845'); + expect(text).toContain('(425) 555-0199'); + expect(text).toContain('98005'); + }); + + it('handles text with no PII', () => { + const input = 'Amazon order for towels, $45.99, shipped 2026-05-12'; + const { text, redactions } = redactPII(input); + expect(text).toBe(input); + expect(redactions.total).toBe(0); + }); + }); +}); From bb46b5dfb511364f6cf11748eefa3f04b4586189 Mon Sep 17 00:00:00 2001 From: Nicholas Hart Date: Mon, 18 May 2026 23:18:33 -0700 Subject: [PATCH 2/4] Fix CC regex boundary handling, surface redaction summary, add edge-case tests The \b word boundary in the CC regex misbehaved around spaces and dashes; replaced with digit lookaround. ParseResult now exposes the redaction summary for callers. Added 8 tests for mixed separators, boundary lengths, and adjacent-text edge cases. Co-Authored-By: Claude Opus 4.6 --- src/core/parse.ts | 10 ++++--- src/utils/redact.ts | 5 ++-- test/utils/redact.test.ts | 55 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/src/core/parse.ts b/src/core/parse.ts index f0e66bb..ec3c5e3 100644 --- a/src/core/parse.ts +++ b/src/core/parse.ts @@ -7,7 +7,7 @@ import type { } from '@anthropic-ai/sdk/resources/messages.js'; import { ReceiptParseResultSchema, type ReceiptParseResult } from '../schemas/receipt.js'; import type { Vendor } from '../schemas/config.js'; -import { redactPII } from '../utils/redact.js'; +import { redactPII, type RedactionSummary } from '../utils/redact.js'; const MODEL_ALIASES: Record = { small: 'claude-haiku-4-5-20251001', @@ -64,6 +64,7 @@ export type ReceiptContent = export interface ParseResult { parsed: ReceiptParseResult; rawResponse: string; + redactions?: RedactionSummary; } export async function parseReceipt( @@ -80,10 +81,12 @@ export async function parseReceipt( : ''; let userContent: Array; + let redactions: RedactionSummary | undefined; if (content.type === 'text') { - const { text: redactedText } = redactPII(content.text); - userContent = [{ type: 'text', text: redactedText + vendorHint }]; + const redacted = redactPII(content.text); + redactions = redacted.redactions; + userContent = [{ type: 'text', text: redacted.text + vendorHint }]; } else if (content.type === 'image') { userContent = [ { @@ -123,5 +126,6 @@ export async function parseReceipt( return { parsed: ReceiptParseResultSchema.parse(parsed), rawResponse, + redactions, }; } diff --git a/src/utils/redact.ts b/src/utils/redact.ts index d5050f0..a82d1a8 100644 --- a/src/utils/redact.ts +++ b/src/utils/redact.ts @@ -27,8 +27,9 @@ export function luhnCheck(digits: string): boolean { return sum % 10 === 0; } -// Match 13-19 digit sequences with optional space/dash separators -const CC_PATTERN = /\b(\d[ -]?){13,19}\b/g; +// Match runs of digits with optional space/dash separators. Length and Luhn are validated +// in the callback. Uses digit lookaround instead of \b which misbehaves around spaces/dashes. +const CC_PATTERN = /(? { }); }); - describe('false negatives (things that should NOT be redacted)', () => { + describe('credit card edge cases', () => { + it('redacts a card with mixed separators', () => { + const { text } = redactPII('Card: 4111-1111 1111-1111'); + expect(text).toBe('Card: [REDACTED-CC-1111]'); + }); + + it('redacts a card at end of string', () => { + const { text } = redactPII('Paid with 4111111111111111'); + expect(text).toBe('Paid with [REDACTED-CC-1111]'); + }); + + it('redacts a card adjacent to non-digit text', () => { + const { text } = redactPII('CC:4111111111111111/done'); + expect(text).toContain('[REDACTED-CC-1111]'); + expect(text).not.toContain('4111111111111111'); + }); + + it('does not redact when digits are part of a longer number', () => { + // 20 digits — too long even though a 16-digit substring might pass Luhn + const input = 'Ref: 12345678901234567890'; + const { text } = redactPII(input); + expect(text).toBe(input); + }); + }); + + describe('account number boundary lengths', () => { + it('redacts a 6-digit account number (minimum)', () => { + const { text, redactions } = redactPII('Account: 123456'); + expect(text).toBe('Account: [REDACTED-ACCT]'); + expect(redactions.accountNumbers).toBe(1); + }); + + it('redacts a 17-digit account number (maximum)', () => { + const { text, redactions } = redactPII('Account: 12345678901234567'); + expect(text).toBe('Account: [REDACTED-ACCT]'); + expect(redactions.accountNumbers).toBe(1); + }); + + it('does not redact a 5-digit account number (too short)', () => { + const input = 'Account: 12345'; + expect(redactPII(input).text).toBe(input); + }); + + it('does not redact an 18-digit account number (too long)', () => { + const input = 'Account: 123456789012345678'; + expect(redactPII(input).text).toBe(input); + }); + }); + + describe('true negatives (things that should NOT be redacted)', () => { it('does not redact dollar amounts', () => { const input = '$8,350.73'; expect(redactPII(input).text).toBe(input); @@ -205,6 +254,10 @@ Account: 9876543210 expect(text).toContain('[REDACTED-SSN]'); expect(text).toContain('[REDACTED-ROUTING]'); expect(text).toContain('[REDACTED-ACCT]'); + expect(redactions.creditCards).toBe(1); + expect(redactions.ssns).toBe(1); + expect(redactions.routingNumbers).toBe(1); + expect(redactions.accountNumbers).toBe(1); expect(redactions.total).toBe(4); // Non-PII should be preserved From 10cdcbc599bd430f63213a83d05a055f402a7719 Mon Sep 17 00:00:00 2001 From: Nicholas Hart Date: Mon, 18 May 2026 23:28:50 -0700 Subject: [PATCH 3/4] Fix critical data integrity and crash bugs found in code review - Validate user input in edit mode with Zod (NaN amounts, invalid dates) - Replace non-null assertions on optional receiptPath with null checks - Wrap JSON.parse in parse.ts and ledger.ts with actionable error messages - Add date validation that rejects impossible dates (month 13, day 32) - Atomic ledger writes (temp file + rename) to prevent corruption - Deduplicate slugify() to utils/files.ts and MODEL_ALIASES to core/models.ts - Remove dead vendor-cache code from init Co-Authored-By: Claude Opus 4.6 --- src/commands/doctor.ts | 8 ++------ src/commands/init.ts | 15 +-------------- src/commands/process.ts | 15 +++++---------- src/core/confirm.ts | 39 +++++++++++++++++++++++++++++---------- src/core/ledger.ts | 16 +++++++++++++--- src/core/models.ts | 12 ++++++++++++ src/core/parse.ts | 22 ++++++++-------------- src/schemas/expense.ts | 11 ++++++++++- src/schemas/receipt.ts | 11 ++++++++++- src/utils/files.ts | 7 +++++++ 10 files changed, 97 insertions(+), 59 deletions(-) create mode 100644 src/core/models.ts diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 654747d..f683225 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -7,6 +7,7 @@ import { fileExists, ensureDir } from '../utils/files.js'; import { currentYear } from '../utils/dates.js'; import { copyTemplate } from '../core/spreadsheet.js'; import { fileURLToPath } from 'url'; +import { MODEL_ALIASES, resolveModel } from '../core/models.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -102,12 +103,7 @@ export async function doctorCommand(options: DoctorOptions): Promise { // 5. Model config const modelEnv = process.env.R2S_MODEL || 'medium'; - const MODEL_ALIASES: Record = { - small: 'claude-haiku-4-5-20251001', - medium: 'claude-sonnet-4-6', - large: 'claude-opus-4-6', - }; - const resolvedModel = MODEL_ALIASES[modelEnv] || modelEnv; + const resolvedModel = resolveModel(); checks.push({ label: 'Model', status: 'ok', diff --git a/src/commands/init.ts b/src/commands/init.ts index 45c058a..0d86551 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -3,7 +3,7 @@ import path from 'path'; import { input, select, confirm } from '@inquirer/prompts'; import chalk from 'chalk'; import { fileURLToPath } from 'url'; -import { ensureDir, fileExists } from '../utils/files.js'; +import { ensureDir, fileExists, slugify } from '../utils/files.js'; import { currentYear } from '../utils/dates.js'; import { copyTemplate } from '../core/spreadsheet.js'; @@ -100,12 +100,6 @@ export async function initCommand(options: InitOptions): Promise { } } - // Create empty vendor cache - const vendorCachePath = path.join(cwd, '.r2s', 'vendor-cache.json'); - if (!(await fileExists(vendorCachePath))) { - await fs.writeFile(vendorCachePath, '{}', 'utf-8'); - } - console.log(); console.log(chalk.green.bold('receipt2sheet initialized!')); console.log(); @@ -115,10 +109,3 @@ export async function initCommand(options: InitOptions): Promise { console.log(); console.log(`Drop receipts in ${chalk.cyan('inbox/')} and run ${chalk.cyan('r2s process')}`); } - -function slugify(str: string): string { - return str - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, ''); -} diff --git a/src/commands/process.ts b/src/commands/process.ts index d027a74..d9e4923 100644 --- a/src/commands/process.ts +++ b/src/commands/process.ts @@ -7,7 +7,7 @@ import { parseReceipt, type ParseResult } from '../core/parse.js'; import { appendExpense, backupSpreadsheet } from '../core/spreadsheet.js'; import { confirmExpenses, type ParsedEntry } from '../core/confirm.js'; import { loadLedger, saveLedger, isAlreadyProcessed } from '../core/ledger.js'; -import { listInboxFiles, isSupportedFile, ensureDir } from '../utils/files.js'; +import { listInboxFiles, isSupportedFile, ensureDir, slugify } from '../utils/files.js'; import { currentYear as getCurrentYear, currentYearMonth } from '../utils/dates.js'; import type { Expense } from '../schemas/expense.js'; @@ -282,7 +282,8 @@ export async function processCommand(files: string[], options: ProcessOptions): const processedBase = path.resolve(configDir, config.processed); for (const entry of toProcess) { - const srcPath = entry.expense.receiptPath!; + const srcPath = entry.expense.receiptPath; + if (!srcPath) continue; let destDir: string; if (config.organize_processed_by === 'year-month') { @@ -319,7 +320,8 @@ export async function processCommand(files: string[], options: ProcessOptions): // Record skipped files in ledger too for (const entry of skipped) { - const srcPath = entry.expense.receiptPath!; + const srcPath = entry.expense.receiptPath; + if (!srcPath) continue; ledger[srcPath] = { processedAt: new Date().toISOString(), movedTo: null, @@ -350,10 +352,3 @@ function getMaxFileSizeBytes(): number { const mb = envVal ? parseInt(envVal, 10) : DEFAULT_MAX_FILE_SIZE_MB; return (isNaN(mb) ? DEFAULT_MAX_FILE_SIZE_MB : mb) * 1024 * 1024; } - -function slugify(str: string): string { - return str - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, ''); -} diff --git a/src/core/confirm.ts b/src/core/confirm.ts index 61b555a..88971cb 100644 --- a/src/core/confirm.ts +++ b/src/core/confirm.ts @@ -1,7 +1,7 @@ import { select, input } from '@inquirer/prompts'; import Table from 'cli-table3'; import chalk from 'chalk'; -import { CategorySchema, type Expense } from '../schemas/expense.js'; +import { CategorySchema, ExpenseSchema, type Expense } from '../schemas/expense.js'; import { truncate } from '../utils/files.js'; import { formatDollars } from '../utils/currency.js'; @@ -109,17 +109,36 @@ async function editExpenses(expenses: ParsedEntry[]): Promise default: expense.expenseType, }); + const amount = Number(amountStr); + if (isNaN(amount)) { + console.log( + chalk.red(` Invalid amount "${amountStr}" — keeping original (${expense.amount})`), + ); + results.push({ file, expense, action: 'confirm' }); + continue; + } + + const edited = { + ...expense, + vendor, + date, + amount, + description, + category, + expenseType, + }; + + const validation = ExpenseSchema.safeParse(edited); + if (!validation.success) { + const issues = validation.error.issues.map((i) => i.message).join('; '); + console.log(chalk.red(` Invalid input: ${issues} — keeping original values`)); + results.push({ file, expense, action: 'confirm' }); + continue; + } + results.push({ file, - expense: { - ...expense, - vendor, - date, - amount: Number(amountStr), - description, - category, - expenseType, - }, + expense: validation.data, action: 'confirm', }); } diff --git a/src/core/ledger.ts b/src/core/ledger.ts index 5e23a77..9d85261 100644 --- a/src/core/ledger.ts +++ b/src/core/ledger.ts @@ -27,13 +27,23 @@ export async function loadLedger(configDir: string): Promise { const p = ledgerPath(configDir); if (!(await fileExists(p))) return {}; const raw = await fs.readFile(p, 'utf-8'); - return JSON.parse(raw); + try { + return JSON.parse(raw); + } catch { + throw new Error( + `Corrupted ledger file at ${p}. Back up the file and delete it to start fresh, or fix the JSON manually.`, + ); + } } export async function saveLedger(configDir: string, ledger: Ledger): Promise { const p = ledgerPath(configDir); - await fs.mkdir(path.dirname(p), { recursive: true }); - await fs.writeFile(p, JSON.stringify(ledger, null, 2), 'utf-8'); + const dir = path.dirname(p); + await fs.mkdir(dir, { recursive: true }); + // Atomic write: write to temp file then rename to avoid corruption on crash + const tmp = path.join(dir, `.processed.json.${Date.now()}.tmp`); + await fs.writeFile(tmp, JSON.stringify(ledger, null, 2), 'utf-8'); + await fs.rename(tmp, p); } export function isAlreadyProcessed(ledger: Ledger, filePath: string): boolean { diff --git a/src/core/models.ts b/src/core/models.ts new file mode 100644 index 0000000..b7ee1b4 --- /dev/null +++ b/src/core/models.ts @@ -0,0 +1,12 @@ +export const MODEL_ALIASES: Record = { + small: 'claude-haiku-4-5-20251001', + medium: 'claude-sonnet-4-6', + large: 'claude-opus-4-6', +}; + +const DEFAULT_MODEL = 'medium'; + +export function resolveModel(): string { + const env = process.env.R2S_MODEL || DEFAULT_MODEL; + return MODEL_ALIASES[env] || env; +} diff --git a/src/core/parse.ts b/src/core/parse.ts index ec3c5e3..a2c78e2 100644 --- a/src/core/parse.ts +++ b/src/core/parse.ts @@ -8,19 +8,7 @@ import type { import { ReceiptParseResultSchema, type ReceiptParseResult } from '../schemas/receipt.js'; import type { Vendor } from '../schemas/config.js'; import { redactPII, type RedactionSummary } from '../utils/redact.js'; - -const MODEL_ALIASES: Record = { - small: 'claude-haiku-4-5-20251001', - medium: 'claude-sonnet-4-6', - large: 'claude-opus-4-6', -}; - -const DEFAULT_MODEL = 'medium'; - -function resolveModel(): string { - const env = process.env.R2S_MODEL || DEFAULT_MODEL; - return MODEL_ALIASES[env] || env; -} +import { resolveModel } from './models.js'; const SYSTEM_PROMPT = `You are a receipt parser for rental property expense tracking. @@ -121,7 +109,13 @@ export async function parseReceipt( }); const rawResponse = response.content[0].type === 'text' ? response.content[0].text : ''; - const parsed = JSON.parse(rawResponse); + + let parsed: unknown; + try { + parsed = JSON.parse(rawResponse); + } catch { + throw new Error(`Claude returned invalid JSON. Raw response: "${rawResponse.slice(0, 200)}"`); + } return { parsed: ReceiptParseResultSchema.parse(parsed), diff --git a/src/schemas/expense.ts b/src/schemas/expense.ts index 1019298..0a0cb04 100644 --- a/src/schemas/expense.ts +++ b/src/schemas/expense.ts @@ -22,7 +22,16 @@ export const ExpenseTypeSchema = z.enum(['operating', 'capital']); export const ExpenseSchema = z.object({ vendor: z.string().min(1), - date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + date: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD format') + .refine( + (s) => { + const d = new Date(s + 'T00:00:00'); + return !isNaN(d.getTime()) && d.toISOString().startsWith(s); + }, + { message: 'Invalid date — must be a real YYYY-MM-DD date' }, + ), amount: z.number().nonnegative(), description: z.string(), category: CategorySchema, diff --git a/src/schemas/receipt.ts b/src/schemas/receipt.ts index 2889147..8b7846f 100644 --- a/src/schemas/receipt.ts +++ b/src/schemas/receipt.ts @@ -5,7 +5,16 @@ export const ExpenseTypeSchema = z.enum(['operating', 'capital']); export const ReceiptParseResultSchema = z.object({ vendor: z.string().min(1), - date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + date: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD format') + .refine( + (s) => { + const d = new Date(s + 'T00:00:00'); + return !isNaN(d.getTime()) && d.toISOString().startsWith(s); + }, + { message: 'Invalid date — must be a real YYYY-MM-DD date' }, + ), amount: z.number().nonnegative(), description: z.string(), category: CategorySchema, diff --git a/src/utils/files.ts b/src/utils/files.ts index f7a9c9e..b6b5a41 100644 --- a/src/utils/files.ts +++ b/src/utils/files.ts @@ -26,6 +26,13 @@ export function isSupportedFile(filePath: string): boolean { return SUPPORTED_EXTENSIONS.includes(ext); } +export function slugify(str: string): string { + return str + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); +} + export async function listInboxFiles(inboxPath: string): Promise { try { const entries = await fs.readdir(inboxPath); From 186a6dd04cba2e741d5f23be6140c2fda0ea17c5 Mon Sep 17 00:00:00 2001 From: Nicholas Hart Date: Mon, 18 May 2026 23:32:27 -0700 Subject: [PATCH 4/4] Add error context, property validation, env var warnings, and test coverage - Error messages now include phase context ([extracting text], [calling Claude API], etc.) so users know what step failed - --property flag validated against config; warns on unknown property - Invalid R2S_MAX_FILE_SIZE_MB warns instead of silently falling back - Tests for: impossible dates (month 13, Feb 30), corrupted ledger recovery, invalid JSON API response message, error phase context, unknown property warning Co-Authored-By: Claude Opus 4.6 --- src/commands/process.ts | 34 +++++++++++++++++++++++++--- test/core/ledger.test.ts | 9 ++++++++ test/core/parse.test.ts | 8 ++++--- test/e2e/process-errors.test.ts | 40 +++++++++++++++++++++++++++++++++ test/schemas/schemas.test.ts | 36 +++++++++++++++++++++++++++++ 5 files changed, 121 insertions(+), 6 deletions(-) diff --git a/src/commands/process.ts b/src/commands/process.ts index d9e4923..8ff8ca2 100644 --- a/src/commands/process.ts +++ b/src/commands/process.ts @@ -23,6 +23,18 @@ export async function processCommand(files: string[], options: ProcessOptions): const { config, configDir } = await loadConfig(); const ledger = await loadLedger(configDir); + // Validate --property flag + if (options.property) { + const knownIds = config.properties.map((p) => p.id); + if (!knownIds.includes(options.property)) { + console.log( + chalk.yellow( + `Warning: property "${options.property}" not found in config. Known properties: ${knownIds.join(', ')}`, + ), + ); + } + } + // Determine which files to process let filesToProcess: string[]; if (files.length > 0) { @@ -71,6 +83,7 @@ export async function processCommand(files: string[], options: ProcessOptions): const filename = path.basename(filePath); process.stdout.write(chalk.dim(` Parsing ${filename}... `)); + let phase = 'reading file'; try { // Check file size const stats = await fs.stat(filePath); @@ -86,23 +99,29 @@ export async function processCommand(files: string[], options: ProcessOptions): if (['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) { // Use vision for image files + phase = 'reading image'; const base64 = await readFileAsBase64(filePath); const mediaType = getMediaType(filePath); + phase = 'calling Claude API'; parseResult = await parseReceipt( { type: 'image', data: base64, mediaType }, config.vendors || {}, ); } else if (ext === '.pdf') { // Try text extraction first; fall back to sending PDF as document + phase = 'extracting text'; const extraction = await extractText(filePath); if (extraction.text && !extraction.needsVision) { + phase = 'calling Claude API'; parseResult = await parseReceipt( { type: 'text', text: extraction.text }, config.vendors || {}, ); } else { // Send PDF directly to Claude as a document + phase = 'reading PDF'; const base64 = await readFileAsBase64(filePath); + phase = 'calling Claude API'; parseResult = await parseReceipt( { type: 'document', data: base64, mediaType: 'application/pdf' }, config.vendors || {}, @@ -134,7 +153,7 @@ export async function processCommand(files: string[], options: ProcessOptions): } } catch (err) { const message = err instanceof Error ? err.message : String(err); - errors.push({ file: filename, error: message }); + errors.push({ file: filename, error: `[${phase}] ${message}` }); console.log(chalk.red('failed')); } } @@ -349,6 +368,15 @@ const DEFAULT_MAX_FILE_SIZE_MB = 10; function getMaxFileSizeBytes(): number { const envVal = process.env.R2S_MAX_FILE_SIZE_MB; - const mb = envVal ? parseInt(envVal, 10) : DEFAULT_MAX_FILE_SIZE_MB; - return (isNaN(mb) ? DEFAULT_MAX_FILE_SIZE_MB : mb) * 1024 * 1024; + if (!envVal) return DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024; + const mb = parseInt(envVal, 10); + if (isNaN(mb) || mb <= 0) { + console.log( + chalk.yellow( + `Warning: invalid R2S_MAX_FILE_SIZE_MB="${envVal}", using default (${DEFAULT_MAX_FILE_SIZE_MB} MB)`, + ), + ); + return DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024; + } + return mb * 1024 * 1024; } diff --git a/test/core/ledger.test.ts b/test/core/ledger.test.ts index 266d967..58a6045 100644 --- a/test/core/ledger.test.ts +++ b/test/core/ledger.test.ts @@ -35,6 +35,15 @@ describe('loadLedger', () => { expect(ledger).toEqual({}); }); + it('throws with actionable message for corrupted ledger', async () => { + const ledgerDir = path.join(tmpDir, '.r2s'); + await fs.mkdir(ledgerDir, { recursive: true }); + await fs.writeFile(path.join(ledgerDir, 'processed.json'), '{not valid json!!!'); + + await expect(loadLedger(tmpDir)).rejects.toThrow(/Corrupted ledger file/); + await expect(loadLedger(tmpDir)).rejects.toThrow(/Back up the file/); + }); + it('loads existing ledger', async () => { const ledgerDir = path.join(tmpDir, '.r2s'); await fs.mkdir(ledgerDir, { recursive: true }); diff --git a/test/core/parse.test.ts b/test/core/parse.test.ts index f3fbf23..3674209 100644 --- a/test/core/parse.test.ts +++ b/test/core/parse.test.ts @@ -84,12 +84,14 @@ describe('parseReceipt', () => { expect(textContent).toContain('Home Depot: Repairs'); }); - it('throws on invalid JSON response', async () => { + it('throws with helpful message on invalid JSON response', async () => { mockCreate.mockResolvedValueOnce({ - content: [{ type: 'text', text: 'not json' }], + content: [{ type: 'text', text: 'Sorry, I cannot process this.' }], }); - await expect(parseReceipt({ type: 'text', text: 'test' }, {})).rejects.toThrow(); + await expect(parseReceipt({ type: 'text', text: 'test' }, {})).rejects.toThrow( + /Claude returned invalid JSON.*Sorry, I cannot process this/, + ); }); it('redacts credit card numbers from text before API call', async () => { diff --git a/test/e2e/process-errors.test.ts b/test/e2e/process-errors.test.ts index 09f0dcb..8d631f2 100644 --- a/test/e2e/process-errors.test.ts +++ b/test/e2e/process-errors.test.ts @@ -102,6 +102,46 @@ describe('process error handling', () => { expect(output).toContain('failed to parse'); }); + it('includes phase context in error messages', async () => { + const srcPdf = path.join(FIXTURES_DIR, 'receipts/service-invoice-landscaping.pdf'); + const inboxPdf = path.join(tmpDir, 'inbox', 'landscaping.pdf'); + await fs.copyFile(srcPdf, inboxPdf); + + mockCreate.mockRejectedValueOnce(new Error('connection timeout')); + + await processCommand([], { yes: true }); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('[calling Claude API]'); + expect(output).toContain('connection timeout'); + }); + + it('warns on unknown --property flag', async () => { + const srcPdf = path.join(FIXTURES_DIR, 'receipts/service-invoice-landscaping.pdf'); + const inboxPdf = path.join(tmpDir, 'inbox', 'landscaping.pdf'); + await fs.copyFile(srcPdf, inboxPdf); + + mockCreate.mockResolvedValueOnce({ + content: [{ + type: 'text', + text: JSON.stringify({ + vendor: 'Test', + date: '2026-04-05', + amount: 100, + description: 'Test', + category: 'Repairs', + expense_type: 'operating', + }), + }], + }); + + await processCommand([], { yes: true, property: 'nonexistent' }); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('not found in config'); + expect(output).toContain('cabin'); + }); + it('--force reprocesses already-processed files', async () => { const srcPdf = path.join(FIXTURES_DIR, 'receipts/service-invoice-landscaping.pdf'); const inboxPdf = path.join(tmpDir, 'inbox', 'landscaping.pdf'); diff --git a/test/schemas/schemas.test.ts b/test/schemas/schemas.test.ts index 3c10415..be771ac 100644 --- a/test/schemas/schemas.test.ts +++ b/test/schemas/schemas.test.ts @@ -28,6 +28,42 @@ describe('ExpenseSchema', () => { ).toThrow(); }); + it('rejects impossible dates (month 13)', () => { + expect(() => + ExpenseSchema.parse({ + vendor: 'Amazon', + date: '2026-13-01', + amount: 45.99, + description: 'Towels', + category: 'Supplies', + }), + ).toThrow(/Invalid date/); + }); + + it('rejects impossible dates (day 32)', () => { + expect(() => + ExpenseSchema.parse({ + vendor: 'Amazon', + date: '2026-01-32', + amount: 45.99, + description: 'Towels', + category: 'Supplies', + }), + ).toThrow(/Invalid date/); + }); + + it('rejects Feb 30', () => { + expect(() => + ExpenseSchema.parse({ + vendor: 'Amazon', + date: '2026-02-30', + amount: 45.99, + description: 'Towels', + category: 'Supplies', + }), + ).toThrow(/Invalid date/); + }); + it('allows zero amount (agreements without invoiced amount)', () => { const result = ExpenseSchema.parse({ vendor: 'Mountain Valley Electric',