/// /** * Bank of America Bill Tracker Pattern * * Tracks Bank of America credit card bills from email notifications, showing * unpaid/upcoming bills with payment status tracking. * * Features: * - Uses BillExtractor building block for data processing * - Uses "likely paid" heuristic for old bills (>45 days past due assumed paid) * - Supports manual "Mark as Paid" for local tracking * - Groups bills by card (last 4 digits) * * Usage: * 1. Deploy a google-auth piece and complete OAuth * 2. Deploy this pattern * 3. Link: ct piece link google-auth/auth bofa-bill-tracker/overrideAuth */ import { computed, Default, handler, ifElse, NAME, pattern, UI, Writable, } from "commontools"; import BillExtractor, { type Auth, type BillAnalysis, formatCurrency, formatDate, formatIdentifier, getIdentifierColor, processBills, type TrackedBill, } from "../core/bill-extractor/index.tsx"; const BOFA_GMAIL_QUERY = "from:onlinebanking@ealerts.bankofamerica.com OR from:alerts@bankofamerica.com"; const BOFA_EXTRACTION_PROMPT = `Analyze this Bank of America credit card email and extract billing/payment information. EMAIL SUBJECT: {{email.subject}} EMAIL DATE: {{email.date}} EMAIL CONTENT: {{email.markdownContent}} Extract: 1. The type of email: - bill_due: A notification that a payment is due - payment_received: Confirmation that a payment was received/processed - payment_reminder: Reminder about upcoming due date - statement_ready: New statement is available - autopay_scheduled: Autopay confirmation - other: Unrelated to billing 2. For "identifier": extract the card's last 4 digits (e.g., "1234"). Look for patterns like "ending in 1234", "...1234", or "card 1234". 3. Amount - the payment amount or bill amount (number only, no $ sign) 4. Due date - in YYYY-MM-DD format 5. Payment date - for payment confirmations, in YYYY-MM-DD format 6. Other details like minimum payment, statement balance, autopay status 7. Brief summary of what this email is about`; // ============================================================================= // HANDLERS (module scope) // ============================================================================= const markAsPaid = handler< void, { paidKeys: Writable; bill: TrackedBill } >((_event, { paidKeys, bill }) => { const current = paidKeys.get() || []; const key = bill.key; if (key && !current.includes(key)) { paidKeys.set([...current, key]); } }); const unmarkAsPaid = handler< void, { paidKeys: Writable; bill: TrackedBill } >((_event, { paidKeys, bill }) => { const current = paidKeys.get() || []; const key = bill.key; paidKeys.set(current.filter((k: string) => k !== key)); }); // ============================================================================= // PATTERN // ============================================================================= interface PatternInput { overrideAuth?: Auth; manuallyPaid?: Writable>; demoMode?: Writable>; } export default pattern( ({ overrideAuth, manuallyPaid, demoMode }) => { // Use BillExtractor building block for data processing const tracker = BillExtractor({ gmailQuery: BOFA_GMAIL_QUERY, extractionPrompt: BOFA_EXTRACTION_PROMPT, identifierType: "card", title: "Bank of America Bill Tracker", shortName: "BofA", brandColor: "#c51f23", websiteUrl: "https://www.bankofamerica.com/", overrideAuth, manuallyPaid, demoMode, // BofA doesn't send payment confirmation emails, so auto-detection isn't possible supportsAutoDetect: false, }); // Create computed arrays in PATTERN scope (not building block scope) // This ensures closures in .map() can access pattern-scope variables like manuallyPaid const bills = computed(() => { // Use .get() to access Writable values inside computed const paidKeys = manuallyPaid?.get() || []; const isDemoMode = demoMode?.get() ?? true; return processBills( (tracker.rawAnalyses || []) as ReadonlyArray< { emailId: string; emailDate: string; analysis?: { result?: BillAnalysis }; } >, tracker.paymentConfirmations || {}, paidKeys, isDemoMode, ); }); const unpaidBills = computed(() => bills.filter((bill) => !bill.isPaid && !bill.isLikelyPaid) ); const likelyPaidBills = computed(() => bills .filter((bill) => bill.isLikelyPaid) .sort((a, b) => b.dueDate.localeCompare(a.dueDate)) ); const paidBills = computed(() => bills .filter((bill) => bill.isPaid && !bill.isLikelyPaid) .sort((a, b) => b.dueDate.localeCompare(a.dueDate)) ); const totalUnpaid = computed(() => unpaidBills.reduce((sum, bill) => sum + (bill.amount || 0), 0) ); const overdueCount = computed(() => unpaidBills.filter((bill) => bill.status === "overdue").length ); // UI components from building block const { title, ui } = tracker; return { [NAME]: "BofA Bill Tracker", bills: bills, unpaidBills: unpaidBills, paidBills: paidBills, totalUnpaid: totalUnpaid, overdueCount: overdueCount, previewUI: ui.previewUI, [UI]: ( {title} {/* Auth UI */} {ui.authStatusUI} {/* Connection Status */} {ui.connectionStatusUI} {/* Manual Tracking Banner (BofA doesn't send payment confirmation emails) */} {ui.manualTrackingBannerUI} {/* Analysis Status */} {ui.analysisProgressUI} {/* Summary Stats */} {ui.summaryStatsUI} {/* Overdue Alert */} {ui.overdueAlertUI} {/* Unpaid Bills Section */} unpaidBills.length > 0 ? "block" : "none" ), }} > Unpaid Bills {unpaidBills.map((bill) => ( {formatCurrency(bill.amount)} {formatIdentifier(bill.identifier, "card")} Due in {bill.daysUntilDue} days -{" "} {formatDate(bill.dueDate)} Mark Paid ))} {/* Likely Paid Bills Section */} likelyPaidBills.length > 0 ? "block" : "none" ), }} > Likely Paid ( {computed(() => likelyPaidBills?.length || 0)}) (i) Old bills without detected payment. Click "Confirm Paid" to move to paid list. {likelyPaidBills.map((bill) => ( {formatCurrency(bill.amount)} {formatIdentifier( bill.identifier, tracker.identifierType, )} Was due: {formatDate(bill.dueDate)} ( {computed(() => Math.abs(bill.daysUntilDue ?? 0))} {" "} days ago) Confirm Paid ))} {/* Paid Bills Section */} paidBills.length > 0 ? "block" : "none" ), }} > Paid Bills ({computed(() => paidBills?.length || 0)} ) {paidBills.map((bill) => ( {formatCurrency(bill.amount)} {formatIdentifier( bill.identifier, tracker.identifierType, )} {ifElse( bill.isManuallyPaid, "(manually marked)", "(auto-detected)", )} Was due: {formatDate(bill.dueDate)} Undo ))} {/* Website Link */} {ui.websiteLinkUI} {/* Demo Mode Toggle */} Demo mode ), }; }, );