190 lines
5.9 KiB
TypeScript
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;
|
|
}
|