TaxPack v1.0.0 - Gmail Receipt Auto-Extractor & Tax Report Generator
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user