Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions workers/expenses-site/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,13 @@ CREATE TABLE IF NOT EXISTS sync_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);

-- Idempotency keys that must never render on the dashboard, even though they
-- still exist upstream. Used for ledger rows that were posted in error
-- (double-posted receipts, superseded gross/discount/correction rows) and
-- later corrected at the display layer. Sync skips these keys on every run.
CREATE TABLE IF NOT EXISTS suppressed_expenses (
idempotency_key TEXT PRIMARY KEY,
reason TEXT NOT NULL,
suppressed_at TEXT NOT NULL
);
24 changes: 22 additions & 2 deletions workers/expenses-site/src/__tests__/categories.test.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
import { CATEGORIES, categorizeExpense, categoryLabel } from "../categories.mjs";
import {
CATEGORIES,
SIDES,
categorizeExpense,
categoryLabel,
sideForCategory,
sideLabel,
} from "../categories.mjs";

describe("categorizeExpense", () => {
it("maps AI vendors to tech-ai", () => {
Expand Down Expand Up @@ -36,8 +43,21 @@ describe("categorizeExpense", () => {
});

it("exposes stable Title Case labels", () => {
expect(categoryLabel("tech-ai")).toBe("Tech / AI");
expect(categoryLabel("tech-ai")).toBe("AI");
expect(categoryLabel("bogus")).toBe("Other");
expect(Object.keys(CATEGORIES)).toContain("tech-ai");
});

it("puts everything business under the Tech side", () => {
expect(sideForCategory("tech-ai")).toBe("tech");
expect(sideForCategory("cloud-hosting")).toBe("tech");
expect(sideForCategory("domains")).toBe("tech");
expect(sideForCategory("software")).toBe("tech");
expect(sideForCategory("services")).toBe("tech");
expect(sideForCategory("other")).toBe("tech");
expect(sideForCategory("personal")).toBe("personal");
expect(sideLabel("personal")).toBe("Personal");
expect(sideLabel("tech")).toBe("Tech");
expect(Object.keys(SIDES)).toEqual(["personal", "tech"]);
});
});
14 changes: 10 additions & 4 deletions workers/expenses-site/src/__tests__/render.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,24 +25,30 @@ const rows = [
describe("renderDashboard", () => {
it("renders a Title Case heading and the 6-month window note", () => {
const html = renderDashboard(rows, "2026-10-05T00:00:00Z", 42);
expect(html).toContain("<h1>Business Expenses</h1>");
expect(html).toContain("<h1>Expenses</h1>");
expect(html).toContain("Last 6 months, rolling.");
expect(html).toContain("Showing 2 of 42 recorded expenses.");
});

it("shows All and Tech / AI filters prominently, plus present categories", () => {
it("leads the filter bar with All, Personal, Tech, then subcategories", () => {
const html = renderDashboard(rows, "2026-10-05T00:00:00Z", 2);
expect(html).toContain('data-filter="all"');
expect(html).toContain("Tech / AI");
expect(html).toContain('data-filter="side:personal"');
expect(html).toContain('data-filter="side:tech"');
expect(html).toContain(">Personal</button>");
expect(html).toContain(">Tech</button>");
expect(html).toContain(">AI</button>");
expect(html).toContain("Cloud / Hosting");
// Rows carry their side for client-side filtering.
expect(html).toContain('data-side="tech"');
});

it("renders vendor, formatted amount, and category pill per row", () => {
const html = renderDashboard(rows, "2026-10-05T00:00:00Z", 2);
expect(html).toContain("Anthropic");
expect(html).toContain("$213.20");
expect(html).toContain("Sep 30, 2026");
expect(html).toContain("Tech / AI");
expect(html).toContain(">AI</span>");
});

it("escapes hostile vendor text", () => {
Expand Down
95 changes: 93 additions & 2 deletions workers/expenses-site/src/__tests__/sync.test.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
import { describe, expect, it, vi } from "vitest";
import { buildUpsertStatements, mapUpstreamExpense, syncExpenses } from "../sync.mjs";
import {
buildUpsertStatements,
dedupeUpstreamRows,
expenseFingerprint,
loadSuppressedKeys,
mapUpstreamExpense,
syncExpenses,
} from "../sync.mjs";

function fakeDb() {
function fakeDb(suppressed = []) {
return {
prepare: vi.fn((sql) => ({
sql,
bind: vi.fn((...args) => ({ sql, args, run: async () => ({}) })),
all: async () => ({
results: sql.includes("suppressed_expenses")
? suppressed.map((idempotency_key) => ({ idempotency_key }))
: [],
}),
run: async () => ({}),
})),
batch: vi.fn(async (stmts) => stmts),
};
Expand Down Expand Up @@ -53,6 +66,49 @@ describe("buildUpsertStatements", () => {
});
});

describe("dedupeUpstreamRows", () => {
const oneTime = { ...upstream, idempotencyKey: "k-one", kind: "one_time" };
const usageDup = { ...upstream, idempotencyKey: "k-use", kind: "usage" };

it("collapses same-receipt double-posts, returning kept and dropped", () => {
const { kept, dropped } = dedupeUpstreamRows([oneTime, usageDup]);
expect(kept).toHaveLength(1);
expect(kept[0].kind).toBe("one_time");
expect(dropped).toHaveLength(1);
expect(dropped[0].idempotencyKey).toBe("k-use");
});

it("keeps genuinely different charges apart", () => {
const other = { ...upstream, idempotencyKey: "k-other", label: "Other invoice" };
expect(dedupeUpstreamRows([oneTime, other]).kept).toHaveLength(2);
const otherAmount = { ...upstream, idempotencyKey: "k-amt", amountUsd: 99.99 };
expect(dedupeUpstreamRows([oneTime, otherAmount]).kept).toHaveLength(2);
const otherTime = {
...upstream,
idempotencyKey: "k-time",
occurredAt: "2026-09-30T13:00:00.000Z",
};
expect(dedupeUpstreamRows([oneTime, otherTime]).kept).toHaveLength(2);
});

it("builds a vendor/amount/timestamp/label fingerprint with NUL separators", () => {
expect(expenseFingerprint(oneTime)).toBe(expenseFingerprint(usageDup));
expect(expenseFingerprint(oneTime)).toContain("");
// Adjacent fields cannot bleed: "ab"+"c" differs from "a"+"bc".
const left = { ...upstream, vendor: "ab", label: "c" };
const right = { ...upstream, vendor: "a", label: "bc" };
expect(expenseFingerprint(left)).not.toBe(expenseFingerprint(right));
});
});

describe("loadSuppressedKeys", () => {
it("reads the suppression table", async () => {
const keys = await loadSuppressedKeys(fakeDb(["k-bad"]));
expect(keys.has("k-bad")).toBe(true);
expect(keys.has("k-good")).toBe(false);
});
});

describe("syncExpenses", () => {
it("pages the upstream ledger and batches into D1", async () => {
const db = fakeDb();
Expand All @@ -76,6 +132,8 @@ describe("syncExpenses", () => {
});
expect(result.upserted).toBe(1);
expect(result.pages).toBe(2);
expect(result.suppressedCount).toBe(0);
expect(result.dedupedCount).toBe(0);
expect(db.batch).toHaveBeenCalledTimes(1);
// Second page requested with the cursor.
expect(String(fetchMock.mock.calls[1][0])).toContain("cursor=cursor-1");
Expand All @@ -88,6 +146,39 @@ describe("syncExpenses", () => {
}
});

it("skips suppressed keys and collapses double-posts", async () => {
const db = fakeDb(["k-bad"]);
const bad = { ...upstream, idempotencyKey: "k-bad", label: "Bad row" };
const oneTime = { ...upstream, idempotencyKey: "k-one", kind: "one_time" };
const usageDupe = { ...upstream, idempotencyKey: "k-dupe", kind: "usage" };
const solo = { ...upstream, idempotencyKey: "k-solo", kind: "one_time", label: "Solo" };
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
expenses: [bad, oneTime, usageDupe, solo],
hasMore: false,
nextCursor: null,
}),
});
vi.stubGlobal("fetch", fetchMock);
try {
const result = await syncExpenses({
UPSTREAM_URL: "https://usage.jays.services",
USAGE_READ_TOKEN: "x",
EXPENSES_DB: db,
});
expect(result.suppressedCount).toBe(1);
expect(result.dedupedCount).toBe(1);
// k-bad suppressed, k-dupe collapsed into k-one: k-one + k-solo upsert.
expect(result.upserted).toBe(2);
const bound = db.batch.mock.calls[0][0];
expect(bound).toHaveLength(2);
expect(bound.map((s) => s.args[0]).sort()).toEqual(["k-one", "k-solo"]);
} finally {
vi.unstubAllGlobals();
}
});

it("throws when the upstream read fails", async () => {
vi.stubGlobal(
"fetch",
Expand Down
22 changes: 21 additions & 1 deletion workers/expenses-site/src/categories.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
/**
* Expense categorization for the expenses dashboard.
*
* Two top-level sides: Personal and Tech. Everything business goes under
* Tech, keeping the subcategories below. Personal is empty for now — no
* personal transaction source is wired up yet.
*
* Maps a vendor/label pair to a stable category slug. The slug is stored in
* D1 at sync time so a future edit UI can override it without a migration.
* Keep slugs stable: the dashboard filter bar and the JSON API both expose
Expand All @@ -11,15 +15,31 @@
* where regex word-boundary anchoring reads as a URL-host pattern).
*/

export const SIDES = {
personal: "Personal",
tech: "Tech",
};

export const CATEGORIES = {
"tech-ai": "Tech / AI",
personal: "Personal",
"tech-ai": "AI",
"cloud-hosting": "Cloud / Hosting",
"domains": "Domains",
"software": "Software",
"services": "Services",
"other": "Other",
};

/** The top-level side a category belongs to. Everything except Personal is Tech. */
export function sideForCategory(slug) {
return slug === "personal" ? "personal" : "tech";
}

/** Human label for a side slug (Title Case for headings). */
export function sideLabel(slug) {
return SIDES[slug] ?? SIDES.tech;
}

// [slug, regexes] — first match wins. Tested against vendor + label joined.
const RULES = [
[
Expand Down
39 changes: 21 additions & 18 deletions workers/expenses-site/src/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,27 +13,29 @@
*/
import { syncExpenses } from "./sync.mjs";
import { formatAmount, formatDate, renderDashboard } from "./render.mjs";
import { sideForCategory } from "./categories.mjs";

const SIX_MONTHS_SQL = "date('now', '-6 months')";
// Side is derived from category (personal -> Personal, everything else ->
// Tech), so the SQL mirrors sideForCategory without a schema change.
const SIDE_SQL = "CASE WHEN category = 'personal' THEN 'personal' ELSE 'tech' END";

async function readDashboardRows(db, category) {
const where =
category && category !== "all"
? "WHERE occurred_at >= " + SIX_MONTHS_SQL + " AND category = ?"
: "WHERE occurred_at >= " + SIX_MONTHS_SQL;
const stmt =
category && category !== "all"
? db.prepare(
`SELECT idempotency_key, vendor, amount_usd, occurred_at, kind,
label, notes, confidence, category, calendar_sort
FROM expenses ${where} ORDER BY occurred_at DESC LIMIT 1000`
).bind(category)
: db.prepare(
`SELECT idempotency_key, vendor, amount_usd, occurred_at, kind,
label, notes, confidence, category, calendar_sort
FROM expenses ${where} ORDER BY occurred_at DESC LIMIT 1000`
);
const result = await stmt.all();
async function readDashboardRows(db, filter) {
let where = "WHERE occurred_at >= " + SIX_MONTHS_SQL;
let params = [];
if (filter === "personal" || filter === "tech") {
where += " AND " + SIDE_SQL + " = ?";
params = [filter];
} else if (filter && filter !== "all") {
where += " AND category = ?";
params = [filter];
}
const stmt = db.prepare(
`SELECT idempotency_key, vendor, amount_usd, occurred_at, kind,
label, notes, confidence, category, calendar_sort
FROM expenses ${where} ORDER BY occurred_at DESC LIMIT 1000`
);
const result = params.length > 0 ? await stmt.bind(...params).all() : await stmt.all();
return result.results ?? [];
}

Expand Down Expand Up @@ -75,7 +77,7 @@
return bearer.length > 0 && timingSafeEqual(bearer, expected);
}

export default {

Check warning on line 80 in workers/expenses-site/src/index.mjs

View workflow job for this annotation

GitHub Actions / verify

Assign object to a variable before exporting as module default
async fetch(request, env) {
const url = new URL(request.url);

Expand Down Expand Up @@ -111,6 +113,7 @@
kind: e.kind,
label: e.label,
category: e.category,
side: sideForCategory(e.category),
})),
asOf,
totalCount,
Expand Down
Loading
Loading