InvoiceChaser v1.0.0 — Freelancer Payment Tracker & Auto Follow-Up Chrome Extension
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.verdict
|
||||
Generated
+1500
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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 |
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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()) });
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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: "",
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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_"));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 })],
|
||||
});
|
||||
Reference in New Issue
Block a user