Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions scripts/generate-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,68 @@ async function generateEmptyPdf(): Promise<void> {
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<void> {
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');

Expand All @@ -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/');
}
Expand Down
69 changes: 69 additions & 0 deletions scripts/preview-redaction.ts
Original file line number Diff line number Diff line change
@@ -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> [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}`);
}
}
8 changes: 2 additions & 6 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -102,12 +103,7 @@ export async function doctorCommand(options: DoctorOptions): Promise<void> {

// 5. Model config
const modelEnv = process.env.R2S_MODEL || 'medium';
const MODEL_ALIASES: Record<string, string> = {
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',
Expand Down
15 changes: 1 addition & 14 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -100,12 +100,6 @@ export async function initCommand(options: InitOptions): Promise<void> {
}
}

// 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();
Expand All @@ -115,10 +109,3 @@ export async function initCommand(options: InitOptions): Promise<void> {
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, '');
}
49 changes: 36 additions & 13 deletions src/commands/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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 || {},
Expand Down Expand Up @@ -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'));
}
}
Expand Down Expand Up @@ -282,7 +301,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') {
Expand Down Expand Up @@ -319,7 +339,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,
Expand Down Expand Up @@ -347,13 +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;
}

function slugify(str: string): string {
return str
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
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;
}
Loading
Loading