Files
taxpack/src/lib/receipt-utils.ts
T

190 lines
5.9 KiB
TypeScript

// 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;
}