/// /** * PGE Bill Tracker Pattern * * Tracks PGE (Pacific Gas & Electric) utility bills from email notifications, * showing unpaid/upcoming bills and automatically or manually marking them as paid. * * Features: * - Uses BillExtractor building block for data processing * - Tracks payment confirmations to auto-mark bills as paid * - Supports manual "Mark as Paid" for local tracking * - Groups bills by account * * Usage: * 1. Deploy a google-auth piece and complete OAuth * 2. Deploy this pattern * 3. Link: ct piece link google-auth/auth pge-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 PGE_GMAIL_QUERY = "from:DoNotReply@billpay.pge.com"; const PGE_EXTRACTION_PROMPT = `Analyze this PGE (Pacific Gas & Electric) utility bill 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 energy statement is available - autopay_scheduled: Autopay confirmation - other: Unrelated to billing 2. For "identifier": extract the PGE account number or last 4 digits. Look for patterns like "Account: 1234567890" or "account ending in 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: PGE_GMAIL_QUERY, extractionPrompt: PGE_EXTRACTION_PROMPT, identifierType: "account", title: "PGE Bill Tracker", shortName: "PGE", brandColor: "#004B87", websiteUrl: "https://www.pge.com/", overrideAuth, manuallyPaid, demoMode, }); // 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]: "PGE Bill Tracker", bills, unpaidBills, paidBills, totalUnpaid, overdueCount, previewUI: ui.previewUI, [UI]: (
{title}
{/* Auth UI */} {ui.authStatusUI} {/* Connection Status */} {ui.connectionStatusUI} {/* 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, "account")}
Due in {bill.daysUntilDue} days -{" "} {formatDate(bill.dueDate)}
))}
{/* 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, "account")}
Was due: {formatDate(bill.dueDate)} ( {computed(() => Math.abs(bill.daysUntilDue ?? 0))} {" "} days ago)
))}
{/* Paid Bills Section */}
paidBills.length > 0 ? "block" : "none" ), }} >
Paid Bills ({computed(() => paidBills?.length || 0)} ) {paidBills.map((bill) => (
{formatCurrency(bill.amount)} {formatIdentifier(bill.identifier, "account")} {ifElse( bill.isManuallyPaid, "(manually marked)", "(auto-detected)", )}
Was due: {formatDate(bill.dueDate)}
))}
{/* Website Link */} {ui.websiteLinkUI} {/* Demo Mode Toggle */}
Demo mode
), }; }, );