InvoiceChaser v1.0.0 — Freelancer Payment Tracker & Auto Follow-Up Chrome Extension

This commit is contained in:
Bun Bun
2026-06-19 18:33:39 +00:00
commit 326d408449
26 changed files with 3519 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
*.log
.verdict
+1500
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "invoicechaser",
"version": "1.0.0",
"description": "Freelancer Payment Tracker & Auto Follow-Up Chrome Extension",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build && tsc",
"test": "node --test dist/test/**/*.test.js",
"lint": "tsc --noEmit"
},
"devDependencies": {
"@crxjs/vite-plugin": "^2.0.0-beta.28",
"@types/chrome": "^0.0.268",
"typescript": "^5.4.5",
"vite": "^5.2.11"
},
"dependencies": {
"@types/node": "^26.0.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 300 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 B

+77
View File
@@ -0,0 +1,77 @@
const fs = require("fs");
const path = require("path");
const zlib = require("zlib");
function crc32(buf) {
const table = new Int32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) {
c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
}
table[i] = c;
}
let crc = -1;
for (let i = 0; i < buf.length; i++) {
crc = table[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
}
return (crc ^ -1) >>> 0;
}
function makeChunk(type, data) {
const typeBuf = Buffer.from(type, "ascii");
const lenBuf = Buffer.alloc(4);
lenBuf.writeUInt32BE(data.length, 0);
const crcData = Buffer.concat([typeBuf, data]);
const crcBuf = Buffer.alloc(4);
crcBuf.writeUInt32BE(crc32(crcData), 0);
return Buffer.concat([lenBuf, typeBuf, data, crcBuf]);
}
function createSolidPng(size, r, g, b) {
const width = size;
const height = size;
const bitDepth = 8;
const colorType = 2; // RGB
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const ihdrData = Buffer.alloc(13);
ihdrData.writeUInt32BE(width, 0);
ihdrData.writeUInt32BE(height, 4);
ihdrData.writeUInt8(bitDepth, 8);
ihdrData.writeUInt8(colorType, 9);
ihdrData.writeUInt8(0, 10);
ihdrData.writeUInt8(0, 11);
ihdrData.writeUInt8(0, 12);
const ihdrChunk = makeChunk("IHDR", ihdrData);
const rowSize = 1 + width * 3;
const imageData = Buffer.alloc(rowSize * height);
for (let y = 0; y < height; y++) {
imageData[y * rowSize] = 0;
for (let x = 0; x < width; x++) {
const offset = y * rowSize + 1 + x * 3;
imageData[offset] = r;
imageData[offset + 1] = g;
imageData[offset + 2] = b;
}
}
const compressed = zlib.deflateRawSync(imageData);
const idatChunk = makeChunk("IDAT", compressed);
const iendChunk = makeChunk("IEND", Buffer.alloc(0));
return Buffer.concat([signature, ihdrChunk, idatChunk, iendChunk]);
}
const iconsDir = path.join(__dirname, "..", "src", "icons");
fs.mkdirSync(iconsDir, { recursive: true });
const sizes = [16, 48, 128];
for (const size of sizes) {
const png = createSolidPng(size, 34, 197, 94);
fs.writeFileSync(path.join(iconsDir, `icon${size}.png`), png);
}
console.log("Icons generated successfully");
+107
View File
@@ -0,0 +1,107 @@
// Generate simple 1-color PNG icons using raw buffer construction
// PNG signature + IHDR + IDAT + IEND for a solid-color square
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
function createSolidPng(size: number, r: number, g: number, b: number): Buffer {
// Simple zlib compression for raw RGBA data
// This is a minimal approach; for solid colors we can construct a valid PNG
// Using a pre-built minimal solid color PNG template would be easier
// Let's use a small helper that creates a valid PNG via canvas-like approach
// Actually, let's just write a simple PNG with the minimal required chunks
const width = size;
const height = size;
const bitDepth = 8;
const colorType = 2; // RGB
// PNG signature
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
// IHDR chunk
const ihdrData = Buffer.alloc(13);
ihdrData.writeUInt32BE(width, 0);
ihdrData.writeUInt32BE(height, 4);
ihdrData.writeUInt8(bitDepth, 8);
ihdrData.writeUInt8(colorType, 9);
ihdrData.writeUInt8(0, 10); // compression
ihdrData.writeUInt8(0, 11); // filter
ihdrData.writeUInt8(0, 12); // interlace
const ihdrChunk = makeChunk("IHDR", ihdrData);
// IDAT chunk - raw image data with filter byte
// Each row: 1 filter byte (0 = none) + width * 3 bytes (RGB)
const rowSize = 1 + width * 3;
const imageData = Buffer.alloc(rowSize * height);
for (let y = 0; y < height; y++) {
imageData[y * rowSize] = 0; // filter: none
for (let x = 0; x < width; x++) {
const offset = y * rowSize + 1 + x * 3;
imageData[offset] = r;
imageData[offset + 1] = g;
imageData[offset + 2] = b;
}
}
const compressed = zlibDeflate(imageData);
const idatChunk = makeChunk("IDAT", compressed);
// IEND chunk
const iendChunk = makeChunk("IEND", Buffer.alloc(0));
return Buffer.concat([signature, ihdrChunk, idatChunk, iendChunk]);
}
function makeChunk(type: string, data: Buffer): Buffer {
const typeBuf = Buffer.from(type, "ascii");
const lenBuf = Buffer.alloc(4);
lenBuf.writeUInt32BE(data.length, 0);
const crcBuf = Buffer.alloc(4);
const crcData = Buffer.concat([typeBuf, data]);
crcBuf.writeInt32BE(crc32(crcData), 0);
return Buffer.concat([lenBuf, typeBuf, data, crcBuf]);
}
function crc32(buf: Buffer): number {
const table = new Int32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) {
c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
}
table[i] = c;
}
let crc = -1;
for (let i = 0; i < buf.length; i++) {
crc = table[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
}
return (crc ^ -1) >>> 0;
}
function zlibDeflate(data: Buffer): Buffer {
// Minimal deflate: no compression blocks (type 0) for small data
// For larger images, we need proper zlib. Let's use Node's built-in zlib.
import("zlib").then((zlib) => {
// Not needed - we'll do this synchronously via a different approach
});
// Actually, we can use the zlib module directly since we're in Node
const zlib = require("zlib");
return zlib.deflateRawSync(data);
}
const iconsDir = path.join(__dirname, "..", "src", "icons");
fs.mkdirSync(iconsDir, { recursive: true });
// Green color (#22c55e) for InvoiceChaser
const sizes = [16, 48, 128];
for (const size of sizes) {
const png = createSolidPng(size, 34, 197, 94);
fs.writeFileSync(path.join(iconsDir, `icon${size}.png`), png);
}
console.log("Icons generated successfully");
+165
View File
@@ -0,0 +1,165 @@
import { getInvoices, saveInvoice, getSettings } from "./lib/storage.js";
import { isOverdue, daysSince, generateInvoiceId } from "./lib/invoice-detector.js";
import { shouldFollowUp, generateFollowUp } from "./lib/follow-up.js";
import type { Invoice } from "./lib/types.js";
// Alarm names
const CHECK_FOLLOWUPS = "check-followups";
const CHECK_OVERDUE = "check-overdue";
// Initialize alarms on extension install
chrome.runtime.onInstalled.addListener(() => {
chrome.alarms.create(CHECK_FOLLOWUPS, { periodInMinutes: 60 }); // every hour
chrome.alarms.create(CHECK_OVERDUE, { periodInMinutes: 360 }); // every 6 hours
console.log("[InvoiceChaser] Alarms initialized");
});
// Handle alarm triggers
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === CHECK_FOLLOWUPS) {
await checkFollowUps();
} else if (alarm.name === CHECK_OVERDUE) {
await checkOverdue();
}
});
// Handle messages from content scripts and popup
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
handleMessage(message, sender).then(sendResponse).catch((err) => sendResponse({ error: err.message }));
return true; // async response
});
async function handleMessage(
message: { type: string; data?: unknown },
_sender: chrome.runtime.MessageSender
): Promise<unknown> {
switch (message.type) {
case "detected-invoice": {
const detected = message.data as {
clientEmail: string;
clientName: string;
subject: string;
amount: number;
currency: string;
dueDate: string;
sentDate: string;
source: "gmail" | "outlook";
sourceThreadId: string;
};
const invoices = await getInvoices();
const existing = invoices.find(
(i) => i.sourceThreadId === detected.sourceThreadId
);
if (existing) return { status: "exists", id: existing.id };
const settings = await getSettings();
const invoice: Invoice = {
id: generateInvoiceId(),
clientEmail: detected.clientEmail,
clientName: detected.clientName,
subject: detected.subject,
amount: detected.amount,
currency: detected.currency,
dueDate: detected.dueDate,
sentDate: detected.sentDate,
status: isOverdue(detected.dueDate) ? "overdue" : "pending",
source: detected.source,
sourceThreadId: detected.sourceThreadId,
followUpCount: 0,
followUpSchedule: settings.followUpDays,
notes: "",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await saveInvoice(invoice);
return { status: "saved", id: invoice.id };
}
case "detected-payment": {
const payment = message.data as { invoiceId: string; provider: string };
const invoices = await getInvoices();
const invoice = invoices.find((i) => i.id === payment.invoiceId);
if (!invoice) return { status: "not-found" };
const updated: Invoice = {
...invoice,
status: "paid",
paymentMethod: payment.provider as "stripe" | "paypal" | "bank" | "other",
paymentDate: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await saveInvoice(updated);
return { status: "marked-paid" };
}
case "get-follow-ups": {
const invoices = await getInvoices();
const settings = await getSettings();
const followUps = [];
for (const invoice of invoices) {
if (shouldFollowUp(invoice)) {
const followUp = generateFollowUp(invoice, settings);
if (followUp) {
followUps.push({ invoice, followUp });
}
}
}
return { followUps };
}
case "mark-sent-follow-up": {
const { invoiceId } = message.data as { invoiceId: string };
const invoices = await getInvoices();
const invoice = invoices.find((i) => i.id === invoiceId);
if (!invoice) return { status: "not-found" };
const updated: Invoice = {
...invoice,
followUpCount: invoice.followUpCount + 1,
lastFollowUpDate: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await saveInvoice(updated);
return { status: "updated" };
}
default:
return { status: "unknown" };
}
}
async function checkFollowUps(): Promise<void> {
const invoices = await getInvoices();
const settings = await getSettings();
if (!settings.enableFollowUpGeneration) return;
let count = 0;
for (const invoice of invoices) {
if (shouldFollowUp(invoice)) {
count++;
}
}
if (count > 0) {
chrome.action.setBadgeText({ text: count > 9 ? "9+" : String(count) });
chrome.action.setBadgeBackgroundColor({ color: "#ef4444" });
} else {
chrome.action.setBadgeText({ text: "" });
}
}
async function checkOverdue(): Promise<void> {
const invoices = await getInvoices();
for (const invoice of invoices) {
if (invoice.status === "pending" && isOverdue(invoice.dueDate)) {
const updated: Invoice = {
...invoice,
status: "overdue",
updatedAt: new Date().toISOString(),
};
await saveInvoice(updated);
}
}
}
+111
View File
@@ -0,0 +1,111 @@
// Content script for Gmail invoice detection
// Scans Gmail DOM for invoice-related emails and sends them to background
import { detectInvoiceFromEmail } from "../lib/invoice-detector.js";
import { scanForPaymentConfirmation } from "../lib/stripe-paypal.js";
let lastProcessedThreads = new Set<string>();
function scanGmail() {
// Look for email rows in the Gmail list view
const emailRows = Array.from(document.querySelectorAll("tr[draggable='true']"));
for (const row of emailRows) {
const subjectEl = row.querySelector("[data-tooltip]");
const senderEl = row.querySelector("[email]");
const dateEl = row.querySelector("[title]");
if (!subjectEl || !senderEl) continue;
const subject = subjectEl.textContent || "";
const fromEmail = senderEl.getAttribute("email") || "";
const fromName = senderEl.getAttribute("name") || "";
const date = dateEl?.getAttribute("title") || new Date().toISOString();
const threadId = row.getAttribute("data-thread-id") || subject + fromEmail;
if (lastProcessedThreads.has(threadId)) continue;
lastProcessedThreads.add(threadId);
// For payment confirmations, we need minimal info
if (subject.toLowerCase().includes("payment") || subject.toLowerCase().includes("receipt")) {
const confirmation = scanForPaymentConfirmation(subject, "", fromEmail);
if (confirmation) {
chrome.runtime.sendMessage({
type: "detected-payment",
data: { invoiceId: "", provider: confirmation.provider }
});
continue;
}
}
const detected = detectInvoiceFromEmail(subject, "", fromEmail, fromName, date, threadId);
if (detected) {
chrome.runtime.sendMessage({
type: "detected-invoice",
data: {
clientEmail: detected.clientEmail,
clientName: detected.clientName,
subject: detected.subject,
amount: detected.amount,
currency: detected.currency,
dueDate: detected.dueDate,
sentDate: detected.sentDate,
source: "gmail" as const,
sourceThreadId: detected.sourceThreadId,
}
}).then((response) => {
console.log("[InvoiceChaser] Gmail detection:", response);
});
}
}
}
// Also scan the open email view (if user is reading an individual email)
function scanOpenEmail() {
const subjectEl = document.querySelector("h2[data-thread-perm-id]");
if (!subjectEl) return;
const subject = subjectEl.textContent || "";
const fromEl = document.querySelector("[email]");
const fromEmail = fromEl?.getAttribute("email") || "";
const fromName = fromEl?.getAttribute("name") || "";
const dateEl = document.querySelector("[title]");
const date = dateEl?.getAttribute("title") || new Date().toISOString();
const threadId = subjectEl.getAttribute("data-thread-perm-id") || subject + fromEmail;
if (lastProcessedThreads.has(threadId)) return;
lastProcessedThreads.add(threadId);
const bodyEl = document.querySelector(".a3s.aiL");
const body = bodyEl?.textContent || "";
const detected = detectInvoiceFromEmail(subject, body, fromEmail, fromName, date, threadId);
if (detected) {
chrome.runtime.sendMessage({
type: "detected-invoice",
data: {
clientEmail: detected.clientEmail,
clientName: detected.clientName,
subject: detected.subject,
amount: detected.amount,
currency: detected.currency,
dueDate: detected.dueDate,
sentDate: detected.sentDate,
source: "gmail" as const,
sourceThreadId: detected.sourceThreadId,
}
});
}
}
// Scan periodically
setInterval(() => {
scanGmail();
scanOpenEmail();
}, 5000);
// Initial scan
scanGmail();
scanOpenEmail();
console.log("[InvoiceChaser] Gmail content script loaded");
+111
View File
@@ -0,0 +1,111 @@
// Content script for Outlook invoice detection
// Scans Outlook web DOM for invoice-related emails
import { detectInvoiceFromEmail } from "../lib/invoice-detector.js";
import { scanForPaymentConfirmation } from "../lib/stripe-paypal.js";
let lastProcessedThreads = new Set<string>();
function scanOutlook() {
// Outlook list view items
const emailItems = Array.from(document.querySelectorAll("[role='listitem']"));
for (const item of emailItems) {
const subjectEl = item.querySelector("[title]");
const senderEl = item.querySelector("[aria-label]");
if (!subjectEl || !senderEl) continue;
const subject = subjectEl.textContent || "";
const senderText = senderEl.getAttribute("aria-label") || "";
const fromEmail = extractEmail(senderText);
const fromName = senderText.split("<")[0].trim();
const date = new Date().toISOString();
const threadId = item.getAttribute("data-id") || subject + fromEmail;
if (lastProcessedThreads.has(threadId)) continue;
lastProcessedThreads.add(threadId);
if (subject.toLowerCase().includes("payment") || subject.toLowerCase().includes("receipt")) {
const confirmation = scanForPaymentConfirmation(subject, "", fromEmail);
if (confirmation) {
chrome.runtime.sendMessage({
type: "detected-payment",
data: { invoiceId: "", provider: confirmation.provider }
});
continue;
}
}
const detected = detectInvoiceFromEmail(subject, "", fromEmail, fromName, date, threadId);
if (detected) {
chrome.runtime.sendMessage({
type: "detected-invoice",
data: {
clientEmail: detected.clientEmail,
clientName: detected.clientName,
subject: detected.subject,
amount: detected.amount,
currency: detected.currency,
dueDate: detected.dueDate,
sentDate: detected.sentDate,
source: "outlook" as const,
sourceThreadId: detected.sourceThreadId,
}
}).then((response) => {
console.log("[InvoiceChaser] Outlook detection:", response);
});
}
}
}
function scanOpenEmail() {
const subjectEl = document.querySelector("[role='heading']");
if (!subjectEl) return;
const subject = subjectEl.textContent || "";
const fromEl = document.querySelector("[aria-label*='From']");
const fromEmail = fromEl ? extractEmail(fromEl.textContent || "") : "";
const fromName = fromEl ? (fromEl.textContent || "").split("<")[0].trim() : "";
const date = new Date().toISOString();
const threadId = subject + fromEmail;
if (lastProcessedThreads.has(threadId)) return;
lastProcessedThreads.add(threadId);
const bodyEl = document.querySelector("[role='document']");
const body = bodyEl?.textContent || "";
const detected = detectInvoiceFromEmail(subject, body, fromEmail, fromName, date, threadId);
if (detected) {
chrome.runtime.sendMessage({
type: "detected-invoice",
data: {
clientEmail: detected.clientEmail,
clientName: detected.clientName,
subject: detected.subject,
amount: detected.amount,
currency: detected.currency,
dueDate: detected.dueDate,
sentDate: detected.sentDate,
source: "outlook" as const,
sourceThreadId: detected.sourceThreadId,
}
});
}
}
function extractEmail(text: string): string {
const match = text.match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/);
return match ? match[0] : "";
}
setInterval(() => {
scanOutlook();
scanOpenEmail();
}, 5000);
scanOutlook();
scanOpenEmail();
console.log("[InvoiceChaser] Outlook content script loaded");
Binary file not shown.

After

Width:  |  Height:  |  Size: 300 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 B

+74
View File
@@ -0,0 +1,74 @@
import type { Invoice, Settings } from "./types.js";
import { daysOverdue, daysSince } from "./invoice-detector.js";
export interface FollowUpEmail {
subject: string;
body: string;
tone: "polite" | "firm" | "urgent";
daysAfter: number;
}
export function generateFollowUp(invoice: Invoice, settings: Settings): FollowUpEmail | null {
const daysSinceSent = daysSince(invoice.sentDate);
const daysOver = daysOverdue(invoice.dueDate);
// Determine which follow-up stage we're at
const schedule = invoice.followUpSchedule;
let stage = -1;
for (let i = 0; i < schedule.length; i++) {
if (daysSinceSent >= schedule[i] && invoice.followUpCount <= i) {
stage = i;
break;
}
}
if (stage === -1) return null;
const daysAfter = schedule[stage];
const tone = stage === 0 ? "polite" : stage === 1 ? "firm" : "urgent";
const userName = settings.userName || "there";
const clientName = invoice.clientName || "there";
const subject = `Re: ${invoice.subject}`;
let body: string;
if (tone === "polite") {
body = `Hi ${clientName},\n\nI hope you're doing well. I wanted to follow up on the invoice I sent on ${formatDate(invoice.sentDate)} for ${formatCurrency(invoice.amount, invoice.currency)}.\n\nThe payment was due on ${formatDate(invoice.dueDate)}. If you've already sent it, please disregard this message.\n\nBest regards,\n${userName}`;
} else if (tone === "firm") {
body = `Hi ${clientName},\n\nThis is a friendly reminder that the invoice I sent on ${formatDate(invoice.sentDate)} for ${formatCurrency(invoice.amount, invoice.currency)} is now ${daysOver} days overdue.\n\nPlease let me know if there's anything preventing payment so we can resolve it.\n\nBest,\n${userName}`;
} else {
body = `Hi ${clientName},\n\nThe invoice for ${formatCurrency(invoice.amount, invoice.currency)} sent on ${formatDate(invoice.sentDate)} is now ${daysOver} days overdue.\n\nThis is impacting my cash flow. Please process this payment urgently or contact me to discuss a payment plan.\n\nRegards,\n${userName}`;
}
return { subject, body, tone, daysAfter };
}
export function shouldFollowUp(invoice: Invoice): boolean {
if (invoice.status !== "pending" && invoice.status !== "overdue") return false;
const daysSinceSent = daysSince(invoice.sentDate);
const schedule = invoice.followUpSchedule;
for (let i = 0; i < schedule.length; i++) {
if (daysSinceSent >= schedule[i] && invoice.followUpCount <= i) {
return true;
}
}
return false;
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
function formatCurrency(amount: number, currency: string): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(amount);
}
+139
View File
@@ -0,0 +1,139 @@
import type { Invoice } from "./types.js";
const INVOICE_KEYWORDS = [
"invoice", "invoiced", "bill", "billing", "payment due",
"amount due", "total due", "please pay", "remit payment",
"outstanding balance", "payment request"
];
const PAID_KEYWORDS = [
"payment received", "paid", "thank you for your payment",
"payment confirmation", "receipt", "payment successful",
"we received your payment", "your payment has been processed"
];
const CURRENCY_REGEX = /[$€£¥]\s?([\d,]+\.?\d*)|([\d,]+\.?\d*)\s?(USD|EUR|GBP|CAD|AUD)/gi;
const DATE_REGEX = /(?:due|by|before)\s*(?:date[:\s]*)?(\d{1,2}[\/\-.]\d{1,2}[\/\-.]\d{2,4})/i;
export interface DetectedInvoice {
clientEmail: string;
clientName: string;
subject: string;
amount: number;
currency: string;
dueDate: string;
sentDate: string;
sourceThreadId: string;
isPaidConfirmation: boolean;
}
export function detectInvoiceFromEmail(
subject: string,
body: string,
fromEmail: string,
fromName: string,
date: string,
threadId: string
): DetectedInvoice | null {
const text = `${subject} ${body}`.toLowerCase();
// Check if this is a payment confirmation
const isPaid = PAID_KEYWORDS.some((kw) => text.includes(kw.toLowerCase()));
// Check for invoice keywords (or payment confirmation)
const hasInvoiceKeyword = INVOICE_KEYWORDS.some((kw) => text.includes(kw.toLowerCase()));
if (!hasInvoiceKeyword && !isPaid) {
return null;
}
// Extract amount
const amount = extractAmount(subject + " " + body);
// Extract currency
const currency = extractCurrency(subject + " " + body) || "USD";
// Extract due date
const dueDate = extractDueDate(body) || extractDueDate(subject) || getDefaultDueDate(date);
return {
clientEmail: fromEmail,
clientName: fromName || fromEmail.split("@")[0],
subject,
amount,
currency,
dueDate,
sentDate: date,
sourceThreadId: threadId,
isPaidConfirmation: isPaid,
};
}
function extractAmount(text: string): number {
const matches = text.match(CURRENCY_REGEX);
if (!matches) return 0;
// Pick the largest amount found
let maxAmount = 0;
for (const match of matches) {
const num = parseFloat(match.replace(/[^\d.]/g, ""));
if (!isNaN(num) && num > maxAmount) {
maxAmount = num;
}
}
return maxAmount;
}
function extractCurrency(text: string): string | null {
if (text.includes("$")) return "USD";
if (text.includes("€")) return "EUR";
if (text.includes("£")) return "GBP";
if (text.includes("¥")) return "JPY";
const match = text.match(/\b(USD|EUR|GBP|CAD|AUD|JPY)\b/);
return match ? match[1] : null;
}
function extractDueDate(text: string): string | null {
const match = text.match(DATE_REGEX);
if (!match) return null;
try {
const parsed = new Date(match[1]);
if (!isNaN(parsed.getTime())) {
return parsed.toISOString().split("T")[0];
}
} catch {
// ignore
}
return null;
}
function getDefaultDueDate(sentDate: string): string {
const date = new Date(sentDate);
date.setDate(date.getDate() + 30);
return date.toISOString().split("T")[0];
}
export function generateInvoiceId(): string {
return `inv_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
export function isOverdue(dueDate: string): boolean {
return new Date(dueDate) < new Date();
}
export function daysOverdue(dueDate: string): number {
const due = new Date(dueDate);
const now = new Date();
if (due >= now) return 0;
const diff = now.getTime() - due.getTime();
return Math.floor(diff / (1000 * 60 * 60 * 24));
}
export function daysSince(date: string): number {
const then = new Date(date);
const now = new Date();
const diff = now.getTime() - then.getTime();
return Math.floor(diff / (1000 * 60 * 60 * 24));
}
+67
View File
@@ -0,0 +1,67 @@
import type { Invoice, Settings, Client } from "./types.js";
import { DEFAULT_SETTINGS } from "./types.js";
const STORAGE_KEYS = {
invoices: "invoices",
settings: "settings",
clients: "clients",
};
export async function getInvoices(): Promise<Invoice[]> {
const result = await chrome.storage.local.get(STORAGE_KEYS.invoices);
return (result[STORAGE_KEYS.invoices] as Invoice[]) || [];
}
export async function saveInvoice(invoice: Invoice): Promise<void> {
const invoices = await getInvoices();
const existingIndex = invoices.findIndex((i) => i.id === invoice.id);
if (existingIndex >= 0) {
invoices[existingIndex] = invoice;
} else {
invoices.push(invoice);
}
await chrome.storage.local.set({ [STORAGE_KEYS.invoices]: invoices });
}
export async function deleteInvoice(id: string): Promise<void> {
const invoices = await getInvoices();
const filtered = invoices.filter((i) => i.id !== id);
await chrome.storage.local.set({ [STORAGE_KEYS.invoices]: filtered });
}
export async function getSettings(): Promise<Settings> {
const result = await chrome.storage.local.get(STORAGE_KEYS.settings);
return { ...DEFAULT_SETTINGS, ...(result[STORAGE_KEYS.settings] || {}) };
}
export async function saveSettings(settings: Settings): Promise<void> {
await chrome.storage.local.set({ [STORAGE_KEYS.settings]: settings });
}
export async function getClients(): Promise<Client[]> {
const result = await chrome.storage.local.get(STORAGE_KEYS.clients);
return (result[STORAGE_KEYS.clients] as Client[]) || [];
}
export async function updateClientsFromInvoices(invoices: Invoice[]): Promise<void> {
const clientMap = new Map<string, Client>();
for (const inv of invoices) {
const existing = clientMap.get(inv.clientEmail);
if (existing) {
existing.totalInvoiced += inv.amount;
if (inv.status === "paid") existing.totalPaid += inv.amount;
if (inv.status === "overdue") existing.totalOverdue += inv.amount;
existing.invoiceCount += 1;
} else {
clientMap.set(inv.clientEmail, {
email: inv.clientEmail,
name: inv.clientName,
totalInvoiced: inv.amount,
totalPaid: inv.status === "paid" ? inv.amount : 0,
totalOverdue: inv.status === "overdue" ? inv.amount : 0,
invoiceCount: 1,
});
}
}
await chrome.storage.local.set({ [STORAGE_KEYS.clients]: Array.from(clientMap.values()) });
}
+82
View File
@@ -0,0 +1,82 @@
import type { Invoice } from "./types.js";
/**
* Checks for payment confirmation emails from Stripe and PayPal
* to automatically update invoice status. Since this is a client-only
* extension without a backend, we scan the user's email for confirmation
* messages from these payment providers.
*/
const STRIPE_SENDERS = ["receipts@stripe.com", "receipts@stripe.com", "support@stripe.com"];
const PAYPAL_SENDERS = ["service@paypal.com", "paypal@paypal.com"];
export interface PaymentConfirmation {
invoiceId?: string;
amount: number;
currency: string;
payerEmail: string;
paymentDate: string;
provider: "stripe" | "paypal";
}
export function scanForPaymentConfirmation(
subject: string,
body: string,
fromEmail: string
): PaymentConfirmation | null {
const isStripe = STRIPE_SENDERS.some((s) => fromEmail.toLowerCase().includes(s.toLowerCase()));
const isPayPal = PAYPAL_SENDERS.some((s) => fromEmail.toLowerCase().includes(s.toLowerCase()));
if (!isStripe && !isPayPal) return null;
const provider = isStripe ? "stripe" : "paypal";
const text = `${subject} ${body}`;
// Extract amount from confirmation email
const amountMatch = text.match(/[\$€£¥]\s?([\d,]+\.?\d*)/);
const amount = amountMatch ? parseFloat(amountMatch[1].replace(/,/g, "")) : 0;
// Extract currency
const currency = text.includes("€") ? "EUR" : text.includes("£") ? "GBP" : text.includes("¥") ? "JPY" : "USD";
// Extract payer email from body
const emailMatch = body.match(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/);
const payerEmail = emailMatch ? emailMatch[1] : "";
return {
amount,
currency,
payerEmail,
paymentDate: new Date().toISOString(),
provider,
};
}
export function matchConfirmationToInvoice(
confirmation: PaymentConfirmation,
invoices: Invoice[]
): Invoice | null {
// Match by payer email and amount (within tolerance)
const matches = invoices.filter((inv) =>
inv.clientEmail.toLowerCase() === confirmation.payerEmail.toLowerCase() &&
Math.abs(inv.amount - confirmation.amount) < 0.01 &&
inv.currency === confirmation.currency &&
inv.status !== "paid"
);
// Return the most recent match
if (matches.length === 0) return null;
return matches.sort((a, b) =>
new Date(b.sentDate).getTime() - new Date(a.sentDate).getTime()
)[0];
}
export function markInvoicePaid(invoice: Invoice, confirmation: PaymentConfirmation): Invoice {
return {
...invoice,
status: "paid",
paymentDate: confirmation.paymentDate,
paymentMethod: confirmation.provider,
updatedAt: new Date().toISOString(),
};
}
+61
View File
@@ -0,0 +1,61 @@
export type PaymentStatus = "pending" | "paid" | "overdue" | "cancelled";
export interface Invoice {
id: string;
clientEmail: string;
clientName: string;
subject: string;
amount: number;
currency: string;
dueDate: string; // ISO date
sentDate: string; // ISO date
status: PaymentStatus;
source: "gmail" | "outlook" | "manual";
sourceThreadId?: string;
lastFollowUpDate?: string;
followUpCount: number;
followUpSchedule: number[]; // days after sent: default [7, 14, 30]
notes: string;
paymentMethod?: "stripe" | "paypal" | "bank" | "other";
paymentDate?: string;
createdAt: string;
updatedAt: string;
}
export interface Client {
email: string;
name: string;
totalInvoiced: number;
totalPaid: number;
totalOverdue: number;
invoiceCount: number;
}
export interface DashboardStats {
totalOutstanding: number;
totalPaid: number;
totalOverdue: number;
overdueCount: number;
pendingCount: number;
paidCount: number;
}
export interface Settings {
followUpDays: number[];
defaultCurrency: string;
enableAutoDetect: boolean;
enableFollowUpGeneration: boolean;
userName: string;
userEmail: string;
paypalEmail?: string;
stripeAccountId?: string;
}
export const DEFAULT_SETTINGS: Settings = {
followUpDays: [7, 14, 30],
defaultCurrency: "USD",
enableAutoDetect: true,
enableFollowUpGeneration: true,
userName: "",
userEmail: "",
};
+46
View File
@@ -0,0 +1,46 @@
{
"manifest_version": 3,
"name": "InvoiceChaser",
"version": "1.0.0",
"description": "Freelancer Payment Tracker & Auto Follow-Up",
"permissions": ["storage", "alarms", "activeTab", "scripting"],
"host_permissions": [
"https://mail.google.com/*",
"https://outlook.live.com/*",
"https://outlook.office.com/*",
"https://outlook.office365.com/*"
],
"background": {
"service_worker": "src/background.ts",
"type": "module"
},
"content_scripts": [
{
"matches": ["https://mail.google.com/*"],
"js": ["src/content/gmail.ts"],
"run_at": "document_idle"
},
{
"matches": [
"https://outlook.live.com/*",
"https://outlook.office.com/*",
"https://outlook.office365.com/*"
],
"js": ["src/content/outlook.ts"],
"run_at": "document_idle"
}
],
"action": {
"default_popup": "src/popup/popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
+378
View File
@@ -0,0 +1,378 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 13px;
color: #1f2937;
background: #f9fafb;
width: 380px;
min-height: 500px;
}
#app {
padding: 0;
}
header {
background: #fff;
border-bottom: 1px solid #e5e7eb;
padding: 12px 16px;
position: sticky;
top: 0;
z-index: 10;
}
h1 {
font-size: 16px;
font-weight: 700;
color: #111827;
margin-bottom: 10px;
}
nav {
display: flex;
gap: 4px;
}
nav button {
flex: 1;
padding: 6px 8px;
border: 1px solid #d1d5db;
background: #fff;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
color: #4b5563;
transition: all 0.15s;
}
nav button:hover {
background: #f3f4f6;
}
nav button.active {
background: #22c55e;
color: #fff;
border-color: #22c55e;
}
main {
padding: 12px 16px 20px;
}
.tab {
display: none;
}
.tab.active {
display: block;
}
/* Stats */
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 16px;
}
.stat-card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px;
text-align: center;
}
.stat-card.pending {
border-left: 3px solid #f59e0b;
}
.stat-card.paid {
border-left: 3px solid #22c55e;
}
.stat-card.overdue {
border-left: 3px solid #ef4444;
}
.stat-value {
display: block;
font-size: 18px;
font-weight: 700;
color: #111827;
}
.stat-label {
display: block;
font-size: 11px;
color: #6b7280;
margin-top: 2px;
}
/* Follow-ups */
.follow-ups h2 {
font-size: 13px;
font-weight: 600;
margin-bottom: 8px;
color: #374151;
}
.follow-up-card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 10px;
margin-bottom: 8px;
}
.follow-up-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.follow-up-meta {
font-size: 11px;
color: #6b7280;
margin-bottom: 6px;
}
.follow-up-body {
background: #f3f4f6;
border-radius: 6px;
padding: 8px;
font-size: 12px;
margin-bottom: 8px;
}
.follow-up-body pre {
white-space: pre-wrap;
word-break: break-word;
font-family: inherit;
margin-top: 4px;
font-size: 11px;
color: #374151;
}
.follow-up-actions {
display: flex;
gap: 6px;
}
.follow-up-actions button {
flex: 1;
padding: 5px 8px;
border: 1px solid #d1d5db;
background: #fff;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
}
.follow-up-actions button:hover {
background: #f3f4f6;
}
.badge {
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
}
.badge.polite {
background: #dbeafe;
color: #1e40af;
}
.badge.firm {
background: #fef3c7;
color: #92400e;
}
.badge.urgent {
background: #fee2e2;
color: #991b1b;
}
/* Invoices */
.filter-bar {
display: flex;
gap: 8px;
margin-bottom: 10px;
}
.filter-bar select,
.filter-bar input {
flex: 1;
padding: 6px 8px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 12px;
background: #fff;
}
.invoice-card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 10px;
margin-bottom: 8px;
}
.invoice-card.pending {
border-left: 3px solid #f59e0b;
}
.invoice-card.paid {
border-left: 3px solid #22c55e;
}
.invoice-card.overdue {
border-left: 3px solid #ef4444;
}
.invoice-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.invoice-subject {
font-size: 12px;
color: #374151;
margin-bottom: 4px;
}
.invoice-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 11px;
color: #6b7280;
}
.status-badge {
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
}
.status-badge.pending {
background: #fef3c7;
color: #92400e;
}
.status-badge.paid {
background: #d1fae5;
color: #065f46;
}
.status-badge.overdue {
background: #fee2e2;
color: #991b1b;
}
.overdue-badge {
background: #ef4444;
color: #fff;
padding: 1px 4px;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
}
.invoice-actions {
display: flex;
gap: 6px;
margin-top: 8px;
}
.invoice-actions button {
padding: 4px 8px;
border: 1px solid #d1d5db;
background: #fff;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
}
.invoice-actions button:hover {
background: #f3f4f6;
}
/* Forms */
form label {
display: block;
font-size: 11px;
font-weight: 600;
color: #374151;
margin: 8px 0 4px;
}
form input,
form select,
form textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 13px;
font-family: inherit;
}
form input:focus,
form select:focus,
form textarea:focus {
outline: none;
border-color: #22c55e;
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.1);
}
form button[type="submit"] {
width: 100%;
padding: 10px;
margin-top: 12px;
border: none;
background: #22c55e;
color: #fff;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
form button[type="submit"]:hover {
background: #16a34a;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 6px;
font-weight: 400;
cursor: pointer;
}
.checkbox-label input {
width: auto;
}
/* Empty state */
.empty {
text-align: center;
padding: 24px;
color: #9ca3af;
font-size: 13px;
}
+139
View File
@@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>InvoiceChaser</title>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<div id="app">
<header>
<h1>InvoiceChaser</h1>
<nav>
<button data-tab="dashboard" class="active">Dashboard</button>
<button data-tab="invoices">Invoices</button>
<button data-tab="add">Add</button>
<button data-tab="settings">Settings</button>
</nav>
</header>
<main>
<!-- Dashboard Tab -->
<section id="tab-dashboard" class="tab active">
<div class="stats-grid">
<div class="stat-card pending">
<span class="stat-value" id="stat-pending">$0</span>
<span class="stat-label">Pending</span>
</div>
<div class="stat-card paid">
<span class="stat-value" id="stat-paid">$0</span>
<span class="stat-label">Paid</span>
</div>
<div class="stat-card overdue">
<span class="stat-value" id="stat-overdue">$0</span>
<span class="stat-label">Overdue</span>
</div>
<div class="stat-card">
<span class="stat-value" id="stat-count">0</span>
<span class="stat-label">Invoices</span>
</div>
</div>
<div class="follow-ups" id="follow-ups-container">
<h2>Follow-ups Needed</h2>
<div id="follow-ups-list"></div>
</div>
</section>
<!-- Invoices Tab -->
<section id="tab-invoices" class="tab">
<div class="filter-bar">
<select id="filter-status">
<option value="all">All</option>
<option value="pending">Pending</option>
<option value="overdue">Overdue</option>
<option value="paid">Paid</option>
</select>
<input type="text" id="search" placeholder="Search clients...">
</div>
<div id="invoices-list"></div>
</section>
<!-- Add Tab -->
<section id="tab-add" class="tab">
<form id="add-invoice-form">
<label>Client Name</label>
<input type="text" id="add-client-name" required placeholder="Acme Corp">
<label>Client Email</label>
<input type="email" id="add-client-email" required placeholder="billing@acme.com">
<label>Invoice Subject</label>
<input type="text" id="add-subject" required placeholder="Website redesign - Invoice #001">
<label>Amount</label>
<input type="number" id="add-amount" required step="0.01" min="0" placeholder="1500.00">
<label>Currency</label>
<select id="add-currency">
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="GBP">GBP</option>
<option value="CAD">CAD</option>
<option value="AUD">AUD</option>
</select>
<label>Due Date</label>
<input type="date" id="add-due-date" required>
<label>Sent Date</label>
<input type="date" id="add-sent-date" required>
<label>Notes</label>
<textarea id="add-notes" rows="2" placeholder="Optional notes..."></textarea>
<button type="submit">Add Invoice</button>
</form>
</section>
<!-- Settings Tab -->
<section id="tab-settings" class="tab">
<form id="settings-form">
<label>Your Name</label>
<input type="text" id="set-user-name" placeholder="Jane Doe">
<label>Your Email</label>
<input type="email" id="set-user-email" placeholder="jane@freelancer.com">
<label>Default Currency</label>
<select id="set-currency">
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="GBP">GBP</option>
<option value="CAD">CAD</option>
<option value="AUD">AUD</option>
</select>
<label>Follow-up Schedule (days after sending)</label>
<input type="text" id="set-follow-up-days" placeholder="7,14,30">
<label class="checkbox-label">
<input type="checkbox" id="set-auto-detect">
Auto-detect invoices from Gmail/Outlook
</label>
<label class="checkbox-label">
<input type="checkbox" id="set-follow-up-gen">
Enable follow-up email generation
</label>
<button type="submit">Save Settings</button>
</form>
</section>
</main>
</div>
<script type="module" src="popup.ts"></script>
</body>
</html>
+300
View File
@@ -0,0 +1,300 @@
import type { Invoice, PaymentStatus, Settings } from "../lib/types.js";
import { getInvoices, saveInvoice, deleteInvoice, getSettings, saveSettings, updateClientsFromInvoices } from "../lib/storage.js";
import { isOverdue, generateInvoiceId, daysSince } from "../lib/invoice-detector.js";
import { generateFollowUp, shouldFollowUp } from "../lib/follow-up.js";
// Tab switching
const tabButtons = document.querySelectorAll("nav button[data-tab]");
const tabSections = document.querySelectorAll(".tab");
tabButtons.forEach((btn) => {
btn.addEventListener("click", () => {
const tab = btn.getAttribute("data-tab");
if (!tab) return;
tabButtons.forEach((b) => b.classList.remove("active"));
tabSections.forEach((s) => s.classList.remove("active"));
btn.classList.add("active");
document.getElementById(`tab-${tab}`)?.classList.add("active");
if (tab === "dashboard" || tab === "invoices") {
loadData();
}
});
});
// Load all data
async function loadData() {
const invoices = await getInvoices();
await updateClientsFromInvoices(invoices);
renderDashboard(invoices);
renderInvoices(invoices);
renderFollowUps(invoices);
}
// Dashboard rendering
function renderDashboard(invoices: Invoice[]) {
let totalPending = 0;
let totalPaid = 0;
let totalOverdue = 0;
let pendingCount = 0;
let paidCount = 0;
let overdueCount = 0;
for (const inv of invoices) {
if (inv.status === "pending") {
totalPending += inv.amount;
pendingCount++;
} else if (inv.status === "paid") {
totalPaid += inv.amount;
paidCount++;
} else if (inv.status === "overdue") {
totalOverdue += inv.amount;
overdueCount++;
}
}
document.getElementById("stat-pending")!.textContent = formatCurrency(totalPending, "USD");
document.getElementById("stat-paid")!.textContent = formatCurrency(totalPaid, "USD");
document.getElementById("stat-overdue")!.textContent = formatCurrency(totalOverdue, "USD");
document.getElementById("stat-count")!.textContent = String(invoices.length);
}
// Follow-ups rendering
async function renderFollowUps(invoices: Invoice[]) {
const settings = await getSettings();
const container = document.getElementById("follow-ups-list")!;
const needed = invoices.filter((inv) => shouldFollowUp(inv));
if (needed.length === 0) {
container.innerHTML = `<p class="empty">No follow-ups needed right now 🎉</p>`;
return;
}
container.innerHTML = needed
.map((inv) => {
const followUp = generateFollowUp(inv, settings);
if (!followUp) return "";
const days = daysSince(inv.sentDate);
return `
<div class="follow-up-card">
<div class="follow-up-header">
<strong>${escapeHtml(inv.clientName)}</strong>
<span class="badge ${followUp.tone}">${followUp.tone}</span>
</div>
<div class="follow-up-meta">${escapeHtml(inv.subject)} · ${formatCurrency(inv.amount, inv.currency)} · ${days} days since sent</div>
<div class="follow-up-body">
<strong>${escapeHtml(followUp.subject)}</strong>
<pre>${escapeHtml(followUp.body)}</pre>
</div>
<div class="follow-up-actions">
<button class="copy-btn" data-id="${inv.id}">Copy to clipboard</button>
<button class="mark-sent-btn" data-id="${inv.id}">Mark sent</button>
</div>
</div>
`;
})
.join("");
container.querySelectorAll(".copy-btn").forEach((btn) => {
btn.addEventListener("click", async (e) => {
const id = (e.target as HTMLElement).getAttribute("data-id")!;
const inv = invoices.find((i) => i.id === id);
if (!inv) return;
const followUp = generateFollowUp(inv, settings);
if (followUp) {
await navigator.clipboard.writeText(followUp.body);
(e.target as HTMLElement).textContent = "Copied!";
setTimeout(() => ((e.target as HTMLElement).textContent = "Copy to clipboard"), 2000);
}
});
});
container.querySelectorAll(".mark-sent-btn").forEach((btn) => {
btn.addEventListener("click", async (e) => {
const id = (e.target as HTMLElement).getAttribute("data-id")!;
await chrome.runtime.sendMessage({ type: "mark-sent-follow-up", data: { invoiceId: id } });
loadData();
});
});
}
// Invoices list rendering
function renderInvoices(invoices: Invoice[]) {
const container = document.getElementById("invoices-list")!;
const filterStatus = (document.getElementById("filter-status") as HTMLSelectElement).value;
const search = (document.getElementById("search") as HTMLInputElement).value.toLowerCase();
let filtered = invoices;
if (filterStatus !== "all") {
filtered = filtered.filter((inv) => inv.status === filterStatus);
}
if (search) {
filtered = filtered.filter(
(inv) =>
inv.clientName.toLowerCase().includes(search) || inv.subject.toLowerCase().includes(search)
);
}
if (filtered.length === 0) {
container.innerHTML = `<p class="empty">No invoices found.</p>`;
return;
}
container.innerHTML = filtered
.sort((a, b) => new Date(b.sentDate).getTime() - new Date(a.sentDate).getTime())
.map((inv) => {
const statusClass = inv.status;
const daysOver = isOverdue(inv.dueDate) ? daysSince(inv.dueDate) : 0;
const overdueBadge = inv.status === "overdue" ? `<span class="overdue-badge">${daysOver}d overdue</span>` : "";
return `
<div class="invoice-card ${statusClass}">
<div class="invoice-main">
<div class="invoice-header">
<strong>${escapeHtml(inv.clientName)}</strong>
<span class="status-badge ${statusClass}">${inv.status}</span>
</div>
<div class="invoice-subject">${escapeHtml(inv.subject)}</div>
<div class="invoice-meta">
<span>${formatCurrency(inv.amount, inv.currency)}</span>
<span>Due: ${formatDate(inv.dueDate)}</span>
<span>Sent: ${formatDate(inv.sentDate)}</span>
${overdueBadge}
</div>
</div>
<div class="invoice-actions">
${inv.status !== "paid" ? `<button class="mark-paid" data-id="${inv.id}">Mark Paid</button>` : ""}
<button class="delete-btn" data-id="${inv.id}">Delete</button>
</div>
</div>
`;
})
.join("");
container.querySelectorAll(".mark-paid").forEach((btn) => {
btn.addEventListener("click", async (e) => {
const id = (e.target as HTMLElement).getAttribute("data-id")!;
const allInvoices = await getInvoices();
const inv = allInvoices.find((i) => i.id === id);
if (!inv) return;
const updated: Invoice = {
...inv,
status: "paid",
paymentDate: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await saveInvoice(updated);
loadData();
});
});
container.querySelectorAll(".delete-btn").forEach((btn) => {
btn.addEventListener("click", async (e) => {
const id = (e.target as HTMLElement).getAttribute("data-id")!;
if (confirm("Delete this invoice?")) {
await deleteInvoice(id);
loadData();
}
});
});
}
// Filter and search listeners
document.getElementById("filter-status")?.addEventListener("change", () => loadData());
document.getElementById("search")?.addEventListener("input", () => loadData());
// Add invoice form
const addForm = document.getElementById("add-invoice-form") as HTMLFormElement;
// Set default dates
document.getElementById("add-sent-date")!.setAttribute("value", new Date().toISOString().split("T")[0]);
document.getElementById("add-due-date")!.setAttribute("value", getDefaultDueDate());
addForm?.addEventListener("submit", async (e) => {
e.preventDefault();
const settings = await getSettings();
const invoice: Invoice = {
id: generateInvoiceId(),
clientName: (document.getElementById("add-client-name") as HTMLInputElement).value,
clientEmail: (document.getElementById("add-client-email") as HTMLInputElement).value,
subject: (document.getElementById("add-subject") as HTMLInputElement).value,
amount: parseFloat((document.getElementById("add-amount") as HTMLInputElement).value),
currency: (document.getElementById("add-currency") as HTMLSelectElement).value,
dueDate: (document.getElementById("add-due-date") as HTMLInputElement).value,
sentDate: (document.getElementById("add-sent-date") as HTMLInputElement).value,
status: "pending",
source: "manual",
followUpCount: 0,
followUpSchedule: settings.followUpDays,
notes: (document.getElementById("add-notes") as HTMLTextAreaElement).value,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await saveInvoice(invoice);
addForm.reset();
document.getElementById("add-sent-date")!.setAttribute("value", new Date().toISOString().split("T")[0]);
document.getElementById("add-due-date")!.setAttribute("value", getDefaultDueDate());
alert("Invoice added!");
// Switch to dashboard
tabButtons.forEach((b) => b.classList.remove("active"));
tabSections.forEach((s) => s.classList.remove("active"));
document.querySelector('[data-tab="dashboard"]')?.classList.add("active");
document.getElementById("tab-dashboard")?.classList.add("active");
loadData();
});
// Settings form
const settingsForm = document.getElementById("settings-form") as HTMLFormElement;
settingsForm?.addEventListener("submit", async (e) => {
e.preventDefault();
const daysStr = (document.getElementById("set-follow-up-days") as HTMLInputElement).value;
const days = daysStr
.split(",")
.map((s) => parseInt(s.trim(), 10))
.filter((n) => !isNaN(n) && n > 0);
const settings: Settings = {
userName: (document.getElementById("set-user-name") as HTMLInputElement).value,
userEmail: (document.getElementById("set-user-email") as HTMLInputElement).value,
defaultCurrency: (document.getElementById("set-currency") as HTMLSelectElement).value,
followUpDays: days.length > 0 ? days : [7, 14, 30],
enableAutoDetect: (document.getElementById("set-auto-detect") as HTMLInputElement).checked,
enableFollowUpGeneration: (document.getElementById("set-follow-up-gen") as HTMLInputElement).checked,
};
await saveSettings(settings);
alert("Settings saved!");
});
async function loadSettings() {
const settings = await getSettings();
(document.getElementById("set-user-name") as HTMLInputElement).value = settings.userName;
(document.getElementById("set-user-email") as HTMLInputElement).value = settings.userEmail;
(document.getElementById("set-currency") as HTMLSelectElement).value = settings.defaultCurrency;
(document.getElementById("set-follow-up-days") as HTMLInputElement).value = settings.followUpDays.join(",");
(document.getElementById("set-auto-detect") as HTMLInputElement).checked = settings.enableAutoDetect;
(document.getElementById("set-follow-up-gen") as HTMLInputElement).checked = settings.enableFollowUpGeneration;
}
// Helpers
function formatCurrency(amount: number, currency: string): string {
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
function getDefaultDueDate(): string {
const d = new Date();
d.setDate(d.getDate() + 30);
return d.toISOString().split("T")[0];
}
function escapeHtml(str: string): string {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
// Init
loadSettings();
loadData();
+101
View File
@@ -0,0 +1,101 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import {
detectInvoiceFromEmail,
isOverdue,
daysOverdue,
generateInvoiceId,
} from "../lib/invoice-detector.js";
describe("invoice-detector", () => {
describe("detectInvoiceFromEmail", () => {
it("detects invoice from subject", () => {
const result = detectInvoiceFromEmail(
"Invoice #123 for $500 from Acme Corp",
"Please pay by 12/31/2024",
"billing@acme.com",
"Acme Corp",
"2024-12-01",
"thread-1"
);
assert.ok(result);
assert.strictEqual(result!.clientEmail, "billing@acme.com");
assert.strictEqual(result!.clientName, "Acme Corp");
assert.strictEqual(result!.amount, 500);
assert.strictEqual(result!.currency, "USD");
});
it("returns null for non-invoice email", () => {
const result = detectInvoiceFromEmail(
"Meeting notes from today",
"Here are the notes...",
"alice@example.com",
"Alice",
"2024-12-01",
"thread-2"
);
assert.strictEqual(result, null);
});
it("detects payment confirmation", () => {
const result = detectInvoiceFromEmail(
"Payment received - thank you!",
"We have received your payment.",
"receipts@stripe.com",
"Stripe",
"2024-12-01",
"thread-3"
);
assert.ok(result);
assert.strictEqual(result!.isPaidConfirmation, true);
});
it("extracts EUR currency", () => {
const result = detectInvoiceFromEmail(
"Invoice for €1,250.00",
"Payment due",
"vendor@example.com",
"Vendor",
"2024-12-01",
"thread-4"
);
assert.ok(result);
assert.strictEqual(result!.currency, "EUR");
assert.strictEqual(result!.amount, 1250);
});
});
describe("isOverdue", () => {
it("returns true for past date", () => {
assert.strictEqual(isOverdue("2024-01-01"), true);
});
it("returns false for future date", () => {
const future = new Date();
future.setFullYear(future.getFullYear() + 1);
assert.strictEqual(isOverdue(future.toISOString().split("T")[0]), false);
});
});
describe("daysOverdue", () => {
it("returns positive days for past due date", () => {
const days = daysOverdue("2020-01-01");
assert.ok(days > 1000);
});
it("returns 0 for future due date", () => {
const future = new Date();
future.setDate(future.getDate() + 10);
assert.strictEqual(daysOverdue(future.toISOString().split("T")[0]), 0);
});
});
describe("generateInvoiceId", () => {
it("generates unique IDs", () => {
const id1 = generateInvoiceId();
const id2 = generateInvoiceId();
assert.notStrictEqual(id1, id2);
assert.ok(id1.startsWith("inv_"));
});
});
});
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"rootDir": "src",
"resolveJsonModule": true,
"declaration": false,
"sourceMap": true,
"types": ["chrome", "node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+16
View File
@@ -0,0 +1,16 @@
import { crx } from "@crxjs/vite-plugin";
import { defineConfig } from "vite";
import manifest from "./src/manifest.json" with { type: "json" };
export default defineConfig({
build: {
outDir: "dist",
emptyOutDir: false,
rollupOptions: {
input: {
popup: "src/popup/popup.html",
},
},
},
plugins: [crx({ manifest })],
});