Initial build: SimpleScan Solo v0.1.0 - Freelancer receipt capture Chrome extension

This commit is contained in:
Bun Bun
2026-06-16 18:27:31 +00:00
commit df4d26c049
33 changed files with 3962 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
*.log
.venv/
.verdict
.DS_Store
*.local
+63
View File
@@ -0,0 +1,63 @@
import { describe, it } from 'node:test';
import assert from 'node:assert';
// Test categories
import { TAX_CATEGORIES, getCategoryLabel, getCategoryIcon } from '../utils/categories.js';
describe('categories', () => {
it('has 10 tax categories', () => {
assert.strictEqual(TAX_CATEGORIES.length, 10);
});
it('gets correct label', () => {
assert.strictEqual(getCategoryLabel('software'), 'Software');
assert.strictEqual(getCategoryLabel('equipment'), 'Equipment');
assert.strictEqual(getCategoryLabel('nonexistent'), 'Other');
});
it('gets correct icon', () => {
assert.strictEqual(getCategoryIcon('software'), '⚙️');
assert.strictEqual(getCategoryIcon('travel'), '✈️');
assert.strictEqual(getCategoryIcon('nonexistent'), '📦');
});
});
// Test export
import { exportToCsv } from '../utils/export.js';
describe('export', () => {
it('exports receipts to CSV', () => {
const receipts = [
{
id: '1',
title: 'Adobe CC',
amount: 59.99,
currency: 'USD',
category: 'software',
date: '2026-06-01',
vendor: 'Adobe',
notes: 'Monthly',
source: 'manual',
createdAt: '2026-06-01T00:00:00Z',
},
];
const csv = exportToCsv(receipts);
console.log('CSV output:', csv);
assert.ok(csv.includes('Date'), 'CSV should have Date header');
assert.ok(csv.includes('Adobe CC'), 'CSV should have title');
assert.ok(csv.includes('59.99'), 'CSV should have amount');
assert.ok(csv.includes('Software'), 'CSV should have category label');
});
it('escapes quotes in CSV', () => {
const receipts = [
{
id: '1',
title: 'My "Special" Item',
amount: 10,
currency: 'USD',
category: 'other',
date: '2026-06-01',
vendor: 'Vendor',
notes: '',
source: 'manual',
createdAt: '2026-06-01T00:00:00Z',
},
];
const csv = exportToCsv(receipts);
assert.ok(csv.includes('""'), 'CSV should escape quotes');
});
});
+5
View File
@@ -0,0 +1,5 @@
export const DEFAULT_SETTINGS = {
defaultCurrency: 'USD',
taxYear: new Date().getFullYear().toString(),
ownerName: '',
};
+68
View File
@@ -0,0 +1,68 @@
export const TAX_CATEGORIES = [
{
id: 'equipment',
label: 'Equipment',
description: 'Computers, cameras, tools, hardware',
icon: '💻',
},
{
id: 'home_office',
label: 'Home Office',
description: 'Desk, chair, lighting, office supplies',
icon: '🏠',
},
{
id: 'software',
label: 'Software',
description: 'Apps, subscriptions, SaaS tools',
icon: '⚙️',
},
{
id: 'internet_phone',
label: 'Internet & Phone',
description: 'ISP, cell plan, domain hosting',
icon: '📡',
},
{
id: 'travel',
label: 'Travel',
description: 'Flights, hotels, rideshare, mileage',
icon: '✈️',
},
{
id: 'meals',
label: 'Meals',
description: 'Business meals (50% deductible)',
icon: '🍽️',
},
{
id: 'professional_services',
label: 'Professional Services',
description: 'Lawyer, accountant, contractor payments',
icon: '📋',
},
{
id: 'marketing',
label: 'Marketing',
description: 'Ads, design, copywriting, PR',
icon: '📢',
},
{
id: 'education',
label: 'Education',
description: 'Courses, books, conferences',
icon: '📚',
},
{
id: 'other',
label: 'Other',
description: 'Miscellaneous business expenses',
icon: '📦',
},
];
export function getCategoryLabel(id) {
return TAX_CATEGORIES.find((c) => c.id === id)?.label || 'Other';
}
export function getCategoryIcon(id) {
return TAX_CATEGORIES.find((c) => c.id === id)?.icon || '📦';
}
+32
View File
@@ -0,0 +1,32 @@
import { getCategoryLabel } from './categories.js';
export function exportToCsv(receipts) {
const headers = ['Date', 'Vendor', 'Title', 'Category', 'Amount', 'Currency', 'Notes', 'Source'];
const rows = receipts.map((r) => [
r.date,
r.vendor,
r.title,
getCategoryLabel(r.category),
r.amount.toFixed(2),
r.currency,
r.notes,
r.source,
]);
const csv = [headers, ...rows]
.map((row) => row
.map((cell) => {
const str = String(cell).replace(/"/g, '""');
return `"${str}"`;
})
.join(','))
.join('\n');
return csv;
}
export function downloadBlob(content, filename, mimeType) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
+33
View File
@@ -0,0 +1,33 @@
import { DEFAULT_SETTINGS } from '../types.js';
const RECEIPTS_KEY = 'simplescan_receipts';
const SETTINGS_KEY = 'simplescan_settings';
export async function getReceipts() {
const result = await chrome.storage.local.get(RECEIPTS_KEY);
return result[RECEIPTS_KEY] || [];
}
export async function saveReceipt(receipt) {
const receipts = await getReceipts();
const existingIndex = receipts.findIndex((r) => r.id === receipt.id);
if (existingIndex >= 0) {
receipts[existingIndex] = receipt;
}
else {
receipts.unshift(receipt);
}
await chrome.storage.local.set({ [RECEIPTS_KEY]: receipts });
}
export async function deleteReceipt(id) {
const receipts = await getReceipts();
const filtered = receipts.filter((r) => r.id !== id);
await chrome.storage.local.set({ [RECEIPTS_KEY]: filtered });
}
export async function getSettings() {
const result = await chrome.storage.local.get(SETTINGS_KEY);
return result[SETTINGS_KEY] || { ...DEFAULT_SETTINGS };
}
export async function saveSettings(settings) {
await chrome.storage.local.set({ [SETTINGS_KEY]: settings });
}
export async function clearAllReceipts() {
await chrome.storage.local.remove(RECEIPTS_KEY);
}
+153
View File
@@ -0,0 +1,153 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SimpleScan Solo - Settings & Export</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.5;
color: #1a1a2e;
background: #f8f9fa;
padding: 24px;
max-width: 720px;
margin: 0 auto;
}
h1 { font-size: 24px; margin-bottom: 4px; }
.subtitle { color: #6c757d; margin-bottom: 20px; }
.card {
background: #fff;
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.card h2 { font-size: 16px; margin-bottom: 14px; color: #16213e; }
.form-group { margin-bottom: 14px; }
label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 5px; color: #343a40; }
input, select {
width: 100%; max-width: 300px;
padding: 8px 10px;
border: 1px solid #ced4da;
border-radius: 6px;
font-size: 14px;
}
input:focus, select:focus {
outline: none;
border-color: #0f3460;
}
.btn-primary {
padding: 10px 20px;
border: none;
border-radius: 8px;
background: #0f3460;
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.btn-primary:hover { background: #16213e; }
.btn-secondary {
padding: 10px 20px;
border: 1px solid #ced4da;
border-radius: 8px;
background: #fff;
color: #343a40;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.btn-secondary:hover { background: #f8f9fa; }
.btn-danger {
padding: 10px 20px;
border: 1px solid #dc3545;
border-radius: 8px;
background: #fff;
color: #dc3545;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.btn-danger:hover { background: #fff5f5; }
.export-grid { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; }
.summary-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #e9ecef; }
.summary-row:last-child { border-bottom: none; }
.summary-label { color: #6c757d; }
.summary-value { font-weight: 600; }
.receipts-table { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 13px; }
.receipts-table th { text-align: left; padding: 8px; border-bottom: 2px solid #e9ecef; color: #6c757d; font-weight: 600; }
.receipts-table td { padding: 8px; border-bottom: 1px solid #e9ecef; }
.toast {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
background: #16213e; color: #fff; padding: 10px 20px; border-radius: 8px;
font-weight: 500; opacity: 1; transition: opacity 0.3s;
}
.toast.hidden { opacity: 0; pointer-events: none; }
.danger-zone { border: 1px solid #f5c6cb; background: #fff5f5; }
.danger-zone h2 { color: #721c24; }
</style>
</head>
<body>
<h1>📸 SimpleScan Solo</h1>
<p class="subtitle">Settings &amp; Export Center</p>
<div class="card">
<h2>⚙️ Your Settings</h2>
<div class="form-group">
<label for="s-name">Your Name (for exports)</label>
<input type="text" id="s-name" placeholder="Jane Doe" />
</div>
<div class="form-group">
<label for="s-currency">Default Currency</label>
<select id="s-currency">
<option value="USD">USD ($)</option>
<option value="EUR">EUR (€)</option>
<option value="GBP">GBP (£)</option>
<option value="CAD">CAD (C$)</option>
<option value="AUD">AUD (A$)</option>
</select>
</div>
<div class="form-group">
<label for="s-year">Tax Year</label>
<input type="text" id="s-year" placeholder="2026" maxlength="4" />
</div>
<button id="save-settings" class="btn-primary">Save Settings</button>
</div>
<div class="card">
<h2>📊 Receipt Summary</h2>
<div id="summary-content">
<div class="summary-row"><span class="summary-label">Total Receipts</span><span class="summary-value" id="sum-count">0</span></div>
<div class="summary-row"><span class="summary-label">Total Amount</span><span class="summary-value" id="sum-total">$0.00</span></div>
</div>
</div>
<div class="card">
<h2>📄 Export Receipts</h2>
<div class="export-grid">
<button id="export-csv" class="btn-secondary">Download CSV</button>
<button id="export-pdf" class="btn-secondary">Download PDF Report</button>
</div>
</div>
<div class="card">
<h2>📋 All Receipts</h2>
<div id="receipts-table-wrap">
<p style="color:#6c757d; padding: 16px 0;">No receipts yet. Add some from the extension popup.</p>
</div>
</div>
<div class="card danger-zone">
<h2>⚠️ Danger Zone</h2>
<p style="color:#721c24; font-size:13px; margin-bottom:12px;">This will permanently delete all your saved receipts. This cannot be undone.</p>
<button id="clear-all" class="btn-danger">Delete All Receipts</button>
</div>
<div id="toast" class="toast hidden"></div>
<script type="module" src="./src/options.ts"></script>
</body>
</html>
+1848
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "simplescan-solo",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "tsc -p tsconfig.test.json && node --test dist-test/**/*.test.js"
},
"dependencies": {
"@types/node": "^25.9.3",
"jspdf": "^2.5.1",
"jspdf-autotable": "^3.8.2",
"tesseract.js": "^5.0.5"
},
"devDependencies": {
"@crxjs/vite-plugin": "^2.0.0-beta.26",
"@types/chrome": "^0.0.268",
"typescript": "^5.4.5",
"vite": "^5.2.12"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 307 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 B

+65
View File
@@ -0,0 +1,65 @@
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
const iconsDir = path.join(__dirname, 'icons');
fs.mkdirSync(iconsDir, { recursive: true });
function crc32(buf) {
const table = [];
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) {
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
}
table[i] = c;
}
let crc = -1;
for (const byte of buf) {
crc = table[(crc ^ byte) & 0xFF] ^ (crc >>> 8);
}
return (crc ^ -1) >>> 0;
}
function makeChunk(typeStr, data) {
const typeBuf = Buffer.from(typeStr, 'ascii');
const c = crc32(Buffer.concat([typeBuf, data]));
const crcBuf = Buffer.alloc(4);
crcBuf.writeUInt32BE(c, 0);
const lenBuf = Buffer.alloc(4);
lenBuf.writeUInt32BE(data.length, 0);
return Buffer.concat([lenBuf, typeBuf, data, crcBuf]);
}
function makePng(size, r, g, b) {
const sig = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
const ihdrData = Buffer.alloc(13);
ihdrData.writeUInt32BE(size, 0);
ihdrData.writeUInt32BE(size, 4);
ihdrData.writeUInt8(8, 8);
ihdrData.writeUInt8(2, 9);
ihdrData.writeUInt8(0, 10);
ihdrData.writeUInt8(0, 11);
ihdrData.writeUInt8(0, 12);
const rawData = [];
for (let y = 0; y < size; y++) {
rawData.push(0);
for (let x = 0; x < size; x++) {
rawData.push(r, g, b);
}
}
const imageData = zlib.deflateSync(Buffer.from(rawData));
const ihdr = makeChunk('IHDR', ihdrData);
const idat = makeChunk('IDAT', imageData);
const iend = makeChunk('IEND', Buffer.alloc(0));
return Buffer.concat([sig, ihdr, idat, iend]);
}
const r = 15, g = 52, b = 96;
fs.writeFileSync(path.join(iconsDir, 'icon16.png'), makePng(16, r, g, b));
fs.writeFileSync(path.join(iconsDir, 'icon48.png'), makePng(48, r, g, b));
fs.writeFileSync(path.join(iconsDir, 'icon128.png'), makePng(128, r, g, b));
console.log('Icons created');
+65
View File
@@ -0,0 +1,65 @@
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
const iconsDir = path.join(__dirname, '..', 'public', 'icons');
fs.mkdirSync(iconsDir, { recursive: true });
function crc32(buf) {
const table = [];
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) {
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
}
table[i] = c;
}
let crc = -1;
for (const byte of buf) {
crc = table[(crc ^ byte) & 0xFF] ^ (crc >>> 8);
}
return (crc ^ -1) >>> 0;
}
function makeChunk(typeStr, data) {
const typeBuf = Buffer.from(typeStr, 'ascii');
const c = crc32(Buffer.concat([typeBuf, data]));
const crcBuf = Buffer.alloc(4);
crcBuf.writeUInt32BE(c, 0);
const lenBuf = Buffer.alloc(4);
lenBuf.writeUInt32BE(data.length, 0);
return Buffer.concat([lenBuf, typeBuf, data, crcBuf]);
}
function makePng(size, r, g, b) {
const sig = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
const ihdrData = Buffer.alloc(13);
ihdrData.writeUInt32BE(size, 0);
ihdrData.writeUInt32BE(size, 4);
ihdrData.writeUInt8(8, 8);
ihdrData.writeUInt8(2, 9);
ihdrData.writeUInt8(0, 10);
ihdrData.writeUInt8(0, 11);
ihdrData.writeUInt8(0, 12);
const rawData = [];
for (let y = 0; y < size; y++) {
rawData.push(0);
for (let x = 0; x < size; x++) {
rawData.push(r, g, b);
}
}
const imageData = zlib.deflateSync(Buffer.from(rawData));
const ihdr = makeChunk('IHDR', ihdrData);
const idat = makeChunk('IDAT', imageData);
const iend = makeChunk('IEND', Buffer.alloc(0));
return Buffer.concat([sig, ihdr, idat, iend]);
}
const r = 15, g = 52, b = 96;
fs.writeFileSync(path.join(iconsDir, 'icon16.png'), makePng(16, r, g, b));
fs.writeFileSync(path.join(iconsDir, 'icon48.png'), makePng(48, r, g, b));
fs.writeFileSync(path.join(iconsDir, 'icon128.png'), makePng(128, r, g, b));
console.log('Icons created in public/icons');
+40
View File
@@ -0,0 +1,40 @@
const fs = require('fs');
const path = require('path');
const secretPatterns = [
/sk-[a-zA-Z0-9]{20,}/g,
/eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*/g,
/api[_-]?key\s*[:=]\s*['"][a-zA-Z0-9]{16,}['"]/gi,
/token\s*[:=]\s*['"][a-zA-Z0-9]{16,}['"]/gi,
];
const filesToCheck = [
'src/types.ts', 'src/background.ts', 'src/options.ts',
'src/popup/popup.ts', 'src/popup/popup.html', 'src/popup/popup.css',
'src/content/email-scanner.ts',
'src/utils/storage.ts', 'src/utils/categories.ts', 'src/utils/export.ts',
'options.html', 'package.json', 'vite.config.ts',
'tsconfig.json', 'tsconfig.test.json', '.gitignore'
];
let found = false;
for (const file of filesToCheck) {
const fp = path.join(__dirname, '..', file);
if (!fs.existsSync(fp)) continue;
const content = fs.readFileSync(fp, 'utf8');
for (const pattern of secretPatterns) {
const matches = content.match(pattern);
if (matches) {
console.log(`SECRET FOUND in ${file}: ${matches[0].substring(0, 30)}...`);
found = true;
}
}
}
if (found) {
console.log('SECRETS DETECTED - aborting');
process.exit(1);
} else {
console.log('No secrets found');
process.exit(0);
}
+21
View File
@@ -0,0 +1,21 @@
// Background service worker for SimpleScan Solo
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === 'install') {
// Set default settings on install
chrome.storage.local.set({
simplescan_settings: {
defaultCurrency: 'USD',
taxYear: new Date().getFullYear().toString(),
ownerName: '',
},
simplescan_receipts: [],
});
}
});
// Keep alive for messaging
chrome.runtime.onMessage.addListener((_request, _sender, sendResponse) => {
sendResponse({ ok: true });
return true;
});
+157
View File
@@ -0,0 +1,157 @@
// Content script: scan Gmail / Outlook web for receipt emails
interface EmailHint {
subject: string;
sender: string;
date: string;
amount?: number;
currency?: string;
vendor?: string;
}
function detectHost(): 'gmail' | 'outlook' | null {
const host = location.hostname;
if (host.includes('mail.google.com')) return 'gmail';
if (host.includes('outlook.')) return 'outlook';
return null;
}
function parseAmount(text: string): { amount?: number; currency?: string } {
// Match patterns like $29.99, USD 29.99, 29.99 USD, €19.50
const patterns = [
/\$\s?([\d,]+\.?\d{0,2})/,
/€\s?([\d,]+\.?\d{0,2})/,
/£\s?([\d,]+\.?\d{0,2})/,
/([\d,]+\.?\d{0,2})\s?USD/i,
/([\d,]+\.?\d{0,2})\s?EUR/i,
/total[\s:]*\$?\s?([\d,]+\.?\d{0,2})/i,
/amount[\s:]*\$?\s?([\d,]+\.?\d{0,2})/i,
/charged[\s:]*\$?\s?([\d,]+\.?\d{0,2})/i,
/payment[\s:]*\$?\s?([\d,]+\.?\d{0,2})/i,
/price[\s:]*\$?\s?([\d,]+\.?\d{0,2})/i,
];
for (const pattern of patterns) {
const match = text.match(pattern);
if (match) {
const amount = parseFloat(match[1].replace(/,/g, ''));
const currency = text.includes('€')
? 'EUR'
: text.includes('£')
? 'GBP'
: text.includes('EUR')
? 'EUR'
: 'USD';
if (!isNaN(amount) && amount > 0) {
return { amount, currency };
}
}
}
return {};
}
function isReceiptEmail(subject: string, body: string): boolean {
const receiptKeywords = [
'receipt', 'invoice', 'payment confirmation', 'order confirmation',
'your receipt', 'purchase confirmation', 'billing receipt',
'subscription receipt', 'payment receipt', 'order summary',
'thank you for your purchase', 'payment successful',
'charge confirmation', 'transaction confirmation',
];
const lowerSub = subject.toLowerCase();
const lowerBody = body.toLowerCase().slice(0, 2000);
return receiptKeywords.some((k) => lowerSub.includes(k) || lowerBody.includes(k));
}
function scanGmail(): EmailHint[] {
const hints: EmailHint[] = [];
// Try to find email rows in conversation view or list view
const subjects = document.querySelectorAll('h2[data-thread-perm-id]');
if (subjects.length > 0) {
// Conversation view - single email thread
for (const subjEl of subjects) {
const subject = subjEl.textContent?.trim() || '';
const senderEl = document.querySelector('span[email]');
const sender = senderEl?.getAttribute('email') || senderEl?.textContent?.trim() || '';
const dateEl = document.querySelector('td[colspan] span[title]');
const date = dateEl?.getAttribute('title') || dateEl?.textContent?.trim() || '';
const bodyEl = document.querySelector('.ii.gt');
const body = bodyEl?.textContent?.trim() || '';
if (isReceiptEmail(subject, body)) {
const { amount, currency } = parseAmount(subject + ' ' + body);
hints.push({ subject, sender, date, amount, currency, vendor: sender.split('@')[1] || sender });
}
}
} else {
// List view - multiple emails
const rows = document.querySelectorAll('tr.zA');
for (const row of rows) {
const subjectEl = row.querySelector('.y6');
const subject = subjectEl?.textContent?.trim() || '';
const senderEl = row.querySelector('.yW span[email]');
const sender = senderEl?.getAttribute('email') || senderEl?.textContent?.trim() || '';
const dateEl = row.querySelector('.xY span');
const date = dateEl?.textContent?.trim() || '';
const snippetEl = row.querySelector('.y2');
const snippet = snippetEl?.textContent?.trim() || '';
if (isReceiptEmail(subject, snippet)) {
const { amount, currency } = parseAmount(subject + ' ' + snippet);
hints.push({ subject, sender, date, amount, currency, vendor: sender.split('@')[1] || sender });
}
}
}
return hints;
}
function scanOutlook(): EmailHint[] {
const hints: EmailHint[] = [];
// Outlook web - reading pane or list view
const readingPaneSubject = document.querySelector('[role="region"] div[title]');
if (readingPaneSubject) {
// Reading pane mode
const subject = readingPaneSubject.getAttribute('title')?.trim() || '';
const senderEl = document.querySelector('[role="region"] span[data-lpc-disable-compression]');
const sender = senderEl?.textContent?.trim() || '';
const dateEl = document.querySelector('[role="region"] [data-testid="messageHeaderDate"]');
const date = dateEl?.textContent?.trim() || '';
const bodyEl = document.querySelector('[role="region"] #UniqueMessageBody, [role="region"] .x_text-container');
const body = bodyEl?.textContent?.trim() || '';
if (isReceiptEmail(subject, body)) {
const { amount, currency } = parseAmount(subject + ' ' + body);
hints.push({ subject, sender, date, amount, currency, vendor: sender.split('@')[1] || sender });
}
} else {
// List view
const items = document.querySelectorAll('[data-testid="mailListItem"], .lvHighlightAllClass');
for (const item of items) {
const subjectEl = item.querySelector('[data-testid="threadItemSubject"]');
const subject = subjectEl?.textContent?.trim() || '';
const senderEl = item.querySelector('[data-testid="threadItemFrom"]');
const sender = senderEl?.textContent?.trim() || '';
const dateEl = item.querySelector('[data-testid="threadItemDate"]');
const date = dateEl?.textContent?.trim() || '';
const snippetEl = item.querySelector('[data-testid="threadItemPreview"]');
const snippet = snippetEl?.textContent?.trim() || '';
if (isReceiptEmail(subject, snippet)) {
const { amount, currency } = parseAmount(subject + ' ' + snippet);
hints.push({ subject, sender, date, amount, currency, vendor: sender.split('@')[1] || sender });
}
}
}
return hints;
}
// Listen for messages from popup
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
if (request.action === 'scanEmails') {
const host = detectHost();
let receipts: EmailHint[] = [];
if (host === 'gmail') {
receipts = scanGmail();
} else if (host === 'outlook') {
receipts = scanOutlook();
}
sendResponse({ receipts });
}
return true; // async response
});
+43
View File
@@ -0,0 +1,43 @@
{
"manifest_version": 3,
"name": "SimpleScan Solo - Freelancer Receipts",
"version": "0.1.0",
"description": "Dead-simple receipt capture for freelancers. No team bloat, just capture, categorize, export.",
"permissions": ["storage", "activeTab", "scripting"],
"host_permissions": [
"https://mail.google.com/*",
"https://outlook.live.com/*",
"https://outlook.office.com/*",
"https://outlook.office365.com/*"
],
"action": {
"default_popup": "src/popup/popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"background": {
"service_worker": "src/background.ts",
"type": "module"
},
"content_scripts": [
{
"matches": [
"https://mail.google.com/*",
"https://outlook.live.com/*",
"https://outlook.office.com/*",
"https://outlook.office365.com/*"
],
"js": ["src/content/email-scanner.ts"],
"run_at": "document_idle"
}
],
"options_page": "options.html",
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
+202
View File
@@ -0,0 +1,202 @@
import { getReceipts, getSettings, saveSettings, clearAllReceipts } from './utils/storage.js';
import { getCategoryLabel } from './utils/categories.js';
import { exportToCsv, downloadBlob } from './utils/export.js';
import type { Receipt, AppSettings } from './types.js';
const nameInput = document.getElementById('s-name') as HTMLInputElement;
const currencySelect = document.getElementById('s-currency') as HTMLSelectElement;
const yearInput = document.getElementById('s-year') as HTMLInputElement;
const saveBtn = document.getElementById('save-settings') as HTMLButtonElement;
const sumCount = document.getElementById('sum-count') as HTMLSpanElement;
const sumTotal = document.getElementById('sum-total') as HTMLSpanElement;
const tableWrap = document.getElementById('receipts-table-wrap') as HTMLDivElement;
const toast = document.getElementById('toast') as HTMLDivElement;
let currentReceipts: Receipt[] = [];
async function init() {
const settings = await getSettings();
nameInput.value = settings.ownerName || '';
currencySelect.value = settings.defaultCurrency || 'USD';
yearInput.value = settings.taxYear || new Date().getFullYear().toString();
await loadData();
saveBtn.addEventListener('click', async () => {
const settings: AppSettings = {
ownerName: nameInput.value.trim(),
defaultCurrency: currencySelect.value,
taxYear: yearInput.value.trim(),
};
await saveSettings(settings);
showToast('Settings saved');
});
document.getElementById('export-csv')!.addEventListener('click', async () => {
if (currentReceipts.length === 0) {
showToast('No receipts to export');
return;
}
const csv = exportToCsv(currentReceipts);
const year = yearInput.value || new Date().getFullYear();
downloadBlob(csv, `simplescan-receipts-${year}.csv`, 'text/csv');
showToast('CSV downloaded');
});
document.getElementById('export-pdf')!.addEventListener('click', async () => {
if (currentReceipts.length === 0) {
showToast('No receipts to export');
return;
}
exportPdf(currentReceipts);
});
document.getElementById('clear-all')!.addEventListener('click', async () => {
if (!confirm('Are you sure? This will delete ALL receipts forever.')) return;
await clearAllReceipts();
await loadData();
showToast('All receipts deleted');
});
}
async function loadData() {
currentReceipts = await getReceipts();
sumCount.textContent = String(currentReceipts.length);
const total = currentReceipts.reduce((s, r) => s + r.amount, 0);
const currency = currentReceipts[0]?.currency || 'USD';
sumTotal.textContent = formatMoney(total, currency);
if (currentReceipts.length === 0) {
tableWrap.innerHTML = '<p style="color:#6c757d; padding: 16px 0;">No receipts yet. Add some from the extension popup.</p>';
return;
}
tableWrap.innerHTML = `
<table class="receipts-table">
<thead>
<tr>
<th>Date</th>
<th>Vendor</th>
<th>Description</th>
<th>Category</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
${currentReceipts
.map(
(r) => `
<tr>
<td>${escapeHtml(r.date)}</td>
<td>${escapeHtml(r.vendor)}</td>
<td>${escapeHtml(r.title)}</td>
<td>${getCategoryLabel(r.category)}</td>
<td style="font-weight:600;">${formatMoney(r.amount, r.currency)}</td>
</tr>
`
)
.join('')}
</tbody>
</table>
`;
}
function exportPdf(receipts: Receipt[]) {
// Simple PDF generation using browser print to PDF approach
const currency = receipts[0]?.currency || 'USD';
const total = receipts.reduce((s, r) => s + r.amount, 0);
const year = yearInput.value || new Date().getFullYear();
const owner = nameInput.value.trim() || 'Freelancer';
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Receipt Report ${year}</title>
<style>
body { font-family: Arial, sans-serif; padding: 40px; color: #1a1a2e; }
h1 { font-size: 22px; margin-bottom: 4px; }
.meta { color: #6c757d; margin-bottom: 20px; }
table { width: 100%; border-collapse: collapse; margin-top: 16px; }
th { text-align: left; padding: 10px; border-bottom: 2px solid #333; font-size: 12px; text-transform: uppercase; }
td { padding: 10px; border-bottom: 1px solid #ddd; font-size: 13px; }
.total-row { font-weight: bold; background: #f8f9fa; }
.footer { margin-top: 30px; font-size: 11px; color: #6c757d; }
</style>
</head>
<body>
<h1>Receipt Report — ${year}</h1>
<div class="meta">${escapeHtml(owner)} · ${receipts.length} receipts · ${formatMoney(total, currency)} total</div>
<table>
<thead>
<tr>
<th>Date</th>
<th>Vendor</th>
<th>Description</th>
<th>Category</th>
<th>Amount</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
${receipts
.map(
(r) => `
<tr>
<td>${escapeHtml(r.date)}</td>
<td>${escapeHtml(r.vendor)}</td>
<td>${escapeHtml(r.title)}</td>
<td>${getCategoryLabel(r.category)}</td>
<td>${formatMoney(r.amount, r.currency)}</td>
<td>${escapeHtml(r.notes)}</td>
</tr>
`
)
.join('')}
<tr class="total-row">
<td colspan="4" style="text-align:right;">Total</td>
<td>${formatMoney(total, currency)}</td>
<td></td>
</tr>
</tbody>
</table>
<div class="footer">Generated by SimpleScan Solo</div>
</body>
</html>
`;
const printWindow = window.open('', '_blank');
if (!printWindow) {
showToast('Popup blocked. Allow popups for PDF export.');
return;
}
printWindow.document.write(html);
printWindow.document.close();
printWindow.focus();
setTimeout(() => {
printWindow.print();
}, 300);
showToast('PDF print dialog opened');
}
function formatMoney(amount: number, currency: string): string {
const map: Record<string, string> = { USD: '$', EUR: '€', GBP: '£', CAD: 'C$', AUD: 'A$' };
const symbol = map[currency] || currency + ' ';
return symbol + amount.toFixed(2);
}
function escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showToast(message: string) {
toast.textContent = message;
toast.classList.remove('hidden');
setTimeout(() => toast.classList.add('hidden'), 2500);
}
init();
+421
View File
@@ -0,0 +1,421 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
font-size: 14px;
line-height: 1.4;
color: #1a1a2e;
background: #f8f9fa;
width: 380px;
min-height: 520px;
}
#app {
padding: 12px;
}
.header {
text-align: center;
margin-bottom: 12px;
}
.header h1 {
font-size: 18px;
font-weight: 700;
color: #16213e;
}
.subtitle {
font-size: 12px;
color: #6c757d;
}
/* Tabs */
.tabs {
display: flex;
gap: 4px;
margin-bottom: 12px;
background: #e9ecef;
border-radius: 8px;
padding: 3px;
}
.tab-btn {
flex: 1;
padding: 8px 4px;
border: none;
background: transparent;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
color: #495057;
cursor: pointer;
transition: all 0.15s;
}
.tab-btn:hover {
background: rgba(255,255,255,0.5);
}
.tab-btn.active {
background: #fff;
color: #0f3460;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
/* Panels */
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
/* Form */
.form-group {
margin-bottom: 10px;
}
.form-row {
display: flex;
gap: 8px;
}
.form-row .form-group {
flex: 1;
}
label {
display: block;
font-size: 12px;
font-weight: 600;
color: #343a40;
margin-bottom: 4px;
}
input, select {
width: 100%;
padding: 8px 10px;
border: 1px solid #ced4da;
border-radius: 6px;
font-size: 13px;
background: #fff;
color: #212529;
}
input:focus, select:focus {
outline: none;
border-color: #0f3460;
box-shadow: 0 0 0 2px rgba(15,52,96,0.1);
}
.upload-area {
border: 2px dashed #ced4da;
border-radius: 8px;
padding: 16px;
text-align: center;
position: relative;
cursor: pointer;
transition: border-color 0.15s;
}
.upload-area:hover {
border-color: #0f3460;
}
.upload-area input[type="file"] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.upload-hint {
font-size: 12px;
color: #6c757d;
}
.image-preview {
max-width: 100%;
max-height: 120px;
margin-top: 8px;
border-radius: 6px;
display: block;
}
.image-preview.hidden {
display: none;
}
/* Buttons */
.btn-primary {
width: 100%;
padding: 10px;
border: none;
border-radius: 8px;
background: #0f3460;
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.btn-primary:hover {
background: #16213e;
}
.btn-secondary {
padding: 8px 14px;
border: 1px solid #ced4da;
border-radius: 6px;
background: #fff;
color: #343a40;
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.btn-secondary:hover {
background: #f8f9fa;
}
.btn-ghost {
padding: 8px 14px;
border: none;
border-radius: 6px;
background: transparent;
color: #6c757d;
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.btn-ghost:hover {
color: #343a40;
}
.btn-delete {
padding: 4px 8px;
border: none;
background: transparent;
cursor: pointer;
font-size: 14px;
opacity: 0.6;
}
.btn-delete:hover {
opacity: 1;
}
.btn-import {
margin-top: 6px;
padding: 6px 12px;
border: 1px solid #0f3460;
border-radius: 6px;
background: #fff;
color: #0f3460;
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.btn-import:hover {
background: #0f3460;
color: #fff;
}
/* Receipts list */
.toolbar {
margin-bottom: 10px;
}
.search-box input {
padding: 8px 10px;
font-size: 13px;
}
.summary {
font-size: 12px;
color: #6c757d;
margin-top: 6px;
text-align: right;
}
.receipts-list {
max-height: 300px;
overflow-y: auto;
}
.empty-state {
text-align: center;
padding: 32px 16px;
color: #6c757d;
}
.empty-icon {
font-size: 36px;
margin-bottom: 8px;
}
.empty-hint {
font-size: 12px;
margin-top: 4px;
}
.receipt-card {
background: #fff;
border-radius: 8px;
padding: 10px;
margin-bottom: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.receipt-main {
display: flex;
align-items: flex-start;
gap: 8px;
}
.receipt-icon {
font-size: 20px;
flex-shrink: 0;
}
.receipt-info {
flex: 1;
min-width: 0;
}
.receipt-title {
font-weight: 600;
font-size: 13px;
color: #212529;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.receipt-meta {
font-size: 11px;
color: #6c757d;
margin-top: 1px;
}
.receipt-notes {
font-size: 11px;
color: #868e96;
margin-top: 2px;
font-style: italic;
}
.receipt-amount {
font-weight: 700;
font-size: 14px;
color: #0f3460;
flex-shrink: 0;
}
.receipt-thumb {
max-width: 100%;
max-height: 80px;
margin-top: 6px;
border-radius: 4px;
}
.receipt-actions {
display: flex;
justify-content: flex-end;
margin-top: 4px;
}
/* Email tab */
.email-intro {
font-size: 13px;
color: #495057;
margin-bottom: 12px;
padding: 10px;
background: #e7f3ff;
border-radius: 8px;
}
.email-results {
margin-top: 12px;
}
.email-item {
background: #fff;
border-radius: 8px;
padding: 10px;
margin-bottom: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.email-subject {
font-weight: 600;
font-size: 13px;
color: #212529;
}
.email-meta {
font-size: 11px;
color: #6c757d;
margin-top: 2px;
}
.email-amount {
font-weight: 700;
font-size: 14px;
color: #0f3460;
margin-top: 4px;
}
.email-empty {
text-align: center;
padding: 20px;
color: #6c757d;
font-size: 13px;
}
.loading {
text-align: center;
padding: 20px;
color: #0f3460;
font-size: 13px;
}
/* Export bar */
.export-bar {
display: flex;
gap: 6px;
justify-content: center;
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid #e9ecef;
}
/* Toast */
.toast {
position: fixed;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
background: #16213e;
color: #fff;
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
z-index: 100;
transition: opacity 0.3s;
}
.toast.hidden {
opacity: 0;
pointer-events: none;
}
+107
View File
@@ -0,0 +1,107 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SimpleScan Solo</title>
<link rel="stylesheet" href="./popup.css" />
</head>
<body>
<div id="app">
<header class="header">
<h1>📸 SimpleScan Solo</h1>
<p class="subtitle">Freelancer receipts, simplified</p>
</header>
<nav class="tabs">
<button class="tab-btn active" data-tab="add">Add</button>
<button class="tab-btn" data-tab="list">Receipts</button>
<button class="tab-btn" data-tab="email">Email</button>
</nav>
<!-- Add Receipt Tab -->
<section id="tab-add" class="tab-panel active">
<form id="receipt-form">
<div class="form-group">
<label for="r-title">Description</label>
<input type="text" id="r-title" placeholder="e.g. Adobe Creative Cloud" required />
</div>
<div class="form-row">
<div class="form-group">
<label for="r-amount">Amount</label>
<input type="number" id="r-amount" step="0.01" min="0" placeholder="29.99" required />
</div>
<div class="form-group">
<label for="r-currency">Currency</label>
<select id="r-currency">
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="GBP">GBP</option>
<option value="CAD">CAD</option>
<option value="AUD">AUD</option>
</select>
</div>
</div>
<div class="form-group">
<label for="r-category">Category</label>
<select id="r-category" required></select>
</div>
<div class="form-row">
<div class="form-group">
<label for="r-date">Date</label>
<input type="date" id="r-date" required />
</div>
<div class="form-group">
<label for="r-vendor">Vendor</label>
<input type="text" id="r-vendor" placeholder="Adobe" required />
</div>
</div>
<div class="form-group">
<label for="r-notes">Notes</label>
<input type="text" id="r-notes" placeholder="Annual subscription" />
</div>
<div class="form-group">
<label>Receipt Image (optional)</label>
<div class="upload-area">
<input type="file" id="r-image" accept="image/*" />
<div class="upload-hint">📎 Click to upload or drag image</div>
<img id="image-preview" class="image-preview hidden" alt="Preview" />
</div>
</div>
<button type="submit" class="btn-primary">💾 Save Receipt</button>
</form>
</section>
<!-- Receipts List Tab -->
<section id="tab-list" class="tab-panel">
<div class="toolbar">
<div class="search-box">
<input type="text" id="search-input" placeholder="🔍 Search receipts..." />
</div>
<div class="summary" id="summary-box">
<span id="receipt-count">0</span> receipts · <span id="total-amount">$0.00</span>
</div>
</div>
<div id="receipts-list" class="receipts-list"></div>
<div class="export-bar">
<button id="export-csv" class="btn-secondary">📄 CSV</button>
<button id="export-pdf" class="btn-secondary">📑 PDF</button>
<button id="open-options" class="btn-ghost">⚙️ Settings</button>
</div>
</section>
<!-- Email Capture Tab -->
<section id="tab-email" class="tab-panel">
<div class="email-intro">
<p>Open <strong>Gmail</strong> or <strong>Outlook</strong> in a tab, then click <strong>Scan Inbox</strong> to find receipt emails.</p>
</div>
<button id="scan-email" class="btn-primary">🔍 Scan Inbox for Receipts</button>
<div id="email-results" class="email-results"></div>
</section>
<div id="toast" class="toast hidden"></div>
</div>
<script type="module" src="./popup.ts"></script>
</body>
</html>
+286
View File
@@ -0,0 +1,286 @@
import { getReceipts, saveReceipt, deleteReceipt } from '../utils/storage.js';
import { TAX_CATEGORIES, getCategoryLabel, getCategoryIcon } from '../utils/categories.js';
import { exportToCsv, downloadBlob } from '../utils/export.js';
import type { Receipt } from '../types.js';
// --- DOM refs ---
const tabButtons = document.querySelectorAll<HTMLButtonElement>('.tab-btn');
const panels = document.querySelectorAll<HTMLElement>('.tab-panel');
const form = document.getElementById('receipt-form') as HTMLFormElement;
const categorySelect = document.getElementById('r-category') as HTMLSelectElement;
const imageInput = document.getElementById('r-image') as HTMLInputElement;
const imagePreview = document.getElementById('image-preview') as HTMLImageElement;
const receiptsList = document.getElementById('receipts-list') as HTMLDivElement;
const searchInput = document.getElementById('search-input') as HTMLInputElement;
const receiptCount = document.getElementById('receipt-count') as HTMLSpanElement;
const totalAmount = document.getElementById('total-amount') as HTMLSpanElement;
const scanEmailBtn = document.getElementById('scan-email') as HTMLButtonElement;
const emailResults = document.getElementById('email-results') as HTMLDivElement;
const toast = document.getElementById('toast') as HTMLDivElement;
let allReceipts: Receipt[] = [];
let imageDataUrl = '';
// --- Init ---
function init() {
// Populate categories
categorySelect.innerHTML = TAX_CATEGORIES.map(
(c) => `<option value="${c.id}">${c.icon} ${c.label}</option>`
).join('');
// Default date to today
(document.getElementById('r-date') as HTMLInputElement).valueAsDate = new Date();
loadReceipts();
setupTabs();
setupForm();
setupSearch();
setupExport();
setupEmailScan();
setupImageUpload();
}
// --- Tabs ---
function setupTabs() {
tabButtons.forEach((tab) => {
tab.addEventListener('click', () => {
const target = tab.dataset.tab!;
tabButtons.forEach((t) => t.classList.toggle('active', t.dataset.tab === target));
panels.forEach((p) => p.classList.toggle('active', p.id === `tab-${target}`));
if (target === 'list') loadReceipts();
});
});
}
// --- Form ---
function setupForm() {
form.addEventListener('submit', async (e) => {
e.preventDefault();
const receipt: Receipt = {
id: crypto.randomUUID(),
title: (document.getElementById('r-title') as HTMLInputElement).value.trim(),
amount: parseFloat((document.getElementById('r-amount') as HTMLInputElement).value),
currency: (document.getElementById('r-currency') as HTMLSelectElement).value,
category: (document.getElementById('r-category') as HTMLSelectElement).value as Receipt['category'],
date: (document.getElementById('r-date') as HTMLInputElement).value,
vendor: (document.getElementById('r-vendor') as HTMLInputElement).value.trim(),
notes: (document.getElementById('r-notes') as HTMLInputElement).value.trim(),
imageDataUrl: imageDataUrl || undefined,
source: imageDataUrl ? 'upload' : 'manual',
createdAt: new Date().toISOString(),
};
await saveReceipt(receipt);
showToast('Receipt saved!');
form.reset();
imageDataUrl = '';
imagePreview.classList.add('hidden');
imagePreview.src = '';
(document.getElementById('r-date') as HTMLInputElement).valueAsDate = new Date();
// Switch to list tab
tabButtons[1].click();
});
}
// --- Image upload ---
function setupImageUpload() {
imageInput.addEventListener('change', () => {
const file = imageInput.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
imageDataUrl = reader.result as string;
imagePreview.src = imageDataUrl;
imagePreview.classList.remove('hidden');
};
reader.readAsDataURL(file);
});
}
// --- Receipts list ---
async function loadReceipts() {
allReceipts = await getReceipts();
renderReceipts(allReceipts);
}
function renderReceipts(receipts: Receipt[]) {
if (receipts.length === 0) {
receiptsList.innerHTML = `
<div class="empty-state">
<div class="empty-icon">📭</div>
<p>No receipts yet.</p>
<p class="empty-hint">Add your first receipt in the "Add" tab.</p>
</div>
`;
receiptCount.textContent = '0';
totalAmount.textContent = '$0.00';
return;
}
const total = receipts.reduce((sum, r) => sum + r.amount, 0);
const currency = receipts[0]?.currency || 'USD';
receiptCount.textContent = String(receipts.length);
totalAmount.textContent = formatMoney(total, currency);
receiptsList.innerHTML = receipts
.map(
(r) => `
<div class="receipt-card" data-id="${r.id}">
<div class="receipt-main">
<div class="receipt-icon">${getCategoryIcon(r.category)}</div>
<div class="receipt-info">
<div class="receipt-title">${escapeHtml(r.title)}</div>
<div class="receipt-meta">
${formatDate(r.date)} · ${escapeHtml(r.vendor)} · ${getCategoryLabel(r.category)}
</div>
${r.notes ? `<div class="receipt-notes">${escapeHtml(r.notes)}</div>` : ''}
</div>
<div class="receipt-amount">${formatMoney(r.amount, r.currency)}</div>
</div>
${r.imageDataUrl ? `<img class="receipt-thumb" src="${r.imageDataUrl}" alt="Receipt" />` : ''}
<div class="receipt-actions">
<button class="btn-delete" data-id="${r.id}" title="Delete">🗑️</button>
</div>
</div>
`
)
.join('');
receiptsList.querySelectorAll('.btn-delete').forEach((btn) => {
btn.addEventListener('click', async (e) => {
const id = (e.currentTarget as HTMLButtonElement).dataset.id!;
await deleteReceipt(id);
await loadReceipts();
showToast('Receipt deleted');
});
});
}
function setupSearch() {
searchInput.addEventListener('input', () => {
const q = searchInput.value.toLowerCase().trim();
if (!q) {
renderReceipts(allReceipts);
return;
}
const filtered = allReceipts.filter(
(r) =>
r.title.toLowerCase().includes(q) ||
r.vendor.toLowerCase().includes(q) ||
r.notes.toLowerCase().includes(q) ||
getCategoryLabel(r.category).toLowerCase().includes(q)
);
renderReceipts(filtered);
});
}
// --- Export ---
function setupExport() {
document.getElementById('export-csv')!.addEventListener('click', async () => {
const receipts = await getReceipts();
if (receipts.length === 0) {
showToast('No receipts to export');
return;
}
const csv = exportToCsv(receipts);
const year = new Date().getFullYear();
downloadBlob(csv, `simplescan-receipts-${year}.csv`, 'text/csv');
showToast('CSV downloaded');
});
document.getElementById('export-pdf')!.addEventListener('click', async () => {
const receipts = await getReceipts();
if (receipts.length === 0) {
showToast('No receipts to export');
return;
}
// PDF export via background service worker (offscreen or simple approach)
// For popup, we'll open options page with receipts pre-loaded
chrome.runtime.openOptionsPage();
showToast('Open Settings tab for PDF export');
});
document.getElementById('open-options')!.addEventListener('click', () => {
chrome.runtime.openOptionsPage();
});
}
// --- Email scan ---
function setupEmailScan() {
scanEmailBtn.addEventListener('click', async () => {
emailResults.innerHTML = '<div class="loading">🔍 Scanning open email tabs...</div>';
try {
const tabs = await chrome.tabs.query({ url: ['*://mail.google.com/*', '*://outlook.live.com/*', '*://outlook.office.com/*', '*://outlook.office365.com/*'] });
if (tabs.length === 0) {
emailResults.innerHTML = '<div class="email-empty">⚠️ No Gmail or Outlook tab found. Open your email and try again.</div>';
return;
}
const results: { receipts: { subject: string; sender: string; amount?: number; vendor?: string; date: string }[] } = await chrome.tabs.sendMessage(tabs[0].id!, { action: 'scanEmails' });
if (!results || !results.receipts || results.receipts.length === 0) {
emailResults.innerHTML = '<div class="email-empty">No receipt emails detected on this page. Try opening a specific receipt email.</div>';
return;
}
emailResults.innerHTML = results.receipts
.map(
(r, i) => `
<div class="email-item" data-index="${i}">
<div class="email-subject">${escapeHtml(r.subject)}</div>
<div class="email-meta">${escapeHtml(r.sender)} · ${r.date}</div>
${r.amount ? `<div class="email-amount">${formatMoney(r.amount, 'USD')}</div>` : ''}
<button class="btn-import" data-index="${i}"> Import as Receipt</button>
</div>
`
)
.join('');
emailResults.querySelectorAll('.btn-import').forEach((btn) => {
btn.addEventListener('click', async (e) => {
const idx = parseInt((e.currentTarget as HTMLButtonElement).dataset.index!, 10);
const er = results.receipts[idx];
const receipt: Receipt = {
id: crypto.randomUUID(),
title: er.subject,
amount: er.amount || 0,
currency: 'USD',
category: 'software',
date: new Date().toISOString().split('T')[0],
vendor: er.vendor || er.sender,
notes: `Imported from email: ${er.subject}`,
source: 'email',
createdAt: new Date().toISOString(),
};
await saveReceipt(receipt);
showToast('Receipt imported from email');
tabButtons[1].click();
});
});
} catch (err) {
emailResults.innerHTML = `<div class="email-empty">Error scanning email: ${escapeHtml(String(err))}</div>`;
}
});
}
// --- Helpers ---
function formatMoney(amount: number, currency: string): string {
const map: Record<string, string> = { USD: '$', EUR: '€', GBP: '£', CAD: 'C$', AUD: 'A$' };
const symbol = map[currency] || currency + ' ';
return symbol + amount.toFixed(2);
}
function formatDate(iso: string): string {
const d = new Date(iso + 'T00:00:00');
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
function escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showToast(message: string) {
toast.textContent = message;
toast.classList.remove('hidden');
setTimeout(() => toast.classList.add('hidden'), 2500);
}
init();
+71
View File
@@ -0,0 +1,71 @@
import { describe, it } from 'node:test';
import assert from 'node:assert';
// Test categories
import { TAX_CATEGORIES, getCategoryLabel, getCategoryIcon } from '../utils/categories.js';
describe('categories', () => {
it('has 10 tax categories', () => {
assert.strictEqual(TAX_CATEGORIES.length, 10);
});
it('gets correct label', () => {
assert.strictEqual(getCategoryLabel('software'), 'Software');
assert.strictEqual(getCategoryLabel('equipment'), 'Equipment');
assert.strictEqual(getCategoryLabel('nonexistent'), 'Other');
});
it('gets correct icon', () => {
assert.strictEqual(getCategoryIcon('software'), '⚙️');
assert.strictEqual(getCategoryIcon('travel'), '✈️');
assert.strictEqual(getCategoryIcon('nonexistent'), '📦');
});
});
// Test export
import { exportToCsv } from '../utils/export.js';
import type { Receipt } from '../types.js';
describe('export', () => {
it('exports receipts to CSV', () => {
const receipts: Receipt[] = [
{
id: '1',
title: 'Adobe CC',
amount: 59.99,
currency: 'USD',
category: 'software',
date: '2026-06-01',
vendor: 'Adobe',
notes: 'Monthly',
source: 'manual',
createdAt: '2026-06-01T00:00:00Z',
},
];
const csv = exportToCsv(receipts);
console.log('CSV output:', csv);
assert.ok(csv.includes('Date'), 'CSV should have Date header');
assert.ok(csv.includes('Adobe CC'), 'CSV should have title');
assert.ok(csv.includes('59.99'), 'CSV should have amount');
assert.ok(csv.includes('Software'), 'CSV should have category label');
});
it('escapes quotes in CSV', () => {
const receipts: Receipt[] = [
{
id: '1',
title: 'My "Special" Item',
amount: 10,
currency: 'USD',
category: 'other',
date: '2026-06-01',
vendor: 'Vendor',
notes: '',
source: 'manual',
createdAt: '2026-06-01T00:00:00Z',
},
];
const csv = exportToCsv(receipts);
assert.ok(csv.includes('""'), 'CSV should escape quotes');
});
});
+53
View File
@@ -0,0 +1,53 @@
export interface Receipt {
id: string;
title: string;
amount: number;
currency: string;
category: TaxCategory;
date: string; // ISO date
vendor: string;
notes: string;
imageDataUrl?: string;
source: 'manual' | 'email' | 'upload' | 'camera';
createdAt: string;
}
export type TaxCategory =
| 'equipment'
| 'home_office'
| 'software'
| 'internet_phone'
| 'travel'
| 'meals'
| 'professional_services'
| 'marketing'
| 'education'
| 'other';
export interface CategoryInfo {
id: TaxCategory;
label: string;
description: string;
icon: string;
}
export interface EmailReceiptHint {
subject: string;
sender: string;
date: string;
amount?: number;
currency?: string;
vendor?: string;
}
export interface AppSettings {
defaultCurrency: string;
taxYear: string;
ownerName: string;
}
export const DEFAULT_SETTINGS: AppSettings = {
defaultCurrency: 'USD',
taxYear: new Date().getFullYear().toString(),
ownerName: '',
};
+72
View File
@@ -0,0 +1,72 @@
import type { CategoryInfo } from '../types.js';
export const TAX_CATEGORIES: CategoryInfo[] = [
{
id: 'equipment',
label: 'Equipment',
description: 'Computers, cameras, tools, hardware',
icon: '💻',
},
{
id: 'home_office',
label: 'Home Office',
description: 'Desk, chair, lighting, office supplies',
icon: '🏠',
},
{
id: 'software',
label: 'Software',
description: 'Apps, subscriptions, SaaS tools',
icon: '⚙️',
},
{
id: 'internet_phone',
label: 'Internet & Phone',
description: 'ISP, cell plan, domain hosting',
icon: '📡',
},
{
id: 'travel',
label: 'Travel',
description: 'Flights, hotels, rideshare, mileage',
icon: '✈️',
},
{
id: 'meals',
label: 'Meals',
description: 'Business meals (50% deductible)',
icon: '🍽️',
},
{
id: 'professional_services',
label: 'Professional Services',
description: 'Lawyer, accountant, contractor payments',
icon: '📋',
},
{
id: 'marketing',
label: 'Marketing',
description: 'Ads, design, copywriting, PR',
icon: '📢',
},
{
id: 'education',
label: 'Education',
description: 'Courses, books, conferences',
icon: '📚',
},
{
id: 'other',
label: 'Other',
description: 'Miscellaneous business expenses',
icon: '📦',
},
];
export function getCategoryLabel(id: string): string {
return TAX_CATEGORIES.find((c) => c.id === id)?.label || 'Other';
}
export function getCategoryIcon(id: string): string {
return TAX_CATEGORIES.find((c) => c.id === id)?.icon || '📦';
}
+37
View File
@@ -0,0 +1,37 @@
import type { Receipt } from '../types.js';
import { getCategoryLabel } from './categories.js';
export function exportToCsv(receipts: Receipt[]): string {
const headers = ['Date', 'Vendor', 'Title', 'Category', 'Amount', 'Currency', 'Notes', 'Source'];
const rows = receipts.map((r) => [
r.date,
r.vendor,
r.title,
getCategoryLabel(r.category),
r.amount.toFixed(2),
r.currency,
r.notes,
r.source,
]);
const csv = [headers, ...rows]
.map((row) =>
row
.map((cell) => {
const str = String(cell).replace(/"/g, '""');
return `"${str}"`;
})
.join(',')
)
.join('\n');
return csv;
}
export function downloadBlob(content: BlobPart, filename: string, mimeType: string): void {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
+40
View File
@@ -0,0 +1,40 @@
import type { Receipt, AppSettings } from '../types.js';
import { DEFAULT_SETTINGS } from '../types.js';
const RECEIPTS_KEY = 'simplescan_receipts';
const SETTINGS_KEY = 'simplescan_settings';
export async function getReceipts(): Promise<Receipt[]> {
const result = await chrome.storage.local.get(RECEIPTS_KEY);
return (result[RECEIPTS_KEY] as Receipt[]) || [];
}
export async function saveReceipt(receipt: Receipt): Promise<void> {
const receipts = await getReceipts();
const existingIndex = receipts.findIndex((r) => r.id === receipt.id);
if (existingIndex >= 0) {
receipts[existingIndex] = receipt;
} else {
receipts.unshift(receipt);
}
await chrome.storage.local.set({ [RECEIPTS_KEY]: receipts });
}
export async function deleteReceipt(id: string): Promise<void> {
const receipts = await getReceipts();
const filtered = receipts.filter((r) => r.id !== id);
await chrome.storage.local.set({ [RECEIPTS_KEY]: filtered });
}
export async function getSettings(): Promise<AppSettings> {
const result = await chrome.storage.local.get(SETTINGS_KEY);
return (result[SETTINGS_KEY] as AppSettings) || { ...DEFAULT_SETTINGS };
}
export async function saveSettings(settings: AppSettings): Promise<void> {
await chrome.storage.local.set({ [SETTINGS_KEY]: settings });
}
export async function clearAllReceipts(): Promise<void> {
await chrome.storage.local.remove(RECEIPTS_KEY);
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["chrome"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["src/test"]
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist-test"
},
"include": ["src/test/**/*.ts", "src/utils/**/*.ts", "src/types.ts"],
"exclude": ["src/popup", "src/content", "src/background.ts", "src/options.ts"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import { crx } from '@crxjs/vite-plugin'
import manifest from './src/manifest.json' with { type: 'json' }
export default defineConfig({
build: {
rollupOptions: {
input: {
options: 'options.html',
},
},
},
plugins: [crx({ manifest })],
})