TaxPack v1.0.0 - Gmail Receipt Auto-Extractor & Tax Report Generator

This commit is contained in:
Bun Bun
2026-06-19 06:28:06 +00:00
commit d0a0615c28
17 changed files with 4309 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
*.log
.verdict
.env
.DS_Store
+63
View File
@@ -0,0 +1,63 @@
// Generate simple placeholder icons for TaxPack
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
function createSimplePNG(size, r, g, b) {
const width = size;
const height = size;
const signature = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
const ihdrData = Buffer.alloc(13);
ihdrData.writeUInt32BE(width, 0);
ihdrData.writeUInt32BE(height, 4);
ihdrData[8] = 8;
ihdrData[9] = 2;
ihdrData[10] = 0;
ihdrData[11] = 0;
ihdrData[12] = 0;
const ihdr = createChunk('IHDR', ihdrData);
const rowSize = 1 + width * 3;
const imageData = Buffer.alloc(height * rowSize);
for (let y = 0; y < height; y++) {
imageData[y * rowSize] = 0;
for (let x = 0; x < width; x++) {
const idx = y * rowSize + 1 + x * 3;
imageData[idx] = r;
imageData[idx + 1] = g;
imageData[idx + 2] = b;
}
}
const compressed = zlib.deflateSync(imageData);
const idat = createChunk('IDAT', compressed);
const iend = createChunk('IEND', Buffer.alloc(0));
return Buffer.concat([signature, ihdr, idat, iend]);
}
function createChunk(type, data) {
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length, 0);
const typeBuf = Buffer.from(type, 'ascii');
const crc = zlib.crc32(Buffer.concat([typeBuf, data]));
const crcBuf = Buffer.alloc(4);
crcBuf.writeUInt32BE(crc >>> 0, 0);
return Buffer.concat([length, typeBuf, data, crcBuf]);
}
const iconsDir = path.join(__dirname, 'public', 'icons');
fs.mkdirSync(iconsDir, { recursive: true });
const purple = { r: 107, g: 99, b: 182 };
[16, 48, 128].forEach(size => {
const png = createSimplePNG(size, purple.r, purple.g, purple.b);
fs.writeFileSync(path.join(iconsDir, `icon${size}.png`), png);
console.log(`Created icon${size}.png`);
});
console.log('Icons generated successfully');
+3277
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "taxpack",
"version": "1.0.0",
"description": "Gmail Receipt Auto-Extractor & Tax-Ready Report Generator",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"test": "vitest run"
},
"devDependencies": {
"@crxjs/vite-plugin": "^2.0.0-beta.28",
"@types/chrome": "^0.0.268",
"typescript": "^5.4.5",
"vite": "^5.2.0",
"vitest": "^1.6.0",
"jsdom": "^24.1.0"
},
"dependencies": {
"jspdf": "^2.5.1",
"jspdf-autotable": "^3.8.2"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 306 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

+161
View File
@@ -0,0 +1,161 @@
// TaxPack Background Service Worker
// Handles Gmail OAuth and message passing
import {
Receipt,
extractReceiptFromEmail,
exportToCSV,
generateTaxSummary,
} from './lib/receipt-utils';
// Store for extracted receipts
const STORAGE_KEY = 'taxpack_receipts';
// Get auth token from Chrome identity API
export async function getAuthToken(interactive: boolean = true): Promise<string | null> {
try {
const token = await chrome.identity.getAuthToken({ interactive });
return token.token ?? null;
} catch (error) {
console.error('Auth error:', error);
return null;
}
}
// Fetch Gmail messages with receipt indicators
export async function fetchReceiptEmails(token: string, maxResults: number = 50): Promise<any[]> {
const queries = [
'subject:receipt',
'subject:invoice',
'subject:order confirmation',
'subject:payment confirmation',
'subject:purchase confirmation',
'from:receipts',
'from:noreply',
];
const allMessages: any[] = [];
const seenIds = new Set<string>();
for (const query of queries) {
try {
const url = `https://www.googleapis.com/gmail/v1/users/me/messages?q=${encodeURIComponent(query)}&maxResults=${Math.ceil(maxResults / queries.length)}`;
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
if (response.status === 401) {
// Token expired, try to remove and re-auth
await chrome.identity.removeCachedAuthToken({ token });
}
continue;
}
const data = await response.json();
if (data.messages) {
for (const msg of data.messages) {
if (!seenIds.has(msg.id)) {
seenIds.add(msg.id);
allMessages.push(msg);
}
}
}
} catch (error) {
console.error(`Query failed: ${query}`, error);
}
}
return allMessages.slice(0, maxResults);
}
// Fetch full message details
export async function fetchMessageDetails(token: string, messageId: string): Promise<any | null> {
try {
const url = `https://www.googleapis.com/gmail/v1/users/me/messages/${messageId}?format=full`;
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) return null;
return await response.json();
} catch (error) {
console.error('Fetch message error:', error);
return null;
}
}
// Save receipts to storage
export async function saveReceipts(receipts: Receipt[]): Promise<void> {
const existing = await loadReceipts();
const merged = [...existing];
const seen = new Set(existing.map(r => r.id));
for (const receipt of receipts) {
if (!seen.has(receipt.id)) {
seen.add(receipt.id);
merged.push(receipt);
}
}
await chrome.storage.local.set({ [STORAGE_KEY]: merged });
}
// Load receipts from storage
export async function loadReceipts(): Promise<Receipt[]> {
const result = await chrome.storage.local.get(STORAGE_KEY);
return result[STORAGE_KEY] || [];
}
// Clear all receipts
export async function clearReceipts(): Promise<void> {
await chrome.storage.local.remove(STORAGE_KEY);
}
// Listen for messages from popup
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
(async () => {
try {
if (request.action === 'scanGmail') {
const token = await getAuthToken(request.interactive ?? true);
if (!token) {
sendResponse({ success: false, error: 'Authentication failed' });
return;
}
const messages = await fetchReceiptEmails(token, request.maxResults || 50);
const receipts: Receipt[] = [];
for (const msg of messages.slice(0, 20)) {
const details = await fetchMessageDetails(token, msg.id);
if (details) {
const receipt = extractReceiptFromEmail(details);
if (receipt) receipts.push(receipt);
}
}
await saveReceipts(receipts);
sendResponse({ success: true, count: receipts.length, total: messages.length });
} else if (request.action === 'getReceipts') {
const receipts = await loadReceipts();
sendResponse({ success: true, receipts });
} else if (request.action === 'clearReceipts') {
await clearReceipts();
sendResponse({ success: true });
} else if (request.action === 'exportCSV') {
const receipts = await loadReceipts();
const csv = exportToCSV(receipts);
sendResponse({ success: true, csv });
} else if (request.action === 'getSummary') {
const receipts = await loadReceipts();
const summary = generateTaxSummary(receipts);
sendResponse({ success: true, summary, total: receipts.reduce((s, r) => s + r.amount, 0) });
} else {
sendResponse({ success: false, error: 'Unknown action' });
}
} catch (error) {
sendResponse({ success: false, error: String(error) });
}
})();
return true; // Keep channel open for async
});
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#667eea"/>
<stop offset="100%" style="stop-color:#764ba2"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="20" fill="url(#grad)"/>
<text x="64" y="82" font-size="72" text-anchor="middle" fill="white" font-family="sans-serif" font-weight="bold">$</text>
</svg>

After

Width:  |  Height:  |  Size: 496 B

+189
View File
@@ -0,0 +1,189 @@
// TaxPack Receipt Utilities - Pure functions for extraction, export, and summarization
export interface Receipt {
id: string;
merchant: string;
amount: number;
currency: string;
date: string;
category: string;
paymentMethod: string;
emailSubject: string;
source: 'gmail';
}
/**
* Extract receipt data from a Gmail message
*/
export function extractReceiptFromEmail(message: any): Receipt | null {
if (!message || !message.payload) return null;
const headers = message.payload.headers || [];
const subject = headers.find((h: any) => h.name === 'Subject')?.value || '';
const from = headers.find((h: any) => h.name === 'From')?.value || '';
const dateHeader = headers.find((h: any) => h.name === 'Date')?.value || '';
// Get email body
let body = '';
const getBody = (part: any): string => {
if (part.body?.data) {
try {
return atob(part.body.data.replace(/-/g, '+').replace(/_/g, '/'));
} catch {
return '';
}
}
if (part.parts) {
for (const p of part.parts) {
const b = getBody(p);
if (b) return b;
}
}
return '';
};
body = getBody(message.payload);
// Extract amount using regex patterns
const amountPatterns = [
/(?:total|amount|charged|paid|payment)[\s:]*[$€£]\s*([\d,]+\.?\d*)/i,
/[$€£]\s*([\d,]+\.?\d*)\s*(?:total|usd|eur|gbp)/i,
/(?:order total|grand total)[\s:]*[$€£]?\s*([\d,]+\.?\d*)/i,
/(?:price|cost)[\s:]*[$€£]?\s*([\d,]+\.?\d*)/i,
/\$\s*([\d,]+\.\d{2})/,
/([\d,]+\.\d{2})\s*(?:USD|EUR|GBP)/i,
];
let amount = 0;
for (const pattern of amountPatterns) {
const match = body.match(pattern) || subject.match(pattern);
if (match) {
amount = parseFloat(match[1].replace(/,/g, ''));
if (amount > 0) break;
}
}
// Skip if no amount found
if (amount <= 0) return null;
// Extract merchant
const merchantPatterns = [
/from\s+([A-Z][A-Za-z0-9\s&]+?)(?:\s[-—]|\s*<|\n|$)/i,
/(?:at|from)\s+([A-Z][A-Za-z0-9\s&]+?)(?:\s[-—]|\s*<|\n|$)/,
/([A-Z][A-Za-z0-9\s&]+?)\s*(?:receipt|invoice|order)/i,
];
let merchant = 'Unknown Merchant';
for (const pattern of merchantPatterns) {
const match = subject.match(pattern) || from.match(pattern);
if (match) {
merchant = match[1].trim();
break;
}
}
// Use from email domain as fallback
if (merchant === 'Unknown Merchant' && from.includes('@')) {
const domain = from.split('@')[1]?.split('>')[0];
if (domain) merchant = domain.replace(/\.com$|\.org$|\.net$/, '');
}
// Parse date
let date = new Date().toISOString().split('T')[0];
try {
const parsed = new Date(dateHeader || Date.now());
if (!isNaN(parsed.getTime())) {
date = parsed.toISOString().split('T')[0];
}
} catch {
// use default
}
// Categorize based on keywords
const category = categorizeReceipt(subject + ' ' + body + ' ' + merchant);
// Extract payment method
const paymentPatterns = [
/(visa|mastercard|amex|american express|discover|paypal|apple pay|google pay)/i,
/ending in (\d{4})/i,
/\*\*\*\*(\d{4})/,
];
let paymentMethod = 'Unknown';
for (const pattern of paymentPatterns) {
const match = body.match(pattern);
if (match) {
paymentMethod = match[1] || match[0];
break;
}
}
return {
id: message.id,
merchant: merchant.substring(0, 100),
amount: Math.round(amount * 100) / 100,
currency: 'USD',
date,
category,
paymentMethod: paymentMethod.substring(0, 50),
emailSubject: subject.substring(0, 200),
source: 'gmail',
};
}
function categorizeReceipt(text: string): string {
const lower = text.toLowerCase();
const categories: [string, string[]][] = [
['Office Supplies', ['office', 'stationery', 'paper', 'ink', 'toner', 'staples', 'office depot']],
['Software & Tools', ['software', 'saas', 'subscription', 'license', 'app', 'tool', 'notion', 'slack', 'github']],
['Travel & Transport', ['flight', 'hotel', 'airbnb', 'uber', 'lyft', 'taxi', 'rental car', 'airline', 'booking']],
['Meals & Entertainment', ['restaurant', 'food', 'meal', 'coffee', 'starbucks', 'doordash', 'grubhub', 'uber eats']],
['Equipment & Hardware', ['computer', 'laptop', 'monitor', 'phone', 'camera', 'electronics', 'best buy', 'amazon']],
['Professional Services', ['legal', 'accounting', 'consulting', 'freelancer', 'upwork', 'fiverr', 'lawyer']],
['Marketing & Advertising', ['ad', 'advertising', 'marketing', 'facebook', 'google ads', 'campaign', 'promotion']],
['Insurance', ['insurance', 'policy', 'premium', 'coverage']],
['Utilities & Internet', ['utility', 'internet', 'phone bill', 'electric', 'gas', 'water']],
['Education & Training', ['course', 'training', 'certification', 'book', 'learning', 'udemy', 'coursera']],
['Health & Medical', ['medical', 'health', 'pharmacy', 'doctor', 'dental', 'vision']],
];
for (const [cat, keywords] of categories) {
if (keywords.some(k => lower.includes(k))) return cat;
}
return 'Other';
}
/**
* Export receipts to CSV format
*/
export function exportToCSV(receipts: Receipt[]): string {
if (receipts.length === 0) return '';
const headers = ['Date', 'Merchant', 'Category', 'Amount', 'Currency', 'Payment Method', 'Email Subject'];
const rows = receipts.map(r => [
r.date,
r.merchant,
r.category,
r.amount.toFixed(2),
r.currency,
r.paymentMethod,
r.emailSubject,
]);
const escape = (s: string) => {
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
};
return [headers.join(','), ...rows.map(row => row.map(escape).join(','))].join('\n');
}
/**
* Generate tax summary grouped by category
*/
export function generateTaxSummary(receipts: Receipt[]): Record<string, number> {
const summary: Record<string, number> = {};
for (const receipt of receipts) {
summary[receipt.category] = (summary[receipt.category] || 0) + receipt.amount;
}
return summary;
}
+38
View File
@@ -0,0 +1,38 @@
{
"manifest_version": 3,
"name": "TaxPack - Receipt Extractor",
"version": "1.0.0",
"description": "Auto-extract receipts from Gmail and generate tax-ready reports",
"permissions": [
"identity",
"storage",
"activeTab"
],
"host_permissions": [
"https://www.googleapis.com/*",
"https://mail.google.com/*"
],
"oauth2": {
"client_id": "TAXPACK_CLIENT_ID.apps.googleusercontent.com",
"scopes": [
"https://www.googleapis.com/auth/gmail.readonly"
]
},
"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"
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
+151
View File
@@ -0,0 +1,151 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TaxPack</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
width: 380px;
min-height: 400px;
background: #f8f9fa;
color: #1a1a2e;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
text-align: center;
}
.header h1 { font-size: 22px; margin-bottom: 4px; }
.header p { font-size: 12px; opacity: 0.9; }
.content { padding: 16px; }
.btn {
display: block;
width: 100%;
padding: 12px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 10px;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(102,126,234,0.3); }
.btn-secondary {
background: white;
color: #667eea;
border: 2px solid #667eea;
}
.btn-secondary:hover { background: #f0f2ff; }
.btn-danger {
background: #fee;
color: #c33;
border: 1px solid #fcc;
}
.status {
padding: 12px;
border-radius: 8px;
margin-bottom: 12px;
font-size: 13px;
display: none;
}
.status.show { display: block; }
.status.success { background: #d4edda; color: #155724; }
.status.error { background: #f8d7da; color: #721c24; }
.status.info { background: #d1ecf1; color: #0c5460; }
.receipts-list {
max-height: 200px;
overflow-y: auto;
background: white;
border-radius: 8px;
padding: 10px;
margin-bottom: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.receipt-item {
padding: 8px;
border-bottom: 1px solid #eee;
font-size: 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.receipt-item:last-child { border-bottom: none; }
.receipt-merchant { font-weight: 600; color: #333; }
.receipt-amount { font-weight: 700; color: #667eea; }
.receipt-meta { color: #888; font-size: 11px; }
.empty-state {
text-align: center;
padding: 30px;
color: #888;
font-size: 13px;
}
.summary {
background: white;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.summary h3 { font-size: 14px; margin-bottom: 8px; color: #555; }
.summary-row {
display: flex;
justify-content: space-between;
font-size: 12px;
padding: 4px 0;
border-bottom: 1px solid #f0f0f0;
}
.summary-row:last-child { border-bottom: none; font-weight: 700; color: #667eea; }
.spinner {
display: none;
text-align: center;
padding: 20px;
}
.spinner.show { display: block; }
.spinner::after {
content: '';
display: inline-block;
width: 24px;
height: 24px;
border: 3px solid #f3f3f3;
border-top: 3px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.hidden { display: none !important; }
</style>
</head>
<body>
<div class="header">
<h1>TaxPack</h1>
<p>Gmail Receipt Extractor</p>
</div>
<div class="content">
<div id="status" class="status"></div>
<div id="spinner" class="spinner"></div>
<button id="btn-scan" class="btn btn-primary">Scan Gmail for Receipts</button>
<button id="btn-export-csv" class="btn btn-secondary">Export CSV</button>
<button id="btn-clear" class="btn btn-danger hidden">Clear All Receipts</button>
<div id="summary" class="summary hidden">
<h3>Tax Summary</h3>
<div id="summary-content"></div>
</div>
<div id="receipts-list" class="receipts-list">
<div class="empty-state">No receipts yet. Click "Scan Gmail" to start.</div>
</div>
</div>
<script type="module" src="./popup.ts"></script>
</body>
</html>
+178
View File
@@ -0,0 +1,178 @@
// TaxPack Popup UI Controller
interface Receipt {
id: string;
merchant: string;
amount: number;
currency: string;
date: string;
category: string;
paymentMethod: string;
emailSubject: string;
source: 'gmail';
}
interface SummaryData {
[category: string]: number;
}
const $ = (id: string) => document.getElementById(id);
function showStatus(message: string, type: 'success' | 'error' | 'info' = 'info') {
const status = $('status');
if (!status) return;
status.textContent = message;
status.className = `status ${type} show`;
setTimeout(() => status.classList.remove('show'), 5000);
}
function showSpinner(show: boolean) {
const spinner = $('spinner');
if (spinner) spinner.classList.toggle('show', show);
}
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
}
async function loadReceipts() {
try {
const response = await chrome.runtime.sendMessage({ action: 'getReceipts' });
if (response.success) {
renderReceipts(response.receipts);
renderSummary(response.receipts);
}
} catch (error) {
showStatus('Error loading receipts: ' + String(error), 'error');
}
}
function renderReceipts(receipts: Receipt[]) {
const container = $('receipts-list');
if (!container) return;
if (receipts.length === 0) {
container.innerHTML = '<div class="empty-state">No receipts yet. Click "Scan Gmail" to start.</div>';
$('btn-clear')?.classList.add('hidden');
return;
}
// Sort by date descending
const sorted = [...receipts].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
container.innerHTML = sorted.map(r => `
<div class="receipt-item">
<div>
<div class="receipt-merchant">${escapeHtml(r.merchant)}</div>
<div class="receipt-meta">${escapeHtml(r.date)} · ${escapeHtml(r.category)}</div>
</div>
<div class="receipt-amount">${formatCurrency(r.amount)}</div>
</div>
`).join('');
$('btn-clear')?.classList.remove('hidden');
}
function renderSummary(receipts: Receipt[]) {
const summaryEl = $('summary');
const contentEl = $('summary-content');
if (!summaryEl || !contentEl) return;
if (receipts.length === 0) {
summaryEl.classList.add('hidden');
return;
}
const summary: SummaryData = {};
let total = 0;
for (const r of receipts) {
summary[r.category] = (summary[r.category] || 0) + r.amount;
total += r.amount;
}
const rows = Object.entries(summary)
.sort((a, b) => b[1] - a[1])
.map(([cat, amt]) => `
<div class="summary-row">
<span>${escapeHtml(cat)}</span>
<span>${formatCurrency(amt)}</span>
</div>
`).join('');
contentEl.innerHTML = rows + `
<div class="summary-row">
<span>Total Deductible</span>
<span>${formatCurrency(total)}</span>
</div>
`;
summaryEl.classList.remove('hidden');
}
function escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function scanGmail() {
showSpinner(true);
showStatus('Scanning Gmail for receipts...', 'info');
try {
const response = await chrome.runtime.sendMessage({ action: 'scanGmail', interactive: true, maxResults: 50 });
if (response.success) {
showStatus(`Found ${response.count} receipts from ${response.total} emails!`, 'success');
await loadReceipts();
} else {
showStatus('Error: ' + (response.error || 'Unknown error'), 'error');
}
} catch (error) {
showStatus('Scan failed: ' + String(error), 'error');
} finally {
showSpinner(false);
}
}
async function exportCSV() {
try {
const response = await chrome.runtime.sendMessage({ action: 'exportCSV' });
if (response.success && response.csv) {
const blob = new Blob([response.csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `taxpack-receipts-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
showStatus('CSV exported successfully!', 'success');
} else {
showStatus('No receipts to export', 'error');
}
} catch (error) {
showStatus('Export failed: ' + String(error), 'error');
}
}
async function clearReceipts() {
if (!confirm('Clear all saved receipts? This cannot be undone.')) return;
try {
const response = await chrome.runtime.sendMessage({ action: 'clearReceipts' });
if (response.success) {
showStatus('All receipts cleared', 'info');
await loadReceipts();
}
} catch (error) {
showStatus('Error: ' + String(error), 'error');
}
}
// Event listeners
document.addEventListener('DOMContentLoaded', () => {
loadReceipts();
$('btn-scan')?.addEventListener('click', scanGmail);
$('btn-export-csv')?.addEventListener('click', exportCSV);
$('btn-clear')?.addEventListener('click', clearReceipts);
});
+170
View File
@@ -0,0 +1,170 @@
/// <reference types="vitest/globals" />
import { describe, it, expect, vi } from 'vitest';
// Mock chrome APIs for tests
const mockChrome = {
identity: {
getAuthToken: vi.fn(),
removeCachedAuthToken: vi.fn(),
},
storage: {
local: {
get: vi.fn(),
set: vi.fn(),
remove: vi.fn(),
},
},
runtime: {
onMessage: { addListener: vi.fn() },
sendMessage: vi.fn(),
},
};
// @ts-ignore - assign mock to global chrome
(globalThis as any).chrome = mockChrome;
import {
extractReceiptFromEmail,
exportToCSV,
generateTaxSummary,
} from '../lib/receipt-utils';
describe('Receipt Extraction', () => {
it('extracts amount from receipt email', () => {
const message = {
id: 'msg1',
payload: {
headers: [
{ name: 'Subject', value: 'Your receipt from Amazon - $49.99' },
{ name: 'From', value: 'orders@amazon.com' },
{ name: 'Date', value: '2024-03-15T10:00:00Z' },
],
body: { data: '' },
},
};
const receipt = extractReceiptFromEmail(message);
expect(receipt).not.toBeNull();
expect(receipt!.amount).toBe(49.99);
expect(receipt!.merchant).toBe('Amazon');
expect(receipt!.category).toBe('Equipment & Hardware');
});
it('extracts amount from body when not in subject', () => {
const message = {
id: 'msg2',
payload: {
headers: [
{ name: 'Subject', value: 'Order Confirmation' },
{ name: 'From', value: 'receipts@stripe.com' },
{ name: 'Date', value: '2024-01-20T14:30:00Z' },
],
body: { data: btoa('Thank you for your purchase. Total: $129.99 paid with Visa ending in 4242.') },
},
};
const receipt = extractReceiptFromEmail(message);
expect(receipt).not.toBeNull();
expect(receipt!.amount).toBe(129.99);
expect(receipt!.paymentMethod).toBe('Visa');
});
it('returns null for emails without amounts', () => {
const message = {
id: 'msg3',
payload: {
headers: [
{ name: 'Subject', value: 'Welcome to our newsletter' },
{ name: 'From', value: 'news@example.com' },
],
body: { data: btoa('Thanks for subscribing!') },
},
};
const receipt = extractReceiptFromEmail(message);
expect(receipt).toBeNull();
});
it('categorizes software subscriptions correctly', () => {
const message = {
id: 'msg4',
payload: {
headers: [
{ name: 'Subject', value: 'Your Slack subscription receipt' },
{ name: 'From', value: 'billing@slack.com' },
],
body: { data: btoa('Amount charged: $15.00') },
},
};
const receipt = extractReceiptFromEmail(message);
expect(receipt).not.toBeNull();
expect(receipt!.category).toBe('Software & Tools');
});
it('categorizes meals correctly', () => {
const message = {
id: 'msg5',
payload: {
headers: [
{ name: 'Subject', value: 'Your DoorDash order receipt' },
{ name: 'From', value: 'receipts@doordash.com' },
],
body: { data: btoa('Total: $34.56') },
},
};
const receipt = extractReceiptFromEmail(message);
expect(receipt).not.toBeNull();
expect(receipt!.category).toBe('Meals & Entertainment');
});
});
describe('CSV Export', () => {
it('exports receipts to CSV format', () => {
const receipts = [
{ id: '1', merchant: 'Amazon', amount: 49.99, currency: 'USD', date: '2024-03-15', category: 'Equipment', paymentMethod: 'Visa', emailSubject: 'Receipt', source: 'gmail' as const },
{ id: '2', merchant: 'Slack', amount: 15.00, currency: 'USD', date: '2024-03-16', category: 'Software', paymentMethod: 'Amex', emailSubject: 'Invoice', source: 'gmail' as const },
];
const csv = exportToCSV(receipts);
expect(csv).toContain('Date,Merchant,Category,Amount,Currency,Payment Method,Email Subject');
expect(csv).toContain('2024-03-15,Amazon,Equipment,49.99,USD,Visa,Receipt');
expect(csv).toContain('2024-03-16,Slack,Software,15.00,USD,Amex,Invoice');
});
it('handles empty receipts', () => {
const csv = exportToCSV([]);
expect(csv).toBe('');
});
it('escapes commas in fields', () => {
const receipts = [
{ id: '1', merchant: 'Amazon, Inc.', amount: 49.99, currency: 'USD', date: '2024-03-15', category: 'Equipment', paymentMethod: 'Visa', emailSubject: 'Receipt, thanks!', source: 'gmail' as const },
];
const csv = exportToCSV(receipts);
expect(csv).toContain('"Amazon, Inc."');
expect(csv).toContain('"Receipt, thanks!"');
});
});
describe('Tax Summary', () => {
it('groups receipts by category and sums amounts', () => {
const receipts = [
{ id: '1', merchant: 'A', amount: 50, currency: 'USD', date: '2024-03-15', category: 'Software', paymentMethod: 'V', emailSubject: 'R', source: 'gmail' as const },
{ id: '2', merchant: 'B', amount: 30, currency: 'USD', date: '2024-03-16', category: 'Software', paymentMethod: 'V', emailSubject: 'R', source: 'gmail' as const },
{ id: '3', merchant: 'C', amount: 20, currency: 'USD', date: '2024-03-17', category: 'Meals', paymentMethod: 'V', emailSubject: 'R', source: 'gmail' as const },
];
const summary = generateTaxSummary(receipts);
expect(summary['Software']).toBe(80);
expect(summary['Meals']).toBe(20);
});
it('returns empty object for no receipts', () => {
const summary = generateTaxSummary([]);
expect(Object.keys(summary)).toHaveLength(0);
});
});
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM"],
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import { crx } from '@crxjs/vite-plugin';
import manifest from './src/manifest.json';
export default defineConfig({
build: {
outDir: 'dist',
emptyOutDir: true,
rollupOptions: {
input: {
popup: 'src/popup/popup.html',
},
},
},
plugins: [crx({ manifest })],
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
},
});