From dbdb5e81aa8163991a79f48d82754834fc26065f Mon Sep 17 00:00:00 2001 From: nazozokc Date: Sun, 16 Aug 2026 14:30:43 +0900 Subject: [PATCH 01/19] fix: accept export CSV format in import The CSV export writes a 16-column header (status, payment_method, contract dates, vendor, discount, ...) that import rejected, breaking the export -> import roundtrip. Parse the header by column name and validate/preserve the optional fields. --- apps/subtrack/src/__tests__/commands.test.ts | 43 ++++++++ apps/subtrack/src/import-csv.ts | 103 ++++++++++++++++--- docs/commands.md | 2 +- docs/guides.md | 2 +- 4 files changed, 132 insertions(+), 18 deletions(-) diff --git a/apps/subtrack/src/__tests__/commands.test.ts b/apps/subtrack/src/__tests__/commands.test.ts index 870a5c1..e0933f3 100644 --- a/apps/subtrack/src/__tests__/commands.test.ts +++ b/apps/subtrack/src/__tests__/commands.test.ts @@ -710,6 +710,49 @@ test("handleImport skips invalid rows", async () => { expect(warnMessages.length).toBeGreaterThan(0) }) +test("handleImport accepts the export CSV header (roundtrip)", async () => { + const header = "name,status,cycle,tags,price,currency,notes,payment_method,contract_start,contract_end,auto_renewal,vendor_name,vendor_url,plan_tier,discount_amount,discount_type" + const row = 'Netflix,active,monthly,video;entertainment,1490,JPY,"my notes",credit_card,2026-01-01,2027-01-01,true,Netflix Inc,https://netflix.com,Standard,,' + const filePath = writeTempFile("export-format.csv", `${header}\n${row}`) + + const { handleImport } = await import("../import-csv.ts") + await handleImport(filePath, {}) + + const db = await import("../db.ts") + const subs = db.getSubscriptions() + expect(subs).toHaveLength(1) + expect(subs[0]).toMatchObject({ + name: "Netflix", + status: "active", + cycle: "monthly", + tags: ["video", "entertainment"], + price: 1490, + currency: "JPY", + notes: "my notes", + paymentMethod: "credit_card", + contractStart: "2026-01-01", + contractEnd: "2027-01-01", + autoRenewal: true, + vendorName: "Netflix Inc", + vendorUrl: "https://netflix.com", + planTier: "Standard", + discountAmount: null, + }) + expect(successMessages.some((m) => m.includes("1 imported"))).toBe(true) +}) + +test("handleImport rejects rows with invalid status in export format", async () => { + const header = "name,status,cycle,tags,price,currency" + const filePath = writeTempFile("bad-status.csv", `${header}\nBad,bogus,monthly,,100,JPY`) + + const { handleImport } = await import("../import-csv.ts") + await handleImport(filePath, {}) + + const db = await import("../db.ts") + expect(db.getSubscriptions()).toHaveLength(0) + expect(warnMessages.some((m) => m.includes('invalid status "bogus"'))).toBe(true) +}) + // ── handleSummary ──────────────────────────────────────── test("handleSummary shows info when no subscriptions", async () => { diff --git a/apps/subtrack/src/import-csv.ts b/apps/subtrack/src/import-csv.ts index 8026dbb..2479701 100644 --- a/apps/subtrack/src/import-csv.ts +++ b/apps/subtrack/src/import-csv.ts @@ -3,9 +3,21 @@ import { fail } from "./error.ts" import { statSync, readFileSync } from "node:fs" import { writeSubscription, findSubscriptionByName } from "./db.ts" import { logAudit } from "./audit.ts" -import { validateName, validatePrice, validateTags, isValidCurrency, isValidCycle } from "./prompts.ts" +import { + validateName, + validatePrice, + validateTags, + isValidCurrency, + isValidCycle, + isValidStatus, + validateDiscountValue, + validateDiscountType, + validateAutoRenewal, + validateDateString, +} from "./prompts.ts" import os from "node:os" import { resolveSafePath } from "./path-utils.ts" +import type { Status, DiscountType } from "./types.ts" const MAX_CSV_SIZE = 10 * 1024 * 1024 // 10 MB const MAX_CSV_ROWS = 10_000 // max data rows to prevent DoS @@ -94,26 +106,38 @@ export async function handleImport( return } - // Validate header + // Validate header: column-name based so both the documented format + // (name,cycle,tags,price,currency[,notes]) and the export format + // (name,status,cycle,tags,price,currency,notes,payment_method,contract_start, + // contract_end,auto_renewal,vendor_name,vendor_url,plan_tier,discount_amount, + // discount_type) are accepted. const header = parseCsvLine(lines[0]).map((h) => h.toLowerCase().trim()) - const hasNotes = header.length >= 6 && header[5] === "notes" - const expectedBase = "name,cycle,tags,price,currency" - const expectedNotes = "name,cycle,tags,price,currency,notes" - const actual = header.join(",") - if (actual !== expectedBase && actual !== expectedNotes) { + const colIndex = new Map() + header.forEach((h, i) => { if (h) colIndex.set(h, i) }) + + const requiredCols = ["name", "cycle", "tags", "price", "currency"] + const missing = requiredCols.filter((c) => !colIndex.has(c)) + if (missing.length > 0) { fail( - `Invalid CSV header. Expected: ${expectedBase} or ${expectedNotes}`, + `Invalid CSV header. Required columns: ${requiredCols.join(", ")} (missing: ${missing.join(", ")})`, ) return } + let fieldsOfRow: string[] = [] + const col = (name: string): string | undefined => { + const idx = colIndex.get(name) + return idx === undefined ? undefined : (fieldsOfRow[idx]?.trim() || undefined) + } + let success = 0 let failed = 0 for (let i = 1; i < lines.length; i++) { const fields = parseCsvLine(lines[i]) - if (fields.length < 5) { - consola.warn(`Line ${i + 1}: skipping (expected 5 fields, got ${fields.length})`) + fieldsOfRow = fields + if (fields.length < requiredCols.length) { + consola.warn(`Line ${i + 1}: skipping (expected ${requiredCols.length} fields, got ${fields.length})`) failed++ continue } @@ -126,12 +150,24 @@ export async function handleImport( continue } - const name = fields[0] - const cycle = fields[1] - const tagsStr = fields[2] - const priceStr = fields[3] - const currency = fields[4] - const notes = hasNotes ? (fields[5]?.trim() || null) : null + const name = col("name") ?? "" + const cycle = col("cycle") ?? "" + const tagsStr = col("tags") ?? "" + const priceStr = col("price") ?? "" + const currency = col("currency") ?? "" + const notes = col("notes") ?? null + + // Optional fields — only read when the column exists in the header + const status = col("status") ?? "active" + const paymentMethod = col("payment_method") ?? null + const contractStart = col("contract_start") ?? null + const contractEnd = col("contract_end") ?? null + const autoRenewal = col("auto_renewal") + const vendorName = col("vendor_name") ?? null + const vendorUrl = col("vendor_url") ?? null + const planTier = col("plan_tier") ?? null + const discountAmount = col("discount_amount") ?? null + const discountType = col("discount_type") ?? null // Sanitize: strip control characters from name/notes (CSV injection defense) const sanitized = name.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "") @@ -153,6 +189,31 @@ export async function handleImport( failed++ continue } + if (!isValidStatus(status)) { + consola.warn(`Line ${i + 1}: invalid status "${status}"`) + failed++ + continue + } + if (discountAmount !== null) { + const discountErr = validateDiscountValue(discountAmount) + if (discountErr !== true) { consola.warn(`Line ${i + 1}: ${discountErr}`); failed++; continue } + } + if (discountType !== null) { + const discountTypeErr = validateDiscountType(discountType) + if (discountTypeErr !== true) { consola.warn(`Line ${i + 1}: ${discountTypeErr}`); failed++; continue } + } + if (autoRenewal !== undefined) { + const autoRenewalErr = validateAutoRenewal(autoRenewal) + if (autoRenewalErr !== true) { consola.warn(`Line ${i + 1}: ${autoRenewalErr}`); failed++; continue } + } + if (contractStart !== null) { + const csErr = validateDateString(contractStart) + if (csErr !== true) { consola.warn(`Line ${i + 1}: ${csErr}`); failed++; continue } + } + if (contractEnd !== null) { + const ceErr = validateDateString(contractEnd) + if (ceErr !== true) { consola.warn(`Line ${i + 1}: ${ceErr}`); failed++; continue } + } const tags = tagsStr.split(";").map((t) => t.trim()).filter(Boolean) const tagsErr = validateTags(tags.join(",")) @@ -182,6 +243,16 @@ export async function handleImport( cycle, tags, notes: notes ?? undefined, + status: status as Status, + paymentMethod: paymentMethod ?? undefined, + contractStart: contractStart ?? undefined, + contractEnd: contractEnd ?? undefined, + autoRenewal: autoRenewal === undefined ? undefined : autoRenewal === "true", + vendorName: vendorName ?? undefined, + vendorUrl: vendorUrl ?? undefined, + planTier: planTier ?? undefined, + discountAmount: discountAmount === null ? undefined : Number(discountAmount), + discountType: discountType as DiscountType | undefined, }) success++ } catch (e) { diff --git a/docs/commands.md b/docs/commands.md index e7baead..ab6bd89 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -522,7 +522,7 @@ subtrack tag merge entertainment fun ## `import ` -Imports subscriptions from a CSV file. The CSV must have a header row with exactly `name,cycle,tags,price,currency`. +Imports subscriptions from a CSV file. The CSV must have a header row with `name,cycle,tags,price,currency` (an optional `notes` column is also accepted). CSVs produced by `subtrack export csv` can be imported as-is (extra columns such as `status`, `payment_method`, and contract fields are preserved). | Argument | Description | |----------|-------------| diff --git a/docs/guides.md b/docs/guides.md index 1f99ef0..0a373a2 100644 --- a/docs/guides.md +++ b/docs/guides.md @@ -105,7 +105,7 @@ Bulk-import subscriptions from a CSV file: subtrack import subscriptions.csv ``` -The CSV must have the header `name,cycle,tags,price,currency`. Tags are separated by semicolons: +The CSV must have the header `name,cycle,tags,price,currency` (an optional `notes` column is also accepted; CSVs produced by `subtrack export csv` work as-is). Tags are separated by semicolons: ```csv name,cycle,tags,price,currency From 4f90970a887b22239dc9c391ba1570ac4401230a Mon Sep 17 00:00:00 2001 From: nazozokc Date: Sun, 16 Aug 2026 14:30:51 +0900 Subject: [PATCH 02/19] fix: convert API usage cost from cents to dollars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getLlmUsageTotal returns USD cents, but compare --api and payment --currency --api passed the value straight to convertPrice / formatPrice, which expect major units — overstating API cost ~100x (e.g. $0.0075 displayed as $1). Also fix the compare divider row to span the full table width via colSpan. --- apps/subtrack/src/__tests__/commands.test.ts | 5 +++++ apps/subtrack/src/__tests__/display.test.ts | 3 ++- apps/subtrack/src/compare.ts | 14 +++++++++----- apps/subtrack/src/payment.ts | 4 ++-- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/subtrack/src/__tests__/commands.test.ts b/apps/subtrack/src/__tests__/commands.test.ts index e0933f3..50745d8 100644 --- a/apps/subtrack/src/__tests__/commands.test.ts +++ b/apps/subtrack/src/__tests__/commands.test.ts @@ -849,6 +849,11 @@ test("handleCompare with --api includes API usage", async () => { const combined = logMessages.join("\n") expect(combined).toContain("API Usage") expect(combined).toContain("Grand Total") + // API cost is stored in cents: 0.5 cents = $0.005 → rounds to $0 + // (regression guard: cents must not be treated as dollars) + expect(combined).toContain("$0") + expect(combined).not.toContain("$1") + expect(combined).toContain("¥980") }) test("handleCompare with --currency converts prices", async () => { diff --git a/apps/subtrack/src/__tests__/display.test.ts b/apps/subtrack/src/__tests__/display.test.ts index 23ce0bf..8e2bbe7 100644 --- a/apps/subtrack/src/__tests__/display.test.ts +++ b/apps/subtrack/src/__tests__/display.test.ts @@ -708,7 +708,8 @@ describe("showPayment with --api", () => { await showPayment("monthly", "JPY", [makeSub({ name: "Sub", price: 1000, currency: "JPY" })], true) const combined = logMessages.join("\n") // --currency path shows "+ API ¥..." inline instead of separate "API usage:" line + // API cost is stored in cents: 100 cents = $1.00 → ¥160 at rate 160 JPY/USD expect(combined).toContain("+ API") - expect(combined).toContain("¥16,000") + expect(combined).toContain("¥160") }) }) diff --git a/apps/subtrack/src/compare.ts b/apps/subtrack/src/compare.ts index e66a3bb..ef973fc 100644 --- a/apps/subtrack/src/compare.ts +++ b/apps/subtrack/src/compare.ts @@ -69,7 +69,7 @@ function renderCompareTable( for (const row of rows) { if (row.isDivider) { - table.push([pc.dim("─".repeat(20)), pc.dim("─"), pc.dim("─"), pc.dim("─")]) + table.push([{ colSpan: 4, content: pc.dim("─") }]) } else if (row.isGrandTotal) { table.push([ pc.bold(pc.yellow(row.label)), @@ -179,13 +179,17 @@ export async function showCompare( const curApi = getLlmUsageTotal(currentRange.from, currentRange.to) const prevApi = getLlmUsageTotal(previousRange.from, previousRange.to) + // API cost is stored in USD cents — convert to dollars (major units) + const curApiUsd = curApi / 100 + const prevApiUsd = prevApi / 100 + // Convert API cost if currency specified - let curApiDisplay = curApi - let prevApiDisplay = prevApi + let curApiDisplay = curApiUsd + let prevApiDisplay = prevApiUsd if (targetCurrency && rates) { try { - curApiDisplay = convertPrice(Math.round(curApi), "USD", targetCurrency, rates.rates) - prevApiDisplay = convertPrice(Math.round(prevApi), "USD", targetCurrency, rates.rates) + curApiDisplay = convertPrice(curApiUsd, "USD", targetCurrency, rates.rates) + prevApiDisplay = convertPrice(prevApiUsd, "USD", targetCurrency, rates.rates) } catch { /* keep as USD */ } } diff --git a/apps/subtrack/src/payment.ts b/apps/subtrack/src/payment.ts index 3737a43..0d712b5 100644 --- a/apps/subtrack/src/payment.ts +++ b/apps/subtrack/src/payment.ts @@ -73,11 +73,11 @@ export const showPayment = async ( } if (includeApi && apiTotal > 0) { - // Convert API cost (USD) to target currency + // Convert API cost (USD cents) to target currency let apiConverted = 0 try { apiConverted = convertPrice( - Math.round(apiTotal), + apiTotal / 100, "USD", currency, rates.rates, From 64751e41a2256081f86e316dee0095d95c8402fa Mon Sep 17 00:00:00 2001 From: nazozokc Date: Sun, 16 Aug 2026 14:30:56 +0900 Subject: [PATCH 03/19] fix: add force flag to bulk tag commands bulk status and bulk delete support --force for non-interactive use, but bulk tag add/remove always prompted for confirmation, making them unusable in scripts. Add -f/--force and thread it through. --- apps/subtrack/src/__tests__/bulk.test.ts | 22 ++++++++++++++++++++++ apps/subtrack/src/bulk.ts | 6 ++++-- apps/subtrack/src/commands/bulk.ts | 6 ++++-- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/apps/subtrack/src/__tests__/bulk.test.ts b/apps/subtrack/src/__tests__/bulk.test.ts index ee14c6a..28d782f 100644 --- a/apps/subtrack/src/__tests__/bulk.test.ts +++ b/apps/subtrack/src/__tests__/bulk.test.ts @@ -305,6 +305,17 @@ test("bulk tag add empty tag shows error", async () => { expect(errorMessages.some((m) => m.includes("Tag name is required"))).toBe(true) }) +test("bulk tag add with force skips confirmation", async () => { + seedSub("Target", { tags: [] }) + + const { handleBulkTagAdd } = await import("../bulk.ts") + await handleBulkTagAdd("newtag", { name: "Target" }, { force: true }) + + const target = dbModule.getSubscriptions().find((s) => s.name === "Target") + expect(target?.tags).toContain("newtag") + expect(confirm).not.toHaveBeenCalled() +}) + // ── handleBulkTagRemove ────────────────────────────────── test("bulk tag remove with name filter", async () => { @@ -327,6 +338,17 @@ test("bulk tag remove empty tag shows error", async () => { expect(errorMessages.some((m) => m.includes("Tag name is required"))).toBe(true) }) +test("bulk tag remove with force skips confirmation", async () => { + seedSub("Target", { tags: ["oldtag"] }) + + const { handleBulkTagRemove } = await import("../bulk.ts") + await handleBulkTagRemove("oldtag", { name: "Target" }, { force: true }) + + const target = dbModule.getSubscriptions().find((s) => s.name === "Target") + expect(target?.tags).toEqual([]) + expect(confirm).not.toHaveBeenCalled() +}) + // ── Interactive mode (no filters given) ────────────────── test("bulk status enters interactive mode when no filters provided", async () => { diff --git a/apps/subtrack/src/bulk.ts b/apps/subtrack/src/bulk.ts index 7679de2..59e811e 100644 --- a/apps/subtrack/src/bulk.ts +++ b/apps/subtrack/src/bulk.ts @@ -156,6 +156,7 @@ export async function handleBulkDelete( export async function handleBulkTagAdd( tag: string, filters: BulkFilters, + options: BulkOptions = {}, ): Promise { if (!tag?.trim()) { fail("Tag name is required") @@ -174,7 +175,7 @@ export async function handleBulkTagAdd( const ok = await confirmAction( `Add tag "${tagName}" to`, list.length, - false, + options.force, ) if (!ok) { consola.info("Cancelled"); return } @@ -191,6 +192,7 @@ export async function handleBulkTagAdd( export async function handleBulkTagRemove( tag: string, filters: BulkFilters, + options: BulkOptions = {}, ): Promise { if (!tag?.trim()) { fail("Tag name is required") @@ -209,7 +211,7 @@ export async function handleBulkTagRemove( const ok = await confirmAction( `Remove tag "${tagName}" from`, list.length, - false, + options.force, ) if (!ok) { consola.info("Cancelled"); return } diff --git a/apps/subtrack/src/commands/bulk.ts b/apps/subtrack/src/commands/bulk.ts index 362a07c..a9127c1 100644 --- a/apps/subtrack/src/commands/bulk.ts +++ b/apps/subtrack/src/commands/bulk.ts @@ -36,8 +36,9 @@ const bulkTagAddCmd = define({ tag: { type: "string", description: "Filter by tag" }, status: { type: "string", description: "Filter by current status" }, name: { type: "string", description: "Filter by name pattern" }, + force: { type: "boolean", short: "f", description: "Skip confirmation" }, }, - run: (ctx) => handleBulkTagAdd(ctx.values.add, { tag: ctx.values.tag, status: ctx.values.status, name: ctx.values.name }), + run: (ctx) => handleBulkTagAdd(ctx.values.add, { tag: ctx.values.tag, status: ctx.values.status, name: ctx.values.name }, { force: ctx.values.force }), }) const bulkTagRemoveCmd = define({ @@ -48,8 +49,9 @@ const bulkTagRemoveCmd = define({ tag: { type: "string", description: "Filter by tag" }, status: { type: "string", description: "Filter by current status" }, name: { type: "string", description: "Filter by name pattern" }, + force: { type: "boolean", short: "f", description: "Skip confirmation" }, }, - run: (ctx) => handleBulkTagRemove(ctx.values.remove, { tag: ctx.values.tag, status: ctx.values.status, name: ctx.values.name }), + run: (ctx) => handleBulkTagRemove(ctx.values.remove, { tag: ctx.values.tag, status: ctx.values.status, name: ctx.values.name }, { force: ctx.values.force }), }) const bulkTagCmd = define({ From 66c874c0ed26c517c3f35300300bddff9075926d Mon Sep 17 00:00:00 2001 From: nazozokc Date: Sun, 16 Aug 2026 14:30:56 +0900 Subject: [PATCH 04/19] fix: exclude dist output from type check tsc --noEmit picked up stale .d.mts files in dist/ referencing hashed .mjs outputs, failing lint:types with TS6053. --- apps/subtrack/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/subtrack/tsconfig.json b/apps/subtrack/tsconfig.json index 670d386..9641246 100644 --- a/apps/subtrack/tsconfig.json +++ b/apps/subtrack/tsconfig.json @@ -9,5 +9,5 @@ "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false }, - "exclude": ["src/__tests__"] + "exclude": ["src/__tests__", "dist"] } From c08ad7f5202ea6264e66eddcde31402057ea8f7b Mon Sep 17 00:00:00 2001 From: nazozokc Date: Sun, 16 Aug 2026 21:12:44 +0900 Subject: [PATCH 05/19] fix: correct billing date calculations in upcoming and calendar --- apps/subtrack/src/__tests__/calendar.test.ts | 174 +++++++++++++++++++ apps/subtrack/src/__tests__/upcoming.test.ts | 75 ++++++++ apps/subtrack/src/calendar.ts | 78 +++++++-- apps/subtrack/src/upcoming.ts | 77 ++++---- 4 files changed, 354 insertions(+), 50 deletions(-) create mode 100644 apps/subtrack/src/__tests__/calendar.test.ts diff --git a/apps/subtrack/src/__tests__/calendar.test.ts b/apps/subtrack/src/__tests__/calendar.test.ts new file mode 100644 index 0000000..6d9b62b --- /dev/null +++ b/apps/subtrack/src/__tests__/calendar.test.ts @@ -0,0 +1,174 @@ +import { test, expect, beforeEach, afterEach, beforeAll } from "vitest" +import { consola } from "consola" +import initSqlJs from "sql.js" +import type { Database } from "sql.js" + +let testDb: Database + +beforeAll(async () => { + const SQL = await initSqlJs() + testDb = new SQL.Database() + testDb.run("PRAGMA foreign_keys = ON") + testDb.run(`CREATE TABLE IF NOT EXISTS subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + price INTEGER NOT NULL, + currency TEXT NOT NULL, + cycle TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + billing_day INTEGER, + created_at TEXT NOT NULL DEFAULT (date('now')), + notes TEXT, + payment_method TEXT, + contract_start TEXT, + contract_end TEXT, + auto_renewal INTEGER NOT NULL DEFAULT 1, + vendor_name TEXT, + vendor_url TEXT, + plan_tier TEXT, + discount_amount INTEGER, + discount_type TEXT + )`) + testDb.run(`CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE + )`) + testDb.run(`CREATE TABLE IF NOT EXISTS subscription_tags ( + subscription_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + PRIMARY KEY (subscription_id, tag_id), + FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE + )`) + testDb.run(`CREATE TABLE IF NOT EXISTS price_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + subscription_id INTEGER NOT NULL, + old_price INTEGER NOT NULL, + new_price INTEGER NOT NULL, + changed_at TEXT NOT NULL DEFAULT (date('now')), + FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE + )`) + + const db = await import("../db.ts") + db.__setDb(testDb) +}) + +beforeEach(() => { + testDb.run("DELETE FROM subscription_tags") + testDb.run("DELETE FROM tags") + testDb.run("DELETE FROM subscriptions") +}) + +afterEach(() => { + consola.mockTypes() +}) + +// ── calcCalendarEntries ───────────────────────────────── + +test("monthly subscription appears every month on billing day", async () => { + const db = await import("../db.ts") + db.writeSubscription({ name: "Netflix", price: 1500, currency: "JPY", cycle: "monthly", tags: [], status: "active", billingDay: 15, createdAt: "2026-01-01" }) + + const { calcCalendarEntries } = await import("../calendar.ts") + for (const month of [1, 2, 3, 12]) { + const entries = calcCalendarEntries(month, 2026) + expect(entries).toHaveLength(1) + expect(entries[0]!.day).toBe(15) + expect(entries[0]!.subs[0]!.name).toBe("Netflix") + } +}) + +test("yearly subscription appears only in the anchor month", async () => { + const db = await import("../db.ts") + db.writeSubscription({ name: "Annual", price: 12000, currency: "JPY", cycle: "yearly", tags: [], status: "active", billingDay: 10, createdAt: "2026-03-05" }) + + const { calcCalendarEntries } = await import("../calendar.ts") + expect(calcCalendarEntries(1, 2026)).toEqual([]) + expect(calcCalendarEntries(2, 2026)).toEqual([]) + const mar = calcCalendarEntries(3, 2026) + expect(mar).toHaveLength(1) + expect(mar[0]!.day).toBe(10) + expect(calcCalendarEntries(4, 2026)).toEqual([]) + // Also next year's anchor month + const marNext = calcCalendarEntries(3, 2027) + expect(marNext).toHaveLength(1) + expect(marNext[0]!.day).toBe(10) +}) + +test("quarterly subscription appears every 3 months from anchor month", async () => { + const db = await import("../db.ts") + db.writeSubscription({ name: "Quarterly", price: 3000, currency: "JPY", cycle: "quarterly", tags: [], status: "active", billingDay: 5, createdAt: "2026-02-03" }) + + const { calcCalendarEntries } = await import("../calendar.ts") + expect(calcCalendarEntries(1, 2026)).toEqual([]) + expect(calcCalendarEntries(2, 2026).map((e) => e.day)).toEqual([5]) + expect(calcCalendarEntries(3, 2026)).toEqual([]) + expect(calcCalendarEntries(4, 2026)).toEqual([]) + expect(calcCalendarEntries(5, 2026).map((e) => e.day)).toEqual([5]) + expect(calcCalendarEntries(8, 2026).map((e) => e.day)).toEqual([5]) + expect(calcCalendarEntries(11, 2026).map((e) => e.day)).toEqual([5]) +}) + +test("semi-annual subscription appears every 6 months from anchor month", async () => { + const db = await import("../db.ts") + db.writeSubscription({ name: "Semi", price: 6000, currency: "JPY", cycle: "semi-annual", tags: [], status: "active", billingDay: 20, createdAt: "2026-01-31" }) + + const { calcCalendarEntries } = await import("../calendar.ts") + expect(calcCalendarEntries(1, 2026).map((e) => e.day)).toEqual([20]) + expect(calcCalendarEntries(7, 2026).map((e) => e.day)).toEqual([20]) + expect(calcCalendarEntries(2, 2026)).toEqual([]) + expect(calcCalendarEntries(1, 2027).map((e) => e.day)).toEqual([20]) +}) + +test("weekly subscription appears on each billing day in the month", async () => { + const db = await import("../db.ts") + // createdAt Jan 5 (Monday), billingDay 5 -> anchor Jan 5, +7d steps + db.writeSubscription({ name: "Weekly", price: 500, currency: "JPY", cycle: "weekly", tags: [], status: "active", billingDay: 5, createdAt: "2026-01-05" }) + + const { calcCalendarEntries } = await import("../calendar.ts") + // Jan 2026: anchor Jan 5, then Jan 12, 19, 26 + const jan = calcCalendarEntries(1, 2026) + expect(jan.map((e) => e.day)).toEqual([5, 12, 19, 26]) + // Feb 2026: Feb 2, 9, 16, 23 + const feb = calcCalendarEntries(2, 2026) + expect(feb.map((e) => e.day)).toEqual([2, 9, 16, 23]) +}) + +test("calendar falls back to createdAt day when billingDay is unset", async () => { + const db = await import("../db.ts") + db.writeSubscription({ name: "NoDay", price: 1000, currency: "JPY", cycle: "monthly", tags: [], status: "active", billingDay: null, createdAt: "2026-01-17" }) + + const { calcCalendarEntries } = await import("../calendar.ts") + expect(calcCalendarEntries(3, 2026).map((e) => e.day)).toEqual([17]) +}) + +test("day 31 clamps to last day of short months", async () => { + const db = await import("../db.ts") + db.writeSubscription({ name: "EndOfMonth", price: 1000, currency: "JPY", cycle: "monthly", tags: [], status: "active", billingDay: 31, createdAt: "2026-01-01" }) + + const { calcCalendarEntries } = await import("../calendar.ts") + expect(calcCalendarEntries(1, 2026).map((e) => e.day)).toEqual([31]) + expect(calcCalendarEntries(2, 2026).map((e) => e.day)).toEqual([28]) + expect(calcCalendarEntries(4, 2026).map((e) => e.day)).toEqual([30]) +}) + +test("cancelled subscriptions are excluded", async () => { + const db = await import("../db.ts") + db.writeSubscription({ name: "Active", price: 100, currency: "USD", cycle: "monthly", tags: [], status: "active", billingDay: 10, createdAt: "2026-01-01" }) + db.writeSubscription({ name: "Cancelled", price: 200, currency: "USD", cycle: "monthly", tags: [], status: "cancelled", billingDay: 10, createdAt: "2026-01-01" }) + + const { calcCalendarEntries } = await import("../calendar.ts") + const entries = calcCalendarEntries(5, 2026) + expect(entries).toHaveLength(1) + expect(entries[0]!.subs.map((s) => s.name)).toEqual(["Active"]) +}) + +test("paused subscriptions are included", async () => { + const db = await import("../db.ts") + db.writeSubscription({ name: "Paused", price: 100, currency: "USD", cycle: "monthly", tags: [], status: "paused", billingDay: 10, createdAt: "2026-01-01" }) + + const { calcCalendarEntries } = await import("../calendar.ts") + const entries = calcCalendarEntries(5, 2026) + expect(entries).toHaveLength(1) + expect(entries[0]!.subs[0]!.name).toBe("Paused") +}) \ No newline at end of file diff --git a/apps/subtrack/src/__tests__/upcoming.test.ts b/apps/subtrack/src/__tests__/upcoming.test.ts index 30804eb..dc97ba8 100644 --- a/apps/subtrack/src/__tests__/upcoming.test.ts +++ b/apps/subtrack/src/__tests__/upcoming.test.ts @@ -122,3 +122,78 @@ test("showUpcoming excludes cancelled subscriptions", async () => { expect(output).toContain("Active") expect(output).not.toContain("Cancelled") }) + +// ── calculateNextBilling / nextDateForCycle ───────────── + +const baseSub = { + id: 1, + name: "Test", + price: 1000, + currency: "JPY", + cycle: "monthly" as const, + tags: [] as string[], + status: "active" as const, + billingDay: 15, + createdAt: "2026-01-01", + notes: null, + paymentMethod: null, + contractStart: null, + contractEnd: null, + autoRenewal: true, + vendorName: null, + vendorUrl: null, + planTier: null, + discountAmount: null, + discountType: null, +} + +test("monthly billing day 31 clamps to last day of short months", async () => { + const { calculateNextBilling } = await import("../upcoming.ts") + const sub = { ...baseSub, cycle: "monthly", billingDay: 31 } + // Feb 10 -> next billing is Feb 28 (not Mar 3 via JS overflow) + expect(calculateNextBilling(sub, new Date(2026, 1, 10))).toEqual(new Date(2026, 1, 28)) + // Apr 10 -> Apr 30 + expect(calculateNextBilling(sub, new Date(2026, 3, 10))).toEqual(new Date(2026, 3, 30)) + // May 1 -> May 31 + expect(calculateNextBilling(sub, new Date(2026, 4, 1))).toEqual(new Date(2026, 4, 31)) +}) + +test("quarterly billing respects billingDay instead of createdAt day", async () => { + const { calculateNextBilling } = await import("../upcoming.ts") + // createdAt Jan 5, billingDay 20 -> next from Feb 1 is Apr 20 (not Mar 31 via clamp bug) + const sub = { ...baseSub, cycle: "quarterly", billingDay: 20, createdAt: "2026-01-05" } + expect(calculateNextBilling(sub, new Date(2026, 1, 1))).toEqual(new Date(2026, 3, 20)) + // From Apr 21 -> Jul 20 + expect(calculateNextBilling(sub, new Date(2026, 3, 21))).toEqual(new Date(2026, 6, 20)) +}) + +test("quarterly without billingDay falls back to createdAt day", async () => { + const { calculateNextBilling } = await import("../upcoming.ts") + const sub = { ...baseSub, cycle: "quarterly", billingDay: null, createdAt: "2026-01-05" } + expect(calculateNextBilling(sub, new Date(2026, 1, 1))).toEqual(new Date(2026, 3, 5)) + // Quarter boundary exactly at fromDate + expect(calculateNextBilling(sub, new Date(2026, 3, 5))).toEqual(new Date(2026, 3, 5)) +}) + +test("semi-annual billing respects billingDay", async () => { + const { calculateNextBilling } = await import("../upcoming.ts") + const sub = { ...baseSub, cycle: "semi-annual", billingDay: 10, createdAt: "2026-02-03" } + expect(calculateNextBilling(sub, new Date(2026, 2, 1))).toEqual(new Date(2026, 7, 10)) +}) + +test("weekly billing anchors on billingDay of the createdAt month", async () => { + const { calculateNextBilling } = await import("../upcoming.ts") + // createdAt Jan 5, billingDay 20 -> anchor Jan 20, then +7d steps + const sub = { ...baseSub, cycle: "weekly", billingDay: 20, createdAt: "2026-01-05" } + expect(calculateNextBilling(sub, new Date(2026, 1, 1))).toEqual(new Date(2026, 1, 3)) + // createdAt Jan 5, no billingDay -> anchor Jan 5 + const sub2 = { ...baseSub, cycle: "weekly", billingDay: null, createdAt: "2026-01-05" } + expect(calculateNextBilling(sub2, new Date(2026, 1, 1))).toEqual(new Date(2026, 1, 2)) +}) + +test("bi-weekly billing follows 14-day steps from anchor", async () => { + const { calculateNextBilling } = await import("../upcoming.ts") + const sub = { ...baseSub, cycle: "bi-weekly", billingDay: null, createdAt: "2026-01-05" } + // Jan 5 + 2*14 = Feb 2 + expect(calculateNextBilling(sub, new Date(2026, 1, 1))).toEqual(new Date(2026, 1, 2)) +}) diff --git a/apps/subtrack/src/calendar.ts b/apps/subtrack/src/calendar.ts index f94e946..f7876ab 100644 --- a/apps/subtrack/src/calendar.ts +++ b/apps/subtrack/src/calendar.ts @@ -34,6 +34,61 @@ function clampDay(day: number, year: number, month: number): number { return Math.min(day, daysInMonth(year, month)) } +function toDate(dateStr: string): Date { + const [y, m, d] = dateStr.split("-").map(Number) + return new Date(y, m - 1, d) +} + +/** + * Billing days of a subscription within a given month (1-31). + * - monthly: every month on the billing day + * - yearly: only the anchor month + * - quarterly/semi-annual: every 3/6 months from the anchor month + * - weekly/bi-weekly: every 7/14 days from the anchor date + * The billing day falls back to the created_at day when unset. + */ +export function billingDaysInMonth(sub: SharedArgs, year: number, month: number): number[] { + const anchorDate = toDate(sub.createdAt) + const anchorMonth = anchorDate.getMonth() + const day = sub.billingDay ?? anchorDate.getDate() + const clampedDay = clampDay(day, year, month) + + switch (sub.cycle) { + case "monthly": + return [clampedDay] + case "yearly": + return anchorMonth === month - 1 ? [clampedDay] : [] + case "quarterly": { + const diff = ((month - 1 - anchorMonth) % 12 + 12) % 12 + return diff % 3 === 0 ? [clampedDay] : [] + } + case "semi-annual": { + const diff = ((month - 1 - anchorMonth) % 12 + 12) % 12 + return diff % 6 === 0 ? [clampedDay] : [] + } + case "weekly": + case "bi-weekly": { + const periodDays = sub.cycle === "weekly" ? 7 : 14 + const msPerPeriod = periodDays * 24 * 60 * 60 * 1000 + const anchor = new Date( + anchorDate.getFullYear(), + anchorMonth, + Math.min(day, daysInMonth(anchorDate.getFullYear(), anchorMonth + 1)), + ) + const start = new Date(year, month - 1, 1).getTime() + const end = new Date(year, month - 1, daysInMonth(year, month)).getTime() + const days: number[] = [] + const kStart = Math.max(0, Math.floor((start - anchor.getTime()) / msPerPeriod)) + for (let k = kStart; ; k++) { + const t = anchor.getTime() + k * msPerPeriod + if (t > end) break + if (t >= start) days.push(new Date(t).getDate()) + } + return days + } + } +} + /** * Calculate billing events for a given month. * @param month - Month number (1-12) @@ -42,22 +97,23 @@ function clampDay(day: number, year: number, month: number): number { */ export function calcCalendarEntries(month: number, year: number): CalendarEntry[] { const subs = getSubscriptions() - const active = subs.filter((s) => s.status !== "cancelled" && s.billingDay != null) + const active = subs.filter((s) => s.status !== "cancelled") const dayMap = new Map() for (const sub of active) { - const day = clampDay(sub.billingDay!, year, month) - if (!dayMap.has(day)) { - dayMap.set(day, []) + for (const day of billingDaysInMonth(sub, year, month)) { + if (!dayMap.has(day)) { + dayMap.set(day, []) + } + dayMap.get(day)!.push({ + name: sub.name, + price: sub.price, + currency: sub.currency, + status: sub.status, + id: sub.id, + }) } - dayMap.get(day)!.push({ - name: sub.name, - price: sub.price, - currency: sub.currency, - status: sub.status, - id: sub.id, - }) } const entries: CalendarEntry[] = [] diff --git a/apps/subtrack/src/upcoming.ts b/apps/subtrack/src/upcoming.ts index 43d151a..1a80469 100644 --- a/apps/subtrack/src/upcoming.ts +++ b/apps/subtrack/src/upcoming.ts @@ -19,47 +19,59 @@ function getBillingDay(sub: SharedArgs): number { return created.getDate() } -function addMonths(date: Date, n: number): Date { - const result = new Date(date) - result.setMonth(result.getMonth() + n) - return result +/** + * Build a date with the given day clamped to the last day of the month, + * avoiding JS Date overflow (e.g. day 31 in February -> Feb 28). + */ +function dateWithClampedDay(year: number, month: number, day: number): Date { + const lastDay = new Date(year, month + 1, 0).getDate() + return new Date(year, month, Math.min(day, lastDay)) +} + +/** + * Date of the k-th period occurrence anchored on `anchorDate`, + * billed on `day` (clamped to the month length). + */ +function periodDate(anchorDate: Date, periodMonths: number, k: number, day: number): Date { + const monthIndex = anchorDate.getMonth() + k * periodMonths + const year = anchorDate.getFullYear() + Math.floor(monthIndex / 12) + const month = ((monthIndex % 12) + 12) % 12 + return dateWithClampedDay(year, month, day) } export function nextDateForCycle(anchorDay: number, anchorDate: Date, cycle: Cycle, fromDate: Date): Date { switch (cycle) { case "monthly": { // Calculate next billing date based on anchor day - const candidate = new Date(fromDate.getFullYear(), fromDate.getMonth(), anchorDay) + const candidate = dateWithClampedDay(fromDate.getFullYear(), fromDate.getMonth(), anchorDay) if (candidate >= fromDate) return candidate // Move to next month - return new Date(fromDate.getFullYear(), fromDate.getMonth() + 1, anchorDay) + return dateWithClampedDay(fromDate.getFullYear(), fromDate.getMonth() + 1, anchorDay) } case "yearly": { - const candidate = new Date(fromDate.getFullYear(), anchorDate.getMonth(), anchorDay) + const candidate = dateWithClampedDay(fromDate.getFullYear(), anchorDate.getMonth(), anchorDay) if (candidate >= fromDate) return candidate - return new Date(fromDate.getFullYear() + 1, anchorDate.getMonth(), anchorDay) - } - case "weekly": { - // Every 7 days from anchor - const diff = fromDate.getTime() - anchorDate.getTime() - const weeksSince = Math.ceil(diff / (7 * 24 * 60 * 60 * 1000)) - return new Date(anchorDate.getTime() + weeksSince * 7 * 24 * 60 * 60 * 1000) + return dateWithClampedDay(fromDate.getFullYear() + 1, anchorDate.getMonth(), anchorDay) } + case "weekly": case "bi-weekly": { - const diff = fromDate.getTime() - anchorDate.getTime() - const periodsSince = Math.ceil(diff / (14 * 24 * 60 * 60 * 1000)) - return new Date(anchorDate.getTime() + periodsSince * 14 * 24 * 60 * 60 * 1000) - } - case "quarterly": { - // Every 3 months from anchor - const monthsSince = (fromDate.getFullYear() - anchorDate.getFullYear()) * 12 + (fromDate.getMonth() - anchorDate.getMonth()) - const quartersSince = Math.ceil(monthsSince / 3) - return addMonths(new Date(anchorDate), quartersSince * 3) + // Every 7/14 days from the anchor (billing day of the anchor month) + const periodDays = cycle === "weekly" ? 7 : 14 + const anchor = dateWithClampedDay(anchorDate.getFullYear(), anchorDate.getMonth(), anchorDay) + const msPerPeriod = periodDays * 24 * 60 * 60 * 1000 + const periodsSince = Math.ceil((fromDate.getTime() - anchor.getTime()) / msPerPeriod) + return new Date(anchor.getTime() + Math.max(0, periodsSince) * msPerPeriod) } + case "quarterly": case "semi-annual": { - const monthsSince = (fromDate.getFullYear() - anchorDate.getFullYear()) * 12 + (fromDate.getMonth() - anchorDate.getMonth()) - const halvesSince = Math.ceil(monthsSince / 6) - return addMonths(new Date(anchorDate), halvesSince * 6) + // Every 3/6 months from the anchor month, billed on anchorDay + const periodMonths = cycle === "quarterly" ? 3 : 6 + let k = 0 + for (;;) { + const candidate = periodDate(anchorDate, periodMonths, k, anchorDay) + if (candidate >= fromDate) return candidate + k++ + } } } } @@ -67,19 +79,6 @@ export function nextDateForCycle(anchorDay: number, anchorDate: Date, cycle: Cyc export function calculateNextBilling(sub: SharedArgs, fromDate: Date): Date { const anchorDate = toDate(sub.createdAt) const day = getBillingDay(sub) - - // For monthly and yearly, use the billing day directly - if (sub.cycle === "monthly" || sub.cycle === "yearly" || sub.cycle === "quarterly" || sub.cycle === "semi-annual") { - const candidate = nextDateForCycle(day, anchorDate, sub.cycle, fromDate) - // Handle month overflow (e.g., day 31 in February) - if (candidate.getDate() !== day) { - // Cap to last day of month - candidate.setDate(0) // go to last day of previous month - } - return candidate - } - - // For weekly/bi-weekly, cycle from anchor return nextDateForCycle(day, anchorDate, sub.cycle, fromDate) } From 09583ba33f9d164df422cbdb93d1984f56c986e8 Mon Sep 17 00:00:00 2001 From: nazozokc Date: Sun, 16 Aug 2026 21:12:50 +0900 Subject: [PATCH 06/19] fix: exclude cancelled subscriptions from payment summaries and validate edit flags --- apps/subtrack/src/__tests__/display.test.ts | 17 ++++++ apps/subtrack/src/__tests__/payment.test.ts | 66 ++++++++++++++++++++- apps/subtrack/src/payment.ts | 48 ++++++++------- apps/subtrack/src/subscription/edit.ts | 30 +++++++++- 4 files changed, 139 insertions(+), 22 deletions(-) diff --git a/apps/subtrack/src/__tests__/display.test.ts b/apps/subtrack/src/__tests__/display.test.ts index 8e2bbe7..900d984 100644 --- a/apps/subtrack/src/__tests__/display.test.ts +++ b/apps/subtrack/src/__tests__/display.test.ts @@ -493,6 +493,23 @@ test("showPayment --currency falls back when fetch fails", async () => { expect(combined).toContain("$10") }) +test("showPayment byMethod groups per currency without mixing", async () => { + const { showPayment } = await import("../payment.ts") + await showPayment("monthly", undefined, [ + makeSub({ name: "A", price: 1000, currency: "JPY", paymentMethod: "card" }), + makeSub({ name: "B", price: 10, currency: "USD", paymentMethod: "card" }), + makeSub({ name: "C", price: 500, currency: "JPY", paymentMethod: "cash" }), + ], false, true) + + const combined = logMessages.join("\n") + expect(combined).toContain("By payment method:") + // card must show JPY + USD separately, not a bogus single "USD" sum + const cardLine = logMessages.find((l) => l.includes("card")) + expect(cardLine).toContain("¥1,000 + $10") + const cashLine = logMessages.find((l) => l.includes("cash")) + expect(cashLine).toContain("¥500") +}) + // ── exportJson tests ────────────────────────────────────── test("exportJson returns empty array for no subscriptions", async () => { diff --git a/apps/subtrack/src/__tests__/payment.test.ts b/apps/subtrack/src/__tests__/payment.test.ts index b52657f..03dd9f8 100644 --- a/apps/subtrack/src/__tests__/payment.test.ts +++ b/apps/subtrack/src/__tests__/payment.test.ts @@ -140,6 +140,21 @@ test("handlePayment json sums monthly totals per subscription", async () => { expect(netflix?.periodPrice).toBe(1500) }) +test("handlePayment json excludes cancelled subscriptions", async () => { + const d = await db() + d.writeSubscription({ name: "Active", price: 1000, currency: "JPY", cycle: "monthly", tags: [] }) + d.writeSubscription({ name: "Gone", price: 9999, currency: "JPY", cycle: "monthly", tags: [], status: "cancelled" }) + + const { handlePayment } = await import("../payment.ts") + const out = await captureJson(() => handlePayment("monthly", { json: true })) as { + total: number + subscriptions: { name: string }[] + } + + expect(out.total).toBe(1000) + expect(out.subscriptions.map((s) => s.name)).toEqual(["Active"]) +}) + test("handlePayment json converts to target currency", async () => { const d = await db() d.writeSubscription({ name: "Netflix", price: 1600, currency: "JPY", cycle: "monthly", tags: [] }) @@ -163,13 +178,47 @@ test("handlePayment json groups by payment method", async () => { const { handlePayment } = await import("../payment.ts") const out = await captureJson(() => handlePayment("monthly", { json: true, method: true })) as { - byMethod: Record + byMethod: Record }> } expect(out.byMethod.credit_card.total).toBe(3000) + expect(out.byMethod.credit_card.byCurrency).toEqual({ JPY: 3000 }) expect(out.byMethod.unspecified.total).toBe(500) }) +test("handlePayment json byMethod keeps currencies separate when mixed", async () => { + const d = await db() + d.writeSubscription({ name: "A", price: 1000, currency: "JPY", cycle: "monthly", tags: [], paymentMethod: "card" }) + d.writeSubscription({ name: "B", price: 100, currency: "USD", cycle: "monthly", tags: [], paymentMethod: "card" }) + + const { handlePayment } = await import("../payment.ts") + const out = await captureJson(() => handlePayment("monthly", { json: true, method: true })) as { + byMethod: Record }> + } + + expect(out.byMethod.card.total).toBe(1000 + 100) // raw sum, documented as such + expect(out.byMethod.card.currencies.sort()).toEqual(["JPY", "USD"]) + expect(out.byMethod.card.byCurrency).toEqual({ JPY: 1000, USD: 100 }) +}) + +test("handlePayment json byMethod converts when target currency is set", async () => { + const d = await db() + d.writeSubscription({ name: "A", price: 1600, currency: "JPY", cycle: "monthly", tags: [], paymentMethod: "card" }) + d.writeSubscription({ name: "B", price: 100, currency: "USD", cycle: "monthly", tags: [], paymentMethod: "card" }) + + const { handlePayment } = await import("../payment.ts") + const out = await captureJson(() => handlePayment("monthly", { json: true, method: true, currency: "USD" })) as { + total: number + currency: string + byMethod: Record }> + } + + expect(out.currency).toBe("USD") + expect(out.total).toBe(10 + 100) + expect(out.byMethod.card.total).toBe(10 + 100) + expect(out.byMethod.card.byCurrency).toEqual({ USD: 110 }) +}) + test("handlePayment json includes API usage when requested", async () => { const d = await db() d.writeSubscription({ name: "A", price: 1000, currency: "JPY", cycle: "monthly", tags: [] }) @@ -211,6 +260,21 @@ test("handleSummary json returns summary data", async () => { expect(out.mostExpensive?.name).toBe("iCloud") }) +test("handleSummary json excludes cancelled subscriptions", async () => { + const d = await db() + d.writeSubscription({ name: "Active", price: 1000, currency: "JPY", cycle: "monthly", tags: [] }) + d.writeSubscription({ name: "Gone", price: 9999, currency: "JPY", cycle: "monthly", tags: [], status: "cancelled" }) + + const { handleSummary } = await import("../payment.ts") + const out = await captureJson(() => handleSummary({ json: true })) as { + totalCount: number + monthlyByCurrency: Record + } + + expect(out.totalCount).toBe(1) + expect(out.monthlyByCurrency).toEqual({ JPY: 1000 }) +}) + // ── calcSubTotal ────────────────────────────────────── function makeSub(overrides: Partial = {}): SharedArgs { diff --git a/apps/subtrack/src/payment.ts b/apps/subtrack/src/payment.ts index 0d712b5..22ef19f 100644 --- a/apps/subtrack/src/payment.ts +++ b/apps/subtrack/src/payment.ts @@ -17,7 +17,7 @@ export const showPayment = async ( includeApi?: boolean, byMethod?: boolean, ): Promise => { - const list = subs ?? getSubscriptions() + const list = subs ?? getSubscriptions().filter((s) => s.status !== "cancelled") if (list.length === 0) { consola.info("No subscriptions found") @@ -113,23 +113,22 @@ export const showPayment = async ( consola.log(`${ccy} ${formatPrice(rounded, ccy)}/${fmtPeriod}`) } - // Group by payment method + // Group by payment method (per-currency to avoid mixing currencies) if (byMethod) { - const methodGroups: Record = {} - const methodCurrencies: Record> = {} + const methodGroups: Record> = {} for (const entry of entries) { const method = entry.paymentMethod || "unspecified" - methodGroups[method] = (methodGroups[method] ?? 0) + entry.convertedPrice - if (!methodCurrencies[method]) methodCurrencies[method] = new Set() - methodCurrencies[method].add(entry.currency) + if (!methodGroups[method]) methodGroups[method] = {} + methodGroups[method][entry.currency] = (methodGroups[method][entry.currency] ?? 0) + entry.convertedPrice } consola.log("") consola.log(pc.bold("By payment method:")) - for (const [method, total] of Object.entries(methodGroups).sort()) { - const rounded = Math.round(total) - const ccies = [...(methodCurrencies[method] ?? new Set())] - const ccy = ccies.length === 1 ? ccies[0] : "USD" - consola.log(` ${method.padEnd(16)} ${formatPrice(rounded, ccy)}/${fmtPeriod}`) + for (const [method, totals] of Object.entries(methodGroups).sort()) { + const priceStr = Object.entries(totals) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([ccy, total]) => formatPrice(Math.round(total), ccy)) + .join(" + ") + consola.log(` ${method.padEnd(16)} ${priceStr}/${fmtPeriod}`) } } @@ -266,7 +265,7 @@ export function calcSummary(subs: SharedArgs[]): SummaryData { } export function showSummary(subs?: SharedArgs[]): void { - const list = subs ?? getSubscriptions() + const list = subs ?? getSubscriptions().filter((s) => s.status !== "cancelled") if (list.length === 0) { consola.info("No subscriptions found") @@ -323,7 +322,7 @@ export async function handlePayment( } if (options.json) { - const subs = getSubscriptions() + const subs = getSubscriptions().filter((s) => s.status !== "cancelled") if (subs.length === 0) { process.stdout.write(JSON.stringify({ period, total: 0, subscriptions: [] }, null, 2) + "\n") return @@ -346,10 +345,10 @@ export async function handlePayment( let targetCurrency = options.currency as Currency | undefined let finalCurrency: string | undefined + let rates: FxRates | null = null let subTotal = 0 if (targetCurrency) { - let rates: FxRates | null = null try { rates = await fetchFxRates() } catch { consola.fail("Failed to fetch exchange rates; reporting in original currencies") } if (rates) { for (const entry of entries) { @@ -366,13 +365,22 @@ export async function handlePayment( subTotal = Object.values(byCurrency).reduce((a, b) => a + b, 0) } - const byMethod: Record = {} + const byMethod: Record }> = {} if (options.method) { for (const entry of entries) { const method = entry.paymentMethod || "unspecified" - if (!byMethod[method]) byMethod[method] = { total: 0, currencies: [] } - byMethod[method].total += entry.convertedPrice - if (!byMethod[method].currencies.includes(entry.currency)) { byMethod[method].currencies.push(entry.currency) } + if (!byMethod[method]) byMethod[method] = { total: 0, currencies: [], byCurrency: {} } + // Sum in the same currency space as the total: converted when a target + // currency is available, otherwise per original currency. + const currency = finalCurrency ?? entry.currency + let amount = entry.convertedPrice + if (finalCurrency && rates) { + try { amount = convertPrice(entry.convertedPrice, entry.currency, finalCurrency, rates.rates) } + catch { /* keep original amount */ } + } + byMethod[method].total += amount + if (!byMethod[method].currencies.includes(currency)) { byMethod[method].currencies.push(currency) } + byMethod[method].byCurrency[currency] = (byMethod[method].byCurrency[currency] ?? 0) + amount } } @@ -403,7 +411,7 @@ export async function handleSummary(options: JsonOptions = {}) { } if (options.json) { - const subs = getSubscriptions() + const subs = getSubscriptions().filter((s) => s.status !== "cancelled") const data = calcSummary(subs) process.stdout.write(JSON.stringify(data, null, 2) + "\n") return diff --git a/apps/subtrack/src/subscription/edit.ts b/apps/subtrack/src/subscription/edit.ts index 889c939..c21bd37 100644 --- a/apps/subtrack/src/subscription/edit.ts +++ b/apps/subtrack/src/subscription/edit.ts @@ -21,6 +21,7 @@ import { validatePrice, validateTags, validateBillingDay, + validateNotes, validatePaymentMethod, validateVendorName, validateVendorUrl, @@ -70,7 +71,14 @@ export async function handleEdit( if (hasFlags) { // Non-interactive: update only flagged fields const newData: Partial = {} - if (flags.name !== undefined) newData.name = flags.name + if (flags.name !== undefined) { + const err = validateName(flags.name) + if (err !== true) { + fail(`Invalid name: ${err}`) + return + } + newData.name = flags.name + } if (flags.price !== undefined) { const err = validatePrice(flags.price) if (err !== true) { @@ -102,17 +110,37 @@ export async function handleEdit( } if (flags.billingDay !== undefined) { const trimmed = flags.billingDay.trim() + const err = validateBillingDay(trimmed) + if (err !== true) { + fail(`Invalid billing day: ${err}`) + return + } newData.billingDay = trimmed ? Number(trimmed) : null } if (flags.tags !== undefined) { + const err = validateTags(flags.tags) + if (err !== true) { + fail(`Invalid tags: ${err}`) + return + } newData.tags = flags.tags.split(",").map((t) => t.trim()).filter(Boolean) } if (flags.notes !== undefined) { const trimmed = flags.notes.trim() + const err = validateNotes(trimmed) + if (err !== true) { + fail(`Invalid notes: ${err}`) + return + } newData.notes = trimmed || null } if (flags.paymentMethod !== undefined) { const trimmed = flags.paymentMethod.trim() + const err = validatePaymentMethod(trimmed) + if (err !== true) { + fail(`Invalid payment method: ${err}`) + return + } newData.paymentMethod = trimmed || null } if (flags.vendorName !== undefined) { From 58aab5b0dd4fd5669f0e14b18107e4fbb9db9229 Mon Sep 17 00:00:00 2001 From: nazozokc Date: Sun, 16 Aug 2026 21:12:55 +0900 Subject: [PATCH 07/19] feat: add list filters, forecast currency conversion, and analytics period options --- apps/subtrack/src/__tests__/analytics.test.ts | 56 +++++++ apps/subtrack/src/__tests__/db.test.ts | 41 +++++ apps/subtrack/src/analytics.ts | 140 ++++++++++++++---- apps/subtrack/src/commands/core.ts | 35 ++++- apps/subtrack/src/commands/report.ts | 47 ++++-- apps/subtrack/src/db/subscriptions.ts | 29 +++- apps/subtrack/src/forecast.ts | 1 + apps/subtrack/src/subscription/core.ts | 32 +++- 8 files changed, 333 insertions(+), 48 deletions(-) diff --git a/apps/subtrack/src/__tests__/analytics.test.ts b/apps/subtrack/src/__tests__/analytics.test.ts index f1f7ade..041d346 100644 --- a/apps/subtrack/src/__tests__/analytics.test.ts +++ b/apps/subtrack/src/__tests__/analytics.test.ts @@ -142,3 +142,59 @@ test("showAnalytics includes budget info when configured", async () => { // Reset budget setConfig("monthlyBudget", "0") }) + +test("showAnalytics converts spending to target currency", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = async () => + new Response(JSON.stringify({ base: "USD", rates: { JPY: 160, USD: 1 } })) + + const db = await import("../db.ts") + db.writeSubscription({ name: "Netflix", price: 1600, currency: "JPY", cycle: "monthly", tags: [] }) + + const { showAnalytics } = await import("../analytics.ts") + await showAnalytics({ currency: "USD" }) + + const output = logMessages.join("\n") + expect(output).toContain("USD $10") + + globalThis.fetch = originalFetch +}) + +test("showAnalytics yearly period shows annual figures", async () => { + const db = await import("../db.ts") + db.writeSubscription({ name: "Netflix", price: 1000, currency: "JPY", cycle: "monthly", tags: [] }) + + const { showAnalytics } = await import("../analytics.ts") + await showAnalytics({ period: "yearly" }) + + const output = logMessages.join("\n") + expect(output).toContain("Yearly spending:") + expect(output).toContain("¥12,000") +}) + +test("showAnalytics compares budget across currencies with --currency", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = async () => + new Response(JSON.stringify({ base: "USD", rates: { JPY: 160, USD: 1 } })) + + const { resetConfig } = await import("../config.ts") + resetConfig() + const { setConfig } = await import("../config.ts") + setConfig("monthlyBudget", "200") + setConfig("defaultCurrency", "USD") + + const db = await import("../db.ts") + // ¥16,000 = $100 + db.writeSubscription({ name: "JP", price: 16000, currency: "JPY", cycle: "monthly", tags: [] }) + + const { showAnalytics } = await import("../analytics.ts") + await showAnalytics({ currency: "JPY" }) + + const output = logMessages.join("\n") + expect(output).toContain("Remaining:") + // budget $200 - spending $100 => remaining $100 + expect(output).toContain("$100") + + setConfig("monthlyBudget", "0") + globalThis.fetch = originalFetch +}) diff --git a/apps/subtrack/src/__tests__/db.test.ts b/apps/subtrack/src/__tests__/db.test.ts index 26b9202..04b85b8 100644 --- a/apps/subtrack/src/__tests__/db.test.ts +++ b/apps/subtrack/src/__tests__/db.test.ts @@ -655,6 +655,47 @@ test("getSubscriptions sorts by status descending", async () => { expect(subs[2].status).toBe("active") }) +test("getSubscriptions filters by status", async () => { + const db = await import("../db.ts") + db.writeSubscription({ name: "A", price: 100, currency: "USD", cycle: "monthly", tags: [], status: "active" }) + db.writeSubscription({ name: "B", price: 100, currency: "USD", cycle: "monthly", tags: [], status: "paused" }) + db.writeSubscription({ name: "C", price: 100, currency: "USD", cycle: "monthly", tags: [], status: "cancelled" }) + + const paused = db.getSubscriptions({ status: "paused" }) + expect(paused).toHaveLength(1) + expect(paused[0].name).toBe("B") + + const cancelled = db.getSubscriptions({ status: "cancelled" }) + expect(cancelled.map((s) => s.name)).toEqual(["C"]) +}) + +test("getSubscriptions filters by min/max price", async () => { + const db = await import("../db.ts") + db.writeSubscription({ name: "Cheap", price: 100, currency: "USD", cycle: "monthly", tags: [] }) + db.writeSubscription({ name: "Mid", price: 500, currency: "USD", cycle: "monthly", tags: [] }) + db.writeSubscription({ name: "Pricy", price: 1000, currency: "USD", cycle: "monthly", tags: [] }) + + const min = db.getSubscriptions({ minPrice: 500 }) + expect(min.map((s) => s.name).sort()).toEqual(["Mid", "Pricy"]) + + const max = db.getSubscriptions({ maxPrice: 500 }) + expect(max.map((s) => s.name).sort()).toEqual(["Cheap", "Mid"]) + + const range = db.getSubscriptions({ minPrice: 200, maxPrice: 900 }) + expect(range.map((s) => s.name)).toEqual(["Mid"]) +}) + +test("getSubscriptions combines status and price filters with pagination", async () => { + const db = await import("../db.ts") + db.writeSubscription({ name: "A", price: 100, currency: "USD", cycle: "monthly", tags: [] }) + db.writeSubscription({ name: "B", price: 500, currency: "USD", cycle: "monthly", tags: [] }) + db.writeSubscription({ name: "C", price: 1000, currency: "USD", cycle: "monthly", tags: [] }) + + const subs = db.getSubscriptions({ status: "active", minPrice: 200, limit: 1 }) + expect(subs).toHaveLength(1) + expect(subs[0].name).toBe("B") +}) + // ── getSubscription ─────────────────────────────────────── test("getSubscription returns a single subscription by id", async () => { diff --git a/apps/subtrack/src/analytics.ts b/apps/subtrack/src/analytics.ts index 1198ba1..0131c82 100644 --- a/apps/subtrack/src/analytics.ts +++ b/apps/subtrack/src/analytics.ts @@ -6,22 +6,54 @@ import { formatPrice } from "./price.ts" import { calcSummary } from "./payment.ts" import { loadConfig } from "./config.ts" import { periodFactor } from "./date-utils.ts" +import { fetchFxRates, convertPrice } from "./fx.ts" +import type { FxRates } from "./fx.ts" -export function handleAnalytics(options: AnalyticsOptions = {}): void { +export async function handleAnalytics(options: AnalyticsOptions = {}): Promise { if (options.json) { const subs = getSubscriptions().filter((s) => s.status !== "cancelled") - if (subs.length === 0) { - process.stdout.write(JSON.stringify({ totalCount: 0, monthlyByCurrency: {}, monthlyByTag: {} }, null, 2) + "\n") - return - } const data = calcSummary(subs) - process.stdout.write(JSON.stringify(data, null, 2) + "\n") + + const output: Record = { + ...data, + period: options.period ?? "monthly", + } + + if (options.currency) { + let rates: FxRates | null = null + try { + rates = await fetchFxRates() + } catch { + consola.warn("Failed to fetch exchange rates; reporting in original currencies") + } + const converted: Record = {} + if (rates) { + for (const [ccy, total] of Object.entries(data.monthlyByCurrency)) { + if (ccy !== options.currency) { + try { + const value = convertPrice(total, ccy, options.currency, rates.rates) + converted[options.currency] = (converted[options.currency] ?? 0) + value + continue + } catch { + // fall through to original currency + } + } + converted[ccy] = (converted[ccy] ?? 0) + total + } + output.monthlyByCurrency = Object.fromEntries( + Object.entries(converted).map(([ccy, total]) => [ccy, Math.round(total)]), + ) + } + output.currency = options.currency + } + + process.stdout.write(JSON.stringify(output, null, 2) + "\n") return } - showAnalytics() + await showAnalytics(options) } -export function showAnalytics(): void { +export async function showAnalytics(options: AnalyticsOptions = {}): Promise { const list = getSubscriptions().filter((s) => s.status !== "cancelled") if (list.length === 0) { consola.info("No active subscriptions found") @@ -31,6 +63,22 @@ export function showAnalytics(): void { const config = loadConfig() const data = calcSummary(list) + // Optional FX conversion + let rates: FxRates | null = null + const targetCurrency = options.currency + if (targetCurrency) { + try { + rates = await fetchFxRates() + } catch { + consola.warn("Failed to fetch exchange rates; showing original currencies") + } + } + + // Period multiplier (yearly shows annual figures) + const isYearly = options.period === "yearly" + const mult = isYearly ? 12 : 1 + const periodLabel = isYearly ? "Yearly" : "Monthly" + // Header consola.log(pc.bold("Subscription Analytics")) consola.log("") @@ -51,55 +99,85 @@ export function showAnalytics(): void { consola.log(` Most expensive: ${pc.bold(me.name)} (${formatPrice(me.price, me.currency)}/${me.cycle})`) } - // Monthly spending + // Spending (per currency, optionally converted to target) consola.log("") - consola.log(pc.bold("Monthly spending:")) - for (const [ccy, total] of Object.entries(data.monthlyByCurrency).sort()) { + consola.log(pc.bold(`${periodLabel} spending:`)) + const byCurrency: Record = {} + for (const sub of list) { + const monthly = sub.price * periodFactor(sub.cycle, "monthly") + const ccy = targetCurrency ?? sub.currency + let amount = monthly + if (targetCurrency && rates && sub.currency !== targetCurrency) { + try { + amount = convertPrice(monthly, sub.currency, targetCurrency, rates.rates) + } catch { + // keep original + } + } + byCurrency[ccy] = (byCurrency[ccy] ?? 0) + amount * mult + } + for (const [ccy, total] of Object.entries(byCurrency).sort()) { consola.log(` ${ccy} ${formatPrice(Math.round(total), ccy)}`) } - // Budget - if (config.monthlyBudget > 0) { - const defaultCurrency = config.defaultCurrency || "USD" - const currencies = new Set(list.map((s) => s.currency)) - const budgetDisplay = formatPrice(config.monthlyBudget, defaultCurrency) + // Budget comparison (converted to the display currency when possible) + const budget = isYearly ? (config.yearlyBudget ?? config.monthlyBudget * 12) : config.monthlyBudget + if (budget > 0) { + const budgetCurrency = config.defaultCurrency || "USD" + const displayCcy = targetCurrency ?? (Object.keys(byCurrency).length === 1 ? Object.keys(byCurrency)[0] : undefined) + consola.log(` ${pc.dim("─".repeat(30))}`) - consola.log(` Budget: ${pc.bold(budgetDisplay)}`) - - if (currencies.size === 1) { - const ccy = [...currencies][0] - const monthlyTotal = list.reduce((sum, sub) => sum + sub.price * periodFactor(sub.cycle, "monthly"), 0) - const remaining = config.monthlyBudget - monthlyTotal - if (ccy === defaultCurrency) { - const remainingDisplay = formatPrice(remaining, defaultCurrency) + consola.log(` Budget: ${pc.bold(formatPrice(budget, budgetCurrency))}${isYearly ? "/year" : ""}`) + + // spending already converted to the display currency (period-adjusted) + const spendingTotal = Object.values(byCurrency).reduce((a, b) => a + b, 0) + + if (displayCcy) { + if (displayCcy === budgetCurrency) { + const remaining = budget - spendingTotal + const remainingDisplay = formatPrice(remaining, budgetCurrency) if (remaining >= 0) { consola.log(` Remaining: ${pc.green(remainingDisplay)}`) } else { consola.log(` Over budget: ${pc.red(remainingDisplay.replace("-", ""))}`) } + } else if (rates) { + try { + // Compare in budget currency: convert spending back from display currency + const spendingInBudget = convertPrice(spendingTotal, displayCcy, budgetCurrency, rates.rates) + const remaining = budget - spendingInBudget + const remainingDisplay = formatPrice(remaining, budgetCurrency) + if (remaining >= 0) { + consola.log(` Remaining: ${pc.green(remainingDisplay)} (${pc.dim(`${formatPrice(spendingTotal, displayCcy)} spent`)} in ${displayCcy})`) + } else { + consola.log(` Over budget: ${pc.red(remainingDisplay.replace("-", ""))} (${pc.dim(`${formatPrice(spendingTotal, displayCcy)} spent`)} in ${displayCcy})`) + } + } catch { + consola.log(pc.dim(` (Cannot convert ${budgetCurrency} budget to ${displayCcy} — missing rate)`)) + } } else { - consola.log(` Spending: ${formatPrice(Math.round(monthlyTotal), ccy)}/${pc.dim(defaultCurrency)}`) + consola.log(pc.dim(" (Cannot compare — no exchange rates available)")) } } else { - consola.log(pc.dim(" (Multiple currencies — set a defaultCurrency for budget comparison)")) + consola.log(pc.dim(` (Multiple currencies — use --currency to compare against budget)`)) } } // Tags breakdown if (Object.keys(data.monthlyByTag).length > 0) { consola.log("") - consola.log(pc.bold("Monthly by tag:")) + consola.log(pc.bold(`${periodLabel} by tag:`)) const sorted = Object.entries(data.monthlyByTag).sort( (a, b) => Object.values(b[1].monthly).reduce((s, v) => s + v, 0) - Object.values(a[1].monthly).reduce((s, v) => s + v, 0), ) for (const [tag, info] of sorted) { const ccyEntries = Object.entries(info.monthly) const priceStr = ccyEntries.length === 1 - ? formatPrice(Math.round(ccyEntries[0][1]), ccyEntries[0][0]) - : ccyEntries.map(([ccy, total]) => formatPrice(Math.round(total), ccy)).join(" + ") + ? formatPrice(Math.round(ccyEntries[0][1] * mult), ccyEntries[0][0]) + : ccyEntries.map(([ccy, total]) => formatPrice(Math.round(total * mult), ccy)).join(" + ") consola.log( - ` ${tag.padEnd(16)} ${priceStr}/month (${info.count} sub${info.count > 1 ? "s" : ""})`, + ` ${tag.padEnd(16)} ${priceStr}/${periodLabel.toLowerCase()} (${info.count} sub${info.count > 1 ? "s" : ""})`, ) } } -} +} \ No newline at end of file diff --git a/apps/subtrack/src/commands/core.ts b/apps/subtrack/src/commands/core.ts index ad648a4..e0048ff 100644 --- a/apps/subtrack/src/commands/core.ts +++ b/apps/subtrack/src/commands/core.ts @@ -24,6 +24,11 @@ export const listCommand = define({ api: { type: "boolean", short: "a", description: "Include LLM API usage for current month" }, notes: { type: "boolean", short: "n", description: "Show notes column" }, method: { type: "boolean", short: "m", description: "Show payment method column" }, + contract: { type: "boolean", description: "Show contract dates column" }, + vendor: { type: "boolean", description: "Show vendor column" }, + status: { type: "string", description: "Filter by status: active, paused, cancelled, archived" }, + "min-price": { type: "string", description: "Filter by minimum price" }, + "max-price": { type: "string", description: "Filter by maximum price" }, json: { type: "boolean", short: "j", description: "Output as JSON" }, tags: { type: "string", description: "Comma-separated tag names to filter by (AND logic)" }, limit: { type: "string", description: "Max number of items to show" }, @@ -41,7 +46,35 @@ export const listCommand = define({ fail("offset must be a non-negative integer") return } - handleList({ ...ctx.values, limit, offset, includeArchived: ctx.values["include-archived"] }) + const status = ctx.values.status + if (status !== undefined && !["active", "paused", "cancelled", "archived"].includes(status)) { + fail("status must be one of: active, paused, cancelled, archived") + return + } + const minPrice = ctx.values["min-price"] !== undefined ? Number(ctx.values["min-price"]) : undefined + if (minPrice !== undefined && (isNaN(minPrice) || minPrice < 0)) { + fail("min-price must be a non-negative number") + return + } + const maxPrice = ctx.values["max-price"] !== undefined ? Number(ctx.values["max-price"]) : undefined + if (maxPrice !== undefined && (isNaN(maxPrice) || maxPrice < 0)) { + fail("max-price must be a non-negative number") + return + } + if (minPrice !== undefined && maxPrice !== undefined && minPrice > maxPrice) { + fail("min-price cannot be greater than max-price") + return + } + handleList({ + ...ctx.values, + limit, + offset, + includeArchived: ctx.values["include-archived"], + showContract: ctx.values.contract, + showVendor: ctx.values.vendor, + minPrice, + maxPrice, + }) }, }) diff --git a/apps/subtrack/src/commands/report.ts b/apps/subtrack/src/commands/report.ts index ca51129..2951abb 100644 --- a/apps/subtrack/src/commands/report.ts +++ b/apps/subtrack/src/commands/report.ts @@ -51,6 +51,7 @@ export const upcomingCommand = define({ description: "Show upcoming bills within a number of days", args: { days: { type: "positional", description: "Number of days (default: 7)", required: false }, + currency: { type: "string", short: "c", description: "Convert all prices to target currency" }, json: { type: "boolean", short: "j", description: "Output as JSON" }, }, run: (ctx) => { @@ -59,14 +60,26 @@ export const upcomingCommand = define({ fail("days must be a non-negative integer") return } - handleUpcoming(days, { json: ctx.values.json }) + handleUpcoming(days, { json: ctx.values.json, currency: ctx.values.currency }) }, }) export const analyticsCommand = define({ name: "analytics", description: "Show detailed subscription analytics", - run: () => handleAnalytics(), + args: { + currency: { type: "string", short: "c", description: "Convert all prices to target currency" }, + period: { type: "string", description: "Period: monthly, yearly (default: monthly)" }, + json: { type: "boolean", short: "j", description: "Output as JSON" }, + }, + run: (ctx) => { + const period = ctx.values.period as "monthly" | "yearly" | undefined + if (period !== undefined && period !== "monthly" && period !== "yearly") { + fail("period must be one of: monthly, yearly") + return + } + handleAnalytics({ currency: ctx.values.currency, period, json: ctx.values.json }) + }, }) export const compareCommand = define({ @@ -89,6 +102,7 @@ export const calendarCommand = define({ args: { month: { type: "string", description: "Month (1-12, default: current)" }, year: { type: "string", description: "Year (default: current)" }, + currency: { type: "string", short: "c", description: "Convert all prices to target currency" }, json: { type: "boolean", short: "j", description: "Output as JSON" }, }, run: (ctx) => { @@ -102,7 +116,7 @@ export const calendarCommand = define({ fail("year must be a positive integer") return } - handleCalendar({ month: rawMonth, year: rawYear, json: ctx.values.json }) + handleCalendar({ month: rawMonth, year: rawYear, json: ctx.values.json, currency: ctx.values.currency }) }, }) @@ -117,16 +131,25 @@ export const forecastCommand = define({ addCurrency: { type: "string", description: "Hypothetical subscription currency" }, addCycle: { type: "string", description: "Hypothetical subscription cycle" }, currency: { type: "string", short: "c", description: "Convert all prices to target currency" }, + json: { type: "boolean", short: "j", description: "Output as JSON" }, + }, + run: (ctx) => { + const rawMonths = ctx.values.months !== undefined ? Number(ctx.values.months) : undefined + if (rawMonths !== undefined && (isNaN(rawMonths) || rawMonths < 1 || !Number.isInteger(rawMonths))) { + fail("months must be a positive integer") + return + } + return handleForecast({ + months: rawMonths, + cancel: ctx.values.cancel?.split(",").map((s: string) => s.trim()).filter(Boolean), + addName: ctx.values.addName, + addPrice: ctx.values.addPrice, + addCurrency: ctx.values.addCurrency, + addCycle: ctx.values.addCycle, + currency: ctx.values.currency, + json: ctx.values.json, + }) }, - run: (ctx) => handleForecast({ - months: ctx.values.months ? Number(ctx.values.months) : undefined, - cancel: ctx.values.cancel?.split(",").map((s: string) => s.trim()).filter(Boolean), - addName: ctx.values.addName, - addPrice: ctx.values.addPrice, - addCurrency: ctx.values.addCurrency, - addCycle: ctx.values.addCycle, - currency: ctx.values.currency, - }), }) export const historyCommand = define({ diff --git a/apps/subtrack/src/db/subscriptions.ts b/apps/subtrack/src/db/subscriptions.ts index 938fecd..4915c1e 100644 --- a/apps/subtrack/src/db/subscriptions.ts +++ b/apps/subtrack/src/db/subscriptions.ts @@ -48,7 +48,16 @@ export function mapTags(subs: SharedArgs[]): SharedArgs[] { } export const getSubscriptions = ( - options?: { sort?: string; desc?: boolean; limit?: number; offset?: number; includeArchived?: boolean }, + options?: { + sort?: string + desc?: boolean + limit?: number + offset?: number + includeArchived?: boolean + status?: string + minPrice?: number + maxPrice?: number + }, ): SharedArgs[] => { const db = getDb() const field = options?.sort && (SORT_FIELDS as readonly string[]).includes(options.sort) ? options.sort : "id" @@ -58,12 +67,30 @@ export const getSubscriptions = ( if (!options?.includeArchived) { conditions.push("status != 'archived'") } + if (options?.status) { + conditions.push("status = ?") + } + if (options?.minPrice !== undefined) { + conditions.push("price >= ?") + } + if (options?.maxPrice !== undefined) { + conditions.push("price <= ?") + } const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "" let limitClause = "" let offsetClause = "" const params: SqlValue[] = [] + if (options?.status) { + params.push(options.status) + } + if (options?.minPrice !== undefined) { + params.push(options.minPrice) + } + if (options?.maxPrice !== undefined) { + params.push(options.maxPrice) + } if (options?.limit) { limitClause = " LIMIT ?" params.push(options.limit) diff --git a/apps/subtrack/src/forecast.ts b/apps/subtrack/src/forecast.ts index 38b367f..5f5ad6b 100644 --- a/apps/subtrack/src/forecast.ts +++ b/apps/subtrack/src/forecast.ts @@ -39,6 +39,7 @@ export async function handleForecast( ): Promise { // Interactive mode when no options given const interactive = + !options.json && options.months === undefined && !options.cancel && !options.addName diff --git a/apps/subtrack/src/subscription/core.ts b/apps/subtrack/src/subscription/core.ts index 140096d..6dcde2d 100644 --- a/apps/subtrack/src/subscription/core.ts +++ b/apps/subtrack/src/subscription/core.ts @@ -22,7 +22,24 @@ import { formatPrice } from "../price.ts" import { spreadSubscription, showApiUsage } from "../display.ts" import { logAudit } from "../audit.ts" -export async function handleList(options: { currency?: string; sort?: string; desc?: boolean; api?: boolean; notes?: boolean; method?: boolean; tags?: string; json?: boolean; limit?: number; offset?: number; includeArchived?: boolean }) { +export async function handleList(options: { + currency?: string + sort?: string + desc?: boolean + api?: boolean + notes?: boolean + method?: boolean + tags?: string + json?: boolean + limit?: number + offset?: number + includeArchived?: boolean + showContract?: boolean + showVendor?: boolean + status?: string + minPrice?: number + maxPrice?: number +}) { // Auto-scan for new suggestions (non-blocking on failure) if (!options.json) { const { autoScan } = await import("../suggest/scan.ts") @@ -33,13 +50,22 @@ export async function handleList(options: { currency?: string; sort?: string; de const list = options.tags ? tagsSubscription(options.tags.split(",").map((t) => t.trim())) - : getSubscriptions({ sort: options.sort, desc: options.desc, limit: options.limit, offset: options.offset, includeArchived: options.includeArchived }) + : getSubscriptions({ + sort: options.sort, + desc: options.desc, + limit: options.limit, + offset: options.offset, + includeArchived: options.includeArchived, + status: options.status, + minPrice: options.minPrice, + maxPrice: options.maxPrice, + }) if (options.json) { process.stdout.write(JSON.stringify(list, null, 2) + "\n") return } - await spreadSubscription(list, options.currency as Currency | undefined, options.notes, options.method) + await spreadSubscription(list, options.currency as Currency | undefined, options.notes, options.method, options.showContract, options.showVendor) if (options.api) { const now = new Date() From f2ba913b80de8ca5cea39906d0b6a4826fbabc4f Mon Sep 17 00:00:00 2001 From: nazozokc Date: Sun, 16 Aug 2026 21:13:00 +0900 Subject: [PATCH 08/19] feat: enhance usage commands with edit, token totals, and paging --- apps/subtrack/src/__tests__/commands.test.ts | 206 +++++++++++++++++++ apps/subtrack/src/__tests__/db.test.ts | 57 +++++ apps/subtrack/src/commands/usage.ts | 51 ++++- apps/subtrack/src/db.ts | 3 +- apps/subtrack/src/db/audit.ts | 1 + apps/subtrack/src/db/usage.ts | 63 ++++++ apps/subtrack/src/usage-edit.ts | 105 ++++++++++ apps/subtrack/src/usage-total.ts | 29 ++- apps/subtrack/src/usage.ts | 6 +- 9 files changed, 512 insertions(+), 9 deletions(-) create mode 100644 apps/subtrack/src/usage-edit.ts diff --git a/apps/subtrack/src/__tests__/commands.test.ts b/apps/subtrack/src/__tests__/commands.test.ts index 50745d8..e17a745 100644 --- a/apps/subtrack/src/__tests__/commands.test.ts +++ b/apps/subtrack/src/__tests__/commands.test.ts @@ -1153,6 +1153,212 @@ test("handleUsageList displays entries", async () => { expect(combined).toContain("Total") }) +test("handleUsageList respects limit", async () => { + const db = await import("../db.ts") + for (let i = 0; i < 5; i++) { + db.addLlmUsage({ + provider: "openai", + model: `model-${i}`, + input_tokens: 10, + output_tokens: 5, + cost: 0.01, + date: `2026-06-0${i + 1}`, + description: null, + }) + } + + const { handleUsageList } = await import("../usage.ts") + await handleUsageList({ limit: 3 }) + const combined = logMessages.join("\n") + expect(combined).toContain("model-4") // newest first + expect(combined).not.toContain("model-0") +}) + +test("handleUsageList respects offset", async () => { + const db = await import("../db.ts") + for (let i = 0; i < 5; i++) { + db.addLlmUsage({ + provider: "openai", + model: `model-${i}`, + input_tokens: 10, + output_tokens: 5, + cost: 0.01, + date: `2026-06-0${i + 1}`, + description: null, + }) + } + + const { handleUsageList } = await import("../usage.ts") + await handleUsageList({ offset: 2 }) + const combined = logMessages.join("\n") + expect(combined).not.toContain("model-4") + expect(combined).toContain("model-2") +}) + +test("handleUsageList with json includes entries", async () => { + const db = await import("../db.ts") + db.addLlmUsage({ + provider: "openai", + model: "gpt-4o", + input_tokens: 100, + output_tokens: 50, + cost: 0.5, + date: "2026-06-19", + description: "json test", + }) + + const writes: string[] = [] + const origWrite = process.stdout.write.bind(process.stdout) + process.stdout.write = ((chunk: string) => { + writes.push(String(chunk)) + return true + }) as typeof process.stdout.write + try { + const { handleUsageList } = await import("../usage.ts") + await handleUsageList({ json: true }) + } finally { + process.stdout.write = origWrite + } + + const parsed = JSON.parse(writes.join("")) + expect(parsed).toHaveLength(1) + expect(parsed[0].model).toBe("gpt-4o") +}) + +// ── handleUsageEdit ───────────────────────────────────── + +test("handleUsageEdit updates fields", async () => { + const db = await import("../db.ts") + db.addLlmUsage({ + provider: "openai", + model: "gpt-4o", + input_tokens: 100, + output_tokens: 50, + cost: 0.5, + date: "2026-06-19", + description: "before", + }) + const id = db.getLlmUsage()[0].id + + const { handleUsageEdit } = await import("../usage.ts") + await handleUsageEdit(id, { cost: "1.25", description: "after edit" }) + + const entries = db.getLlmUsage() + expect(entries).toHaveLength(1) + expect(entries[0].cost).toBe(125) + expect(entries[0].description).toBe("after edit") + expect(entries[0].model).toBe("gpt-4o") // untouched + expect(successMessages.some((m) => m.includes(String(id)))).toBe(true) +}) + +test("handleUsageEdit updates tokens and date", async () => { + const db = await import("../db.ts") + db.addLlmUsage({ + provider: "openai", + model: "gpt-4o", + input_tokens: 100, + output_tokens: 50, + cost: 0.5, + date: "2026-06-19", + description: null, + }) + const id = db.getLlmUsage()[0].id + + const { handleUsageEdit } = await import("../usage.ts") + await handleUsageEdit(id, { inputTokens: "500", outputTokens: "250", date: "2026-07-01" }) + + const entries = db.getLlmUsage() + expect(entries[0].input_tokens).toBe(500) + expect(entries[0].output_tokens).toBe(250) + expect(entries[0].date).toBe("2026-07-01") +}) + +test("handleUsageEdit with non-existent id shows error", async () => { + const { handleUsageEdit } = await import("../usage.ts") + await handleUsageEdit(999, { cost: "1.00" }) + expect(errorMessages.some((m) => m.includes("not found"))).toBe(true) +}) + +test("handleUsageEdit with no fields shows error", async () => { + const { handleUsageEdit } = await import("../usage.ts") + await handleUsageEdit(1, {}) + expect(errorMessages.some((m) => m.includes("No fields to update"))).toBe(true) +}) + +test("handleUsageEdit with invalid cost shows error", async () => { + const { handleUsageEdit } = await import("../usage.ts") + await handleUsageEdit(1, { cost: "abc" }) + expect(errorMessages.some((m) => m.toLowerCase().includes("invalid cost"))).toBe(true) +}) + +test("handleUsageEdit with invalid tokens shows error", async () => { + const { handleUsageEdit } = await import("../usage.ts") + await handleUsageEdit(1, { inputTokens: "abc" }) + expect(errorMessages.some((m) => m.toLowerCase().includes("invalid input tokens"))).toBe(true) +}) + +test("handleUsageEdit with invalid provider shows error", async () => { + const { handleUsageEdit } = await import("../usage.ts") + await handleUsageEdit(1, { provider: "nonexistent" }) + expect(errorMessages.some((m) => m.toLowerCase().includes("invalid provider"))).toBe(true) +}) + +test("handleUsageEdit with invalid id shows error", async () => { + const { handleUsageEdit } = await import("../usage.ts") + await handleUsageEdit(0, { cost: "1.00" }) + expect(errorMessages.some((m) => m.includes("positive integer"))).toBe(true) +}) + +// ── handleUsageTotal ───────────────────────────────────── + +test("handleUsageTotal json includes tokens and byModel", async () => { + const db = await import("../db.ts") + db.addLlmUsage({ provider: "openai", model: "gpt-4o", input_tokens: 100, output_tokens: 50, cost: 1.0, date: "2026-06-01", description: null }) + db.addLlmUsage({ provider: "openai", model: "gpt-4o", input_tokens: 200, output_tokens: 100, cost: 2.0, date: "2026-06-15", description: null }) + db.addLlmUsage({ provider: "anthropic", model: "claude-3", input_tokens: 300, output_tokens: 150, cost: 3.0, date: "2026-06-10", description: null }) + + const writes: string[] = [] + const origWrite = process.stdout.write.bind(process.stdout) + process.stdout.write = ((chunk: string) => { + writes.push(String(chunk)) + return true + }) as typeof process.stdout.write + try { + const { handleUsageTotal } = await import("../usage-total.ts") + handleUsageTotal({ from: "2026-06-01", to: "2026-06-30", json: true }) + } finally { + process.stdout.write = origWrite + } + + const out = JSON.parse(writes.join("")) + expect(out.total).toBe(6.0) + expect(out.tokens).toEqual({ inputTokens: 600, outputTokens: 300 }) + expect(out.byProvider).toHaveLength(2) + expect(out.byModel).toHaveLength(2) + const gpt4o = out.byModel.find((m: { model: string }) => m.model === "gpt-4o") + expect(gpt4o).toMatchObject({ total: 3.0, inputTokens: 300, outputTokens: 150 }) +}) + +test("handleUsageTotal display shows tokens and by model", async () => { + const db = await import("../db.ts") + db.addLlmUsage({ provider: "openai", model: "gpt-4o", input_tokens: 100, output_tokens: 50, cost: 1.0, date: "2026-06-01", description: null }) + + const { handleUsageTotal } = await import("../usage-total.ts") + handleUsageTotal({ from: "2026-06-01", to: "2026-06-30" }) + const combined = logMessages.join("\n") + expect(combined).toContain("By provider") + expect(combined).toContain("By model") + expect(combined).toContain("gpt-4o") + expect(combined).toContain("Tokens: 100 in / 50 out") + expect(combined).toContain("Total: $0.01") // cost 1.0 = 1 cent +}) + +test("handleUsageTotal shows info when no usage in range", async () => { + const { handleUsageTotal } = await import("../usage-total.ts") + handleUsageTotal({ from: "2020-01-01", to: "2020-01-31" }) + expect(infoMessages.some((m) => m.includes("No API usage found"))).toBe(true) +}) + // ── handleUsageDelete ───────────────────────────────────── test("handleUsageDelete deletes by ID (non-interactive)", async () => { diff --git a/apps/subtrack/src/__tests__/db.test.ts b/apps/subtrack/src/__tests__/db.test.ts index 04b85b8..9d1c2b6 100644 --- a/apps/subtrack/src/__tests__/db.test.ts +++ b/apps/subtrack/src/__tests__/db.test.ts @@ -987,6 +987,63 @@ test("getLlmUsageTotalByProvider groups cost by provider", async () => { expect(anthropic?.total).toBe(3.0) }) +test("getLlmUsageTokenTotal sums tokens in date range", async () => { + const db = await import("../db.ts") + db.addLlmUsage({ provider: "openai", model: "gpt-4o", input_tokens: 100, output_tokens: 50, cost: 1.0, date: "2026-06-01", description: null }) + db.addLlmUsage({ provider: "openai", model: "gpt-4o-mini", input_tokens: 200, output_tokens: 100, cost: 2.0, date: "2026-06-15", description: null }) + db.addLlmUsage({ provider: "anthropic", model: "claude-3", input_tokens: 300, output_tokens: 150, cost: 3.0, date: "2026-07-01", description: null }) + + const tokens = db.getLlmUsageTokenTotal("2026-06-01", "2026-06-30") + expect(tokens).toEqual({ inputTokens: 300, outputTokens: 150 }) // 100+200 / 50+100 +}) + +test("getLlmUsageTokenTotal returns zeros for empty range", async () => { + const db = await import("../db.ts") + const tokens = db.getLlmUsageTokenTotal("2020-01-01", "2020-01-31") + expect(tokens).toEqual({ inputTokens: 0, outputTokens: 0 }) +}) + +test("getLlmUsageTotalByModel groups cost and tokens by model", async () => { + const db = await import("../db.ts") + db.addLlmUsage({ provider: "openai", model: "gpt-4o", input_tokens: 100, output_tokens: 50, cost: 1.0, date: "2026-06-01", description: null }) + db.addLlmUsage({ provider: "openai", model: "gpt-4o", input_tokens: 200, output_tokens: 100, cost: 2.0, date: "2026-06-15", description: null }) + db.addLlmUsage({ provider: "anthropic", model: "claude-3", input_tokens: 300, output_tokens: 150, cost: 3.0, date: "2026-06-10", description: null }) + + const byModel = db.getLlmUsageTotalByModel("2026-06-01", "2026-06-30") + expect(byModel).toHaveLength(2) + const gpt4o = byModel.find((m) => m.model === "gpt-4o") + const claude3 = byModel.find((m) => m.model === "claude-3") + expect(gpt4o).toMatchObject({ provider: "openai", total: 3.0, inputTokens: 300, outputTokens: 150 }) + expect(claude3).toMatchObject({ provider: "anthropic", total: 3.0, inputTokens: 300, outputTokens: 150 }) +}) + +test("updateLlmUsage updates provided fields only", async () => { + const db = await import("../db.ts") + db.addLlmUsage({ provider: "openai", model: "gpt-4o", input_tokens: 100, output_tokens: 50, cost: 1.0, date: "2026-06-01", description: null }) + const id = db.getLlmUsage()[0].id + + const ok = db.updateLlmUsage(id, { cost: 2.5, description: "updated" }) + expect(ok).toBe(true) + + const entries = db.getLlmUsage() + expect(entries).toHaveLength(1) + expect(entries[0].cost).toBe(2.5) + expect(entries[0].description).toBe("updated") + expect(entries[0].provider).toBe("openai") // untouched + expect(entries[0].input_tokens).toBe(100) // untouched +}) + +test("updateLlmUsage returns false for non-existent id", async () => { + const db = await import("../db.ts") + expect(db.updateLlmUsage(99999, { cost: 1.0 })).toBe(false) +}) + +test("updateLlmUsage returns false with no fields", async () => { + const db = await import("../db.ts") + db.addLlmUsage({ provider: "openai", model: "gpt-4o", input_tokens: 100, output_tokens: 50, cost: 1.0, date: "2026-06-01", description: null }) + expect(db.updateLlmUsage(db.getLlmUsage()[0].id, {})).toBe(false) +}) + // ── Backup / Restore ───────────────────────────────────── test("getDefaultBackupDir returns path under getDbDir", async () => { diff --git a/apps/subtrack/src/commands/usage.ts b/apps/subtrack/src/commands/usage.ts index 3b310fc..04850f4 100644 --- a/apps/subtrack/src/commands/usage.ts +++ b/apps/subtrack/src/commands/usage.ts @@ -2,7 +2,7 @@ import { define } from "gunshi" import { consola } from "consola" import { handleUsageAdd } from "../usage-add.ts" -import { handleUsageList, handleUsageDelete } from "../usage.ts" +import { handleUsageList, handleUsageDelete, handleUsageEdit } from "../usage.ts" import { handleUsageImport } from "../usage-import.ts" import { handleUsageRefresh } from "../usage-refresh.ts" import { handleUsageTotal } from "../usage-total.ts" @@ -31,9 +31,53 @@ const usageListCommand = define({ provider: { type: "string", description: "Filter by provider" }, from: { type: "string", description: "Start date (YYYY-MM-DD)" }, to: { type: "string", description: "End date (YYYY-MM-DD)" }, + limit: { type: "string", description: "Max entries to show (default: 100)" }, + offset: { type: "string", description: "Skip the first N entries (for paging)" }, json: { type: "boolean", short: "j", description: "Output as JSON" }, }, - run: (ctx) => handleUsageList(ctx.values), + run: (ctx) => { + let limit: number | undefined + if (ctx.values.limit !== undefined) { + limit = Number(ctx.values.limit) + if (!Number.isInteger(limit) || limit < 1) { + consola.fail("Invalid --limit. Enter a positive integer (e.g. --limit 200)") + return + } + } + let offset: number | undefined + if (ctx.values.offset !== undefined) { + offset = Number(ctx.values.offset) + if (!Number.isInteger(offset) || offset < 0) { + consola.fail("Invalid --offset. Enter a non-negative integer (e.g. --offset 100)") + return + } + } + handleUsageList({ ...ctx.values, limit, offset }) + }, +}) + +const usageEditCommand = define({ + name: "edit", + description: "Update fields of an LLM API usage entry", + toKebab: true, + args: { + id: { type: "positional", description: "Entry ID to edit" }, + provider: { type: "string", description: "Provider name (openai, anthropic, ...)" }, + model: { type: "string", description: "Model name (e.g. gpt-4o)" }, + inputTokens: { type: "string", description: "Input tokens used" }, + outputTokens: { type: "string", description: "Output tokens used" }, + date: { type: "string", description: "Date (YYYY-MM-DD)" }, + description: { type: "string", description: "Optional description" }, + cost: { type: "string", description: "Total cost in USD (e.g. 0.50 for 50 cents)" }, + }, + run: (ctx) => { + const id = Number(ctx.values.id) + if (isNaN(id)) { + consola.fail("Invalid id. Provide the usage entry ID (e.g. usage edit 5 --cost 0.50)") + return + } + handleUsageEdit(id, ctx.values) + }, }) const usageDeleteCommand = define({ @@ -91,10 +135,11 @@ export const usageCommand = define({ subCommands: { add: usageAddCommand, list: usageListCommand, + edit: usageEditCommand, delete: usageDeleteCommand, import: usageImportCommand, refresh: usageRefreshCommand, total: usageTotalCommand, }, - run: () => consola.info("Usage: subtrack usage add|list|delete|import|refresh|total"), + run: () => consola.info("Usage: subtrack usage add|list|edit|delete|import|refresh|total"), }) diff --git a/apps/subtrack/src/db.ts b/apps/subtrack/src/db.ts index 9c622c2..2b9ccdb 100644 --- a/apps/subtrack/src/db.ts +++ b/apps/subtrack/src/db.ts @@ -12,7 +12,8 @@ export { } from "./db/tags.ts" export { addLlmUsage, addLlmUsageFromLog, batchAddLlmUsageFromLog, - getLlmUsage, deleteLlmUsage, getLlmUsageTotal, getLlmUsageTotalByProvider, + getLlmUsage, deleteLlmUsage, updateLlmUsage, + getLlmUsageTotal, getLlmUsageTokenTotal, getLlmUsageTotalByProvider, getLlmUsageTotalByModel, } from "./db/usage.ts" export { writeTrial, getTrials, getTrial, deleteTrial, getTrialsExpiringSoon, diff --git a/apps/subtrack/src/db/audit.ts b/apps/subtrack/src/db/audit.ts index 1f651b2..e70f79b 100644 --- a/apps/subtrack/src/db/audit.ts +++ b/apps/subtrack/src/db/audit.ts @@ -31,6 +31,7 @@ export type AuditAction = | "config.reset" | "backup.restore" | "usage.add" + | "usage.edit" | "usage.delete" | "cleanup" diff --git a/apps/subtrack/src/db/usage.ts b/apps/subtrack/src/db/usage.ts index 6543196..9a7c82f 100644 --- a/apps/subtrack/src/db/usage.ts +++ b/apps/subtrack/src/db/usage.ts @@ -157,6 +157,35 @@ export const deleteLlmUsage = (id: number): boolean => { return modified } +/** Update fields of a usage entry. Returns false if the entry does not exist. */ +export const updateLlmUsage = (id: number, fields: Partial): boolean => { + const db = getDb() + const allowed: (keyof AddLlmUsageArgs)[] = [ + "provider", + "model", + "input_tokens", + "output_tokens", + "cost", + "date", + "description", + ] + const sets: string[] = [] + const params: SqlValue[] = [] + for (const key of allowed) { + if (fields[key] !== undefined) { + sets.push(`${key} = ?`) + params.push(fields[key] as SqlValue) + } + } + if (sets.length === 0) return false + + params.push(id) + db.run(`UPDATE llm_usage SET ${sets.join(", ")} WHERE id = ?`, params) + const modified = db.getRowsModified() > 0 + if (modified) saveDb() + return modified +} + /** Sum `cost` for all entries whose `date` falls within [from, to]. Returns USD cents. */ export const getLlmUsageTotal = (from: string, to: string): number => { const db = getDb() @@ -168,6 +197,22 @@ export const getLlmUsageTotal = (from: string, to: string): number => { return row?.total ?? 0 } +/** Sum input/output tokens for all entries within [from, to]. */ +export const getLlmUsageTokenTotal = ( + from: string, + to: string, +): { inputTokens: number; outputTokens: number } => { + const db = getDb() + const row = execObj<{ inputTokens: number; outputTokens: number }>( + db, + `SELECT COALESCE(SUM(input_tokens), 0) AS inputTokens, + COALESCE(SUM(output_tokens), 0) AS outputTokens + FROM llm_usage WHERE date >= ? AND date <= ?`, + [from, to], + ) + return { inputTokens: row?.inputTokens ?? 0, outputTokens: row?.outputTokens ?? 0 } +} + /** Get the sum of `cost` grouped by provider for a date range. */ export const getLlmUsageTotalByProvider = ( from: string, @@ -184,3 +229,21 @@ export const getLlmUsageTotalByProvider = ( [from, to], ) } + +/** Get cost + token totals grouped by model for a date range. */ +export const getLlmUsageTotalByModel = ( + from: string, + to: string, +): { model: string; provider: string; total: number; inputTokens: number; outputTokens: number }[] => { + const db = getDb() + return execObjs<{ model: string; provider: string; total: number; inputTokens: number; outputTokens: number }>( + db, + `SELECT model, MAX(provider) AS provider, SUM(cost) AS total, + SUM(input_tokens) AS inputTokens, SUM(output_tokens) AS outputTokens + FROM llm_usage + WHERE date >= ? AND date <= ? + GROUP BY model + ORDER BY total DESC`, + [from, to], + ) +} diff --git a/apps/subtrack/src/usage-edit.ts b/apps/subtrack/src/usage-edit.ts new file mode 100644 index 0000000..7d8224c --- /dev/null +++ b/apps/subtrack/src/usage-edit.ts @@ -0,0 +1,105 @@ +/** + * Usage edit command — update fields of an existing LLM API usage entry. + * Flag-based only: each provided flag updates the corresponding field. + */ + +import { consola } from "consola" +import { fail } from "./error.ts" +import type { UsageAddFlags, AddLlmUsageArgs } from "./types.ts" +import { updateLlmUsage } from "./db.ts" +import { logAudit } from "./audit.ts" +import { + LLM_PROVIDER_CHOICES, + validateTokens, + validateDate, + validateModelName, +} from "./prompts.ts" + +export async function handleUsageEdit(id: number, flags: UsageAddFlags): Promise { + if (!Number.isInteger(id) || id < 1) { + fail("id must be a positive integer") + return + } + + const fields: Partial = {} + + if (flags.provider !== undefined) { + if (!LLM_PROVIDER_CHOICES.some((c) => c.value === flags.provider)) { + fail( + `Invalid provider "${flags.provider}". Use one of: openai, anthropic, google-ai, mistral, groq, together, deepseek, cohere, or a custom name.`, + ) + return + } + fields.provider = flags.provider + } + + if (flags.model !== undefined) { + const err = validateModelName(flags.model) + if (err !== true) { + fail(`Invalid model: ${err}`) + return + } + fields.model = flags.model + } + + if (flags.inputTokens !== undefined) { + const err = validateTokens(flags.inputTokens) + if (err !== true) { + fail(`Invalid input tokens: ${err}`) + return + } + fields.input_tokens = Number(flags.inputTokens) + } + + if (flags.outputTokens !== undefined) { + const err = validateTokens(flags.outputTokens) + if (err !== true) { + fail(`Invalid output tokens: ${err}`) + return + } + fields.output_tokens = Number(flags.outputTokens) + } + + if (flags.date !== undefined) { + const err = validateDate(flags.date) + if (err !== true) { + fail(`Invalid date: ${err}`) + return + } + fields.date = flags.date + } + + if (flags.cost !== undefined) { + const costNum = Number(flags.cost) + if (isNaN(costNum) || costNum < 0) { + fail("Invalid cost. Enter a non-negative number (e.g. 0.50 for 50 cents)") + return + } + fields.cost = Math.round(costNum * 100) + } + + if (flags.description !== undefined) { + const trimmed = flags.description.trim() + fields.description = trimmed || null + } + + if (Object.keys(fields).length === 0) { + fail( + "No fields to update. Provide at least one flag (e.g. usage edit 5 --cost 0.50 --description 'refined prompt')", + ) + return + } + + const ok = updateLlmUsage(id, fields) + if (!ok) { + fail(`Usage entry with id ${id} not found`) + return + } + + logAudit("usage.edit", { + targetType: "usage", + targetId: id, + details: Object.keys(fields).join(", "), + }) + consola.success(`Updated usage entry: ${id}`) +} \ No newline at end of file diff --git a/apps/subtrack/src/usage-total.ts b/apps/subtrack/src/usage-total.ts index 3300f0e..85c7867 100644 --- a/apps/subtrack/src/usage-total.ts +++ b/apps/subtrack/src/usage-total.ts @@ -3,7 +3,13 @@ */ import { consola } from "consola" -import { getLlmUsageTotal, getLlmUsageTotalByProvider } from "./db.ts" +import pc from "picocolors" +import { + getLlmUsageTotal, + getLlmUsageTokenTotal, + getLlmUsageTotalByProvider, + getLlmUsageTotalByModel, +} from "./db.ts" import { getPeriodDateRange } from "./date-utils.ts" import type { Cycle } from "./types.ts" @@ -29,13 +35,17 @@ export function handleUsageTotal(options: UsageTotalOptions = {}): void { const total = getLlmUsageTotal(from, to) const byProvider = getLlmUsageTotalByProvider(from, to) + const byModel = getLlmUsageTotalByModel(from, to) + const tokens = getLlmUsageTokenTotal(from, to) if (options.json) { process.stdout.write(JSON.stringify({ from, to, total, + tokens, byProvider, + byModel, }, null, 2) + "\n") return } @@ -46,9 +56,22 @@ export function handleUsageTotal(options: UsageTotalOptions = {}): void { } consola.log(`── LLM API Usage (${from} → ${to}) ──`) + consola.log(pc.bold(" By provider:")) for (const p of byProvider) { - consola.log(` ${p.provider}: $${(p.total / 100).toFixed(2)}`) + consola.log(` ${p.provider}: $${(p.total / 100).toFixed(2)}`) + } + if (byModel.length > 0) { + consola.log(pc.bold(" By model:")) + for (const m of byModel) { + consola.log( + ` ${m.model}: $${(m.total / 100).toFixed(2)} ` + + `(${m.inputTokens.toLocaleString()} in / ${m.outputTokens.toLocaleString()} out)`, + ) + } } consola.log(` ${"─".repeat(20)}`) + consola.log( + ` Tokens: ${tokens.inputTokens.toLocaleString()} in / ${tokens.outputTokens.toLocaleString()} out`, + ) consola.log(` Total: $${(total / 100).toFixed(2)}`) -} +} \ No newline at end of file diff --git a/apps/subtrack/src/usage.ts b/apps/subtrack/src/usage.ts index c0c58eb..765291c 100644 --- a/apps/subtrack/src/usage.ts +++ b/apps/subtrack/src/usage.ts @@ -10,15 +10,17 @@ export { handleUsageAdd } from "./usage-add.ts" export { handleUsageImport } from "./usage-import.ts" export { handleUsageRefresh } from "./usage-refresh.ts" export { handleUsageTotal } from "./usage-total.ts" +export { handleUsageEdit } from "./usage-edit.ts" export async function handleUsageList( - options: { provider?: string; from?: string; to?: string; json?: boolean }, + options: { provider?: string; from?: string; to?: string; json?: boolean; limit?: number; offset?: number }, ) { const entries = getLlmUsage({ provider: options.provider, from: options.from, to: options.to, - limit: 100, + limit: options.limit ?? 100, + offset: options.offset, minCost: 0, }) From ce1dafa142b754605e810bed8dbef2b02cc30f31 Mon Sep 17 00:00:00 2001 From: nazozokc Date: Sun, 16 Aug 2026 21:13:11 +0900 Subject: [PATCH 09/19] feat: extend MCP server with tag and usage tools, enum validation, and paging --- apps/subtrack/src/__tests__/mcp.test.ts | 173 ++++++++++++++++++++++++ apps/subtrack/src/mcp/handlers.ts | 140 +++++++++++++++++-- apps/subtrack/src/mcp/security.ts | 16 +++ apps/subtrack/src/mcp/tools.ts | 42 ++++++ 4 files changed, 359 insertions(+), 12 deletions(-) diff --git a/apps/subtrack/src/__tests__/mcp.test.ts b/apps/subtrack/src/__tests__/mcp.test.ts index ef1eb40..421cf40 100644 --- a/apps/subtrack/src/__tests__/mcp.test.ts +++ b/apps/subtrack/src/__tests__/mcp.test.ts @@ -220,3 +220,176 @@ describe("MCP input validation", () => { } }) }) + +describe("MCP handlers", () => { + test("handleAddSubscription validates cycle and status enums", async () => { + const { handleAddSubscription } = await import("../mcp/handlers.ts") + const badCycle = await handleAddSubscription({ + name: "X", price: 100, currency: "USD", cycle: "fortnightly", + }) + expect(badCycle.isError).toBe(true) + expect(JSON.stringify(badCycle)).toMatch(/Invalid cycle/) + + const badStatus = await handleAddSubscription({ + name: "X", price: 100, currency: "USD", cycle: "monthly", status: "deleted", + }) + expect(badStatus.isError).toBe(true) + expect(JSON.stringify(badStatus)).toMatch(/Invalid status/) + + const badCurrency = await handleAddSubscription({ + name: "X", price: 100, currency: "XX", cycle: "monthly", + }) + expect(badCurrency.isError).toBe(true) + expect(JSON.stringify(badCurrency)).toMatch(/Invalid currency/) + + const ok = await handleAddSubscription({ + name: "Valid", price: 100, currency: "USD", cycle: "monthly", status: "paused", + }) + expect(ok.isError).toBeUndefined() + }) + + test("handleEditSubscription validates enums", async () => { + testDb.run( + `INSERT INTO subscriptions (id, name, price, currency, cycle, status, billing_day, created_at) + VALUES (1, 'Netflix', 1990, 'JPY', 'monthly', 'active', 15, '2026-01-01')`, + ) + const { handleEditSubscription } = await import("../mcp/handlers.ts") + const bad = await handleEditSubscription({ id: 1, cycle: "fortnightly" }) + expect(bad.isError).toBe(true) + expect(JSON.stringify(bad)).toMatch(/Invalid cycle/) + const badStatus = await handleEditSubscription({ id: 1, status: "deleted" }) + expect(badStatus.isError).toBe(true) + }) + + test("handleGetAnalytics includes statusBreakdown distinct from summary", async () => { + testDb.run( + `INSERT INTO subscriptions (id, name, price, currency, cycle, status, billing_day, created_at) + VALUES (1, 'Netflix', 1990, 'JPY', 'monthly', 'active', 15, '2026-01-01'), + (2, 'Spotify', 980, 'JPY', 'monthly', 'paused', 1, '2026-01-10'), + (3, 'Old', 500, 'JPY', 'monthly', 'cancelled', 5, '2026-03-01'), + (4, 'Legacy', 300, 'JPY', 'monthly', 'archived', 5, '2026-03-01')`, + ) + const { handleGetAnalytics } = await import("../mcp/handlers.ts") + const res = await handleGetAnalytics({}) + const data = JSON.parse(res.content[0].text) + expect(data.statusBreakdown).toEqual({ active: 1, paused: 1, cancelled: 1, archived: 1 }) + expect(data.totalCount).toBe(2) // cancelled excluded from summary + }) + + test("handleListSubscriptions supports limit and offset", async () => { + testDb.run( + `INSERT INTO subscriptions (id, name, price, currency, cycle, status, billing_day, created_at) + VALUES (1, 'A', 100, 'USD', 'monthly', 'active', 1, '2026-01-01'), + (2, 'B', 200, 'USD', 'monthly', 'active', 1, '2026-01-01'), + (3, 'C', 300, 'USD', 'monthly', 'active', 1, '2026-01-01')`, + ) + const { handleListSubscriptions } = await import("../mcp/handlers.ts") + const res = await handleListSubscriptions({ limit: 2 }) + const data = JSON.parse(res.content[0].text) + expect(data).toHaveLength(2) + const res2 = await handleListSubscriptions({ limit: 2, offset: 2 }) + const data2 = JSON.parse(res2.content[0].text) + expect(data2).toHaveLength(1) + expect(data2[0].name).toBe("C") + }) + + test("handleListTags returns tags with counts", async () => { + testDb.run( + `INSERT INTO subscriptions (id, name, price, currency, cycle, status, billing_day, created_at) + VALUES (1, 'Netflix', 1990, 'JPY', 'monthly', 'active', 15, '2026-01-01'), + (2, 'Spotify', 980, 'JPY', 'monthly', 'active', 1, '2026-01-10')`, + ) + testDb.run(`INSERT INTO tags (id, name) VALUES (1, 'video'), (2, 'music'), (3, 'work')`) + testDb.run(`INSERT INTO subscription_tags (subscription_id, tag_id) VALUES (1, 1), (2, 2), (1, 3)`) + + const { handleListTags } = await import("../mcp/handlers.ts") + const res = await handleListTags({}) + const data = JSON.parse(res.content[0].text) + expect(data).toEqual([ + { name: "music", count: 1 }, + { name: "video", count: 1 }, + { name: "work", count: 1 }, + ]) + }) + + test("handleGetTagSubscriptions filters by tags", async () => { + testDb.run( + `INSERT INTO subscriptions (id, name, price, currency, cycle, status, billing_day, created_at) + VALUES (1, 'Netflix', 1990, 'JPY', 'monthly', 'active', 15, '2026-01-01'), + (2, 'Spotify', 980, 'JPY', 'monthly', 'active', 1, '2026-01-10')`, + ) + testDb.run(`INSERT INTO tags (id, name) VALUES (1, 'video'), (2, 'music')`) + testDb.run(`INSERT INTO subscription_tags (subscription_id, tag_id) VALUES (1, 1), (2, 2)`) + + const { handleGetTagSubscriptions } = await import("../mcp/handlers.ts") + const res = await handleGetTagSubscriptions({ tag: "video" }) + const data = JSON.parse(res.content[0].text) + expect(data).toHaveLength(1) + expect(data[0].name).toBe("Netflix") + + const noTag = await handleGetTagSubscriptions({}) + expect(noTag.isError).toBe(true) + }) + + test("handleGetUsageTotal aggregates tokens and models", async () => { + const db = await import("../db.ts") + testDb.run(`CREATE TABLE IF NOT EXISTS llm_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL, + model TEXT NOT NULL, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cost REAL NOT NULL DEFAULT 0, + date TEXT NOT NULL, + description TEXT, + generation_id TEXT + )`) + testDb.run("DELETE FROM llm_usage") + db.addLlmUsage({ provider: "openai", model: "gpt-4o", input_tokens: 100, output_tokens: 50, cost: 1.0, date: "2026-08-01", description: null }) + db.addLlmUsage({ provider: "openai", model: "gpt-4o", input_tokens: 200, output_tokens: 100, cost: 2.0, date: "2026-08-02", description: null }) + + const { handleGetUsageTotal } = await import("../mcp/handlers.ts") + const res = await handleGetUsageTotal({ from: "2026-08-01", to: "2026-08-31" }) + const data = JSON.parse(res.content[0].text) + expect(data.total).toBe(3.0) + expect(data.tokens).toEqual({ inputTokens: 300, outputTokens: 150 }) + expect(data.byModel).toHaveLength(1) + expect(data.byModel[0].model).toBe("gpt-4o") + }) + + test("handleListUsage lists entries with filters", async () => { + const db = await import("../db.ts") + testDb.run(`CREATE TABLE IF NOT EXISTS llm_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL, + model TEXT NOT NULL, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cost REAL NOT NULL DEFAULT 0, + date TEXT NOT NULL, + description TEXT, + generation_id TEXT + )`) + testDb.run("DELETE FROM llm_usage") + db.addLlmUsage({ provider: "openai", model: "gpt-4o", input_tokens: 100, output_tokens: 50, cost: 1.0, date: "2026-08-01", description: null }) + db.addLlmUsage({ provider: "anthropic", model: "claude-3", input_tokens: 100, output_tokens: 50, cost: 1.0, date: "2026-08-02", description: null }) + + const { handleListUsage } = await import("../mcp/handlers.ts") + const res = await handleListUsage({ provider: "openai" }) + const data = JSON.parse(res.content[0].text) + expect(data).toHaveLength(1) + expect(data[0].provider).toBe("openai") + }) + + test("handleBulkOperations reports errors instead of swallowing them", async () => { + testDb.run( + `INSERT INTO subscriptions (id, name, price, currency, cycle, status, billing_day, created_at) + VALUES (1, 'Netflix', 1990, 'JPY', 'monthly', 'active', 15, '2026-01-01'), + (2, 'Spotify', 980, 'JPY', 'monthly', 'active', 1, '2026-01-10')`, + ) + const { handleBulkOperations } = await import("../mcp/handlers.ts") + const res = await handleBulkOperations({ action: "status", status: "invalid-status" }) + expect(res.isError).toBe(true) + expect(JSON.stringify(res)).toMatch(/Invalid status/) + }) +}) diff --git a/apps/subtrack/src/mcp/handlers.ts b/apps/subtrack/src/mcp/handlers.ts index f526a3d..8bb6185 100644 --- a/apps/subtrack/src/mcp/handlers.ts +++ b/apps/subtrack/src/mcp/handlers.ts @@ -16,6 +16,13 @@ import { getAllPriceChanges, getTrials, getTrialsExpiringSoon, + getTagsWithCount, + tagsSubscription, + getLlmUsage, + getLlmUsageTotal, + getLlmUsageTokenTotal, + getLlmUsageTotalByProvider, + getLlmUsageTotalByModel, } from "../db.ts" import { calcSummary, calcSubTotal, calcPreviousTotals } from "../payment.ts" import { getPeriodDateRange, getPreviousPeriodDateRange, periodFactor } from "../date-utils.ts" @@ -24,11 +31,14 @@ import { exportCsv, exportJson, exportMd } from "../export.ts" import { fetchFxRates, convertPrice } from "../fx.ts" import { searchSubscriptions } from "../search.ts" import { calcUpcoming } from "../upcoming.ts" +import { isValidCycle, isValidStatus, isValidCurrency } from "../prompts.ts" export async function handleListSubscriptions(args?: Record): Promise { const subs = getSubscriptions({ sort: args?.sort as string | undefined, desc: args?.desc as boolean | undefined, + limit: args?.limit as number | undefined, + offset: args?.offset as number | undefined, }) return { content: [{ type: "text", text: JSON.stringify(subs) }] } } @@ -57,16 +67,28 @@ export async function handleAddSubscription(args?: Record): Pro if (!args?.name || args?.price === undefined || !args?.currency || !args?.cycle) { return { content: [{ type: "text", text: "name, price, currency, and cycle are required" }], isError: true } } + const currency = String(args.currency) + if (!isValidCurrency(currency)) { + return { content: [{ type: "text", text: `Invalid currency "${currency}". Use a supported 3-letter ISO code (e.g. USD, JPY)` }], isError: true } + } + const cycle = String(args.cycle) + if (!isValidCycle(cycle)) { + return { content: [{ type: "text", text: `Invalid cycle "${cycle}". Use: weekly, bi-weekly, monthly, quarterly, semi-annual, yearly` }], isError: true } + } + const status = (args.status as Status | undefined) ?? "active" + if (!isValidStatus(status)) { + return { content: [{ type: "text", text: `Invalid status "${status}". Use: active, paused, cancelled, archived` }], isError: true } + } const tags = args.tags ? String(args.tags).split(",").map((t: string) => t.trim()).filter(Boolean) : [] const addArgs: AddSharedArgs = { name: String(args.name), price: Number(args.price), - currency: String(args.currency), - cycle: String(args.cycle) as Cycle, + currency, + cycle: cycle as Cycle, tags, - status: (args.status as Status | undefined) ?? "active", + status, billingDay: args.billingDay !== undefined ? Number(args.billingDay) : null, paymentMethod: args.paymentMethod as string | undefined, notes: args.notes as string | undefined, @@ -84,7 +106,7 @@ export async function handleDeleteSubscription(args?: Record): } export async function handleGetSummary(_args?: Record): Promise { - const subs = getSubscriptions() + const subs = getSubscriptions().filter((s) => s.status !== "cancelled") const summary = calcSummary(subs) return { content: [{ type: "text", text: JSON.stringify(summary) }] } } @@ -130,6 +152,15 @@ export async function handleEditSubscription(args?: Record): Pr if (args?.id === undefined) { return { content: [{ type: "text", text: "id is required" }], isError: true } } + if (args.currency !== undefined && !isValidCurrency(String(args.currency))) { + return { content: [{ type: "text", text: `Invalid currency "${String(args.currency)}". Use a supported 3-letter ISO code (e.g. USD, JPY)` }], isError: true } + } + if (args.cycle !== undefined && !isValidCycle(String(args.cycle))) { + return { content: [{ type: "text", text: `Invalid cycle "${String(args.cycle)}". Use: weekly, bi-weekly, monthly, quarterly, semi-annual, yearly` }], isError: true } + } + if (args.status !== undefined && !isValidStatus(String(args.status))) { + return { content: [{ type: "text", text: `Invalid status "${String(args.status)}". Use: active, paused, cancelled, archived` }], isError: true } + } const editFields: Partial = {} if (args.name !== undefined) editFields.name = String(args.name) if (args.price !== undefined) editFields.price = Number(args.price) @@ -159,9 +190,24 @@ export async function handleGetHistory(args?: Record): Promise< } export async function handleGetAnalytics(_args?: Record): Promise { - const subs = getSubscriptions() - const summary = calcSummary(subs) - return { content: [{ type: "text", text: JSON.stringify(summary) }] } + const all = getSubscriptions({ includeArchived: true }) + const active = all.filter((s) => s.status !== "cancelled" && s.status !== "archived") + const summary = calcSummary(active) + const statusBreakdown = { + active: all.filter((s) => s.status === "active").length, + paused: all.filter((s) => s.status === "paused").length, + cancelled: all.filter((s) => s.status === "cancelled").length, + archived: all.filter((s) => s.status === "archived").length, + } + return { + content: [{ + type: "text", + text: JSON.stringify({ + ...summary, + statusBreakdown, + }), + }], + } } export async function handleGetForecast(args?: Record): Promise { @@ -297,18 +343,26 @@ export async function handleBulkOperations(args?: Record): Prom const affectedIds = affected.map((s) => s.id) let resultCount = 0 + const errors: string[] = [] + + const reportError = (id: number, error: unknown) => { + errors.push(`id ${id}: ${error instanceof Error ? error.message : String(error)}`) + } switch (action) { case "status": { const targetStatus = String(args?.status ?? "active") + if (!isValidStatus(targetStatus)) { + return { content: [{ type: "text", text: `Invalid status "${targetStatus}". Use: active, paused, cancelled, archived` }], isError: true } + } for (const id of affectedIds) { - try { updateSubscription(id, { status: targetStatus as Status }); resultCount++ } catch { /* skip */ } + try { updateSubscription(id, { status: targetStatus as Status }); resultCount++ } catch (error) { reportError(id, error) } } break } case "delete": { for (const id of affectedIds) { - try { deleteSubscription(id); resultCount++ } catch { /* skip */ } + try { deleteSubscription(id); resultCount++ } catch (error) { reportError(id, error) } } break } @@ -320,7 +374,7 @@ export async function handleBulkOperations(args?: Record): Prom for (const s of affected) { const currentTags = s.tags ?? [] if (!currentTags.includes(tagName)) { - try { updateSubscription(s.id, { tags: [...currentTags, tagName] }); resultCount++ } catch { /* skip */ } + try { updateSubscription(s.id, { tags: [...currentTags, tagName] }); resultCount++ } catch (error) { reportError(s.id, error) } } } break @@ -333,7 +387,7 @@ export async function handleBulkOperations(args?: Record): Prom for (const s of affected) { const currentTags = s.tags ?? [] if (currentTags.includes(tagName)) { - try { updateSubscription(s.id, { tags: currentTags.filter((t) => t !== tagName) }); resultCount++ } catch { /* skip */ } + try { updateSubscription(s.id, { tags: currentTags.filter((t) => t !== tagName) }); resultCount++ } catch (error) { reportError(s.id, error) } } } break @@ -345,7 +399,14 @@ export async function handleBulkOperations(args?: Record): Prom return { content: [{ type: "text", - text: JSON.stringify({ action, filters, matchedCount: affected.length, affectedCount: resultCount, affectedIds }), + text: JSON.stringify({ + action, + filters, + matchedCount: affected.length, + affectedCount: resultCount, + affectedIds, + errors, + }), }], } } @@ -361,6 +422,57 @@ export async function handleGetTrials(args?: Record): Promise): Promise { + const tags = getTagsWithCount() + return { content: [{ type: "text", text: JSON.stringify(tags) }] } +} + +export async function handleGetTagSubscriptions(args?: Record): Promise { + if (!args?.tag) { + return { content: [{ type: "text", text: "tag is required" }], isError: true } + } + const names = String(args.tag).split(",").map((t: string) => t.trim()).filter(Boolean) + if (names.length === 0) { + return { content: [{ type: "text", text: "tag is required" }], isError: true } + } + const subs = tagsSubscription(names) + return { content: [{ type: "text", text: JSON.stringify(subs) }] } +} + +export async function handleGetUsageTotal(args?: Record): Promise { + let from: string + let to: string + if (args?.from && args?.to) { + from = String(args.from) + to = String(args.to) + } else { + const range = getPeriodDateRange("monthly") + from = range.from + to = range.to + } + const total = getLlmUsageTotal(from, to) + const tokens = getLlmUsageTokenTotal(from, to) + const byProvider = getLlmUsageTotalByProvider(from, to) + const byModel = getLlmUsageTotalByModel(from, to) + return { + content: [{ + type: "text", + text: JSON.stringify({ from, to, total, tokens, byProvider, byModel }), + }], + } +} + +export async function handleListUsage(args?: Record): Promise { + const entries = getLlmUsage({ + provider: args?.provider as string | undefined, + from: args?.from as string | undefined, + to: args?.to as string | undefined, + limit: (args?.limit as number | undefined) ?? 100, + minCost: 0, + }) + return { content: [{ type: "text", text: JSON.stringify(entries) }] } +} + /** Map of tool name to handler function. */ export const HANDLER_MAP: Record) => Promise> = { list_subscriptions: handleListSubscriptions, @@ -379,4 +491,8 @@ export const HANDLER_MAP: Record) => Pro compare: handleCompare, bulk_operations: handleBulkOperations, get_trials: handleGetTrials, + list_tags: handleListTags, + get_tag_subscriptions: handleGetTagSubscriptions, + get_usage_total: handleGetUsageTotal, + list_usage: handleListUsage, } diff --git a/apps/subtrack/src/mcp/security.ts b/apps/subtrack/src/mcp/security.ts index 81f97fe..567c516 100644 --- a/apps/subtrack/src/mcp/security.ts +++ b/apps/subtrack/src/mcp/security.ts @@ -92,6 +92,8 @@ export const INPUT_VALIDATIONS: Record Date: Sun, 16 Aug 2026 21:13:18 +0900 Subject: [PATCH 10/19] docs: update command, development, and MCP documentation --- docs/commands.md | 37 ++++++++++++++++++++++++++++++++++--- docs/development.md | 3 ++- docs/mcp.md | 43 ++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index ab6bd89..e7e7ed3 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -750,6 +750,8 @@ Lists LLM API usage entries with optional filtering. | `--provider ` | Filter by provider | | `--from ` | Start date (inclusive) | | `--to ` | End date (inclusive) | +| `--limit ` | Max entries to show (default: 100) | +| `--offset ` | Skip the first N entries (for paging) | | `-j, --json` | Output as JSON | ```bash @@ -759,11 +761,40 @@ subtrack usage list # Filter by provider and date range subtrack usage list --provider openai --from 2026-01-01 --to 2026-06-30 +# Page through entries +subtrack usage list --limit 50 --offset 100 + # JSON output subtrack usage list --json ``` -Shows up to 100 entries with provider, model, token counts, cost, date, and description. Displays a total cost at the bottom. +Shows up to 100 entries by default (configurable with `--limit`) with provider, model, token counts, cost, date, and description. Displays a total cost at the bottom. + +### `usage edit` + +Updates fields of an existing LLM API usage entry. Only the fields you pass as flags are changed; everything else is left untouched. + +| Option | Description | +|--------|-------------| +| `` | Entry ID to edit (required) | +| `--provider ` | New provider name | +| `--model ` | New model name | +| `--input-tokens ` | New input token count | +| `--output-tokens ` | New output token count | +| `--date ` | New date | +| `--description ` | New description (empty string clears it) | +| `--cost ` | New total cost in USD (e.g. `0.50` for 50 cents) | + +```bash +# Fix the cost of entry 3 +subtrack usage edit 3 --cost 0.75 + +# Update model and tokens +subtrack usage edit 5 --model gpt-4o-mini --input-tokens 500 --output-tokens 250 + +# Clear the description +subtrack usage edit 7 --description "" +``` ### `usage delete` @@ -839,7 +870,7 @@ subtrack usage refresh --all ### `usage total` -Shows aggregated LLM API usage costs for a given period, including a provider breakdown. +Shows aggregated LLM API usage for a given period: cost broken down by provider and by model, plus total input/output tokens. | Option | Description | |--------|-------------| @@ -848,7 +879,7 @@ Shows aggregated LLM API usage costs for a given period, including a provider br | `--period ` | Period: `monthly`, `quarterly`, `yearly` (default: `monthly`) | | `-j, --json` | Output as JSON | -When neither `--from`/`--to` nor `--period` is specified, defaults to the current month. +When neither `--from`/`--to` nor `--period` is specified, defaults to the current month. The JSON output includes `total` (cost in USD cents), `tokens` (`inputTokens`/`outputTokens`), `byProvider`, and `byModel` (per-model cost and token totals). ```bash # Current month total diff --git a/docs/development.md b/docs/development.md index 239f491..1bda2a6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -131,9 +131,10 @@ subtrack/ │ │ ├── price.ts # Price formatting helpers │ │ ├── usage.ts # LLM API usage list & delete │ │ ├── usage-add.ts # LLM usage add (interactive & flags) +│ │ ├── usage-edit.ts # LLM usage field updates (flags) │ │ ├── usage-import.ts # LLM usage import from JSONL/JSON logs │ │ ├── usage-refresh.ts # Auto-scanner for AI tool usage data -│ │ ├── usage-total.ts # Aggregated usage cost summary +│ │ ├── usage-total.ts # Aggregated usage cost/token summary │ │ ├── scanner.ts # Scanner framework for AI tool log parsing │ │ ├── scanner-types.ts # Scanner type definitions │ │ ├── claude-scanner.ts # Claude Code log scanner diff --git a/docs/mcp.md b/docs/mcp.md index ab90b31..dcf4ff9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -46,13 +46,13 @@ In Windsurf settings, add an MCP server pointing to the same command. ## Available tools -The MCP server exposes 16 tools covering all subscription management operations. +The MCP server exposes 20 tools covering subscription management, analytics, tagging, and LLM API usage tracking. ### Subscription CRUD | Tool | Description | |------|-------------| -| `list_subscriptions` | List all subscriptions with optional sort | +| `list_subscriptions` | List all subscriptions with optional sort and paging | | `get_subscription` | Get a single subscription by ID | | `add_subscription` | Add a new subscription | | `edit_subscription` | Edit an existing subscription | @@ -64,13 +64,27 @@ The MCP server exposes 16 tools covering all subscription management operations. | Tool | Description | |------|-------------| | `get_summary` | Subscription summary statistics | -| `get_analytics` | Detailed analytics with budget tracking | +| `get_analytics` | Analytics: summary plus per-status breakdown | | `get_upcoming` | Upcoming bills within N days | | `get_calendar` | Calendar entries for a month | | `get_forecast` | Spending forecast with what-if scenarios | | `compare` | Compare current vs previous period spending | | `get_history` | Price change history | +### Tags + +| Tool | Description | +|------|-------------| +| `list_tags` | List all tags with subscription counts | +| `get_tag_subscriptions` | Subscriptions matching one or more tags (AND logic) | + +### LLM API Usage + +| Tool | Description | +|------|-------------| +| `get_usage_total` | Aggregated usage: cost, tokens, provider/model breakdown | +| `list_usage` | List usage entries with provider/date filters | + ### Data Management | Tool | Description | @@ -86,6 +100,8 @@ Each tool accepts a JSON object with the following parameters: **`list_subscriptions`** - `sort` (string, optional): Sort field — `name`, `price`, `currency`, `cycle`, `status` - `desc` (boolean, optional): Sort descending +- `limit` (number, optional): Max entries to return +- `offset` (number, optional): Skip the first N entries (for paging) **`get_subscription`** - `id` (number, required): Subscription ID @@ -148,6 +164,27 @@ Each tool accepts a JSON object with the following parameters: **`get_trials`** - `expiring_soon` (number, optional): Filter trials expiring within N days +**`list_tags`** +- No parameters + +**`get_tag_subscriptions`** +- `tag` (string, required): Comma-separated tag names (all must match) + +**`get_usage_total`** +- `from` (string, optional): Start date `YYYY-MM-DD` (default: current month) +- `to` (string, optional): End date `YYYY-MM-DD` (default: current month) +- Returns `total` (cost in USD cents), `tokens`, `byProvider`, and `byModel` + +**`list_usage`** +- `provider` (string, optional): Filter by provider +- `from` (string, optional): Start date `YYYY-MM-DD` +- `to` (string, optional): End date `YYYY-MM-DD` +- `limit` (number, optional): Max entries (default: 100) + +## Validation + +`add_subscription` and `edit_subscription` validate `currency` (supported ISO 4217 codes), `cycle` (`weekly`, `bi-weekly`, `monthly`, `quarterly`, `semi-annual`, `yearly`), and `status` (`active`, `paused`, `cancelled`, `archived`). Invalid values are rejected with an error. `bulk_operations` validates the target status the same way and reports per-entry errors instead of silently skipping them. + ## Example usage Ask your AI assistant: From 741b103a4eea1427189a0d3ed0f604fe5adaf65f Mon Sep 17 00:00:00 2001 From: nazozokc Date: Mon, 17 Aug 2026 21:06:07 +0900 Subject: [PATCH 11/19] edit --- AGENTS.md | 6 +- apps/subtrack/AGENTS.md | 6 +- apps/subtrack/README.md | 74 +++++ apps/subtrack/src/__tests__/budget.test.ts | 320 ++++++++++++++++++++ apps/subtrack/src/__tests__/cancel.test.ts | 262 +++++++++++++++++ apps/subtrack/src/__tests__/config.test.ts | 46 +++ apps/subtrack/src/__tests__/dedupe.test.ts | 299 +++++++++++++++++++ apps/subtrack/src/__tests__/report.test.ts | 327 +++++++++++++++++++++ apps/subtrack/src/budget.ts | 245 +++++++++++++++ apps/subtrack/src/cancel.ts | 144 +++++++++ apps/subtrack/src/commands/core.ts | 20 ++ apps/subtrack/src/commands/index.ts | 12 +- apps/subtrack/src/commands/misc.ts | 42 +++ apps/subtrack/src/commands/report.ts | 46 +++ apps/subtrack/src/config.ts | 84 +++++- apps/subtrack/src/db.ts | 2 +- apps/subtrack/src/db/audit.ts | 2 + apps/subtrack/src/db/subscriptions.ts | 31 ++ apps/subtrack/src/dedupe.ts | 166 +++++++++++ apps/subtrack/src/report.ts | 323 ++++++++++++++++++++ apps/subtrack/src/timeline.ts | 3 +- docs/commands.md | 106 +++++++ 22 files changed, 2559 insertions(+), 7 deletions(-) create mode 100644 apps/subtrack/src/__tests__/budget.test.ts create mode 100644 apps/subtrack/src/__tests__/cancel.test.ts create mode 100644 apps/subtrack/src/__tests__/dedupe.test.ts create mode 100644 apps/subtrack/src/__tests__/report.test.ts create mode 100644 apps/subtrack/src/budget.ts create mode 100644 apps/subtrack/src/cancel.ts create mode 100644 apps/subtrack/src/dedupe.ts create mode 100644 apps/subtrack/src/report.ts diff --git a/AGENTS.md b/AGENTS.md index 6ed7cb6..96ff677 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,14 +105,18 @@ Available CLI commands (run `subtrack --help` in the package for the full list): | `subtrack add` | Add a subscription | | `subtrack edit [id]` | Edit a subscription | | `subtrack delete [ids...]` | Delete subscriptions | +| `subtrack cancel [id]` | Cancel a subscription with a guided checklist | | `subtrack tags ` | Filter by tags (AND logic) | | `subtrack tag list\|rename\|delete\|prune` | Manage tags | -| `subtrack export csv\|json\|md` | Export subscriptions | +| `subtrack dedupe [merge]` | Detect/merge duplicate subscriptions | +| `subtrack export csv\|json\|md\|ics` | Export subscriptions | | `subtrack import ` | Import from CSV | | `subtrack summary` | Show subscription summary | | `subtrack backup [destination]` | Backup database | | `subtrack restore [file]` | Restore database | | `subtrack payment [period]` | Show payment totals | +| `subtrack budget` | Show spending vs budget (overrun detection) | +| `subtrack report` | Show yearly subscription report | | `subtrack usage add\|list\|delete\|refresh` | Track LLM API usage | ## Environment Variables diff --git a/apps/subtrack/AGENTS.md b/apps/subtrack/AGENTS.md index 00f4743..d152486 100644 --- a/apps/subtrack/AGENTS.md +++ b/apps/subtrack/AGENTS.md @@ -81,10 +81,14 @@ CLI tool to manage subscription services from the terminal. Node.js + TypeScript | `subtrack delete [ids...]` | Delete subscriptions | | `subtrack tags ` | Filter by tags (AND logic) | | `subtrack tag list\|rename\|delete\|prune` | Manage tags | -| `subtrack export csv\|json\|md` | Export subscriptions | +| `subtrack export csv\|json\|md\|ics` | Export subscriptions | | `subtrack import ` | Import from CSV | | `subtrack summary` | Show subscription summary | | `subtrack backup [destination]` | Backup database | | `subtrack restore [file]` | Restore database | | `subtrack payment [period]` | Show payment totals | +| `subtrack budget` | Show spending vs budget | +| `subtrack dedupe [merge]` | Detect/merge duplicate subscriptions | +| `subtrack cancel [id]` | Cancel with guided checklist | +| `subtrack report` | Yearly subscription report | | `subtrack usage add\|list\|delete\|refresh` | Track LLM API usage | diff --git a/apps/subtrack/README.md b/apps/subtrack/README.md index b36ef25..0e50350 100644 --- a/apps/subtrack/README.md +++ b/apps/subtrack/README.md @@ -35,6 +35,10 @@ A CLI tool to manage your subscription services from the terminal. - **Upcoming bills** — see what's due soon with `subtrack upcoming` - **Analytics** — detailed spending breakdown with `subtrack analytics` - **Configuration** — customize default currency, monthly budget via `subtrack config` +- **Budget tracking** — compare spending against monthly/yearly/named budgets with `subtrack budget` (overrun detection for scripts via `--check`) +- **Duplicate detection** — find and merge duplicate subscriptions with `subtrack dedupe` +- **Guided cancellation** — cancel subscriptions with a checklist via `subtrack cancel` +- **Yearly report** — spending overview, monthly chart, price changes, and budget comparison via `subtrack report` - **SQLite** storage — portable, zero-config, lives in `~/.config/subtrack/subtrack.db` - **Input validation** — name length, price bounds, tag limits @@ -490,6 +494,76 @@ subtrack usage refresh --from 2026-01-01 --to 2026-06-22 subtrack usage import usage_log.jsonl ``` +#### `budget` + +Shows spending vs a configured budget and detects overruns. Supports monthly, +yearly, and multiple named budgets. + +| Option | Description | +| ------ | ----------- | +| `--check` | Exit with code 1 when over budget (for scripts) | +| `--period ` | Comparison period (default: `monthly`) | +| `-c, --currency ` | Convert all prices to target currency | +| `--name ` | Compare against a named budget from `config budgets` | +| `-j, --json` | Output as JSON | + +```bash +subtrack config set monthlyBudget 5000 +subtrack budget --check + +subtrack config set yearlyBudget 60000 +subtrack budget --period yearly + +subtrack config set budgets '[{"name":"streaming","amount":3000,"currency":"JPY","categories":["video"]}]' +subtrack budget --name streaming +``` + +#### `dedupe` + +Detects duplicate subscriptions by name similarity. Merges duplicates with +`dedupe merge ` (price history and tags are preserved). + +| Option | Description | +| ------ | ----------- | +| `--threshold <0-1>` | Similarity threshold (default: `0.8`) | +| `-j, --json` | Output as JSON | + +```bash +subtrack dedupe +subtrack dedupe merge 4 5 +``` + +#### `cancel ` + +Cancels a subscription with a guided checklist (export, alternatives, note, +confirmation). Marks it `cancelled` and sets `contractEnd` when unset. + +| Option | Description | +| ------ | ----------- | +| `-f, --force` | Skip the checklist and cancel immediately | +| `-j, --json` | Output subscription info as JSON (no changes) | + +```bash +subtrack cancel 3 +subtrack cancel 3 --force +``` + +#### `report` + +Shows a yearly report: total spending, monthly bar chart, top subscriptions, +added/cancelled this year, price changes, and budget comparison. + +| Option | Description | +| ------ | ----------- | +| `--year ` | Target year (default: current year) | +| `-c, --currency ` | Convert all prices to target currency | +| `-j, --json` | Output as JSON | + +```bash +subtrack report +subtrack report --year 2025 --currency USD +``` + ### Non-interactive mode All flags on `add` can be combined for fully automated usage: diff --git a/apps/subtrack/src/__tests__/budget.test.ts b/apps/subtrack/src/__tests__/budget.test.ts new file mode 100644 index 0000000..2117274 --- /dev/null +++ b/apps/subtrack/src/__tests__/budget.test.ts @@ -0,0 +1,320 @@ +import { test, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from "vitest" +import initSqlJs from "sql.js" +import type { Database } from "sql.js" +import { consola } from "consola" +import { mkdtempSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +const logMessages: string[] = [] +const infoMessages: string[] = [] +const errorMessages: string[] = [] +const warnMessages: string[] = [] + +let originalEnv: string | undefined +let originalFetch: typeof globalThis.fetch + +let SQL: Awaited> +let testDb: Database + +beforeAll(async () => { + SQL = await initSqlJs() +}) + +beforeEach(async () => { + // Isolate config to a temporary directory + originalEnv = process.env.SUBSC_CLI_DB_DIR + const testConfigDir = mkdtempSync(join(tmpdir(), "subtrack-budget-")) + process.env.SUBSC_CLI_DB_DIR = testConfigDir + + logMessages.length = 0 + infoMessages.length = 0 + errorMessages.length = 0 + warnMessages.length = 0 + + const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "") + + consola.mockTypes((_type: string, _defaults: object) => { + return (...args: unknown[]) => { + const str = args.map((a) => String(a)).join(" ") + const clean = stripAnsi(str) + if (_type === "log") logMessages.push(clean) + if (_type === "info") infoMessages.push(clean) + if (_type === "error") errorMessages.push(clean) + if (_type === "warn") warnMessages.push(clean) + } + }) + + // Fresh in-memory DB + testDb = new SQL.Database() + testDb.run(`CREATE TABLE IF NOT EXISTS subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + price INTEGER NOT NULL, + currency TEXT NOT NULL, + cycle TEXT NOT NULL DEFAULT 'monthly', + status TEXT NOT NULL DEFAULT 'active', + billing_day INTEGER, + created_at TEXT NOT NULL DEFAULT (date('now')), + notes TEXT, + payment_method TEXT, + contract_start TEXT, + contract_end TEXT, + auto_renewal INTEGER NOT NULL DEFAULT 1, + vendor_name TEXT, + vendor_url TEXT, + plan_tier TEXT, + discount_amount INTEGER, + discount_type TEXT + )`) + testDb.run( + "CREATE TABLE IF NOT EXISTS tags (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE)", + ) + testDb.run( + "CREATE TABLE IF NOT EXISTS subscription_tags (subscription_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, PRIMARY KEY (subscription_id, tag_id))", + ) + const dbMod = await import("../db.ts") + dbMod.__setDb(testDb) + + // Reset config cache + const { resetConfig } = await import("../config.ts") + resetConfig() + + originalFetch = globalThis.fetch + globalThis.fetch = async () => + new Response( + JSON.stringify({ + base: "USD", + rates: { JPY: 160, USD: 1 }, + }), + ) +}) + +afterEach(() => { + consola.mockTypes() + globalThis.fetch = originalFetch + if (originalEnv === undefined) { + delete process.env.SUBSC_CLI_DB_DIR + } else { + process.env.SUBSC_CLI_DB_DIR = originalEnv + } + const { resetConfig } = vi.importActual("../config.ts") as never + void resetConfig +}) + +afterAll(() => { + testDb.close() +}) + +// ── Helper ───────────────────────────────────────────── + +function insertSub(overrides: Record = {}): number { + const db = testDb + const fields = { + name: "Test Sub", + price: 1000, + currency: "JPY", + cycle: "monthly", + status: "active", + billingDay: 1, + createdAt: "2026-01-01", + ...overrides, + } + db.run( + "INSERT INTO subscriptions (name, price, currency, cycle, status, billing_day, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + [fields.name, fields.price, fields.currency, fields.cycle, fields.status, fields.billingDay, fields.createdAt], + ) + const row = db.exec("SELECT last_insert_rowid() AS id") + const id = Number(row[0].values[0][0]) + + const tags = (overrides.tags as string[] | undefined) ?? [] + for (const t of tags) { + db.run("INSERT OR IGNORE INTO tags (name) VALUES (?)", [t]) + const tagRow = db.exec("SELECT id FROM tags WHERE name = ?", [t]) + const tagId = Number(tagRow[0].values[0][0]) + db.run("INSERT INTO subscription_tags (subscription_id, tag_id) VALUES (?, ?)", [id, tagId]) + } + return id +} + +async function setConfig(patch: Record): Promise { + const { loadConfig, saveConfig } = await import("../config.ts") + const config = loadConfig() + Object.assign(config, patch) + saveConfig(config) +} + +// ── resolveBudget ────────────────────────────────────── + +test("resolveBudget returns null when no budget set", async () => { + const { resolveBudget } = await import("../budget.ts") + expect(resolveBudget("monthly")).toBeNull() + expect(resolveBudget("yearly")).toBeNull() +}) + +test("resolveBudget reads monthlyBudget from config", async () => { + await setConfig({ monthlyBudget: 5000, defaultCurrency: "JPY" }) + const { resolveBudget } = await import("../budget.ts") + const budget = resolveBudget("monthly") + expect(budget).toEqual({ name: null, amount: 5000, currency: "JPY" }) +}) + +test("resolveBudget yearly falls back to monthlyBudget * 12", async () => { + await setConfig({ monthlyBudget: 5000, defaultCurrency: "JPY" }) + const { resolveBudget } = await import("../budget.ts") + expect(resolveBudget("yearly")).toEqual({ name: null, amount: 60000, currency: "JPY" }) +}) + +test("resolveBudget uses yearlyBudget when set", async () => { + await setConfig({ monthlyBudget: 5000, yearlyBudget: 100000, defaultCurrency: "JPY" }) + const { resolveBudget } = await import("../budget.ts") + expect(resolveBudget("yearly")?.amount).toBe(100000) +}) + +test("resolveBudget finds named budget with categories", async () => { + await setConfig({ + budgets: [{ name: "streaming", amount: 3000, currency: "JPY", categories: ["video"] }], + }) + const { resolveBudget } = await import("../budget.ts") + expect(resolveBudget("monthly", "streaming")).toEqual({ + name: "streaming", + amount: 3000, + currency: "JPY", + categories: ["video"], + }) + expect(resolveBudget("monthly", "unknown")).toBeNull() +}) + +// ── convertTotals ────────────────────────────────────── + +test("convertTotals converts per-currency totals to target", async () => { + const { convertTotals } = await import("../budget.ts") + const rates = { base: "USD", rates: { JPY: 160, USD: 1 } } + const { sum, missing } = convertTotals({ JPY: 1600, USD: 1 }, "USD", rates) + expect(missing).toBe(false) + expect(sum).toBe(11) +}) + +test("convertTotals flags missing rates", async () => { + const { convertTotals } = await import("../budget.ts") + const rates = { base: "USD", rates: { USD: 1 } } + const { sum, missing } = convertTotals({ XYZ: 100 }, "USD", rates) + expect(missing).toBe(true) + expect(sum).toBe(0) +}) + +// ── handleBudget ─────────────────────────────────────── + +test("handleBudget shows info when no budget set", async () => { + const { handleBudget } = await import("../budget.ts") + await handleBudget({}) + expect(infoMessages.some((m) => m.includes("No budget set"))).toBe(true) + expect(process.exitCode).not.toBe(1) +}) + +test("handleBudget JSON when no budget set", async () => { + const writes: string[] = [] + const origWrite = process.stdout.write.bind(process.stdout) + process.stdout.write = ((chunk: string) => { + writes.push(String(chunk)) + return true + }) as typeof process.stdout.write + + const { handleBudget } = await import("../budget.ts") + await handleBudget({ json: true }) + + process.stdout.write = origWrite + const parsed = JSON.parse(writes.join("")) + expect(parsed.set).toBe(false) + expect(parsed.period).toBe("monthly") +}) + +test("handleBudget shows remaining when under budget", async () => { + await setConfig({ monthlyBudget: 5000, defaultCurrency: "JPY" }) + insertSub({ name: "Netflix", price: 1000, currency: "JPY" }) + + const { handleBudget } = await import("../budget.ts") + await handleBudget({}) + expect(logMessages.some((m) => m.includes("Monthly spending: ¥1,000/month"))).toBe(true) + expect(logMessages.some((m) => m.includes("Budget: ¥5,000/month"))).toBe(true) + expect(logMessages.some((m) => m.includes("Remaining: ¥4,000"))).toBe(true) + expect(process.exitCode).not.toBe(1) +}) + +test("handleBudget --check exits 1 when over budget", async () => { + await setConfig({ monthlyBudget: 5000, defaultCurrency: "JPY" }) + insertSub({ name: "Netflix", price: 1000, currency: "JPY" }) + insertSub({ name: "Spotify", price: 3000, currency: "JPY" }) + insertSub({ name: "AWS", price: 2000, currency: "JPY" }) + + const prevExit = process.exitCode + const { handleBudget } = await import("../budget.ts") + await handleBudget({ check: true }) + expect(logMessages.some((m) => m.includes("Over budget: ¥1,000"))).toBe(true) + expect(process.exitCode).toBe(1) + process.exitCode = prevExit +}) + +test("handleBudget yearly period uses yearly budget", async () => { + await setConfig({ yearlyBudget: 60000, defaultCurrency: "JPY" }) + insertSub({ name: "Netflix", price: 1000, currency: "JPY" }) + + const { handleBudget } = await import("../budget.ts") + await handleBudget({ period: "yearly" }) + expect(logMessages.some((m) => m.includes("Yearly spending: ¥12,000/year"))).toBe(true) + expect(logMessages.some((m) => m.includes("Budget: ¥60,000/year"))).toBe(true) +}) + +test("handleBudget converts currencies when budget currency differs", async () => { + await setConfig({ monthlyBudget: 100, defaultCurrency: "USD" }) + insertSub({ name: "Netflix", price: 1600, currency: "JPY" }) + + const { handleBudget } = await import("../budget.ts") + await handleBudget({}) + // JPY 1600 = USD 10 at rate 160 + expect(logMessages.some((m) => m.includes("$10/month"))).toBe(true) + expect(logMessages.some((m) => m.includes("Over budget"))).toBe(false) +}) + +test("handleBudget JSON output shape", async () => { + await setConfig({ monthlyBudget: 5000, defaultCurrency: "JPY" }) + insertSub({ name: "Netflix", price: 1000, currency: "JPY" }) + + const writes: string[] = [] + const origWrite = process.stdout.write.bind(process.stdout) + process.stdout.write = ((chunk: string) => { + writes.push(String(chunk)) + return true + }) as typeof process.stdout.write + + const { handleBudget } = await import("../budget.ts") + await handleBudget({ json: true }) + + process.stdout.write = origWrite + const parsed = JSON.parse(writes.join("")) + expect(parsed).toMatchObject({ + set: true, + period: "monthly", + budgetName: null, + budget: 5000, + budgetCurrency: "JPY", + spending: 1000, + currency: "JPY", + remaining: 4000, + over: false, + }) +}) + +test("handleBudget named budget filters by categories", async () => { + await setConfig({ + budgets: [{ name: "streaming", amount: 3000, currency: "JPY", categories: ["video"] }], + }) + insertSub({ name: "Netflix", price: 2500, currency: "JPY", tags: ["video"] }) + insertSub({ name: "AWS", price: 5000, currency: "JPY", tags: ["infra"] }) + + const { handleBudget } = await import("../budget.ts") + await handleBudget({ name: "streaming" }) + // Only the video-tagged sub counts toward the streaming budget + expect(logMessages.some((m) => m.includes("Monthly spending: ¥2,500/month"))).toBe(true) + expect(logMessages.some((m) => m.includes("Remaining: ¥500"))).toBe(true) +}) \ No newline at end of file diff --git a/apps/subtrack/src/__tests__/cancel.test.ts b/apps/subtrack/src/__tests__/cancel.test.ts new file mode 100644 index 0000000..259afba --- /dev/null +++ b/apps/subtrack/src/__tests__/cancel.test.ts @@ -0,0 +1,262 @@ +import { test, expect, beforeAll, beforeEach, afterEach, afterAll, vi } from "vitest" +import initSqlJs from "sql.js" +import type { Database } from "sql.js" +import { consola } from "consola" + +const { confirmMock } = vi.hoisted(() => ({ confirmMock: vi.fn() })) + +vi.mock("@inquirer/prompts", () => ({ + confirm: confirmMock, +})) + +const logMessages: string[] = [] +const infoMessages: string[] = [] +const errorMessages: string[] = [] +const successMessages: string[] = [] +const warnMessages: string[] = [] + +let SQL: Awaited> +let testDb: Database + +beforeAll(async () => { + SQL = await initSqlJs() +}) + +beforeEach(async () => { + logMessages.length = 0 + infoMessages.length = 0 + errorMessages.length = 0 + successMessages.length = 0 + warnMessages.length = 0 + + const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "") + + consola.mockTypes((_type: string, _defaults: object) => { + return (...args: unknown[]) => { + const str = args.map((a) => String(a)).join(" ") + const clean = stripAnsi(str) + if (_type === "log") logMessages.push(clean) + if (_type === "info") infoMessages.push(clean) + if (_type === "error") errorMessages.push(clean) + if (_type === "success") successMessages.push(clean) + if (_type === "warn") warnMessages.push(clean) + } + }) + + testDb = new SQL.Database() + testDb.run(`CREATE TABLE IF NOT EXISTS subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + price INTEGER NOT NULL, + currency TEXT NOT NULL, + cycle TEXT NOT NULL DEFAULT 'monthly', + status TEXT NOT NULL DEFAULT 'active', + billing_day INTEGER, + created_at TEXT NOT NULL DEFAULT (date('now')), + notes TEXT, + payment_method TEXT, + contract_start TEXT, + contract_end TEXT, + auto_renewal INTEGER NOT NULL DEFAULT 1, + vendor_name TEXT, + vendor_url TEXT, + plan_tier TEXT, + discount_amount INTEGER, + discount_type TEXT + )`) + testDb.run( + "CREATE TABLE IF NOT EXISTS tags (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE)", + ) + testDb.run( + "CREATE TABLE IF NOT EXISTS subscription_tags (subscription_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, PRIMARY KEY (subscription_id, tag_id))", + ) + testDb.run( + "CREATE TABLE IF NOT EXISTS audit_log (id INTEGER PRIMARY KEY AUTOINCREMENT, action TEXT NOT NULL, target_type TEXT, target_id INTEGER, details TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')))", + ) + + const dbMod = await import("../db.ts") + dbMod.__setDb(testDb) +}) + +afterEach(() => { + consola.mockTypes() +}) + +afterAll(() => { + testDb.close() +}) + +// ── Helper ───────────────────────────────────────────── + +function insertSub(overrides: Record = {}): number { + const fields = { + name: "Test Sub", + price: 1000, + currency: "JPY", + cycle: "monthly", + status: "active", + billingDay: 1, + createdAt: "2026-01-01", + notes: null, + contractEnd: null, + ...overrides, + } + testDb.run( + "INSERT INTO subscriptions (name, price, currency, cycle, status, billing_day, created_at, notes, contract_end) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + [fields.name, fields.price, fields.currency, fields.cycle, fields.status, fields.billingDay, fields.createdAt, fields.notes, fields.contractEnd], + ) + const row = testDb.exec("SELECT last_insert_rowid() AS id") + return Number(row[0].values[0][0]) +} + +function getSub(id: number): Record { + const db = testDb + const rows = db.exec("SELECT * FROM subscriptions WHERE id = ?", [id]) + if (!rows.length || !rows[0].values.length) return {} + const { columns, values } = rows[0] + const obj: Record = {} + for (let i = 0; i < columns.length; i++) obj[columns[i]!] = values[0]![i] + return obj +} + +// ── handleCancel: --force ────────────────────────────── + +test("cancel --force sets status to cancelled and contractEnd", async () => { + const id = insertSub({ name: "Netflix" }) + + const { handleCancel } = await import("../cancel.ts") + await handleCancel(id, { force: true }) + + const sub = getSub(id) + expect(sub.status).toBe("cancelled") + expect(sub.contract_end).toBeTruthy() + expect(successMessages.some((m) => m.includes("Cancelled: \"Netflix\""))).toBe(true) +}) + +test("cancel --force keeps existing contractEnd", async () => { + const id = insertSub({ name: "Netflix", contractEnd: "2026-12-31" }) + + const { handleCancel } = await import("../cancel.ts") + await handleCancel(id, { force: true }) + + expect(getSub(id).contract_end).toBe("2026-12-31") +}) + +test("cancel --force writes an audit entry", async () => { + const id = insertSub({ name: "Spotify" }) + + const { handleCancel } = await import("../cancel.ts") + await handleCancel(id, { force: true }) + + const rows = testDb.exec("SELECT action FROM audit_log WHERE target_id = ?", [id]) + expect(rows.length).toBeGreaterThan(0) + expect(String(rows[0]!.values[0]![0])).toBe("subscription.cancel") +}) + +test("cancel fails for unknown id", async () => { + const { handleCancel } = await import("../cancel.ts") + await handleCancel(999, { force: true }) + expect(errorMessages.some((m) => m.includes("not found"))).toBe(true) + expect(process.exitCode).toBe(1) + process.exitCode = 0 +}) + +test("cancel reports already-cancelled subscriptions", async () => { + const id = insertSub({ name: "Netflix", status: "cancelled" }) + + const { handleCancel } = await import("../cancel.ts") + await handleCancel(id, { force: true }) + expect(infoMessages.some((m) => m.includes("already cancelled"))).toBe(true) +}) + +test("cancel refuses archived subscriptions", async () => { + const id = insertSub({ name: "Netflix", status: "archived" }) + + const { handleCancel } = await import("../cancel.ts") + await handleCancel(id, { force: true }) + expect(infoMessages.some((m) => m.includes("archived"))).toBe(true) +}) + +// ── handleCancel: --json ─────────────────────────────── + +test("cancel --json outputs subscription info without changes", async () => { + const id = insertSub({ name: "Netflix", price: 1500 }) + + const writes: string[] = [] + const origWrite = process.stdout.write.bind(process.stdout) + process.stdout.write = ((chunk: string) => { + writes.push(String(chunk)) + return true + }) as typeof process.stdout.write + + const { handleCancel } = await import("../cancel.ts") + await handleCancel(id, { json: true }) + + process.stdout.write = origWrite + const parsed = JSON.parse(writes.join("")) + expect(parsed).toMatchObject({ + id, + name: "Netflix", + price: 1500, + currency: "JPY", + cycle: "monthly", + status: "active", + }) + expect(parsed.cancellationDate).toBeTruthy() + // No change made + expect(getSub(id).status).toBe("active") +}) + +// ── handleCancel: interactive checklist ──────────────── + +test("cancel aborts when final confirmation is declined", async () => { + const id = insertSub({ name: "Netflix" }) + + confirmMock + .mockReset() + .mockResolvedValueOnce(false) // export data + .mockResolvedValueOnce(true) // alternatives checked + .mockResolvedValueOnce(true) // note cancellation date + .mockResolvedValueOnce(false) // final confirm + + const { handleCancel } = await import("../cancel.ts") + await handleCancel(id) + + expect(infoMessages.some((m2) => m2.includes("Aborted"))).toBe(true) + expect(getSub(id).status).toBe("active") +}) + +test("cancel completes checklist flow and updates subscription", async () => { + const id = insertSub({ name: "Netflix", notes: "Family plan" }) + + confirmMock + .mockReset() + .mockResolvedValueOnce(false) // export data + .mockResolvedValueOnce(true) // alternatives checked + .mockResolvedValueOnce(true) // note cancellation date + .mockResolvedValueOnce(true) // final confirm + + const { handleCancel } = await import("../cancel.ts") + await handleCancel(id) + + const sub = getSub(id) + expect(sub.status).toBe("cancelled") + expect(String(sub.notes)).toContain("Cancelled:") + expect(String(sub.notes)).toContain("Family plan") +}) + +test("cancel does not add note when declined", async () => { + const id = insertSub({ name: "Netflix", notes: "Keep notes" }) + + confirmMock + .mockReset() + .mockResolvedValueOnce(false) // export data + .mockResolvedValueOnce(true) // alternatives checked + .mockResolvedValueOnce(false) // note cancellation date + .mockResolvedValueOnce(true) // final confirm + + const { handleCancel } = await import("../cancel.ts") + await handleCancel(id) + + expect(getSub(id).notes).toBe("Keep notes") +}) \ No newline at end of file diff --git a/apps/subtrack/src/__tests__/config.test.ts b/apps/subtrack/src/__tests__/config.test.ts index 75bd396..d162670 100644 --- a/apps/subtrack/src/__tests__/config.test.ts +++ b/apps/subtrack/src/__tests__/config.test.ts @@ -91,6 +91,52 @@ test("handleConfigSet shows error for negative budget", async () => { expect(errorMessages.some((m) => m.includes("non-negative"))).toBe(true) }) +test("handleConfigSet sets yearlyBudget", async () => { + const { handleConfigSet } = await import("../commands.ts") + const { loadConfig } = await import("../config.ts") + + handleConfigSet("yearlyBudget", "120000") + expect(loadConfig().yearlyBudget).toBe(120000) + expect(successMessages.some((m) => m.includes("Set yearlyBudget = 120000"))).toBe(true) +}) + +test("handleConfigSet rejects negative yearlyBudget", async () => { + const { handleConfigSet } = await import("../commands.ts") + handleConfigSet("yearlyBudget", "-1") + expect(errorMessages.some((m) => m.includes("non-negative"))).toBe(true) +}) + +test("handleConfigSet stores named budgets from JSON", async () => { + const { handleConfigSet } = await import("../commands.ts") + const { loadConfig } = await import("../config.ts") + + const json = JSON.stringify([ + { name: "streaming", amount: 3000, currency: "JPY", period: "monthly", categories: ["video", "music"] }, + { name: "infra", amount: 60000, currency: "JPY", period: "yearly" }, + ]) + handleConfigSet("budgets", json) + + const budgets = loadConfig().budgets + expect(budgets).toHaveLength(2) + expect(budgets![0]).toMatchObject({ name: "streaming", amount: 3000, currency: "JPY" }) + expect(budgets![0].categories).toEqual(["video", "music"]) + expect(budgets![1]).toMatchObject({ name: "infra", amount: 60000, period: "yearly" }) +}) + +test("handleConfigSet rejects invalid budgets JSON", async () => { + const { handleConfigSet } = await import("../commands.ts") + handleConfigSet("budgets", "not-json") + expect(errorMessages.some((m) => m.includes("valid JSON array"))).toBe(true) +}) + +test("handleConfigSet rejects budget entries with bad fields", async () => { + const { handleConfigSet } = await import("../commands.ts") + handleConfigSet("budgets", JSON.stringify([{ name: "x", amount: -5, currency: "JPY" }])) + expect(errorMessages.some((m) => m.includes("non-negative number"))).toBe(true) + handleConfigSet("budgets", JSON.stringify([{ name: "x", amount: 100, currency: "JP" }])) + expect(errorMessages.some((m) => m.includes("3-letter code"))).toBe(true) +}) + test("handleConfigReset resets config to defaults", async () => { const { handleConfigSet, handleConfigReset } = await import("../commands.ts") const { loadConfig, resetConfig } = await import("../config.ts") diff --git a/apps/subtrack/src/__tests__/dedupe.test.ts b/apps/subtrack/src/__tests__/dedupe.test.ts new file mode 100644 index 0000000..7b1252d --- /dev/null +++ b/apps/subtrack/src/__tests__/dedupe.test.ts @@ -0,0 +1,299 @@ +import { test, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest" +import initSqlJs from "sql.js" +import type { Database } from "sql.js" +import { consola } from "consola" + +const logMessages: string[] = [] +const infoMessages: string[] = [] +const errorMessages: string[] = [] +const successMessages: string[] = [] + +let SQL: Awaited> +let testDb: Database + +beforeAll(async () => { + SQL = await initSqlJs() +}) + +beforeEach(async () => { + logMessages.length = 0 + infoMessages.length = 0 + errorMessages.length = 0 + successMessages.length = 0 + + const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "") + + consola.mockTypes((_type: string, _defaults: object) => { + return (...args: unknown[]) => { + const str = args.map((a) => String(a)).join(" ") + const clean = stripAnsi(str) + if (_type === "log") logMessages.push(clean) + if (_type === "info") infoMessages.push(clean) + if (_type === "error") errorMessages.push(clean) + if (_type === "success") successMessages.push(clean) + } + }) + + testDb = new SQL.Database() + testDb.run(`CREATE TABLE IF NOT EXISTS subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + price INTEGER NOT NULL, + currency TEXT NOT NULL, + cycle TEXT NOT NULL DEFAULT 'monthly', + status TEXT NOT NULL DEFAULT 'active', + billing_day INTEGER, + created_at TEXT NOT NULL DEFAULT (date('now')), + notes TEXT, + payment_method TEXT, + contract_start TEXT, + contract_end TEXT, + auto_renewal INTEGER NOT NULL DEFAULT 1, + vendor_name TEXT, + vendor_url TEXT, + plan_tier TEXT, + discount_amount INTEGER, + discount_type TEXT + )`) + testDb.run( + "CREATE TABLE IF NOT EXISTS tags (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE)", + ) + testDb.run( + "CREATE TABLE IF NOT EXISTS subscription_tags (subscription_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, PRIMARY KEY (subscription_id, tag_id), FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE, FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE)", + ) + testDb.run( + "CREATE TABLE IF NOT EXISTS price_history (id INTEGER PRIMARY KEY AUTOINCREMENT, subscription_id INTEGER NOT NULL, old_price INTEGER, new_price INTEGER NOT NULL, old_currency TEXT, new_currency TEXT NOT NULL, changed_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE)", + ) + testDb.run( + "CREATE TABLE IF NOT EXISTS audit_log (id INTEGER PRIMARY KEY AUTOINCREMENT, action TEXT NOT NULL, target_type TEXT, target_id INTEGER, details TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')))", + ) + + const dbMod = await import("../db.ts") + dbMod.__setDb(testDb) +}) + +afterEach(() => { + consola.mockTypes() +}) + +afterAll(() => { + testDb.close() +}) + +// ── Helper ───────────────────────────────────────────── + +function insertSub(overrides: Record = {}): number { + const fields = { + name: "Test Sub", + price: 1000, + currency: "JPY", + cycle: "monthly", + status: "active", + billingDay: 1, + createdAt: "2026-01-01", + vendorUrl: null, + ...overrides, + } + testDb.run( + "INSERT INTO subscriptions (name, price, currency, cycle, status, billing_day, created_at, vendor_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [fields.name, fields.price, fields.currency, fields.cycle, fields.status, fields.billingDay, fields.createdAt, fields.vendorUrl], + ) + const row = testDb.exec("SELECT last_insert_rowid() AS id") + const id = Number(row[0].values[0][0]) + + const tags = (overrides.tags as string[] | undefined) ?? [] + for (const t of tags) { + testDb.run("INSERT OR IGNORE INTO tags (name) VALUES (?)", [t]) + const tagRow = testDb.exec("SELECT id FROM tags WHERE name = ?", [t]) + const tagId = Number(tagRow[0].values[0][0]) + testDb.run("INSERT INTO subscription_tags (subscription_id, tag_id) VALUES (?, ?)", [id, tagId]) + } + return id +} + +// ── normalizeName ────────────────────────────────────── + +test("normalizeName lowercases and strips punctuation/space", async () => { + const { normalizeName } = await import("../dedupe.ts") + expect(normalizeName("Netflix")).toBe("netflix") + expect(normalizeName("Net-flix Premium+")).toBe("netflixpremium") + expect(normalizeName("Crunchy Roll (JP)")).toBe("crunchyrolljp") + expect(normalizeName("ネット フリックス")).toBe("ネットフリックス") +}) + +test("similarity is 1 for identical names after normalization", async () => { + const { similarity } = await import("../dedupe.ts") + expect(similarity("Netflix", "netflix")).toBe(1) + expect(similarity("Netflix", "Net-flix")).toBe(1) + expect(similarity("Crunchyroll", "Crunchy Roll")).toBe(1) +}) + +test("similarity handles completely different names", async () => { + const { similarity } = await import("../dedupe.ts") + expect(similarity("Netflix", "AWS")).toBeLessThan(0.5) +}) + +test("similarity detects near-duplicates above threshold", async () => { + const { similarity } = await import("../dedupe.ts") + // "netflix" vs "netflx" (typo) — 1 edit in 7 chars + expect(similarity("Netflix", "Netflx")).toBeGreaterThan(0.8) +}) + +test("similarity handles Japanese names", async () => { + const { similarity } = await import("../dedupe.ts") + expect(similarity("ネットフリックス", "ネットフリックス")).toBe(1) + expect(similarity("ネットフリックス", "Netflix")).toBe(0) +}) + +// ── levenshtein ──────────────────────────────────────── + +test("levenshtein distance basics", async () => { + const { levenshtein } = await import("../dedupe.ts") + expect(levenshtein("", "")).toBe(0) + expect(levenshtein("abc", "abc")).toBe(0) + expect(levenshtein("abc", "")).toBe(3) + expect(levenshtein("kitten", "sitting")).toBe(3) + expect(levenshtein("flaw", "lawn")).toBe(2) +}) + +// ── findDuplicates ───────────────────────────────────── + +test("findDuplicates returns pairs above threshold sorted by score", async () => { + const { findDuplicates } = await import("../dedupe.ts") + const subs = [ + { id: 1, name: "Netflix", price: 1000, currency: "JPY", cycle: "monthly", tags: [], status: "active", billingDay: 1, createdAt: "2026-01-01", notes: null, paymentMethod: null, contractStart: null, contractEnd: null, autoRenewal: true, vendorName: null, vendorUrl: null, planTier: null, discountAmount: null, discountType: null }, + { id: 2, name: "Netflix", price: 1500, currency: "JPY", cycle: "monthly", tags: [], status: "active", billingDay: 1, createdAt: "2026-01-01", notes: null, paymentMethod: null, contractStart: null, contractEnd: null, autoRenewal: true, vendorName: null, vendorUrl: null, planTier: null, discountAmount: null, discountType: null }, + { id: 3, name: "AWS", price: 500, currency: "JPY", cycle: "monthly", tags: [], status: "active", billingDay: 1, createdAt: "2026-01-01", notes: null, paymentMethod: null, contractStart: null, contractEnd: null, autoRenewal: true, vendorName: null, vendorUrl: null, planTier: null, discountAmount: null, discountType: null }, + ] as never[] + const pairs = findDuplicates(subs) + expect(pairs.length).toBe(1) + expect(pairs[0]!.score).toBe(1) +}) + +test("findDuplicates flags matching vendor URLs even with different names", async () => { + const { findDuplicates } = await import("../dedupe.ts") + const mk = (id: number, name: string, vendorUrl: string | null) => ({ + id, name, price: 1000, currency: "JPY", cycle: "monthly", tags: [], status: "active", + billingDay: 1, createdAt: "2026-01-01", notes: null, paymentMethod: null, + contractStart: null, contractEnd: null, autoRenewal: true, vendorName: null, + vendorUrl, planTier: null, discountAmount: null, discountType: null, + }) + const pairs = findDuplicates( + [mk(1, "GitHub Pro", "https://github.com"), mk(2, "GitHub Copilot", "https://github.com"), mk(3, "AWS", null)], + 0.9, + ) + expect(pairs.length).toBe(1) + expect(pairs[0]!.vendorUrlMatch).toBe(true) +}) + +test("findDuplicates respects threshold", async () => { + const { findDuplicates } = await import("../dedupe.ts") + const mk = (id: number, name: string) => ({ + id, name, price: 1000, currency: "JPY", cycle: "monthly", tags: [], status: "active", + billingDay: 1, createdAt: "2026-01-01", notes: null, paymentMethod: null, + contractStart: null, contractEnd: null, autoRenewal: true, vendorName: null, + vendorUrl: null, planTier: null, discountAmount: null, discountType: null, + }) + // "netflix" vs "netflx": 1/7 edits = 0.857 similarity + const loose = findDuplicates([mk(1, "Netflix"), mk(2, "Netflx")], 0.8) + expect(loose.length).toBe(1) + const strict = findDuplicates([mk(1, "Netflix"), mk(2, "Netflx")], 0.9) + expect(strict.length).toBe(0) +}) + +// ── handleDedupe ─────────────────────────────────────── + +test("handleDedupe shows info when no duplicates", async () => { + insertSub({ name: "Netflix", price: 1000 }) + insertSub({ name: "AWS", price: 500 }) + + const { handleDedupe } = await import("../dedupe.ts") + handleDedupe({}) + expect(infoMessages.some((m) => m.includes("No duplicate subscriptions found"))).toBe(true) +}) + +test("handleDedupe lists duplicate pairs", async () => { + insertSub({ name: "Netflix", price: 1000 }) + insertSub({ name: "Netflix", price: 1500 }) + + const { handleDedupe } = await import("../dedupe.ts") + handleDedupe({}) + expect(logMessages.some((m) => m.includes("Potential duplicates"))).toBe(true) + expect(logMessages.some((m) => m.includes("#1 Netflix"))).toBe(true) + expect(logMessages.some((m) => m.includes("#2 Netflix"))).toBe(true) + expect(infoMessages.some((m) => m.includes("dedupe merge"))).toBe(true) +}) + +test("handleDedupe JSON output", async () => { + insertSub({ name: "Netflix", price: 1000 }) + insertSub({ name: "Netflix", price: 1500 }) + + const writes: string[] = [] + const origWrite = process.stdout.write.bind(process.stdout) + process.stdout.write = ((chunk: string) => { + writes.push(String(chunk)) + return true + }) as typeof process.stdout.write + + const { handleDedupe } = await import("../dedupe.ts") + handleDedupe({ json: true }) + + process.stdout.write = origWrite + const parsed = JSON.parse(writes.join("")) + expect(parsed.length).toBe(1) + expect(parsed[0]).toMatchObject({ score: 1, vendorUrlMatch: false }) + expect(parsed[0].a.id).toBe(1) + expect(parsed[0].b.id).toBe(2) +}) + +test("handleDedupe rejects invalid threshold", async () => { + const { handleDedupe } = await import("../dedupe.ts") + handleDedupe({ threshold: 1.5 }) + expect(errorMessages.some((m) => m.includes("threshold"))).toBe(true) + expect(process.exitCode).toBe(1) + process.exitCode = 0 +}) + +// ── handleDedupeMerge ────────────────────────────────── + +test("mergeSubscriptions transfers tags and deletes the removed one", async () => { + const keepId = insertSub({ name: "Netflix", price: 1000, tags: ["video"] }) + const removeId = insertSub({ name: "Netflix", price: 1500, tags: ["family", "video"] }) + + const { mergeSubscriptions } = await import("../db.ts") + expect(mergeSubscriptions(keepId, removeId)).toBe(true) + + const { getSubscription } = await import("../db.ts") + const kept = getSubscription(keepId)! + expect(kept.tags.sort()).toEqual(["family", "video"]) + expect(getSubscription(removeId)).toBeUndefined() +}) + +test("handleDedupeMerge merges and logs success", async () => { + const keepId = insertSub({ name: "Netflix", price: 1000 }) + const removeId = insertSub({ name: "Netflix", price: 1500 }) + + const { handleDedupeMerge } = await import("../dedupe.ts") + handleDedupeMerge(keepId, removeId) + expect(successMessages.some((m) => m.includes("Merged"))).toBe(true) + + const { getSubscription } = await import("../db.ts") + expect(getSubscription(removeId)).toBeUndefined() +}) + +test("handleDedupeMerge fails when subscription not found", async () => { + const { handleDedupeMerge } = await import("../dedupe.ts") + handleDedupeMerge(999, 1) + expect(errorMessages.some((m) => m.includes("not found"))).toBe(true) + expect(process.exitCode).toBe(1) + process.exitCode = 0 +}) + +test("handleDedupeMerge rejects identical IDs", async () => { + const id = insertSub({ name: "Netflix" }) + const { handleDedupeMerge } = await import("../dedupe.ts") + handleDedupeMerge(id, id) + expect(errorMessages.some((m) => m.includes("must differ"))).toBe(true) + expect(process.exitCode).toBe(1) + process.exitCode = 0 +}) \ No newline at end of file diff --git a/apps/subtrack/src/__tests__/report.test.ts b/apps/subtrack/src/__tests__/report.test.ts new file mode 100644 index 0000000..75bbb4c --- /dev/null +++ b/apps/subtrack/src/__tests__/report.test.ts @@ -0,0 +1,327 @@ +import { test, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest" +import initSqlJs from "sql.js" +import type { Database } from "sql.js" +import { consola } from "consola" +import { mkdtempSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +const logMessages: string[] = [] +const errorMessages: string[] = [] + +let originalEnv: string | undefined +let originalFetch: typeof globalThis.fetch + +let SQL: Awaited> +let testDb: Database + +beforeAll(async () => { + SQL = await initSqlJs() +}) + +beforeEach(async () => { + originalEnv = process.env.SUBSC_CLI_DB_DIR + const testConfigDir = mkdtempSync(join(tmpdir(), "subtrack-report-")) + process.env.SUBSC_CLI_DB_DIR = testConfigDir + + logMessages.length = 0 + errorMessages.length = 0 + + const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "") + + consola.mockTypes((_type: string, _defaults: object) => { + return (...args: unknown[]) => { + const str = args.map((a) => String(a)).join(" ") + const clean = stripAnsi(str) + if (_type === "log") logMessages.push(clean) + if (_type === "error") errorMessages.push(clean) + } + }) + + testDb = new SQL.Database() + testDb.run(`CREATE TABLE IF NOT EXISTS subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + price INTEGER NOT NULL, + currency TEXT NOT NULL, + cycle TEXT NOT NULL DEFAULT 'monthly', + status TEXT NOT NULL DEFAULT 'active', + billing_day INTEGER, + created_at TEXT NOT NULL DEFAULT (date('now')), + notes TEXT, + payment_method TEXT, + contract_start TEXT, + contract_end TEXT, + auto_renewal INTEGER NOT NULL DEFAULT 1, + vendor_name TEXT, + vendor_url TEXT, + plan_tier TEXT, + discount_amount INTEGER, + discount_type TEXT + )`) + testDb.run( + "CREATE TABLE IF NOT EXISTS tags (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE)", + ) + testDb.run( + "CREATE TABLE IF NOT EXISTS subscription_tags (subscription_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, PRIMARY KEY (subscription_id, tag_id))", + ) + testDb.run( + "CREATE TABLE IF NOT EXISTS price_history (id INTEGER PRIMARY KEY AUTOINCREMENT, subscription_id INTEGER NOT NULL, old_price INTEGER, new_price INTEGER NOT NULL, old_currency TEXT, new_currency TEXT NOT NULL, changed_at TEXT NOT NULL DEFAULT (datetime('now')))", + ) + testDb.run( + "CREATE TABLE IF NOT EXISTS audit_log (id INTEGER PRIMARY KEY AUTOINCREMENT, action TEXT NOT NULL, target_type TEXT, target_id INTEGER, details TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')))", + ) + + const dbMod = await import("../db.ts") + dbMod.__setDb(testDb) + + const { resetConfig } = await import("../config.ts") + resetConfig() + + originalFetch = globalThis.fetch + globalThis.fetch = async () => + new Response( + JSON.stringify({ + base: "USD", + rates: { JPY: 160, USD: 1 }, + }), + ) +}) + +afterEach(() => { + consola.mockTypes() + globalThis.fetch = originalFetch + if (originalEnv === undefined) { + delete process.env.SUBSC_CLI_DB_DIR + } else { + process.env.SUBSC_CLI_DB_DIR = originalEnv + } +}) + +afterAll(() => { + testDb.close() +}) + +// ── Helper ───────────────────────────────────────────── + +function insertSub(overrides: Record = {}): number { + const fields = { + name: "Test Sub", + price: 1000, + currency: "JPY", + cycle: "monthly", + status: "active", + billingDay: 1, + createdAt: "2026-01-01", + contractEnd: null, + ...overrides, + } + testDb.run( + "INSERT INTO subscriptions (name, price, currency, cycle, status, billing_day, created_at, contract_end) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [fields.name, fields.price, fields.currency, fields.cycle, fields.status, fields.billingDay, fields.createdAt, fields.contractEnd], + ) + const row = testDb.exec("SELECT last_insert_rowid() AS id") + return Number(row[0].values[0][0]) +} + +// ── calcYearlyTotals ─────────────────────────────────── + +test("calcYearlyTotals computes per-month totals for a year", async () => { + const { calcYearlyTotals } = await import("../report.ts") + const subs = [ + { id: 1, name: "Netflix", price: 1000, currency: "JPY", cycle: "monthly", status: "active", tags: [], billingDay: 1, createdAt: "2026-01-01", notes: null, paymentMethod: null, contractStart: null, contractEnd: null, autoRenewal: true, vendorName: null, vendorUrl: null, planTier: null, discountAmount: null, discountType: null }, + ] as never[] + + const totals = calcYearlyTotals(subs, 2026) + expect(totals.length).toBe(12) + expect(totals[0]).toEqual({ label: "2026-01", year: 2026, month: 0, total: 1000 }) + expect(totals[11]!.total).toBe(1000) +}) + +test("calcYearlyTotals excludes subs created after the month", async () => { + const { calcYearlyTotals } = await import("../report.ts") + const subs = [ + { id: 1, name: "Netflix", price: 1000, currency: "JPY", cycle: "monthly", status: "active", tags: [], billingDay: 1, createdAt: "2026-06-15", notes: null, paymentMethod: null, contractStart: null, contractEnd: null, autoRenewal: true, vendorName: null, vendorUrl: null, planTier: null, discountAmount: null, discountType: null }, + ] as never[] + + const totals = calcYearlyTotals(subs, 2026) + expect(totals[0]!.total).toBe(0) + expect(totals[5]!.total).toBe(1000) + expect(totals[11]!.total).toBe(1000) +}) + +test("calcYearlyTotals counts cancelled subs until contractEnd", async () => { + const { calcYearlyTotals } = await import("../report.ts") + const subs = [ + { id: 1, name: "Netflix", price: 1000, currency: "JPY", cycle: "monthly", status: "cancelled", tags: [], billingDay: 1, createdAt: "2026-01-01", notes: null, paymentMethod: null, contractStart: null, contractEnd: "2026-03-31", autoRenewal: true, vendorName: null, vendorUrl: null, planTier: null, discountAmount: null, discountType: null }, + ] as never[] + + const totals = calcYearlyTotals(subs, 2026) + expect(totals[0]!.total).toBe(1000) + expect(totals[2]!.total).toBe(1000) + expect(totals[3]!.total).toBe(0) +}) + +test("calcYearlyTotals scales non-monthly cycles", async () => { + const { calcYearlyTotals } = await import("../report.ts") + const subs = [ + { id: 1, name: "AWS", price: 12000, currency: "JPY", cycle: "yearly", status: "active", tags: [], billingDay: 1, createdAt: "2026-01-01", notes: null, paymentMethod: null, contractStart: null, contractEnd: null, autoRenewal: true, vendorName: null, vendorUrl: null, planTier: null, discountAmount: null, discountType: null }, + ] as never[] + + const totals = calcYearlyTotals(subs, 2026) + expect(totals[0]!.total).toBe(1000) // 12000 / 12 +}) + +// ── yearlyCost / calcTopSubscriptions ────────────────── + +test("yearlyCost uses occurrences per year", async () => { + const { yearlyCost } = await import("../report.ts") + const mk = (cycle: string) => ({ + id: 1, name: "X", price: 100, currency: "JPY", cycle, status: "active", tags: [], + billingDay: 1, createdAt: "2026-01-01", notes: null, paymentMethod: null, + contractStart: null, contractEnd: null, autoRenewal: true, vendorName: null, + vendorUrl: null, planTier: null, discountAmount: null, discountType: null, + }) as never as Parameters[0] + expect(yearlyCost(mk("monthly"))).toBe(1200) + expect(yearlyCost(mk("yearly"))).toBe(100) + expect(yearlyCost(mk("weekly"))).toBe(5200) +}) + +test("calcTopSubscriptions returns top 5 by yearly cost", async () => { + const { calcTopSubscriptions } = await import("../report.ts") + const mk = (id: number, name: string, price: number) => ({ + id, name, price, currency: "JPY", cycle: "monthly", status: "active", tags: [], + billingDay: 1, createdAt: "2026-01-01", notes: null, paymentMethod: null, + contractStart: null, contractEnd: null, autoRenewal: true, vendorName: null, + vendorUrl: null, planTier: null, discountAmount: null, discountType: null, + }) as never as Parameters[0] + const subs = [mk(1, "A", 100), mk(2, "B", 500), mk(3, "C", 300), mk(4, "D", 200), mk(5, "E", 900), mk(6, "F", 700)] + const top = calcTopSubscriptions(subs, 5) + expect(top.map((s) => s.name)).toEqual(["E", "F", "B", "C", "D"]) +}) + +// ── calcAddedThisYear / calcCancelledThisYear ────────── + +test("calcAddedThisYear filters by createdAt year", async () => { + insertSub({ name: "Netflix", createdAt: "2025-11-01" }) + insertSub({ name: "Spotify", createdAt: "2026-02-10" }) + insertSub({ name: "AWS", createdAt: "2026-07-01", status: "cancelled", contractEnd: "2026-09-30" }) + + const { calcAddedThisYear, calcCancelledThisYear } = await import("../report.ts") + const { getSubscriptions } = await import("../db.ts") + const subs = getSubscriptions({ includeArchived: true }) + + const added = calcAddedThisYear(subs, 2026) + expect(added.map((s) => s.name).sort()).toEqual(["AWS", "Spotify"]) + + const cancelled = calcCancelledThisYear(subs, 2026) + expect(cancelled.map((c) => c.name)).toEqual(["AWS"]) +}) + +test("calcCancelledThisYear includes audit log entries", async () => { + const id = insertSub({ name: "Netflix", status: "cancelled", contractEnd: null }) + testDb.run( + "INSERT INTO audit_log (action, target_type, target_id, details, created_at) VALUES ('subscription.cancel', 'subscription', ?, 'Netflix', '2026-05-15 10:00:00')", + [id], + ) + + const { calcCancelledThisYear } = await import("../report.ts") + const { getSubscriptions } = await import("../db.ts") + const subs = getSubscriptions({ includeArchived: true }) + const cancelled = calcCancelledThisYear(subs, 2026) + expect(cancelled.some((c) => c.name === "Netflix")).toBe(true) +}) + +// ── handleReport ─────────────────────────────────────── + +test("handleReport renders yearly report", async () => { + insertSub({ name: "Netflix", price: 1000, createdAt: "2025-11-01" }) + insertSub({ name: "Spotify", price: 980, createdAt: "2026-02-10" }) + + const { handleReport } = await import("../report.ts") + await handleReport({ year: 2026 }) + + expect(logMessages.some((m) => m.includes("Subscription Report — 2026"))).toBe(true) + expect(logMessages.some((m) => m.includes("Total spending"))).toBe(true) + expect(logMessages.some((m) => m.includes("Top subscriptions"))).toBe(true) + expect(logMessages.some((m) => m.includes("Spotify"))).toBe(true) + expect(errorMessages.length).toBe(0) +}) + +test("handleReport JSON output", async () => { + insertSub({ name: "Netflix", price: 1000, createdAt: "2026-01-01" }) + + const writes: string[] = [] + const origWrite = process.stdout.write.bind(process.stdout) + process.stdout.write = ((chunk: string) => { + writes.push(String(chunk)) + return true + }) as typeof process.stdout.write + + const { handleReport } = await import("../report.ts") + await handleReport({ year: 2026, json: true }) + + process.stdout.write = origWrite + const parsed = JSON.parse(writes.join("")) + expect(parsed.year).toBe(2026) + expect(parsed.total).toBe(12000) + expect(parsed.monthly.length).toBe(12) + expect(parsed.top[0].name).toBe("Netflix") + expect(parsed.added.length).toBe(1) + expect(parsed.cancelled).toEqual([]) +}) + +test("handleReport JSON includes price changes for the year", async () => { + const id = insertSub({ name: "Netflix", price: 2000, createdAt: "2025-01-01" }) + testDb.run( + "INSERT INTO price_history (subscription_id, old_price, new_price, old_currency, new_currency, changed_at) VALUES (?, 1000, 2000, 'JPY', 'JPY', '2026-03-01 12:00:00')", + [id], + ) + // Entry from another year should be excluded + testDb.run( + "INSERT INTO price_history (subscription_id, old_price, new_price, old_currency, new_currency, changed_at) VALUES (?, 500, 1000, 'JPY', 'JPY', '2025-03-01 12:00:00')", + [id], + ) + + const writes: string[] = [] + const origWrite = process.stdout.write.bind(process.stdout) + process.stdout.write = ((chunk: string) => { + writes.push(String(chunk)) + return true + }) as typeof process.stdout.write + + const { handleReport } = await import("../report.ts") + await handleReport({ year: 2026, json: true }) + + process.stdout.write = origWrite + const parsed = JSON.parse(writes.join("")) + expect(parsed.priceChanges.length).toBe(1) + expect(parsed.priceChanges[0].diff).toBe(1000) +}) + +test("handleReport converts currency when requested", async () => { + insertSub({ name: "Netflix", price: 1600, currency: "JPY", createdAt: "2026-01-01" }) + + const writes: string[] = [] + const origWrite = process.stdout.write.bind(process.stdout) + process.stdout.write = ((chunk: string) => { + writes.push(String(chunk)) + return true + }) as typeof process.stdout.write + + const { handleReport } = await import("../report.ts") + await handleReport({ year: 2026, currency: "USD", json: true }) + + process.stdout.write = origWrite + const parsed = JSON.parse(writes.join("")) + expect(parsed.currency).toBe("USD") + expect(parsed.total).toBe(120) // JPY 1600/mo × 12 = 19200 → USD 120 +}) + +test("handleReport validates year", async () => { + const { handleReport } = await import("../report.ts") + await handleReport({ year: 99 }) + expect(errorMessages.some((m) => m.includes("year"))).toBe(true) + expect(process.exitCode).toBe(1) + process.exitCode = 0 +}) \ No newline at end of file diff --git a/apps/subtrack/src/budget.ts b/apps/subtrack/src/budget.ts new file mode 100644 index 0000000..39e1d74 --- /dev/null +++ b/apps/subtrack/src/budget.ts @@ -0,0 +1,245 @@ +import { consola } from "consola" +import pc from "picocolors" +import type { Currency, SharedArgs } from "./types.ts" +import { getSubscriptions } from "./db.ts" +import { loadConfig } from "./config.ts" +import { formatPrice } from "./price.ts" +import { calcSubTotal } from "./payment.ts" +import { fetchFxRates, convertPrice } from "./fx.ts" +import type { FxRates } from "./fx.ts" + +export type BudgetOptions = { + /** Exit with code 1 when over budget (for cron/scripts) */ + check?: boolean + /** Comparison period: monthly or yearly (default: monthly) */ + period?: "monthly" | "yearly" + /** Convert all prices to target currency */ + currency?: string + /** Compare against a named budget from config.budgets */ + name?: string + /** Output as JSON */ + json?: boolean +} + +type ResolvedBudget = { + name: string | null + amount: number + currency: string + categories?: string[] +} + +/** + * Resolve the budget to compare against. + * - Named budget: entry from config.budgets (uses its own period/currency) + * - Yearly: config.yearlyBudget, falling back to monthlyBudget * 12 + * - Monthly: config.monthlyBudget + * Returns null when no budget is configured. + */ +export function resolveBudget( + period: "monthly" | "yearly", + name?: string, +): ResolvedBudget | null { + const config = loadConfig() + + if (name) { + const entry = config.budgets?.find((b) => b.name === name) + if (!entry) return null + return { + name: entry.name, + amount: entry.amount, + currency: entry.currency || config.defaultCurrency || "USD", + categories: entry.categories, + } + } + + if (period === "yearly") { + const amount = config.yearlyBudget ?? (config.monthlyBudget > 0 ? config.monthlyBudget * 12 : 0) + return amount > 0 + ? { name: null, amount, currency: config.defaultCurrency || "USD" } + : null + } + + return config.monthlyBudget > 0 + ? { name: null, amount: config.monthlyBudget, currency: config.defaultCurrency || "USD" } + : null +} + +/** + * Convert per-currency totals into a single target currency. + * Returns the sum and whether any rate was missing. + */ +export function convertTotals( + totals: Record, + target: string, + rates: FxRates, +): { sum: number; missing: boolean } { + let sum = 0 + let missing = false + for (const [ccy, total] of Object.entries(totals)) { + if (ccy === target) { + sum += total + continue + } + try { + sum += convertPrice(total, ccy, target, rates.rates) + } catch { + missing = true + } + } + return { sum, missing } +} + +export async function handleBudget(options: BudgetOptions = {}): Promise { + const period = options.period ?? "monthly" + const subs = getSubscriptions().filter((s) => s.status !== "cancelled") + + const budget = resolveBudget(period, options.name) + if (!budget) { + if (options.json) { + process.stdout.write( + JSON.stringify({ set: false, period, budgetName: options.name ?? null }, null, 2) + "\n", + ) + return + } + if (options.name) { + consola.info(`Budget "${options.name}" not found — check: subtrack config set budgets ...`) + return + } + const config = loadConfig() + if (period === "monthly" && (config.yearlyBudget ?? 0) > 0) { + consola.info( + "Monthly budget not set — use --period yearly to compare against yearlyBudget, " + + "or set: subtrack config set monthlyBudget ", + ) + return + } + consola.info( + period === "yearly" + ? "No budget set. Use: subtrack config set yearlyBudget " + : "No budget set. Use: subtrack config set monthlyBudget ", + ) + return + } + + // Named budgets may carry their own period (e.g. yearly vs monthly compare) + const comparePeriod = options.name + ? (loadConfig().budgets?.find((b) => b.name === options.name)?.period ?? period) + : period + + // Fetch FX rates when any conversion might be needed + const targetCurrency = options.currency as Currency | undefined + let rates: FxRates | null = null + if (targetCurrency || budget.currency) { + try { + rates = await fetchFxRates() + } catch { + consola.warn("Failed to fetch exchange rates; comparing in original currencies") + } + } + + // Named budgets can filter by categories (tags) + let filtered: SharedArgs[] = subs + if (budget.categories && budget.categories.length > 0) { + filtered = subs.filter((s) => s.tags.some((t) => budget.categories!.includes(t))) + } + + const totals = calcSubTotal(filtered, rates, targetCurrency, comparePeriod) + + const periodLabel = comparePeriod === "yearly" ? "year" : "month" + const periodName = comparePeriod === "yearly" ? "Yearly" : "Monthly" + + // Determine a single comparable (currency, spending) pair + let currency: string | null = null + let spending = 0 + let budgetDisplay: number = budget.amount + + if (targetCurrency && rates) { + currency = targetCurrency + spending = Object.values(totals).reduce((a, b) => a + b, 0) + // Convert budget into display currency for a fair comparison + try { + budgetDisplay = convertPrice(budget.amount, budget.currency, targetCurrency, rates.rates) + } catch { + consola.warn(`Cannot convert budget from ${budget.currency} to ${targetCurrency} — missing rate`) + } + } else if (rates && budget.currency) { + const { sum, missing } = convertTotals(totals, budget.currency, rates) + if (missing) consola.warn("Some prices could not be converted (missing rate)") + currency = budget.currency + spending = sum + } else { + const keys = Object.keys(totals) + if (keys.length === 1 && keys[0] === budget.currency) { + currency = keys[0] + spending = totals[keys[0]] ?? 0 + } + } + + if (currency === null) { + const parts = Object.entries(totals) + .map(([ccy, total]) => formatPrice(Math.round(total), ccy)) + .join(" + ") + if (options.json) { + process.stdout.write( + JSON.stringify({ + set: true, + period: comparePeriod, + budgetName: budget.name, + budget: budget.amount, + budgetCurrency: budget.currency, + spendingByCurrency: Object.fromEntries( + Object.entries(totals).map(([ccy, total]) => [ccy, Math.round(total)]), + ), + comparable: false, + }, null, 2) + "\n", + ) + return + } + consola.log(`${periodName} spending: ${parts}/${periodLabel}`) + consola.info( + "Cannot compare against budget — multiple currencies. Use --currency to convert.", + ) + return + } + + const remaining = budgetDisplay - spending + const over = remaining < 0 + + if (options.json) { + process.stdout.write( + JSON.stringify({ + set: true, + period: comparePeriod, + budgetName: budget.name, + budget: Math.round(budgetDisplay), + budgetCurrency: currency, + spending: Math.round(spending), + currency, + remaining: Math.round(remaining), + over, + }, null, 2) + "\n", + ) + return + } + + const budgetLabel = budget.name ? `Budget (${budget.name})` : "Budget" + consola.log( + `${periodName} spending: ${pc.bold(formatPrice(Math.round(spending), currency))}/${periodLabel}`, + ) + consola.log( + `${budgetLabel}: ${pc.bold(formatPrice(Math.round(budgetDisplay), currency))}/${periodLabel}` + + (budget.currency !== currency ? ` (${budget.currency})` : ""), + ) + if (over) { + consola.log(`Over budget: ${pc.red(formatPrice(Math.round(-remaining), currency))}`) + } else { + consola.log(`Remaining: ${pc.green(formatPrice(Math.round(remaining), currency))}`) + } + if (budget.categories && budget.categories.length > 0) { + consola.log(pc.dim(`(filtered by categories: ${budget.categories.join(", ")})`)) + } + + if (options.check && over) { + process.exitCode = 1 + } +} \ No newline at end of file diff --git a/apps/subtrack/src/cancel.ts b/apps/subtrack/src/cancel.ts new file mode 100644 index 0000000..35d504d --- /dev/null +++ b/apps/subtrack/src/cancel.ts @@ -0,0 +1,144 @@ +import { confirm } from "@inquirer/prompts" +import { consola } from "consola" +import pc from "picocolors" +import { mkdirSync, writeFileSync } from "node:fs" +import os from "node:os" +import path from "node:path" +import { getSubscription, updateSubscription } from "./db.ts" +import { logAudit } from "./audit.ts" +import { fail } from "./error.ts" +import { formatPrice } from "./price.ts" +import { calculateNextBilling } from "./upcoming.ts" +import { today } from "./date-utils.ts" +import { exportCsv } from "./export.ts" +import { resolveSafeOutputPath } from "./path-utils.ts" +import type { AddSharedArgs } from "./types.ts" + +export type CancelOptions = { + /** Skip the checklist and cancel immediately */ + force?: boolean + /** Output subscription info as JSON (no changes made) */ + json?: boolean +} + +function formatDate(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}` +} + +/** Sanitize a subscription name for use in a file name. */ +function safeFileName(name: string): string { + return name.replace(/[^\p{L}\p{N}._-]+/gu, "_").slice(0, 60) || "subscription" +} + +export async function handleCancel(id: number, options: CancelOptions = {}): Promise { + const sub = getSubscription(id) + if (!sub) { + fail(`Subscription with id ${id} not found`) + return + } + + if (sub.status === "cancelled") { + consola.info(`"${sub.name}" is already cancelled`) + return + } + if (sub.status === "archived") { + consola.info(`"${sub.name}" is archived — unarchive it first: subtrack unarchive ${id}`) + return + } + + const nextBilling = calculateNextBilling(sub, new Date()) + const cancellationDate = today() + + if (options.json) { + process.stdout.write( + JSON.stringify( + { + id: sub.id, + name: sub.name, + price: sub.price, + currency: sub.currency, + cycle: sub.cycle, + status: sub.status, + nextBilling: formatDate(nextBilling), + cancellationDate, + }, + null, + 2, + ) + "\n", + ) + return + } + + consola.log(pc.bold(`Cancelling: ${sub.name}`)) + consola.log(` Price: ${formatPrice(sub.price, sub.currency)}/${sub.cycle}`) + consola.log(` Next billing: ${formatDate(nextBilling)}`) + consola.log("") + + let noteCancellationDate = false + if (!options.force) { + // Checklist 1: export data + const doExport = await confirm({ + message: "Export this subscription's data before cancelling?", + default: true, + }) + if (doExport) { + const exportPath = path.join(os.homedir(), "exports", `${safeFileName(sub.name)}-${cancellationDate}.csv`) + const safePath = resolveSafeOutputPath([os.homedir(), os.tmpdir()], exportPath) + if (!safePath) { + consola.warn("Cannot export — invalid output path; skipping export") + } else { + try { + mkdirSync(path.dirname(safePath), { recursive: true, mode: 0o700 }) + writeFileSync(safePath, exportCsv([sub]), { mode: 0o600 }) + consola.success(`Exported data to: ${safePath}`) + } catch (err) { + consola.warn(`Export failed: ${err instanceof Error ? err.message : String(err)}`) + } + } + } + + // Checklist 2: alternatives + const checkedAlternatives = await confirm({ + message: "Have you checked alternative services?", + default: false, + }) + if (!checkedAlternatives) { + consola.warn("You may want to review alternatives before cancelling") + } + + // Checklist 3: note the cancellation date + noteCancellationDate = await confirm({ + message: "Note the cancellation date in the subscription notes?", + default: true, + }) + + // Final confirmation + const ok = await confirm({ + message: `Confirm cancellation of "${sub.name}"?`, + default: false, + }) + if (!ok) { + consola.info("Aborted — no changes made") + return + } + } + + const updates: Partial = { status: "cancelled" } + if (noteCancellationDate) { + updates.notes = sub.notes + ? `${sub.notes}\nCancelled: ${cancellationDate}` + : `Cancelled: ${cancellationDate}` + } + if (!sub.contractEnd) { + updates.contractEnd = cancellationDate + } + + updateSubscription(id, updates) + logAudit("subscription.cancel", { + targetType: "subscription", + targetId: id, + details: sub.name, + }) + consola.success(`Cancelled: "${sub.name}"`) + consola.info(`Remove it permanently with: subtrack delete ${id}`) +} \ No newline at end of file diff --git a/apps/subtrack/src/commands/core.ts b/apps/subtrack/src/commands/core.ts index e0048ff..242db97 100644 --- a/apps/subtrack/src/commands/core.ts +++ b/apps/subtrack/src/commands/core.ts @@ -11,6 +11,7 @@ import { handleUnarchive, } from "../subscription.ts" import { handleSearch } from "../search.ts" +import { handleCancel } from "../cancel.ts" import { saveDb } from "../db.ts" import { fail } from "../error.ts" @@ -139,6 +140,25 @@ export const deleteCommand = define({ }, }) +export const cancelCommand = define({ + name: "cancel", + description: "Cancel a subscription with a guided checklist", + args: { + id: { type: "positional", description: "Subscription ID to cancel" }, + force: { type: "boolean", short: "f", description: "Skip the checklist and cancel immediately" }, + json: { type: "boolean", short: "j", description: "Output subscription info as JSON (no changes)" }, + }, + run: async (ctx) => { + const positionals = ctx.positionals as string[] + const id = ctx.values.id !== undefined ? Number(ctx.values.id) : positionals[1] ? Number(positionals[1]) : undefined + if (id === undefined || isNaN(id) || !Number.isInteger(id) || id < 1) { + fail("Valid subscription ID is required") + return + } + await handleCancel(id, { force: ctx.values.force, json: ctx.values.json }) + }, +}) + export const cloneCommand = define({ name: "clone", description: "Clone an existing subscription", diff --git a/apps/subtrack/src/commands/index.ts b/apps/subtrack/src/commands/index.ts index 8b4f015..70a63d3 100644 --- a/apps/subtrack/src/commands/index.ts +++ b/apps/subtrack/src/commands/index.ts @@ -1,7 +1,7 @@ // ── Barrel: re-exports all command definitions and builds the subCommands map ── import { - listCommand, addCommand, editCommand, deleteCommand, + listCommand, addCommand, editCommand, deleteCommand, cancelCommand, cloneCommand, archiveCommand, unarchiveCommand, searchCommand, } from "./core.ts" import { tagsCommand, tagCommand } from "./tag.ts" @@ -16,16 +16,18 @@ import { analyticsCommand, compareCommand, calendarCommand, forecastCommand, historyCommand, notifyCommand, timelineCommand, optimizeCommand, statsCommand, + budgetCommand, reportCommand, } from "./report.ts" import { mcpCommand, profileCommand, auditCommand, auditListCmd, auditPruneCmd, maintenanceCommand, cleanupCommand, currencyCommand, + dedupeCommand, } from "./misc.ts" import { suggestCommand } from "./suggest.ts" export { - listCommand, addCommand, editCommand, deleteCommand, + listCommand, addCommand, editCommand, deleteCommand, cancelCommand, cloneCommand, archiveCommand, unarchiveCommand, searchCommand, tagsCommand, tagCommand, trialCommand, @@ -38,9 +40,11 @@ export { analyticsCommand, compareCommand, calendarCommand, forecastCommand, historyCommand, notifyCommand, timelineCommand, optimizeCommand, statsCommand, + budgetCommand, reportCommand, mcpCommand, profileCommand, auditCommand, auditListCmd, auditPruneCmd, maintenanceCommand, cleanupCommand, currencyCommand, + dedupeCommand, suggestCommand, } @@ -50,6 +54,7 @@ export const subCommands = { add: addCommand, edit: editCommand, delete: deleteCommand, + cancel: cancelCommand, clone: cloneCommand, archive: archiveCommand, unarchive: unarchiveCommand, @@ -76,7 +81,10 @@ export const subCommands = { maintenance: maintenanceCommand, cleanup: cleanupCommand, stats: statsCommand, + budget: budgetCommand, + report: reportCommand, currency: currencyCommand, + dedupe: dedupeCommand, mcp: mcpCommand, analytics: analyticsCommand, compare: compareCommand, diff --git a/apps/subtrack/src/commands/misc.ts b/apps/subtrack/src/commands/misc.ts index e3f4028..f14355f 100644 --- a/apps/subtrack/src/commands/misc.ts +++ b/apps/subtrack/src/commands/misc.ts @@ -7,6 +7,7 @@ import { handleAuditList, handleAuditPrune } from "../audit.ts" import { handleMaintenance } from "../maintenance.ts" import { handleCleanup } from "../cleanup.ts" import { handleCurrencyList } from "../currency.ts" +import { handleDedupe, handleDedupeMerge } from "../dedupe.ts" // Lazy imports for MCP to avoid loading MCP SDK WASM at module load time import type { Status } from "../types.ts" @@ -180,3 +181,44 @@ export const currencyCommand = define({ args: { json: { type: "boolean", short: "j", description: "Output as JSON" } }, run: (ctx) => handleCurrencyList({ json: ctx.values.json }), }) + +// ── Dedupe ──────────────────────────────────────────── + +const dedupeMergeCmd = define({ + name: "merge", + description: "Merge a duplicate subscription into another", + args: { + keep: { type: "positional", description: "Subscription ID to keep" }, + remove: { type: "positional", description: "Subscription ID to remove" }, + }, + run: (ctx) => { + const positionals = ctx.positionals as string[] + const keep = ctx.values.keep !== undefined ? Number(ctx.values.keep) : positionals[1] ? Number(positionals[1]) : undefined + const remove = ctx.values.remove !== undefined ? Number(ctx.values.remove) : positionals[2] ? Number(positionals[2]) : undefined + if (keep === undefined || remove === undefined || isNaN(keep) || isNaN(remove) || keep < 1 || remove < 1) { + fail("Usage: subtrack dedupe merge ") + return + } + handleDedupeMerge(keep, remove) + }, +}) + +export const dedupeCommand = define({ + name: "dedupe", + description: "Detect duplicate subscriptions by name similarity", + args: { + threshold: { type: "string", description: "Similarity threshold 0-1 (default: 0.8)" }, + json: { type: "boolean", short: "j", description: "Output as JSON" }, + }, + subCommands: { + merge: dedupeMergeCmd, + }, + run: (ctx) => { + const threshold = ctx.values.threshold !== undefined ? Number(ctx.values.threshold) : undefined + if (threshold !== undefined && (isNaN(threshold) || threshold < 0 || threshold > 1)) { + fail("threshold must be between 0 and 1") + return + } + handleDedupe({ threshold, json: ctx.values.json }) + }, +}) diff --git a/apps/subtrack/src/commands/report.ts b/apps/subtrack/src/commands/report.ts index 2951abb..a477f50 100644 --- a/apps/subtrack/src/commands/report.ts +++ b/apps/subtrack/src/commands/report.ts @@ -13,6 +13,8 @@ import { handleNotify } from "../notify.ts" import { handleTimeline } from "../timeline.ts" import { handleOptimize } from "../optimize.ts" import { handleStats } from "../stats.ts" +import { handleBudget } from "../budget.ts" +import { handleReport } from "../report.ts" import type { Cycle, NotifyChannel } from "../types.ts" import { fail } from "../error.ts" @@ -257,3 +259,47 @@ export const statsCommand = define({ }, run: (ctx) => handleStats({ json: ctx.values.json }), }) + +export const budgetCommand = define({ + name: "budget", + description: "Show spending vs budget and detect budget overruns", + args: { + check: { type: "boolean", description: "Exit with code 1 when over budget (for scripts)" }, + period: { type: "string", description: "Period: monthly, yearly (default: monthly)" }, + currency: { type: "string", short: "c", description: "Convert all prices to target currency" }, + name: { type: "string", description: "Compare against a named budget (config budgets)" }, + json: { type: "boolean", short: "j", description: "Output as JSON" }, + }, + run: async (ctx) => { + const period = (ctx.values.period as "monthly" | "yearly" | undefined) ?? "monthly" + if (period !== "monthly" && period !== "yearly") { + fail("period must be one of: monthly, yearly") + return + } + await handleBudget({ + check: ctx.values.check, + period, + currency: ctx.values.currency, + name: ctx.values.name, + json: ctx.values.json, + }) + }, +}) + +export const reportCommand = define({ + name: "report", + description: "Show a yearly subscription report", + args: { + year: { type: "string", description: "Target year (default: current year)" }, + currency: { type: "string", short: "c", description: "Convert all prices to target currency" }, + json: { type: "boolean", short: "j", description: "Output as JSON" }, + }, + run: async (ctx) => { + const rawYear = ctx.values.year !== undefined ? Number(ctx.values.year) : undefined + if (rawYear !== undefined && (isNaN(rawYear) || rawYear < 1970 || rawYear > 9999 || !Number.isInteger(rawYear))) { + fail("year must be a valid year (e.g. 2025)") + return + } + await handleReport({ year: rawYear, currency: ctx.values.currency, json: ctx.values.json }) + }, +}) diff --git a/apps/subtrack/src/config.ts b/apps/subtrack/src/config.ts index 6182757..ee91967 100644 --- a/apps/subtrack/src/config.ts +++ b/apps/subtrack/src/config.ts @@ -11,6 +11,7 @@ import type { SubtrackConfig } from "./types.ts" export const CONFIG_KEYS = [ "defaultCurrency", "monthlyBudget", + "yearlyBudget", "theme", "notifyDays", "notifyChannels", @@ -115,6 +116,21 @@ export function setConfig(key: string, value: string): boolean { config.monthlyBudget = num break } + case "yearlyBudget": { + const num = Number(value) + if (isNaN(num) || num < 0) { + fail("yearlyBudget must be a non-negative number") + return false + } + config.yearlyBudget = num + break + } + case "budgets": { + const parsed = parseBudgets(value) + if (!parsed) return false + config.budgets = parsed + break + } case "theme": config.theme = value break @@ -213,6 +229,15 @@ export function handleConfigList(): void { for (const key of CONFIG_KEYS) { consola.log(`${key}: ${getConfigDisplayValue(key, config)}`) } + // Show named budgets + if (config.budgets && config.budgets.length > 0) { + for (const b of config.budgets) { + const cat = b.categories?.length ? ` (categories: ${b.categories.join(", ")})` : "" + consola.log(`budgets: ${b.name} = ${b.amount} ${b.currency}/${b.period ?? "monthly"}${cat}`) + } + } else { + consola.log(`budgets: (not set)`) + } // Show IMAP config if (config.imap) { consola.log(`imapHost: ${config.imap.host}`) @@ -229,7 +254,64 @@ export function handleConfigList(): void { function isKnownKey(key: string): boolean { return (CONFIG_KEYS as readonly string[]).includes(key as ConfigKey) || - (IMAP_KEYS as readonly string[]).includes(key as typeof IMAP_KEYS[number]) + (IMAP_KEYS as readonly string[]).includes(key as typeof IMAP_KEYS[number]) || + key === "budgets" +} + +/** + * Parse a JSON array of named budget entries, e.g. + * `[{"name":"entertainment","amount":5000,"currency":"JPY","period":"monthly","categories":["video","music"]}]` + * Returns null (after reporting) when the value is invalid. + */ +function parseBudgets(value: string): import("./types.ts").BudgetEntry[] | null { + let parsed: unknown + try { + parsed = safeJsonParse(value) + } catch { + fail("budgets must be a valid JSON array of budget entries") + return null + } + if (!Array.isArray(parsed)) { + fail("budgets must be a valid JSON array of budget entries") + return null + } + const entries: import("./types.ts").BudgetEntry[] = [] + for (const item of parsed) { + const entry = item as Record + if (!entry || typeof entry !== "object") { + fail("Each budget entry must be an object with name, amount, currency") + return null + } + if (typeof entry.name !== "string" || entry.name.trim() === "") { + fail("Each budget entry needs a non-empty name") + return null + } + if (typeof entry.amount !== "number" || !isFinite(entry.amount) || entry.amount < 0) { + fail(`Budget "${entry.name}": amount must be a non-negative number`) + return null + } + if (typeof entry.currency !== "string" || !/^[A-Z]{3}$/.test(entry.currency)) { + fail(`Budget "${entry.name}": currency must be a 3-letter code (e.g. JPY)`) + return null + } + if (entry.period !== undefined && entry.period !== "monthly" && entry.period !== "yearly") { + fail(`Budget "${entry.name}": period must be "monthly" or "yearly"`) + return null + } + if (entry.categories !== undefined && + (!Array.isArray(entry.categories) || entry.categories.some((c) => typeof c !== "string"))) { + fail(`Budget "${entry.name}": categories must be an array of tag names`) + return null + } + entries.push({ + name: entry.name, + amount: entry.amount, + currency: entry.currency, + period: entry.period as "monthly" | "yearly" | undefined, + categories: entry.categories as string[] | undefined, + }) + } + return entries } /** diff --git a/apps/subtrack/src/db.ts b/apps/subtrack/src/db.ts index 2b9ccdb..1a06992 100644 --- a/apps/subtrack/src/db.ts +++ b/apps/subtrack/src/db.ts @@ -5,7 +5,7 @@ export { } from "./db/connection.ts" export { runMigrations } from "./db/schema.ts" export { - getSubscriptions, getSubscription, writeSubscription, updateSubscription, deleteSubscription, archiveSubscription, unarchiveSubscription, mapTags, findSubscriptionByName, + getSubscriptions, getSubscription, writeSubscription, updateSubscription, deleteSubscription, archiveSubscription, unarchiveSubscription, mapTags, findSubscriptionByName, mergeSubscriptions, } from "./db/subscriptions.ts" export { getAllTags, tagsSubscription, getTagsWithCount, renameTag, deleteTag, pruneTags, mergeTag, diff --git a/apps/subtrack/src/db/audit.ts b/apps/subtrack/src/db/audit.ts index e70f79b..8403236 100644 --- a/apps/subtrack/src/db/audit.ts +++ b/apps/subtrack/src/db/audit.ts @@ -21,6 +21,8 @@ export type AuditAction = | "subscription.bulk_tag_add" | "subscription.bulk_tag_remove" | "subscription.clone" + | "subscription.merge" + | "subscription.cancel" | "trial.add" | "trial.delete" | "tag.rename" diff --git a/apps/subtrack/src/db/subscriptions.ts b/apps/subtrack/src/db/subscriptions.ts index 4915c1e..09f470e 100644 --- a/apps/subtrack/src/db/subscriptions.ts +++ b/apps/subtrack/src/db/subscriptions.ts @@ -277,3 +277,34 @@ export const findSubscriptionByName = (name: string): SharedArgs | undefined => if (subs.length === 0) return undefined return mapTags(subs)[0] } + +/** + * Merge a duplicate subscription into another (transactional). + * Tags from the removed subscription are transferred to the kept one, + * then the removed subscription is deleted (price_history cascades). + */ +export const mergeSubscriptions = (keepId: number, removeId: number): boolean => { + const db = getDb() + if (keepId === removeId) return true + + db.run("BEGIN TRANSACTION") + try { + db.run( + `INSERT OR IGNORE INTO subscription_tags (subscription_id, tag_id) + SELECT ?, tag_id FROM subscription_tags WHERE subscription_id = ?`, + [keepId, removeId], + ) + db.run("DELETE FROM subscriptions WHERE id = ?", [removeId]) + const modified = db.getRowsModified() > 0 + if (!modified) { + db.run("ROLLBACK") + return false + } + db.run("COMMIT") + saveDb() + return true + } catch (error) { + try { db.run("ROLLBACK") } catch { /* ok */ } + throw error + } +} diff --git a/apps/subtrack/src/dedupe.ts b/apps/subtrack/src/dedupe.ts new file mode 100644 index 0000000..3c6e1e2 --- /dev/null +++ b/apps/subtrack/src/dedupe.ts @@ -0,0 +1,166 @@ +import { consola } from "consola" +import pc from "picocolors" +import { getSubscriptions, getSubscription, mergeSubscriptions } from "./db.ts" +import { logAudit } from "./audit.ts" +import { fail } from "./error.ts" +import { formatPrice } from "./price.ts" +import type { SharedArgs } from "./types.ts" + +export type DedupeOptions = { + /** Similarity threshold 0-1 (default: 0.8) */ + threshold?: number + /** Output as JSON */ + json?: boolean +} + +export type DuplicatePair = { + a: SharedArgs + b: SharedArgs + score: number + vendorUrlMatch: boolean +} + +/** + * Normalize a name for comparison: lowercase, strip all non-letter/non-digit + * characters (spaces, punctuation, hyphens). + */ +export function normalizeName(name: string): string { + return name.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "") +} + +/** Levenshtein edit distance between two strings (O(m*n) with rolling arrays). */ +export function levenshtein(a: string, b: string): number { + if (a === b) return 0 + const m = a.length + const n = b.length + if (m === 0) return n + if (n === 0) return m + + let prev = new Array(n + 1) + let curr = new Array(n + 1) + for (let j = 0; j <= n; j++) prev[j] = j + + for (let i = 1; i <= m; i++) { + curr[0] = i + for (let j = 1; j <= n; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1 + curr[j] = Math.min(prev[j]! + 1, curr[j - 1]! + 1, prev[j - 1]! + cost) + } + ;[prev, curr] = [curr, prev] + } + return prev[n]! +} + +/** + * Similarity score between two names in [0, 1]. + * 1 = identical after normalization, 0 = completely different. + */ +export function similarity(a: string, b: string): number { + const na = normalizeName(a) + const nb = normalizeName(b) + if (na === nb) return 1 + if (na.length === 0 || nb.length === 0) return 0 + const dist = levenshtein(na, nb) + return 1 - dist / Math.max(na.length, nb.length) +} + +/** + * Find potential duplicate pairs among subscriptions. + * A pair is reported when the normalized-name similarity is at or above + * the threshold, or when vendor URLs match exactly (boosted to >= 0.9). + */ +export function findDuplicates(subs: SharedArgs[], threshold = 0.8): DuplicatePair[] { + const pairs: DuplicatePair[] = [] + for (let i = 0; i < subs.length; i++) { + for (let j = i + 1; j < subs.length; j++) { + const a = subs[i]! + const b = subs[j]! + const nameScore = similarity(a.name, b.name) + const vendorUrlMatch = !!(a.vendorUrl && a.vendorUrl === b.vendorUrl) + const score = vendorUrlMatch ? Math.max(nameScore, 0.9) : nameScore + if (score >= threshold) { + pairs.push({ a, b, score, vendorUrlMatch }) + } + } + } + return pairs.sort((x, y) => y.score - x.score) +} + +export function handleDedupe(options: DedupeOptions = {}): void { + const threshold = options.threshold ?? 0.8 + if (threshold < 0 || threshold > 1) { + fail("threshold must be between 0 and 1") + return + } + + const subs = getSubscriptions().filter((s) => s.status !== "cancelled") + const pairs = findDuplicates(subs, threshold) + + if (options.json) { + process.stdout.write( + JSON.stringify( + pairs.map((p) => ({ + a: { id: p.a.id, name: p.a.name, price: p.a.price, currency: p.a.currency }, + b: { id: p.b.id, name: p.b.name, price: p.b.price, currency: p.b.currency }, + score: Number(p.score.toFixed(3)), + vendorUrlMatch: p.vendorUrlMatch, + })), + null, + 2, + ) + "\n", + ) + return + } + + if (pairs.length === 0) { + consola.info("No duplicate subscriptions found") + return + } + + consola.log(pc.bold(`Potential duplicates (threshold: ${threshold}):`)) + consola.log("") + for (const p of pairs) { + const scoreStr = pc.cyan(`${Math.round(p.score * 100)}%`) + const vendor = p.vendorUrlMatch ? pc.dim(" [same vendor URL]") : "" + consola.log( + ` ${pc.bold(`#${p.a.id} ${p.a.name}`)} ${formatPrice(p.a.price, p.a.currency)}/${p.a.cycle}`, + ) + consola.log( + ` ${pc.bold(`#${p.b.id} ${p.b.name}`)} ${formatPrice(p.b.price, p.b.currency)}/${p.b.cycle} ${scoreStr}${vendor}`, + ) + consola.log("") + } + consola.info("Merge with: subtrack dedupe merge ") +} + +export function handleDedupeMerge(keepId: number, removeId: number): void { + if (keepId === removeId) { + fail("keep and remove IDs must differ") + return + } + const keep = getSubscription(keepId) + const remove = getSubscription(removeId) + if (!keep) { + fail(`Subscription with id ${keepId} not found`) + return + } + if (!remove) { + fail(`Subscription with id ${removeId} not found`) + return + } + + try { + if (mergeSubscriptions(keepId, removeId)) { + logAudit("subscription.merge", { + targetType: "subscription", + targetId: keepId, + details: `Merged #${removeId} "${remove.name}" into "${keep.name}"`, + }) + consola.success(`Merged: "${remove.name}" (#${removeId}) → "${keep.name}" (#${keepId})`) + } else { + fail(`Failed to merge: subscription #${removeId} not found`) + } + } catch (error) { + fail(`Failed to merge subscriptions: ${String(error)}`) + } +} \ No newline at end of file diff --git a/apps/subtrack/src/report.ts b/apps/subtrack/src/report.ts new file mode 100644 index 0000000..2c7292b --- /dev/null +++ b/apps/subtrack/src/report.ts @@ -0,0 +1,323 @@ +import { consola } from "consola" +import pc from "picocolors" +import type { Currency, SharedArgs } from "./types.ts" +import type { AuditEntry } from "./db.ts" +import { getSubscriptions, getAllPriceChanges, getAuditLogs } from "./db.ts" +import { loadConfig } from "./config.ts" +import { formatPrice } from "./price.ts" +import { periodFactor, OCCURRENCES_PER_YEAR } from "./date-utils.ts" +import { fetchFxRates, convertPrice } from "./fx.ts" +import type { FxRates } from "./fx.ts" +import { renderBarChart } from "./timeline.ts" + +export type ReportOptions = { + /** Target year (default: current year) */ + year?: number + /** Convert all prices to target currency */ + currency?: string + /** Output as JSON */ + json?: boolean +} + +export type MonthTotal = { + label: string + year: number + month: number + total: number +} + +/** + * Calculate monthly spending totals for a specific calendar year. + * Cancelled subscriptions count until their contractEnd; archived ones are excluded. + */ +export function calcYearlyTotals(subs: SharedArgs[], year: number): MonthTotal[] { + const results: MonthTotal[] = [] + for (let m = 0; m < 12; m++) { + const monthStart = new Date(year, m, 1) + const monthEnd = new Date(year, m + 1, 0) + let total = 0 + for (const sub of subs) { + if (sub.status === "archived") continue + // Subscription must already exist by the end of the month + const created = new Date(sub.createdAt + "T00:00:00") + if (created > monthEnd) continue + // Cancelled subscriptions only count until their contract end + if (sub.status === "cancelled") { + if (!sub.contractEnd) continue + const end = new Date(sub.contractEnd + "T23:59:59") + if (end < monthStart) continue + } + total += sub.price * periodFactor(sub.cycle, "monthly") + } + results.push({ + label: `${year}-${String(m + 1).padStart(2, "0")}`, + year, + month: m, + total: Math.round(total), + }) + } + return results +} + +/** Yearly cost of a single subscription (price × occurrences per year). */ +export function yearlyCost(sub: SharedArgs): number { + return sub.price * OCCURRENCES_PER_YEAR[sub.cycle] +} + +/** Top N subscriptions by yearly cost. */ +export function calcTopSubscriptions(subs: SharedArgs[], n = 5): SharedArgs[] { + return subs + .filter((s) => s.status !== "archived") + .sort((a, b) => yearlyCost(b) - yearlyCost(a)) + .slice(0, n) +} + +/** Subscriptions created in the given year. */ +export function calcAddedThisYear(subs: SharedArgs[], year: number): SharedArgs[] { + const prefix = `${year}-` + return subs + .filter((s) => s.createdAt.startsWith(prefix) && s.status !== "archived") + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)) +} + +/** + * Subscriptions cancelled in the given year. + * Derived from contractEnd dates (primary) and audit log entries (fallback). + */ +export function calcCancelledThisYear(subs: SharedArgs[], year: number): { name: string; date: string }[] { + const prefix = `${year}-` + const results = new Map() + + for (const sub of subs) { + if (sub.status === "cancelled" && sub.contractEnd?.startsWith(prefix)) { + results.set(sub.name, sub.contractEnd) + } + } + + const auditEntries = getAuditLogs({ + action: "subscription.cancel", + from: `${year}-01-01`, + limit: 1000, + }) + for (const entry of auditEntries) { + if (entry.created_at.startsWith(prefix)) { + results.set(entry.details ?? `#${entry.target_id}`, entry.created_at.slice(0, 10)) + } + } + + return [...results.entries()] + .map(([name, date]) => ({ name, date })) + .sort((a, b) => a.date.localeCompare(b.date)) +} + +type YearlyTotals = { + totals: MonthTotal[] + total: number +} + +/** Sum of monthly totals. */ +export function sumYearlyTotals(totals: MonthTotal[]): number { + return totals.reduce((s, t) => s + t.total, 0) +} + +export async function handleReport(options: ReportOptions = {}): Promise { + const year = options.year ?? new Date().getFullYear() + if (year < 1970 || year > 9999 || !Number.isInteger(year)) { + consola.error("year must be a valid year (e.g. 2025)") + process.exitCode = 1 + return + } + + const subs = getSubscriptions({ includeArchived: true }) + + // Convert to target currency when requested + let displaySubs: SharedArgs[] = subs + let rates: FxRates | null = null + let displayCurrency: string | null = null + if (options.currency) { + const target = options.currency + try { + rates = await fetchFxRates() + displayCurrency = target + displaySubs = subs.map((s) => ({ + ...s, + price: Math.round(convertPrice(s.price, s.currency, target as Currency, rates!.rates)), + currency: target, + })) + } catch { + consola.warn("Failed to fetch exchange rates; reporting in original currencies") + displayCurrency = null + } + } + + const totals = calcYearlyTotals(displaySubs, year) + const total = sumYearlyTotals(totals) + + // Per-currency yearly totals (when no conversion) + const byCurrency: Record = {} + if (!displayCurrency) { + for (const sub of subs) { + if (sub.status === "archived") continue + byCurrency[sub.currency] = (byCurrency[sub.currency] ?? 0) + yearlyCost(sub) + } + // Round for display + for (const ccy of Object.keys(byCurrency)) { + byCurrency[ccy] = Math.round(byCurrency[ccy]!) + } + } + + const top = calcTopSubscriptions(displaySubs, 5) + const priceChanges = getAllPriceChanges().filter((c) => c.changedAt.startsWith(`${year}-`)) + const added = calcAddedThisYear(subs, year) + const cancelled = calcCancelledThisYear(subs, year) + + // Budget comparison + const config = loadConfig() + const yearlyBudget = config.yearlyBudget ?? (config.monthlyBudget > 0 ? config.monthlyBudget * 12 : 0) + const budgetCurrency = config.defaultCurrency || "USD" + let budgetInfo: { amount: number; currency: string; remaining: number; over: boolean } | null = null + if (yearlyBudget > 0) { + const chartCcy = displayCurrency ?? (Object.keys(byCurrency).length === 1 ? Object.keys(byCurrency)[0] : null) + let spending = total + let ccy = chartCcy + if (chartCcy && rates && chartCcy !== budgetCurrency) { + try { + spending = Math.round(convertPrice(total, chartCcy, budgetCurrency, rates.rates)) + ccy = budgetCurrency + } catch { + // keep as-is + } + } + if (ccy === budgetCurrency) { + const remaining = yearlyBudget - spending + budgetInfo = { + amount: yearlyBudget, + currency: budgetCurrency, + remaining, + over: remaining < 0, + } + } + } + + if (options.json) { + const output: Record = { + year, + total: Math.round(total), + currency: displayCurrency ?? (Object.keys(byCurrency).length === 1 ? Object.keys(byCurrency)[0] : null), + byCurrency, + monthly: totals.map((t) => ({ month: t.label, total: t.total })), + top: top.map((s) => ({ + id: s.id, + name: s.name, + yearlyCost: Math.round(yearlyCost(s)), + currency: s.currency, + })), + priceChanges: priceChanges.map((c) => ({ + subscriptionId: c.subscriptionId, + subscriptionName: c.subscriptionName, + oldPrice: c.oldPrice, + newPrice: c.newPrice, + oldCurrency: c.oldCurrency, + newCurrency: c.newCurrency, + changedAt: c.changedAt, + diff: c.oldPrice !== null && c.oldCurrency === c.newCurrency ? c.newPrice - c.oldPrice : null, + })), + added: added.map((s) => ({ id: s.id, name: s.name, price: s.price, currency: s.currency, cycle: s.cycle, createdAt: s.createdAt })), + cancelled, + budget: budgetInfo, + } + process.stdout.write(JSON.stringify(output, null, 2) + "\n") + return + } + + consola.log(pc.bold(`📊 Subscription Report — ${year}`)) + consola.log("") + + // Total spending + consola.log(pc.bold("Total spending:")) + if (displayCurrency) { + consola.log(` ${formatPrice(Math.round(total), displayCurrency)}`) + } else if (Object.keys(byCurrency).length === 1) { + const [ccy, amount] = Object.entries(byCurrency)[0]! + consola.log(` ${formatPrice(amount, ccy)}`) + } else { + const parts = Object.entries(byCurrency) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([ccy, amount]) => `${formatPrice(amount, ccy)}`) + consola.log(` ${parts.join(" + ")}`) + } + + // Monthly chart + consola.log("") + const chartCcy = displayCurrency ?? (Object.keys(byCurrency).length === 1 ? Object.keys(byCurrency)[0] : null) + if (chartCcy) { + consola.log(renderBarChart(totals, chartCcy)) + } else { + consola.log(pc.dim("(Monthly chart requires a single currency — use --currency to convert)")) + } + + // Top subscriptions + consola.log("") + consola.log(pc.bold("Top subscriptions by yearly cost:")) + if (top.length === 0) { + consola.log(" (none)") + } + for (const s of top) { + consola.log(` ${s.name.padEnd(24)} ${formatPrice(Math.round(yearlyCost(s)), s.currency)}/year`) + } + + // Price changes + if (priceChanges.length > 0) { + consola.log("") + consola.log(pc.bold("Price changes:")) + for (const c of priceChanges) { + const date = c.changedAt.slice(0, 10) + if (c.oldPrice !== null && c.oldCurrency === c.newCurrency && c.oldPrice !== c.newPrice) { + const diff = c.newPrice - c.oldPrice + const sign = diff > 0 ? "+" : "" + consola.log( + ` ${pc.dim(date)} ${c.subscriptionName}: ${formatPrice(c.oldPrice, c.newCurrency)} → ${formatPrice(c.newPrice, c.newCurrency)} (${sign}${formatPrice(diff, c.newCurrency)})`, + ) + } else if (c.oldCurrency && c.oldCurrency !== c.newCurrency) { + consola.log( + ` ${pc.dim(date)} ${c.subscriptionName}: ${formatPrice(c.oldPrice ?? c.newPrice, c.oldCurrency)} (${c.oldCurrency}) → ${formatPrice(c.newPrice, c.newCurrency)} (${c.newCurrency})`, + ) + } else { + consola.log(` ${pc.dim(date)} ${c.subscriptionName}: → ${formatPrice(c.newPrice, c.newCurrency)}`) + } + } + } + + // Added / cancelled + consola.log("") + consola.log(pc.bold(`Added this year (${added.length}):`)) + for (const s of added) { + consola.log(` ${pc.green("+")} ${s.name} ${formatPrice(s.price, s.currency)}/${s.cycle} (${s.createdAt})`) + } + if (added.length === 0) consola.log(" (none)") + + consola.log("") + consola.log(pc.bold(`Cancelled this year (${cancelled.length}):`)) + for (const c of cancelled) { + consola.log(` ${pc.red("-")} ${c.name} (${c.date})`) + } + if (cancelled.length === 0) consola.log(" (none)") + + // Budget + if (budgetInfo) { + consola.log("") + consola.log(pc.bold("Budget:")) + consola.log(` Budget: ${formatPrice(budgetInfo.amount, budgetInfo.currency)}/year`) + if (budgetInfo.over) { + consola.log(` Over budget: ${pc.red(formatPrice(-budgetInfo.remaining, budgetInfo.currency))}`) + } else { + consola.log(` Remaining: ${pc.green(formatPrice(budgetInfo.remaining, budgetInfo.currency))}`) + } + } else if (yearlyBudget > 0) { + consola.log("") + consola.log(pc.dim("(Cannot compare budget — multiple currencies. Use --currency.)")) + } else { + consola.log("") + consola.log(pc.dim("(No budget set — use: subtrack config set yearlyBudget )")) + } +} \ No newline at end of file diff --git a/apps/subtrack/src/timeline.ts b/apps/subtrack/src/timeline.ts index 70a57ab..6c7a1c6 100644 --- a/apps/subtrack/src/timeline.ts +++ b/apps/subtrack/src/timeline.ts @@ -114,8 +114,9 @@ function calcMonthlyTotalsByCategory( /** * Render a bar chart showing monthly spending. + * Shared with the yearly report (report.ts). */ -function renderBarChart(totals: MonthTotal[], currency: string = "USD"): string { +export function renderBarChart(totals: MonthTotal[], currency: string = "USD"): string { const max = Math.max(...totals.map((t) => t.total), 1) const barWidth = 40 const labelWidth = 4 // "Dec " or "Jun " diff --git a/docs/commands.md b/docs/commands.md index e7e7ed3..ede84a7 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -668,12 +668,22 @@ Manages subtrack configuration. Configuration is stored in `~/.config/subtrack/c |-----|-------------|---------| | `defaultCurrency` | Default currency for display and analytics | `USD` | | `monthlyBudget` | Monthly spending budget in USD (0 = disabled) | `0` | +| `yearlyBudget` | Yearly spending budget (used by `budget --period yearly` and `report`) | — | +| `budgets` | JSON array of named budgets (see below) | — | | `theme` | Display theme | `default` | | `notifyDays` | Notification look-ahead in days | `7` | | `notifyChannels` | Comma-separated channels: `os`, `slack`, `webhook` | `os` | | `slackWebhook` | Slack webhook URL for `slack` notifications | — | | `webhookUrl` | Generic webhook URL for `webhook` notifications | — | +Named budgets accept a JSON array of entries with `name`, `amount`, `currency`, +and optional `period` (`monthly`/`yearly`) and `categories` (tag filter): + +```bash +subtrack config set budgets \ + '[{"name":"streaming","amount":3000,"currency":"JPY","categories":["video","music"]}]' +``` + ### Examples ```bash @@ -686,6 +696,9 @@ subtrack config get defaultCurrency # Set a monthly budget of $500 subtrack config set monthlyBudget 500 +# Set a yearly budget +subtrack config set yearlyBudget 60000 + # Set default display currency subtrack config set defaultCurrency JPY @@ -1375,3 +1388,96 @@ subtrack mcp ``` The MCP server exposes 16 tools for subscription management. See the [MCP page](/mcp) for full details, tool reference, and integration examples. + +## `budget` + +Shows spending vs a configured budget and detects budget overruns. Supports a single monthly/yearly budget, or multiple named budgets from config. + +| Option | Description | +|--------|-------------| +| `--check` | Exit with code 1 when over budget (for cron/scripts) | +| `--period ` | Comparison period (default: `monthly`) | +| `-c, --currency ` | Convert all prices to target currency | +| `--name ` | Compare against a named budget from `config budgets` | +| `-j, --json` | Output as JSON | + +Budgets are configured via `config set`: + +```bash +# Monthly budget +subtrack config set monthlyBudget 5000 +subtrack budget + +# Yearly budget +subtrack config set yearlyBudget 60000 +subtrack budget --period yearly --check + +# Named budget (JSON array — amount, currency, optional period/categories) +subtrack config set budgets '[{"name":"streaming","amount":3000,"currency":"JPY","categories":["video","music"]}]' +subtrack budget --name streaming + +# Convert everything for a fair comparison +subtrack budget --currency USD +``` + +Named budgets filter subscriptions by their tags when `categories` is set, and can use their own period (`monthly` or `yearly`). `--check` is useful in cron scripts: it sets the exit code to 1 when spending exceeds the budget. + +## `dedupe` + +Detects duplicate subscriptions by name similarity (Levenshtein distance on normalized names, default threshold 0.8). Pairs sharing the same vendor URL are boosted to a 0.9 score. + +| Option | Description | +|--------|-------------| +| `--threshold <0-1>` | Similarity threshold (default: `0.8`) | +| `-j, --json` | Output as JSON | + +```bash +subtrack dedupe +subtrack dedupe --threshold 0.7 +subtrack dedupe --json + +# Merge a duplicate into the keeper (prices, tags, history preserved) +subtrack dedupe merge +``` + +`dedupe merge` moves the removed subscription's price history and tags onto the kept one and deletes the duplicate. Cancelled subscriptions are excluded from detection. + +## `cancel ` + +Cancels a subscription with a guided checklist: optional data export, alternative-service check, cancellation-date note, and a final confirmation. The subscription is marked `cancelled` (not deleted), `contractEnd` is set to today when unset, and the cancellation is written to the audit log. + +| Option | Description | +|--------|-------------| +| `-f, --force` | Skip the checklist and cancel immediately | +| `-j, --json` | Output subscription info as JSON (no changes made) | + +```bash +subtrack cancel 3 + +# Non-interactive (cron / scripting) +subtrack cancel 3 --force + +# Inspect what would be cancelled without changing anything +subtrack cancel 3 --json +``` + +When the checklist's "note the cancellation date" step is confirmed, `Cancelled: ` is appended to the subscription notes. Use `subtrack delete ` to remove a cancelled subscription permanently. + +## `report` + +Shows a yearly subscription report: total spending, monthly bar chart (single currency), top subscriptions by yearly cost, subscriptions added/cancelled during the year, price changes, and budget comparison. + +| Option | Description | +|--------|-------------| +| `--year ` | Target year (default: current year) | +| `-c, --currency ` | Convert all prices to target currency | +| `-j, --json` | Output as JSON | + +```bash +subtrack report +subtrack report --year 2025 +subtrack report --currency USD +subtrack report --json +``` + +The monthly chart requires a single currency — use `--currency` to convert. Cancelled subscriptions are counted only until their contract end date. The report compares yearly spending against `yearlyBudget` when configured. From 27658b7cb33d29fbb7cf848a22b36b3ccec2619a Mon Sep 17 00:00:00 2001 From: nazozokc Date: Mon, 17 Aug 2026 21:25:17 +0900 Subject: [PATCH 12/19] edit --- apps/subtrack/CHANGELOG.md | 55 ++++++++++++++++++++++ apps/subtrack/package.json | 2 +- apps/subtrack/src/__tests__/budget.test.ts | 13 +++++ apps/subtrack/src/__tests__/dedupe.test.ts | 8 ++++ apps/subtrack/src/budget.ts | 7 +-- apps/subtrack/src/db/subscriptions.ts | 2 +- apps/subtrack/src/report.ts | 32 ++++++++----- 7 files changed, 103 insertions(+), 16 deletions(-) diff --git a/apps/subtrack/CHANGELOG.md b/apps/subtrack/CHANGELOG.md index bac0da7..5db6bcb 100644 --- a/apps/subtrack/CHANGELOG.md +++ b/apps/subtrack/CHANGELOG.md @@ -1,5 +1,60 @@ # Changelog +## 9.0.0 (unreleased) + +### ✨ Features + +- **Budget Command**: `subtrack budget` compares spending against configured budgets with overrun detection. `--check` exits with code 1 when over budget (cron/scripts), `--period monthly|yearly`, `-c/--currency` conversion, `--name` for named budgets, `-j/--json`. ([PR #104](https://github.com/nazozokc/subtrack/pull/104)) +- **Named Budgets**: `subtrack config set budgets ''` stores named budgets with amount, currency, optional period and category (tag) filters; `subtrack config set yearlyBudget ` for yearly targets. Both shown in `config list`. ([PR #104](https://github.com/nazozokc/subtrack/pull/104)) +- **Duplicate Detection**: `subtrack dedupe` finds duplicate subscriptions by normalized-name Levenshtein similarity (default threshold 0.8, same vendor URL boosted). `subtrack dedupe merge ` merges duplicates transactionally, preserving price history and tags. ([PR #104](https://github.com/nazozokc/subtrack/pull/104)) +- **Guided Cancellation**: `subtrack cancel ` cancels with a checklist — optional CSV export, alternative-service check, cancellation-date note, final confirmation. Marks as `cancelled`, sets `contractEnd`, writes audit log. `-f/--force` skips the checklist; `-j/--json` previews without changes. ([PR #104](https://github.com/nazozokc/subtrack/pull/104)) +- **Yearly Report**: `subtrack report` shows total spending, monthly bar chart, top 5 subscriptions by yearly cost, added/cancelled during the year, price changes, and budget comparison. `--year`, `-c/--currency`, `-j/--json`. ([PR #104](https://github.com/nazozokc/subtrack/pull/104)) +- **MCP Server**: tag and usage tools, enum validation, and paging support. ([`ce1dafa`](https://github.com/nazozokc/subtrack/commit/ce1dafa)) +- **Usage Commands**: `usage edit`, token totals, and paging. ([`f2ba913`](https://github.com/nazozokc/subtrack/commit/f2ba913)) +- **List/Analytics/Forecast**: list filters (`--limit`, `--offset`), forecast currency conversion, analytics period options. ([`58aab5b`](https://github.com/nazozokc/subtrack/commit/58aab5b)) + +### 🐛 Bug Fixes + +- Exclude cancelled subscriptions from payment summaries; validate edit flags. ([`09583ba`](https://github.com/nazozokc/subtrack/commit/09583ba)) +- Correct billing date calculations in `upcoming` and `calendar`. ([`c08ad7f`](https://github.com/nazozokc/subtrack/commit/c08ad7f)) +- Exclude dist output from type check. ([`66c874c`](https://github.com/nazozokc/subtrack/commit/66c874c)) +- Add `--force` flag to bulk tag commands. ([`64751e4`](https://github.com/nazozokc/subtrack/commit/64751e4)) +- Convert API usage cost from cents to dollars. ([`4f90970`](https://github.com/nazozokc/subtrack/commit/4f90970)) +- Accept export CSV format in import. ([`dbdb5e8`](https://github.com/nazozokc/subtrack/commit/dbdb5e8)) + +### 📝 Documentation + +- Commands, development, and MCP documentation updates. ([`ac2c325`](https://github.com/nazozokc/subtrack/commit/ac2c325)) + +## 8.4.0 (2026-08-15) + +### ✨ Features + +- **Extended Subscription Fields**: vendor, plan tier, discounts, contract dates, and auto-renewal with notification config. ([`e45a853`](https://github.com/nazozokc/subtrack/commit/e45a853)) +- **Interactive Category Menu**: replaced the TUI with an interactive main menu. ([`791aa6b`](https://github.com/nazozokc/subtrack/commit/791aa6b)) + +### 🐛 Bug Fixes + +- Harden against decompression bombs, secret leaks, and formula injection. ([`205ae33`](https://github.com/nazozokc/subtrack/commit/205ae33)) +- Reject drive roots on Windows when validating the DB directory. ([`72ef676`](https://github.com/nazozokc/subtrack/commit/72ef676)) +- Raise scrypt memory limit for passphrase-derived keys. ([`26f137e`](https://github.com/nazozokc/subtrack/commit/26f137e)) +- Set non-zero exit code on CLI errors. ([`8652dce`](https://github.com/nazozokc/subtrack/commit/8652dce)) +- Remove email notification channel. ([`085f585`](https://github.com/nazozokc/subtrack/commit/085f585)) + +### 📝 Documentation + +- Migrate documentation site from SvelteKit to VitePress. ([`7446e23`](https://github.com/nazozokc/subtrack/commit/7446e23)) + +## 8.2.0 (2026-07-26) + +### ✨ Features + +- **Email Suggestion**: `subtrack suggest` scans email via IMAP and suggests subscription candidates. ([`6a9ad3d`](https://github.com/nazozokc/subtrack/commit/6a9ad3d)) + +### 🔧 CI & Supply Chain + +- Correct `pnpm-lock.yaml` path in `lint:lockfile` script. ([`0f3e1b6`](https://github.com/nazozokc/subtrack/commit/0f3e1b6)) + ## 8.1.0 (2026-07-05) ### ✨ Features diff --git a/apps/subtrack/package.json b/apps/subtrack/package.json index ab96fe1..fcaa983 100644 --- a/apps/subtrack/package.json +++ b/apps/subtrack/package.json @@ -1,6 +1,6 @@ { "name": "subtrack", - "version": "8.0.0", + "version": "9.0.0", "author": "nazozokc", "type": "module", "license": "MIT", diff --git a/apps/subtrack/src/__tests__/budget.test.ts b/apps/subtrack/src/__tests__/budget.test.ts index 2117274..862a316 100644 --- a/apps/subtrack/src/__tests__/budget.test.ts +++ b/apps/subtrack/src/__tests__/budget.test.ts @@ -317,4 +317,17 @@ test("handleBudget named budget filters by categories", async () => { // Only the video-tagged sub counts toward the streaming budget expect(logMessages.some((m) => m.includes("Monthly spending: ¥2,500/month"))).toBe(true) expect(logMessages.some((m) => m.includes("Remaining: ¥500"))).toBe(true) +}) + +test("handleBudget named budget uses its own period", async () => { + await setConfig({ + budgets: [{ name: "infra", amount: 60000, currency: "JPY", period: "yearly" }], + }) + insertSub({ name: "AWS", price: 5000, currency: "JPY" }) + + const { handleBudget } = await import("../budget.ts") + // No explicit period — the named budget's own period (yearly) wins + await handleBudget({ name: "infra" }) + expect(logMessages.some((m) => m.includes("Yearly spending: ¥60,000/year"))).toBe(true) + expect(logMessages.some((m) => m.includes("Budget (infra): ¥60,000/year"))).toBe(true) }) \ No newline at end of file diff --git a/apps/subtrack/src/__tests__/dedupe.test.ts b/apps/subtrack/src/__tests__/dedupe.test.ts index 7b1252d..834c48a 100644 --- a/apps/subtrack/src/__tests__/dedupe.test.ts +++ b/apps/subtrack/src/__tests__/dedupe.test.ts @@ -269,6 +269,14 @@ test("mergeSubscriptions transfers tags and deletes the removed one", async () = expect(getSubscription(removeId)).toBeUndefined() }) +test("mergeSubscriptions returns false for identical IDs", async () => { + const id = insertSub({ name: "Netflix" }) + const { mergeSubscriptions } = await import("../db.ts") + expect(mergeSubscriptions(id, id)).toBe(false) + const { getSubscription } = await import("../db.ts") + expect(getSubscription(id)).toBeDefined() +}) + test("handleDedupeMerge merges and logs success", async () => { const keepId = insertSub({ name: "Netflix", price: 1000 }) const removeId = insertSub({ name: "Netflix", price: 1500 }) diff --git a/apps/subtrack/src/budget.ts b/apps/subtrack/src/budget.ts index 39e1d74..5252b43 100644 --- a/apps/subtrack/src/budget.ts +++ b/apps/subtrack/src/budget.ts @@ -26,6 +26,8 @@ type ResolvedBudget = { amount: number currency: string categories?: string[] + /** Period carried by named budgets (defaults to the requested period) */ + period?: "monthly" | "yearly" } /** @@ -49,6 +51,7 @@ export function resolveBudget( amount: entry.amount, currency: entry.currency || config.defaultCurrency || "USD", categories: entry.categories, + period: entry.period, } } @@ -122,9 +125,7 @@ export async function handleBudget(options: BudgetOptions = {}): Promise { } // Named budgets may carry their own period (e.g. yearly vs monthly compare) - const comparePeriod = options.name - ? (loadConfig().budgets?.find((b) => b.name === options.name)?.period ?? period) - : period + const comparePeriod = budget.period ?? period // Fetch FX rates when any conversion might be needed const targetCurrency = options.currency as Currency | undefined diff --git a/apps/subtrack/src/db/subscriptions.ts b/apps/subtrack/src/db/subscriptions.ts index 09f470e..e87c40b 100644 --- a/apps/subtrack/src/db/subscriptions.ts +++ b/apps/subtrack/src/db/subscriptions.ts @@ -285,7 +285,7 @@ export const findSubscriptionByName = (name: string): SharedArgs | undefined => */ export const mergeSubscriptions = (keepId: number, removeId: number): boolean => { const db = getDb() - if (keepId === removeId) return true + if (keepId === removeId) return false db.run("BEGIN TRANSACTION") try { diff --git a/apps/subtrack/src/report.ts b/apps/subtrack/src/report.ts index 2c7292b..ebc35f7 100644 --- a/apps/subtrack/src/report.ts +++ b/apps/subtrack/src/report.ts @@ -9,6 +9,7 @@ import { periodFactor, OCCURRENCES_PER_YEAR } from "./date-utils.ts" import { fetchFxRates, convertPrice } from "./fx.ts" import type { FxRates } from "./fx.ts" import { renderBarChart } from "./timeline.ts" +import { fail } from "./error.ts" export type ReportOptions = { /** Target year (default: current year) */ @@ -110,21 +111,28 @@ export function calcCancelledThisYear(subs: SharedArgs[], year: number): { name: .sort((a, b) => a.date.localeCompare(b.date)) } -type YearlyTotals = { - totals: MonthTotal[] - total: number -} - /** Sum of monthly totals. */ export function sumYearlyTotals(totals: MonthTotal[]): number { return totals.reduce((s, t) => s + t.total, 0) } +/** + * Resolve a single comparable currency: the conversion target when set, + * otherwise the only currency present (or null when mixed). + */ +function singleCurrency( + displayCurrency: string | null, + byCurrency: Record, +): string | null { + if (displayCurrency) return displayCurrency + const keys = Object.keys(byCurrency) + return keys.length === 1 ? keys[0] : null +} + export async function handleReport(options: ReportOptions = {}): Promise { const year = options.year ?? new Date().getFullYear() if (year < 1970 || year > 9999 || !Number.isInteger(year)) { - consola.error("year must be a valid year (e.g. 2025)") - process.exitCode = 1 + fail("year must be a valid year (e.g. 2025)") return } @@ -177,7 +185,7 @@ export async function handleReport(options: ReportOptions = {}): Promise { const budgetCurrency = config.defaultCurrency || "USD" let budgetInfo: { amount: number; currency: string; remaining: number; over: boolean } | null = null if (yearlyBudget > 0) { - const chartCcy = displayCurrency ?? (Object.keys(byCurrency).length === 1 ? Object.keys(byCurrency)[0] : null) + const chartCcy = singleCurrency(displayCurrency, byCurrency) let spending = total let ccy = chartCcy if (chartCcy && rates && chartCcy !== budgetCurrency) { @@ -203,7 +211,7 @@ export async function handleReport(options: ReportOptions = {}): Promise { const output: Record = { year, total: Math.round(total), - currency: displayCurrency ?? (Object.keys(byCurrency).length === 1 ? Object.keys(byCurrency)[0] : null), + currency: singleCurrency(displayCurrency, byCurrency), byCurrency, monthly: totals.map((t) => ({ month: t.label, total: t.total })), top: top.map((s) => ({ @@ -220,7 +228,9 @@ export async function handleReport(options: ReportOptions = {}): Promise { oldCurrency: c.oldCurrency, newCurrency: c.newCurrency, changedAt: c.changedAt, - diff: c.oldPrice !== null && c.oldCurrency === c.newCurrency ? c.newPrice - c.oldPrice : null, + diff: c.oldPrice !== null && c.oldCurrency === c.newCurrency && c.oldPrice !== c.newPrice + ? c.newPrice - c.oldPrice + : null, })), added: added.map((s) => ({ id: s.id, name: s.name, price: s.price, currency: s.currency, cycle: s.cycle, createdAt: s.createdAt })), cancelled, @@ -249,7 +259,7 @@ export async function handleReport(options: ReportOptions = {}): Promise { // Monthly chart consola.log("") - const chartCcy = displayCurrency ?? (Object.keys(byCurrency).length === 1 ? Object.keys(byCurrency)[0] : null) + const chartCcy = singleCurrency(displayCurrency, byCurrency) if (chartCcy) { consola.log(renderBarChart(totals, chartCcy)) } else { From 3f5bfea4ce13f666ad5344a42d47c6b6954d06df Mon Sep 17 00:00:00 2001 From: nazozokc Date: Mon, 17 Aug 2026 21:55:28 +0900 Subject: [PATCH 13/19] edit --- apps/subtrack/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/subtrack/CHANGELOG.md b/apps/subtrack/CHANGELOG.md index 5db6bcb..69ece5e 100644 --- a/apps/subtrack/CHANGELOG.md +++ b/apps/subtrack/CHANGELOG.md @@ -10,12 +10,12 @@ - **Guided Cancellation**: `subtrack cancel ` cancels with a checklist — optional CSV export, alternative-service check, cancellation-date note, final confirmation. Marks as `cancelled`, sets `contractEnd`, writes audit log. `-f/--force` skips the checklist; `-j/--json` previews without changes. ([PR #104](https://github.com/nazozokc/subtrack/pull/104)) - **Yearly Report**: `subtrack report` shows total spending, monthly bar chart, top 5 subscriptions by yearly cost, added/cancelled during the year, price changes, and budget comparison. `--year`, `-c/--currency`, `-j/--json`. ([PR #104](https://github.com/nazozokc/subtrack/pull/104)) - **MCP Server**: tag and usage tools, enum validation, and paging support. ([`ce1dafa`](https://github.com/nazozokc/subtrack/commit/ce1dafa)) -- **Usage Commands**: `usage edit`, token totals, and paging. ([`f2ba913`](https://github.com/nazozokc/subtrack/commit/f2ba913)) +- **Usage Commands**: `usage edit`, token totals, and paging. - **List/Analytics/Forecast**: list filters (`--limit`, `--offset`), forecast currency conversion, analytics period options. ([`58aab5b`](https://github.com/nazozokc/subtrack/commit/58aab5b)) ### 🐛 Bug Fixes -- Exclude cancelled subscriptions from payment summaries; validate edit flags. ([`09583ba`](https://github.com/nazozokc/subtrack/commit/09583ba)) +- Exclude cancelled subscriptions from payment summaries; validate edit flags. - Correct billing date calculations in `upcoming` and `calendar`. ([`c08ad7f`](https://github.com/nazozokc/subtrack/commit/c08ad7f)) - Exclude dist output from type check. ([`66c874c`](https://github.com/nazozokc/subtrack/commit/66c874c)) - Add `--force` flag to bulk tag commands. ([`64751e4`](https://github.com/nazozokc/subtrack/commit/64751e4)) From eedbd7a306331791ed3e6d802f7f3a101d4670ef Mon Sep 17 00:00:00 2001 From: nazozokc Date: Tue, 18 Aug 2026 14:48:49 +0900 Subject: [PATCH 14/19] fix: use local date and correct audit action --- apps/subtrack/src/maintenance.ts | 2 +- apps/subtrack/src/opencode-scanner.ts | 5 ----- apps/subtrack/src/usage-add.ts | 5 +++-- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/apps/subtrack/src/maintenance.ts b/apps/subtrack/src/maintenance.ts index 690d29e..5dcaa64 100644 --- a/apps/subtrack/src/maintenance.ts +++ b/apps/subtrack/src/maintenance.ts @@ -81,7 +81,7 @@ export function handleMaintenance(options: MaintenanceOptions = {}): void { } } - logAudit("subscription.edit", { + logAudit("cleanup", { targetType: "database", details: `VACUUM: ${formatBytes(beforeSize)} → ${formatBytes(afterSize)}`, }) diff --git a/apps/subtrack/src/opencode-scanner.ts b/apps/subtrack/src/opencode-scanner.ts index 780062a..db7380f 100644 --- a/apps/subtrack/src/opencode-scanner.ts +++ b/apps/subtrack/src/opencode-scanner.ts @@ -87,11 +87,6 @@ export function scanOpenCodeDb(from?: string, to?: string): ScanResult { return { source: "opencode", entries: [] } } - if (!existsSync(dbPath)) { - consola.info("OpenCode DB not found — skip") - return { source: "opencode", entries: [] } - } - consola.info(`Reading OpenCode DB: ${dbPath}`) let data: Buffer diff --git a/apps/subtrack/src/usage-add.ts b/apps/subtrack/src/usage-add.ts index 764b908..f48a946 100644 --- a/apps/subtrack/src/usage-add.ts +++ b/apps/subtrack/src/usage-add.ts @@ -9,6 +9,7 @@ import { validateTokens, validateDate, } from "./prompts.ts" +import { today } from "./date-utils.ts" import { ensurePricingCache, searchPricingModels, @@ -126,13 +127,13 @@ async function resolveUsageAddOptions(flags: UsageAddFlags) { // Date let date: string - const today = new Date().toISOString().split("T")[0] + const todayStr = today() if (flags.date !== undefined) { const result = validateDate(flags.date) if (result !== true) { fail(result); return null } date = flags.date } else { - date = today + date = todayStr } // Description From 518a640db5f44f0828d3ec98dcd2e86b02610049 Mon Sep 17 00:00:00 2001 From: nazozokc Date: Tue, 18 Aug 2026 14:48:55 +0900 Subject: [PATCH 15/19] refactor: consolidate duplicated helpers across modules - Remove dead barrel (subscription.ts) and empty source directories - Centralize date helpers (toDate, formatDate, daysUntil, month names, pad2) - Add fx helpers (convertSubsWithRates, tryConvert) for subscription conversion - Extract pre-command hooks (autoScan + notification banner) into pre-command.ts - Add getNonCancelledSubscriptions db helper - Extract restoreFromFile and safePath/safeOutputPath helpers - Unify table column width calculation in display.ts - Drop unused imports and dead exports --- apps/subtrack/src/analytics.ts | 26 ++--- apps/subtrack/src/audit.ts | 5 +- apps/subtrack/src/backup.ts | 138 ++++++++++--------------- apps/subtrack/src/budget.ts | 4 +- apps/subtrack/src/calendar.ts | 31 ++---- apps/subtrack/src/cancel.ts | 10 +- apps/subtrack/src/commands.ts | 4 +- apps/subtrack/src/commands/core.ts | 12 +-- apps/subtrack/src/commands/tag.ts | 2 +- apps/subtrack/src/compare.ts | 14 +-- apps/subtrack/src/date-utils.ts | 98 +++++++++++++++--- apps/subtrack/src/db.ts | 2 +- apps/subtrack/src/db/subscriptions.ts | 4 + apps/subtrack/src/dedupe.ts | 4 +- apps/subtrack/src/display.ts | 73 ++----------- apps/subtrack/src/export.ts | 14 +-- apps/subtrack/src/forecast.ts | 21 ++-- apps/subtrack/src/fx.ts | 35 +++++++ apps/subtrack/src/import-csv.ts | 4 +- apps/subtrack/src/mcp/handlers.ts | 3 +- apps/subtrack/src/notify.ts | 7 +- apps/subtrack/src/optimize.ts | 8 +- apps/subtrack/src/path-utils.ts | 20 ++++ apps/subtrack/src/payment.ts | 25 ++--- apps/subtrack/src/pre-command.ts | 17 +++ apps/subtrack/src/profile.ts | 6 -- apps/subtrack/src/report.ts | 8 +- apps/subtrack/src/subscription.ts | 18 ---- apps/subtrack/src/subscription/core.ts | 8 +- apps/subtrack/src/timeline.ts | 22 +--- apps/subtrack/src/trial.ts | 11 +- apps/subtrack/src/upcoming.ts | 53 ++-------- apps/subtrack/src/usage-import.ts | 16 ++- apps/subtrack/src/usage-refresh.ts | 4 +- 34 files changed, 322 insertions(+), 405 deletions(-) create mode 100644 apps/subtrack/src/pre-command.ts delete mode 100644 apps/subtrack/src/subscription.ts diff --git a/apps/subtrack/src/analytics.ts b/apps/subtrack/src/analytics.ts index 0131c82..800fd6b 100644 --- a/apps/subtrack/src/analytics.ts +++ b/apps/subtrack/src/analytics.ts @@ -1,17 +1,17 @@ import { consola } from "consola" import pc from "picocolors" -import type { SharedArgs, AnalyticsOptions } from "./types.ts" -import { getSubscriptions } from "./db.ts" +import type { AnalyticsOptions } from "./types.ts" +import { getSubscriptions, getNonCancelledSubscriptions } from "./db.ts" import { formatPrice } from "./price.ts" import { calcSummary } from "./payment.ts" import { loadConfig } from "./config.ts" import { periodFactor } from "./date-utils.ts" -import { fetchFxRates, convertPrice } from "./fx.ts" +import { fetchFxRates, convertPrice, tryConvert } from "./fx.ts" import type { FxRates } from "./fx.ts" export async function handleAnalytics(options: AnalyticsOptions = {}): Promise { if (options.json) { - const subs = getSubscriptions().filter((s) => s.status !== "cancelled") + const subs = getNonCancelledSubscriptions() const data = calcSummary(subs) const output: Record = { @@ -30,12 +30,10 @@ export async function handleAnalytics(options: AnalyticsOptions = {}): Promise { - const list = getSubscriptions().filter((s) => s.status !== "cancelled") + const all = getSubscriptions() + const list = all.filter((s) => s.status !== "cancelled") if (list.length === 0) { consola.info("No active subscriptions found") return @@ -89,7 +88,7 @@ export async function showAnalytics(options: AnalyticsOptions = {}): Promise s.status === "active").length const pausedCount = list.filter((s) => s.status === "paused").length - const cancelledCount = getSubscriptions().filter((s) => s.status === "cancelled").length + const cancelledCount = all.filter((s) => s.status === "cancelled").length consola.log(` ${pc.green(`active: ${activeCount}`)}`) if (pausedCount > 0) consola.log(` ${pc.yellow(`paused: ${pausedCount}`)}`) if (cancelledCount > 0) consola.log(` ${pc.red(`cancelled: ${cancelledCount}`)}`) @@ -108,11 +107,8 @@ export async function showAnalytics(options: AnalyticsOptions = {}): Promise String(n).padStart(2, "0") - return `${months[d.getMonth()]} ${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` + return `${SHORT_MONTH_NAMES[d.getMonth()]} ${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}` } // ── Command handlers ──────────────────────────────────── diff --git a/apps/subtrack/src/backup.ts b/apps/subtrack/src/backup.ts index 494297c..5229c53 100644 --- a/apps/subtrack/src/backup.ts +++ b/apps/subtrack/src/backup.ts @@ -9,12 +9,10 @@ import { logAudit } from "./audit.ts" import path from "node:path" import os from "node:os" import type { BackupFileInfo } from "./types.ts" -import { resolveSafePath, resolveSafeOutputPath } from "./path-utils.ts" +import { safePath, safeOutputPath } from "./path-utils.ts" import { getSubscriptions, - getDbPath, getDb, - getDbDir, getDefaultBackupDir, getBackupFiles, restoreDb, @@ -22,14 +20,14 @@ import { writeBackupHash, verifyBackupHash, } from "./db.ts" -import { input, confirm, select } from "@inquirer/prompts" +import { confirm, select } from "@inquirer/prompts" import { formatBytes } from "./format.ts" +import { pad2 } from "./date-utils.ts" /** Generate a compact timestamp string for backup filenames. */ function getTimestamp(): string { const now = new Date() - const pad = (n: number) => String(n).padStart(2, "0") - return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}` + return `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}_${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}` } /** @@ -99,7 +97,7 @@ export async function handleBackup(destination?: string, options: { encrypt?: bo try { // Validate the backup destination path (directory may not exist yet) if (destination) { - const safeDest = resolveSafeOutputPath([os.homedir(), os.tmpdir()], destination) + const safeDest = safeOutputPath(destination) if (!safeDest) { fail(`Invalid backup destination — must be within home directory`) return @@ -139,65 +137,71 @@ export async function handleBackup(destination?: string, options: { encrypt?: bo } } -export async function handleRestore( - file?: string, - options: { force?: boolean; dir?: string } = {}, -) { - if (file) { - // ── Non-interactive ────────────────────────────────── - const safePath = resolveSafePath([os.homedir(), os.tmpdir()], path.resolve(file)) - if (!safePath) { - fail(`Invalid backup file — must be within home directory`) +/** + * Restore a backup file with confirmation (unless `force`), integrity check, + * and an automatic safety backup of the current data. + */ +async function restoreFromFile(filePath: string, force: boolean): Promise { + const currentCount = getSubscriptions().length + if (!force) { + const ok = await confirm({ + message: + `Restore "${path.basename(filePath)}"? Current data (${currentCount} subscription${currentCount !== 1 ? "s" : ""}) will be backed up automatically.`, + default: false, + }) + if (!ok) { + consola.info("Cancelled") return } + } - const resolvedPath = safePath - - const currentCount = getSubscriptions().length - if (!options.force) { + if (!verifyBackupHash(filePath)) { + consola.warn("Backup integrity check failed (SHA256 mismatch)") + if (!force) { const ok = await confirm({ - message: - `Restore "${path.basename(resolvedPath)}"? Current data (${currentCount} subscription${currentCount !== 1 ? "s" : ""}) will be backed up automatically.`, + message: "SHA256 mismatch — restore anyway?", default: false, }) - if (!ok) { - consola.info("Cancelled") - return - } + if (!ok) { consola.info("Cancelled"); return } } + } - if (!verifyBackupHash(resolvedPath)) { - consola.warn("Backup integrity check failed (SHA256 mismatch)") - if (!options.force) { - const ok = await confirm({ - message: "SHA256 mismatch — restore anyway?", - default: false, - }) - if (!ok) { consola.info("Cancelled"); return } - } - } + await safeAutoBackup() - await safeAutoBackup() + try { + restoreDb(filePath) + const subs = getSubscriptions() + logAudit("backup.restore", { + details: `Restored ${subs.length} subscriptions from ${path.basename(filePath)}`, + }) + consola.success( + `Restored ${subs.length} subscription${subs.length !== 1 ? "s" : ""} from: ${filePath}`, + ) + } catch (e) { + fail(`Restore failed: ${String(e)}`) + } +} - try { - restoreDb(resolvedPath) - const subs = getSubscriptions() - logAudit("backup.restore", { - details: `Restored ${subs.length} subscriptions from ${path.basename(resolvedPath)}`, - }) - consola.success( - `Restored ${subs.length} subscription${subs.length !== 1 ? "s" : ""} from: ${resolvedPath}`, - ) - } catch (e) { - fail(`Restore failed: ${String(e)}`) +export async function handleRestore( + file?: string, + options: { force?: boolean; dir?: string } = {}, +) { + if (file) { + // ── Non-interactive ────────────────────────────────── + const resolvedPath = safePath(path.resolve(file)) + if (!resolvedPath) { + fail(`Invalid backup file — must be within home directory`) + return } + + await restoreFromFile(resolvedPath, options.force ?? false) return } // ── Interactive ──────────────────────────────────────── let searchDir: string if (options.dir) { - const safeDir = resolveSafePath([os.homedir(), os.tmpdir()], path.resolve(options.dir)) + const safeDir = safePath(path.resolve(options.dir)) if (!safeDir) { fail(`Invalid search directory — must be within home directory`) return @@ -230,39 +234,5 @@ export async function handleRestore( })), }) - const currentCount = getSubscriptions().length - const ok = await confirm({ - message: - `Restore "${path.basename(selected)}"? Current data (${currentCount} subscription${currentCount !== 1 ? "s" : ""}) will be backed up automatically.`, - default: false, - }) - - if (!ok) { - consola.info("Cancelled") - return - } - - if (!verifyBackupHash(selected)) { - consola.warn("Backup integrity check failed (SHA256 mismatch)") - const proceed = await confirm({ - message: "SHA256 mismatch — restore anyway?", - default: false, - }) - if (!proceed) { consola.info("Cancelled"); return } - } - - await safeAutoBackup() - - try { - restoreDb(selected) - const subs = getSubscriptions() - logAudit("backup.restore", { - details: `Restored ${subs.length} subscriptions from ${path.basename(selected)}`, - }) - consola.success( - `Restored ${subs.length} subscription${subs.length !== 1 ? "s" : ""} from: ${selected}`, - ) - } catch (e) { - fail(`Restore failed: ${String(e)}`) - } + await restoreFromFile(selected, false) } diff --git a/apps/subtrack/src/budget.ts b/apps/subtrack/src/budget.ts index 5252b43..cdd358b 100644 --- a/apps/subtrack/src/budget.ts +++ b/apps/subtrack/src/budget.ts @@ -1,7 +1,7 @@ import { consola } from "consola" import pc from "picocolors" import type { Currency, SharedArgs } from "./types.ts" -import { getSubscriptions } from "./db.ts" +import { getSubscriptions, getNonCancelledSubscriptions } from "./db.ts" import { loadConfig } from "./config.ts" import { formatPrice } from "./price.ts" import { calcSubTotal } from "./payment.ts" @@ -94,7 +94,7 @@ export function convertTotals( export async function handleBudget(options: BudgetOptions = {}): Promise { const period = options.period ?? "monthly" - const subs = getSubscriptions().filter((s) => s.status !== "cancelled") + const subs = getNonCancelledSubscriptions() const budget = resolveBudget(period, options.name) if (!budget) { diff --git a/apps/subtrack/src/calendar.ts b/apps/subtrack/src/calendar.ts index f7876ab..be8a62d 100644 --- a/apps/subtrack/src/calendar.ts +++ b/apps/subtrack/src/calendar.ts @@ -1,10 +1,11 @@ import { consola } from "consola" import pc from "picocolors" -import { getSubscriptions } from "./db.ts" +import { getNonCancelledSubscriptions } from "./db.ts" import { formatPrice } from "./price.ts" import type { SharedArgs, Currency } from "./types.ts" -import { fetchFxRates, convertPrice } from "./fx.ts" +import { fetchFxRates, tryConvert } from "./fx.ts" import type { FxRates } from "./fx.ts" +import { toDate, clampDay, daysInMonth } from "./date-utils.ts" /** Options for the calendar command */ export type CalendarOptions = { @@ -26,19 +27,6 @@ export type CalendarEntry = { subs: { name: string; price: number; currency: string; status: string; id: number }[] } -function daysInMonth(year: number, month: number): number { - return new Date(year, month, 0).getDate() -} - -function clampDay(day: number, year: number, month: number): number { - return Math.min(day, daysInMonth(year, month)) -} - -function toDate(dateStr: string): Date { - const [y, m, d] = dateStr.split("-").map(Number) - return new Date(y, m - 1, d) -} - /** * Billing days of a subscription within a given month (1-31). * - monthly: every month on the billing day @@ -96,12 +84,11 @@ export function billingDaysInMonth(sub: SharedArgs, year: number, month: number) * @returns Array of calendar entries keyed by day */ export function calcCalendarEntries(month: number, year: number): CalendarEntry[] { - const subs = getSubscriptions() - const active = subs.filter((s) => s.status !== "cancelled") + const subs = getNonCancelledSubscriptions() const dayMap = new Map() - for (const sub of active) { + for (const sub of subs) { for (const day of billingDaysInMonth(sub, year, month)) { if (!dayMap.has(day)) { dayMap.set(day, []) @@ -151,12 +138,8 @@ export async function showCalendar(options: CalendarOptions): Promise { entries = entries.map((entry) => ({ day: entry.day, subs: entry.subs.map((sub) => { - try { - const converted = convertPrice(sub.price, sub.currency, targetCcy as Currency, rates.rates) - return { ...sub, price: Math.round(converted), currency: targetCcy } - } catch { - return sub - } + const converted = tryConvert(sub.price, sub.currency, targetCcy as Currency, rates.rates) + return converted !== null ? { ...sub, price: Math.round(converted), currency: targetCcy } : sub }), })) } catch { diff --git a/apps/subtrack/src/cancel.ts b/apps/subtrack/src/cancel.ts index 35d504d..ac38adc 100644 --- a/apps/subtrack/src/cancel.ts +++ b/apps/subtrack/src/cancel.ts @@ -9,9 +9,9 @@ import { logAudit } from "./audit.ts" import { fail } from "./error.ts" import { formatPrice } from "./price.ts" import { calculateNextBilling } from "./upcoming.ts" -import { today } from "./date-utils.ts" +import { today, formatDate } from "./date-utils.ts" import { exportCsv } from "./export.ts" -import { resolveSafeOutputPath } from "./path-utils.ts" +import { safeOutputPath } from "./path-utils.ts" import type { AddSharedArgs } from "./types.ts" export type CancelOptions = { @@ -21,10 +21,6 @@ export type CancelOptions = { json?: boolean } -function formatDate(d: Date): string { - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}` -} - /** Sanitize a subscription name for use in a file name. */ function safeFileName(name: string): string { return name.replace(/[^\p{L}\p{N}._-]+/gu, "_").slice(0, 60) || "subscription" @@ -83,7 +79,7 @@ export async function handleCancel(id: number, options: CancelOptions = {}): Pro }) if (doExport) { const exportPath = path.join(os.homedir(), "exports", `${safeFileName(sub.name)}-${cancellationDate}.csv`) - const safePath = resolveSafeOutputPath([os.homedir(), os.tmpdir()], exportPath) + const safePath = safeOutputPath(exportPath) if (!safePath) { consola.warn("Cannot export — invalid output path; skipping export") } else { diff --git a/apps/subtrack/src/commands.ts b/apps/subtrack/src/commands.ts index ac766ad..8bc0e96 100644 --- a/apps/subtrack/src/commands.ts +++ b/apps/subtrack/src/commands.ts @@ -2,7 +2,9 @@ // This file exists for backward compatibility — new code should import directly // from the domain module. -export { handleList, handleAdd, handleDelete, handleEdit, handleTags, handleClone, handleArchive, handleUnarchive } from "./subscription.ts" +export { handleList, handleDelete, handleTags, handleClone, handleArchive, handleUnarchive } from "./subscription/core.ts" +export { handleAdd } from "./subscription/add.ts" +export { handleEdit } from "./subscription/edit.ts" export { handleSearch } from "./search.ts" export { handleTrialAdd, handleTrialList, handleTrialExpiring, handleTrialDelete } from "./trial.ts" export { handleBulkStatus, handleBulkDelete, handleBulkTagAdd, handleBulkTagRemove } from "./bulk.ts" diff --git a/apps/subtrack/src/commands/core.ts b/apps/subtrack/src/commands/core.ts index 242db97..7e3247e 100644 --- a/apps/subtrack/src/commands/core.ts +++ b/apps/subtrack/src/commands/core.ts @@ -1,15 +1,9 @@ // ── Core subscription commands ────────────────────────── import { define } from "gunshi" import { consola } from "consola" -import { - handleList, - handleAdd, - handleEdit, - handleDelete, - handleClone, - handleArchive, - handleUnarchive, -} from "../subscription.ts" +import { handleList, handleDelete, handleClone, handleArchive, handleUnarchive } from "../subscription/core.ts" +import { handleAdd } from "../subscription/add.ts" +import { handleEdit } from "../subscription/edit.ts" import { handleSearch } from "../search.ts" import { handleCancel } from "../cancel.ts" import { saveDb } from "../db.ts" diff --git a/apps/subtrack/src/commands/tag.ts b/apps/subtrack/src/commands/tag.ts index f283780..00c27d0 100644 --- a/apps/subtrack/src/commands/tag.ts +++ b/apps/subtrack/src/commands/tag.ts @@ -2,7 +2,7 @@ import { define } from "gunshi" import { consola } from "consola" import { fail } from "../error.ts" -import { handleTags } from "../subscription.ts" +import { handleTags } from "../subscription/core.ts" import { handleTagList, handleTagRename, handleTagDelete, handleTagPrune, handleTagMerge } from "../tag.ts" export const tagsCommand = define({ diff --git a/apps/subtrack/src/compare.ts b/apps/subtrack/src/compare.ts index ef973fc..8bf966b 100644 --- a/apps/subtrack/src/compare.ts +++ b/apps/subtrack/src/compare.ts @@ -2,8 +2,8 @@ import { consola } from "consola" import pc from "picocolors" import CliTable3 from "cli-table3" import type { Currency, Cycle, CompareOptions } from "./types.ts" -import { periodFactor, getPeriodDateRange, getPreviousPeriodDateRange } from "./date-utils.ts" -import { getSubscriptions, getLlmUsageTotal, getLlmUsageTotalByProvider, getAllPriceChanges } from "./db.ts" +import { periodFactor, getPeriodDateRange, getPreviousPeriodDateRange, SHORT_MONTH_NAMES } from "./date-utils.ts" +import { getNonCancelledSubscriptions, getLlmUsageTotal, getAllPriceChanges } from "./db.ts" import { formatPrice } from "./price.ts" import { fetchFxRates, convertPrice } from "./fx.ts" import type { FxRates } from "./fx.ts" @@ -37,7 +37,7 @@ function formatDateRange(from: string, to: string): string { // Format: "Jun 1–27, 2026" const f = new Date(from + "T00:00:00") const t = new Date(to + "T00:00:00") - const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + const months = SHORT_MONTH_NAMES if (f.getFullYear() === t.getFullYear()) { if (f.getMonth() === t.getMonth()) { return `${months[f.getMonth()]} ${f.getDate()}–${t.getDate()}, ${f.getFullYear()}` @@ -96,7 +96,7 @@ export async function showCompare( period: Cycle = "monthly", options: { currency?: string; api?: boolean } = {}, ): Promise { - const subs = getSubscriptions() + const subs = getNonCancelledSubscriptions() const currentRange = getPeriodDateRange(period) const previousRange = getPreviousPeriodDateRange(period) @@ -118,7 +118,7 @@ export async function showCompare( const targetCurrency = options.currency as Currency | undefined - const activeSubs = subs.filter((s) => s.status !== "cancelled") + const activeSubs = subs if (activeSubs.length === 0) { consola.info("No active subscriptions found") return @@ -230,13 +230,13 @@ export async function handleCompare( options: CompareOptions = {}, ): Promise { if (options.json) { - const subs = getSubscriptions().filter((s) => s.status !== "cancelled") + const subs = getNonCancelledSubscriptions() if (subs.length === 0) { process.stdout.write(JSON.stringify({ period, current: {}, previous: {}, change: {} }, null, 2) + "\n") return } - const activeSubs = subs.filter((s) => s.status !== "cancelled") + const activeSubs = subs const currentTotals: Record = {} for (const sub of activeSubs) { const monthly = sub.price * periodFactor(sub.cycle, "monthly") diff --git a/apps/subtrack/src/date-utils.ts b/apps/subtrack/src/date-utils.ts index 3c8d600..142a7b5 100644 --- a/apps/subtrack/src/date-utils.ts +++ b/apps/subtrack/src/date-utils.ts @@ -90,7 +90,71 @@ export function estimateTokenSplit(totalTokens: number): { inputTokens: number; return { inputTokens, outputTokens: totalTokens - inputTokens } } -const pad = (n: number) => String(n).padStart(2, "0") +export const pad2 = (n: number) => String(n).padStart(2, "0") + +/** Short month names (Jan, Feb, ...) for display formatting. */ +export const SHORT_MONTH_NAMES = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +] as const + +/** + * Convert a YYYY-MM-DD date string to a local Date. + * Invalid input produces an Invalid Date (caller's responsibility). + */ +export function toDate(dateStr: string): Date { + const [y, m, d] = dateStr.split("-").map(Number) + return new Date(y, m - 1, d) +} + +/** + * Format a Date as YYYY-MM-DD (local timezone). + */ +export function formatDate(d: Date): string { + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}` +} + +/** + * Format a Date as a short display string, e.g. "Jan 5". + */ +export function formatShortDate(d: Date): string { + return `${SHORT_MONTH_NAMES[d.getMonth()]} ${d.getDate()}` +} + +/** + * Days in a month. `month` is 1-12 (human convention). + */ +export function daysInMonth(year: number, month: number): number { + return new Date(year, month, 0).getDate() +} + +/** + * Clamp a day to the length of the given month. `month` is 1-12. + */ +export function clampDay(day: number, year: number, month: number): number { + return Math.min(day, daysInMonth(year, month)) +} + +/** + * Build a Date with the given day clamped to the month length, + * avoiding JS Date overflow (e.g. day 31 in February -> Feb 28). + * `month` is 0-based (JS convention). + */ +export function dateWithClampedDay(year: number, month: number, day: number): Date { + return new Date(year, month, clampDay(day, year, month + 1)) +} + +/** + * Days until a target date (YYYY-MM-DD string or Date), relative to today. + * Returns 0 for today, negative for past dates. + */ +export function daysUntil(target: string | Date): number { + const now = new Date() + now.setHours(0, 0, 0, 0) + const parsed = typeof target === "string" ? new Date(target + "T00:00:00") : new Date(target) + parsed.setHours(0, 0, 0, 0) + return Math.ceil((parsed.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)) +} /** * Returns the [from, to] date range (inclusive, YYYY-MM-DD) for a given period. @@ -102,11 +166,11 @@ export function getPeriodDateRange(period: Cycle): { from: string; to: string } const y = now.getFullYear() const m = now.getMonth() // 0‑based const d = now.getDate() - const to = `${y}-${pad(m + 1)}-${pad(d)}` + const to = `${y}-${pad2(m + 1)}-${pad2(d)}` switch (period) { case "monthly": - return { from: `${y}-${pad(m + 1)}-01`, to } + return { from: `${y}-${pad2(m + 1)}-01`, to } case "yearly": return { from: `${y}-01-01`, to } case "weekly": { @@ -115,7 +179,7 @@ export function getPeriodDateRange(period: Cycle): { from: string; to: string } const mon = new Date(now) mon.setDate(d - diff) return { - from: `${mon.getFullYear()}-${pad(mon.getMonth() + 1)}-${pad(mon.getDate())}`, + from: `${mon.getFullYear()}-${pad2(mon.getMonth() + 1)}-${pad2(mon.getDate())}`, to, } } @@ -123,17 +187,17 @@ export function getPeriodDateRange(period: Cycle): { from: string; to: string } const twoWeeksAgo = new Date(now) twoWeeksAgo.setDate(d - 13) return { - from: `${twoWeeksAgo.getFullYear()}-${pad(twoWeeksAgo.getMonth() + 1)}-${pad(twoWeeksAgo.getDate())}`, + from: `${twoWeeksAgo.getFullYear()}-${pad2(twoWeeksAgo.getMonth() + 1)}-${pad2(twoWeeksAgo.getDate())}`, to, } } case "quarterly": { const qs = Math.floor(m / 3) * 3 - return { from: `${y}-${pad(qs + 1)}-01`, to } + return { from: `${y}-${pad2(qs + 1)}-01`, to } } case "semi-annual": { const hs = Math.floor(m / 6) * 6 - return { from: `${y}-${pad(hs + 1)}-01`, to } + return { from: `${y}-${pad2(hs + 1)}-01`, to } } } } @@ -154,8 +218,8 @@ export function getPreviousPeriodDateRange(period: Cycle): { from: string; to: s const prevY = m === 0 ? y - 1 : y const lastDay = new Date(prevY, prevM + 1, 0).getDate() return { - from: `${prevY}-${pad(prevM + 1)}-01`, - to: `${prevY}-${pad(prevM + 1)}-${pad(lastDay)}`, + from: `${prevY}-${pad2(prevM + 1)}-01`, + to: `${prevY}-${pad2(prevM + 1)}-${pad2(lastDay)}`, } } case "yearly": { @@ -171,8 +235,8 @@ export function getPreviousPeriodDateRange(period: Cycle): { from: string; to: s const prevSun = new Date(thisMon) prevSun.setDate(thisMon.getDate() - 1) return { - from: `${prevMon.getFullYear()}-${pad(prevMon.getMonth() + 1)}-${pad(prevMon.getDate())}`, - to: `${prevSun.getFullYear()}-${pad(prevSun.getMonth() + 1)}-${pad(prevSun.getDate())}`, + from: `${prevMon.getFullYear()}-${pad2(prevMon.getMonth() + 1)}-${pad2(prevMon.getDate())}`, + to: `${prevSun.getFullYear()}-${pad2(prevSun.getMonth() + 1)}-${pad2(prevSun.getDate())}`, } } case "bi-weekly": { @@ -185,8 +249,8 @@ export function getPreviousPeriodDateRange(period: Cycle): { from: string; to: s const prevEnd = new Date(thisMon2) prevEnd.setDate(thisMon2.getDate() - 1) return { - from: `${prevStart.getFullYear()}-${pad(prevStart.getMonth() + 1)}-${pad(prevStart.getDate())}`, - to: `${prevEnd.getFullYear()}-${pad(prevEnd.getMonth() + 1)}-${pad(prevEnd.getDate())}`, + from: `${prevStart.getFullYear()}-${pad2(prevStart.getMonth() + 1)}-${pad2(prevStart.getDate())}`, + to: `${prevEnd.getFullYear()}-${pad2(prevEnd.getMonth() + 1)}-${pad2(prevEnd.getDate())}`, } } case "quarterly": { @@ -196,8 +260,8 @@ export function getPreviousPeriodDateRange(period: Cycle): { from: string; to: s const qM = ((prevQStart % 12) + 12) % 12 const lastDayQ = new Date(qY, qM + 3, 0).getDate() return { - from: `${qY}-${pad(qM + 1)}-01`, - to: `${qY}-${pad(qM + 3)}-${pad(lastDayQ)}`, + from: `${qY}-${pad2(qM + 1)}-01`, + to: `${qY}-${pad2(qM + 3)}-${pad2(lastDayQ)}`, } } case "semi-annual": { @@ -207,8 +271,8 @@ export function getPreviousPeriodDateRange(period: Cycle): { from: string; to: s const hM = ((prevHStart % 12) + 12) % 12 const lastDayH = new Date(hY, hM + 6, 0).getDate() return { - from: `${hY}-${pad(hM + 1)}-01`, - to: `${hY}-${pad(hM + 6)}-${pad(lastDayH)}`, + from: `${hY}-${pad2(hM + 1)}-01`, + to: `${hY}-${pad2(hM + 6)}-${pad2(lastDayH)}`, } } } diff --git a/apps/subtrack/src/db.ts b/apps/subtrack/src/db.ts index 1a06992..29d31d9 100644 --- a/apps/subtrack/src/db.ts +++ b/apps/subtrack/src/db.ts @@ -5,7 +5,7 @@ export { } from "./db/connection.ts" export { runMigrations } from "./db/schema.ts" export { - getSubscriptions, getSubscription, writeSubscription, updateSubscription, deleteSubscription, archiveSubscription, unarchiveSubscription, mapTags, findSubscriptionByName, mergeSubscriptions, + getSubscriptions, getNonCancelledSubscriptions, getSubscription, writeSubscription, updateSubscription, deleteSubscription, archiveSubscription, unarchiveSubscription, mapTags, findSubscriptionByName, mergeSubscriptions, } from "./db/subscriptions.ts" export { getAllTags, tagsSubscription, getTagsWithCount, renameTag, deleteTag, pruneTags, mergeTag, diff --git a/apps/subtrack/src/db/subscriptions.ts b/apps/subtrack/src/db/subscriptions.ts index e87c40b..d9eb917 100644 --- a/apps/subtrack/src/db/subscriptions.ts +++ b/apps/subtrack/src/db/subscriptions.ts @@ -108,6 +108,10 @@ export const getSubscriptions = ( return mapTags(subs) } +/** All subscriptions except cancelled ones — the "payable" set used by analytics. */ +export const getNonCancelledSubscriptions = (): SharedArgs[] => + getSubscriptions().filter((s) => s.status !== "cancelled") + export const writeSubscription = (data: AddSharedArgs): number => { const db = getDb() const uniqueTags = Array.from(new Set(data.tags)) diff --git a/apps/subtrack/src/dedupe.ts b/apps/subtrack/src/dedupe.ts index 3c6e1e2..7ee5134 100644 --- a/apps/subtrack/src/dedupe.ts +++ b/apps/subtrack/src/dedupe.ts @@ -1,6 +1,6 @@ import { consola } from "consola" import pc from "picocolors" -import { getSubscriptions, getSubscription, mergeSubscriptions } from "./db.ts" +import { getSubscriptions, getNonCancelledSubscriptions, getSubscription, mergeSubscriptions } from "./db.ts" import { logAudit } from "./audit.ts" import { fail } from "./error.ts" import { formatPrice } from "./price.ts" @@ -93,7 +93,7 @@ export function handleDedupe(options: DedupeOptions = {}): void { return } - const subs = getSubscriptions().filter((s) => s.status !== "cancelled") + const subs = getNonCancelledSubscriptions() const pairs = findDuplicates(subs, threshold) if (options.json) { diff --git a/apps/subtrack/src/display.ts b/apps/subtrack/src/display.ts index ca65950..d11e89e 100644 --- a/apps/subtrack/src/display.ts +++ b/apps/subtrack/src/display.ts @@ -66,6 +66,8 @@ type ColumnConfig = { headers: readonly string[] minWidths: readonly number[] maxWidths: readonly number[] + /** Minimum available width (default 40) */ + minAvail?: number } const BASE_COLS: ColumnConfig = { @@ -120,7 +122,7 @@ const BORDER_AND_PADDING = 16 function calcColumnWidths(rows: string[][], config: ColumnConfig): number[] { const termWidth = process.stdout.columns ?? 80 - const avail = Math.max(40, termWidth - BORDER_AND_PADDING) + const avail = Math.max(config.minAvail ?? 40, termWidth - BORDER_AND_PADDING) const weights = config.headers.map((hdr, i) => { let max = hdr.length @@ -330,54 +332,11 @@ const USAGE_HEADERS = ["Provider", "Model", "Input", "Output", "Cost", "Date", " const USAGE_MIN_WIDTHS = [10, 20, 10, 10, 10, 12, 15] as const const USAGE_MAX_WIDTHS = [20, 50, 14, 14, 14, 12, 60] as const -function calcUsageColumnWidths(rows: UsageRow[]): number[] { - const termWidth = process.stdout.columns ?? 80 - const avail = Math.max(50, termWidth - BORDER_AND_PADDING) - - const weights = USAGE_HEADERS.map((hdr, i) => { - let max = hdr.length - for (const row of rows) { - const len = row[i].length - if (len > max) max = len - } - return Math.min(max, USAGE_MAX_WIDTHS[i]) - }) - - const totalWeight = weights.reduce((a, b) => a + b, 0) - const widths = weights.map((w, i) => - Math.max( - USAGE_MIN_WIDTHS[i], - Math.min(USAGE_MAX_WIDTHS[i], Math.round((avail * w) / totalWeight)), - ), - ) - - // Fit to available width - let sum = widths.reduce((a, b) => a + b, 0) - let diff = sum - avail - let iterations = 0 - while (diff > 0 && iterations < 100) { - let idx = -1 - for (let i = 0; i < widths.length; i++) { - if (widths[i] > USAGE_MIN_WIDTHS[i] && (idx === -1 || widths[i] > widths[idx])) idx = i - } - if (idx === -1) break - widths[idx]-- - diff-- - iterations++ - } - iterations = 0 - while (diff < 0 && iterations < 100) { - let idx = -1 - for (let i = 0; i < widths.length; i++) { - if (widths[i] < USAGE_MAX_WIDTHS[i] && (idx === -1 || weights[i] > weights[idx])) idx = i - } - if (idx === -1) break - widths[idx]++ - diff++ - iterations++ - } - - return widths +const USAGE_COLS: ColumnConfig = { + headers: USAGE_HEADERS, + minWidths: USAGE_MIN_WIDTHS, + maxWidths: USAGE_MAX_WIDTHS, + minAvail: 50, } function renderUsageTableBody( @@ -415,22 +374,12 @@ function renderUsageTableBody( return table.toString() } +/** TABLE_CHARS with a footer-style top border (used for the total row). */ const TABLE_CHARS_FOOTER = { - top: "─", + ...TABLE_CHARS, "top-mid": "┴", "top-left": "├", "top-right": "┤", - bottom: "─", - "bottom-mid": "┴", - "bottom-left": "└", - "bottom-right": "┘", - left: "│", - "left-mid": "├", - mid: "─", - "mid-mid": "┼", - right: "│", - "right-mid": "┤", - middle: "│", } as const export function renderUsageTable(entries: LlmUsageEntry[]): void { @@ -464,7 +413,7 @@ export function renderUsageTable(entries: LlmUsageEntry[]): void { ] as UsageRow, ] - const widths = calcUsageColumnWidths(allRows) + const widths = calcColumnWidths(allRows, USAGE_COLS) consola.log(renderUsageTableBody(entries, widths)) // Render TOTAL footer row diff --git a/apps/subtrack/src/export.ts b/apps/subtrack/src/export.ts index 6d70848..6601d5e 100644 --- a/apps/subtrack/src/export.ts +++ b/apps/subtrack/src/export.ts @@ -7,8 +7,8 @@ import { formatPrice } from "./price.ts" import ExcelJS from "exceljs" import { calculateNextBilling } from "./upcoming.ts" import { tagsSubscription, getSubscriptions } from "./db.ts" -import { fetchFxRates, convertPrice } from "./fx.ts" -import { resolveSafeOutputPath } from "./path-utils.ts" +import { fetchFxRates, convertSubsWithRates } from "./fx.ts" +import { safeOutputPath } from "./path-utils.ts" /** * Escape a value for CSV output, protecting against CSV injection attacks. @@ -275,11 +275,7 @@ export async function handleExport( try { const rates = await fetchFxRates() const targetCurrency = options.currency as Currency - list = list.map((sub) => ({ - ...sub, - price: Math.round(convertPrice(sub.price, sub.currency, targetCurrency, rates.rates)), - currency: targetCurrency, - })) + list = convertSubsWithRates(list, targetCurrency, rates) } catch (e) { consola.fail(`Failed to fetch exchange rates; exporting in original currencies: ${String(e)}`) } @@ -288,7 +284,7 @@ export async function handleExport( if (format === "excel") { const buf = await exportExcel(list) if (options.output) { - const safePath = resolveSafeOutputPath([os.homedir(), os.tmpdir()], options.output) + const safePath = safeOutputPath(options.output) if (!safePath) { fail(`Invalid output path — must be within home directory`); return } writeFileSync(safePath, buf, { mode: 0o600 }) consola.success(`Exported to: ${safePath}`) @@ -307,7 +303,7 @@ export async function handleExport( : exportIcs(list) if (options.output) { - const safePath = resolveSafeOutputPath([os.homedir(), os.tmpdir()], options.output) + const safePath = safeOutputPath(options.output) if (!safePath) { fail(`Invalid output path — must be within home directory`); return } writeFileSync(safePath, content, { mode: 0o600 }) consola.success(`Exported to: ${safePath}`) diff --git a/apps/subtrack/src/forecast.ts b/apps/subtrack/src/forecast.ts index 5f5ad6b..00912bf 100644 --- a/apps/subtrack/src/forecast.ts +++ b/apps/subtrack/src/forecast.ts @@ -5,9 +5,9 @@ import CliTable3 from "cli-table3" import type { SharedArgs, Currency, Cycle } from "./types.ts" import { TABLE_CHARS, TABLE_STYLE } from "./display-constants.ts" import { periodFactor } from "./date-utils.ts" -import { getSubscriptions } from "./db.ts" +import { getSubscriptions, getNonCancelledSubscriptions } from "./db.ts" import { formatPrice } from "./price.ts" -import { fetchFxRates, convertPrice } from "./fx.ts" +import { fetchFxRates, convertPrice, tryConvert } from "./fx.ts" import type { FxRates } from "./fx.ts" import { CURRENCY_CHOICES, @@ -60,7 +60,7 @@ export async function handleForecast( if (monthsStr.trim()) months = Number(monthsStr) // Ask for cancellations - const allSubs = getSubscriptions().filter((s) => s.status !== "cancelled") + const allSubs = getNonCancelledSubscriptions() if (allSubs.length > 0) { const toCancel = await checkbox({ message: "Select subscriptions to exclude (optional)", @@ -101,7 +101,7 @@ export async function handleForecast( } // Calculate entries - const subs = getSubscriptions().filter((s) => s.status !== "cancelled") + const subs = getNonCancelledSubscriptions() const entries: ForecastEntry[] = subs .filter((s) => !cancelNames.includes(s.name)) @@ -145,17 +145,8 @@ export async function handleForecast( let monthly = entry.monthly if (targetCurrency && rates && entry.currency !== targetCurrency) { - try { - monthly = convertPrice( - entry.monthly, - entry.currency, - targetCurrency, - rates.rates, - ) - } catch { - // Keep original - monthly = entry.monthly - } + // Keep original on missing rate + monthly = tryConvert(entry.monthly, entry.currency, targetCurrency, rates.rates) ?? entry.monthly } currencyGroups[ccy].entries.push({ diff --git a/apps/subtrack/src/fx.ts b/apps/subtrack/src/fx.ts index 4b58bdf..87c63e1 100644 --- a/apps/subtrack/src/fx.ts +++ b/apps/subtrack/src/fx.ts @@ -1,4 +1,5 @@ import { safeResponseJson } from "./safe-json.ts" +import type { SharedArgs } from "./types.ts" export type FxRates = { base: string @@ -59,3 +60,37 @@ export function convertPrice( const inUsd = from === "USD" ? price : price / fromRate return to === "USD" ? inUsd : inUsd * toRate } + +/** + * Convert each subscription's price to the target currency using fetched rates. + * All-or-nothing: throws if any conversion fails (e.g. missing rate), + * matching the previous per-site try/catch behavior. + */ +export function convertSubsWithRates( + subs: SharedArgs[], + targetCurrency: string, + rates: FxRates, +): SharedArgs[] { + return subs.map((s) => ({ + ...s, + price: Math.round(convertPrice(s.price, s.currency, targetCurrency, rates.rates)), + currency: targetCurrency, + })) +} + +/** + * Convert a single price, returning null (instead of throwing) when + * no rate is available. Callers keep the original price on null. + */ +export function tryConvert( + price: number, + from: string, + to: string, + rates: Record, +): number | null { + try { + return convertPrice(price, from, to, rates) + } catch { + return null + } +} diff --git a/apps/subtrack/src/import-csv.ts b/apps/subtrack/src/import-csv.ts index 2479701..169cefd 100644 --- a/apps/subtrack/src/import-csv.ts +++ b/apps/subtrack/src/import-csv.ts @@ -16,7 +16,7 @@ import { validateDateString, } from "./prompts.ts" import os from "node:os" -import { resolveSafePath } from "./path-utils.ts" +import { safePath } from "./path-utils.ts" import type { Status, DiscountType } from "./types.ts" const MAX_CSV_SIZE = 10 * 1024 * 1024 // 10 MB @@ -67,7 +67,7 @@ export async function handleImport( } // Validate path is within allowed base directories (also verifies existence) - const safeFile = resolveSafePath([os.homedir(), os.tmpdir()], file) + const safeFile = safePath(file) if (!safeFile) { fail(`File not found or path not allowed — must be within home or temp directory`) return diff --git a/apps/subtrack/src/mcp/handlers.ts b/apps/subtrack/src/mcp/handlers.ts index 8bb6185..5c7fb2f 100644 --- a/apps/subtrack/src/mcp/handlers.ts +++ b/apps/subtrack/src/mcp/handlers.ts @@ -8,6 +8,7 @@ import type { FxRates } from "../fx.ts" import type { McpResponse } from "./types.ts" import { getSubscriptions, + getNonCancelledSubscriptions, getSubscription, writeSubscription, deleteSubscription, @@ -106,7 +107,7 @@ export async function handleDeleteSubscription(args?: Record): } export async function handleGetSummary(_args?: Record): Promise { - const subs = getSubscriptions().filter((s) => s.status !== "cancelled") + const subs = getNonCancelledSubscriptions() const summary = calcSummary(subs) return { content: [{ type: "text", text: JSON.stringify(summary) }] } } diff --git a/apps/subtrack/src/notify.ts b/apps/subtrack/src/notify.ts index 3734aa6..e1bde54 100644 --- a/apps/subtrack/src/notify.ts +++ b/apps/subtrack/src/notify.ts @@ -2,7 +2,8 @@ import { consola } from "consola" import { calcUpcoming } from "./upcoming.ts" import { formatPrice } from "./price.ts" import { loadConfig } from "./config.ts" -import type { Currency, NotifyChannel } from "./types.ts" +import type { NotifyChannel } from "./types.ts" +import { formatDate } from "./date-utils.ts" export type NotifyOptions = { days?: number @@ -41,7 +42,7 @@ export async function handleNotify(options: NotifyOptions = {}): Promise { price: e.sub.price, currency: e.sub.currency, cycle: e.sub.cycle, - nextDate: `${e.nextDate.getFullYear()}-${String(e.nextDate.getMonth() + 1).padStart(2, "0")}-${String(e.nextDate.getDate()).padStart(2, "0")}`, + nextDate: formatDate(e.nextDate), tags: e.sub.tags, })) process.stdout.write(JSON.stringify({ days, count: entries.length, entries: data }, null, 2) + "\n") @@ -57,7 +58,7 @@ export async function handleNotify(options: NotifyOptions = {}): Promise { if (options.dryRun) { consola.info(`Upcoming bills (next ${days} day${days > 1 ? "s" : ""}):`) for (const e of entries) { - const date = `${e.nextDate.getFullYear()}-${String(e.nextDate.getMonth() + 1).padStart(2, "0")}-${String(e.nextDate.getDate()).padStart(2, "0")}` + const date = formatDate(e.nextDate) consola.log(` ${date} ${e.sub.name} ${formatPrice(e.sub.price, e.sub.currency)}/${e.sub.cycle}`) } return diff --git a/apps/subtrack/src/optimize.ts b/apps/subtrack/src/optimize.ts index 43828c7..277afe0 100644 --- a/apps/subtrack/src/optimize.ts +++ b/apps/subtrack/src/optimize.ts @@ -4,7 +4,7 @@ import { getSubscriptions, getAllPriceChanges } from "./db.ts" import type { SharedArgs, Currency } from "./types.ts" import { periodFactor, OCCURRENCES_PER_YEAR } from "./date-utils.ts" import { formatPrice } from "./price.ts" -import { fetchFxRates, convertPrice } from "./fx.ts" +import { fetchFxRates, convertSubsWithRates } from "./fx.ts" import type { FxRates } from "./fx.ts" export type OptimizeOptions = { @@ -388,11 +388,7 @@ export async function handleOptimize(options: OptimizeOptions = {}): Promise ({ - ...s, - price: Math.round(convertPrice(s.price, s.currency, displayCurrency as Currency, rates.rates)), - currency: displayCurrency, - })) + subs = convertSubsWithRates(subs, displayCurrency as Currency, rates) } catch { consola.warn("Failed to fetch exchange rates; showing in original currencies") displayCurrency = "USD" diff --git a/apps/subtrack/src/path-utils.ts b/apps/subtrack/src/path-utils.ts index 418d155..1421e04 100644 --- a/apps/subtrack/src/path-utils.ts +++ b/apps/subtrack/src/path-utils.ts @@ -1,5 +1,25 @@ import { resolve, normalize, isAbsolute, dirname, sep } from "node:path" import { realpathSync, existsSync } from "node:fs" +import os from "node:os" + +/** Default trusted base directories: home and temp. */ +function defaultBases(): string[] { + return [os.homedir(), os.tmpdir()] +} + +/** + * resolveSafePath with the default home/tmp base directories. + */ +export function safePath(userPath: string): string | null { + return resolveSafePath(defaultBases(), userPath) +} + +/** + * resolveSafeOutputPath with the default home/tmp base directories. + */ +export function safeOutputPath(targetPath: string): string | null { + return resolveSafeOutputPath(defaultBases(), targetPath) +} /** Check if `child` path starts with `parent` directory, using platform separator. */ function isWithin(child: string, parent: string): boolean { diff --git a/apps/subtrack/src/payment.ts b/apps/subtrack/src/payment.ts index 22ef19f..05fa90d 100644 --- a/apps/subtrack/src/payment.ts +++ b/apps/subtrack/src/payment.ts @@ -2,10 +2,11 @@ import { consola } from "consola" import pc from "picocolors" import type { SharedArgs, Currency, Cycle } from "./types.ts" import { periodFactor, getPeriodDateRange } from "./date-utils.ts" -import { getSubscriptions, getLlmUsageTotal, getLlmUsageTotalByProvider, getAllPriceChanges } from "./db.ts" +import { getSubscriptions, getNonCancelledSubscriptions, getLlmUsageTotal, getLlmUsageTotalByProvider, getAllPriceChanges } from "./db.ts" import { formatPrice } from "./price.ts" import { fetchFxRates, convertPrice } from "./fx.ts" import type { FxRates } from "./fx.ts" +import { runPreCommandHooks } from "./pre-command.ts" // ── JSON options helper ─────────────────────────────── export type JsonOptions = { json?: boolean } @@ -17,7 +18,7 @@ export const showPayment = async ( includeApi?: boolean, byMethod?: boolean, ): Promise => { - const list = subs ?? getSubscriptions().filter((s) => s.status !== "cancelled") + const list = subs ?? getNonCancelledSubscriptions() if (list.length === 0) { consola.info("No subscriptions found") @@ -265,7 +266,7 @@ export function calcSummary(subs: SharedArgs[]): SummaryData { } export function showSummary(subs?: SharedArgs[]): void { - const list = subs ?? getSubscriptions().filter((s) => s.status !== "cancelled") + const list = subs ?? getNonCancelledSubscriptions() if (list.length === 0) { consola.info("No subscriptions found") @@ -314,15 +315,10 @@ export async function handlePayment( options: { currency?: string; api?: boolean; method?: boolean } & JsonOptions, ) { // Show notification banner for non-JSON output - if (!options.json) { - const { autoScan } = await import("./suggest/scan.ts") - await autoScan() - const { showNotificationBanner } = await import("./notifications/banner.ts") - showNotificationBanner() - } + await runPreCommandHooks(options) if (options.json) { - const subs = getSubscriptions().filter((s) => s.status !== "cancelled") + const subs = getNonCancelledSubscriptions() if (subs.length === 0) { process.stdout.write(JSON.stringify({ period, total: 0, subscriptions: [] }, null, 2) + "\n") return @@ -403,15 +399,10 @@ export async function handlePayment( } export async function handleSummary(options: JsonOptions = {}) { - if (!options.json) { - const { autoScan } = await import("./suggest/scan.ts") - await autoScan() - const { showNotificationBanner } = await import("./notifications/banner.ts") - showNotificationBanner() - } + await runPreCommandHooks(options) if (options.json) { - const subs = getSubscriptions().filter((s) => s.status !== "cancelled") + const subs = getNonCancelledSubscriptions() const data = calcSummary(subs) process.stdout.write(JSON.stringify(data, null, 2) + "\n") return diff --git a/apps/subtrack/src/pre-command.ts b/apps/subtrack/src/pre-command.ts new file mode 100644 index 0000000..0e06e73 --- /dev/null +++ b/apps/subtrack/src/pre-command.ts @@ -0,0 +1,17 @@ +/** + * Pre-command hooks shared by interactive commands. + */ + +/** + * Run non-blocking pre-command hooks for interactive output: + * 1. Auto-scan for new suggestions (silent on failure) + * 2. Show the pending notification banner + * Skipped entirely for JSON output. + */ +export async function runPreCommandHooks(options: { json?: boolean } = {}): Promise { + if (options.json) return + const { autoScan } = await import("./suggest/scan.ts") + await autoScan() + const { showNotificationBanner } = await import("./notifications/banner.ts") + showNotificationBanner() +} \ No newline at end of file diff --git a/apps/subtrack/src/profile.ts b/apps/subtrack/src/profile.ts index ee9b76a..d2d2142 100644 --- a/apps/subtrack/src/profile.ts +++ b/apps/subtrack/src/profile.ts @@ -122,12 +122,6 @@ export function getActiveFilter(): ProfileFilter | null { return profiles[config.activeProfile] ?? null } -/** Get the name of the active profile, if any. */ -export function getActiveProfileName(): string | null { - const config = loadConfig() - return config.activeProfile ?? null -} - /** Apply a profile filter to a subscription query. Returns filter params. */ export function buildFilterParams( profile: ProfileFilter, diff --git a/apps/subtrack/src/report.ts b/apps/subtrack/src/report.ts index ebc35f7..f9e6fff 100644 --- a/apps/subtrack/src/report.ts +++ b/apps/subtrack/src/report.ts @@ -6,7 +6,7 @@ import { getSubscriptions, getAllPriceChanges, getAuditLogs } from "./db.ts" import { loadConfig } from "./config.ts" import { formatPrice } from "./price.ts" import { periodFactor, OCCURRENCES_PER_YEAR } from "./date-utils.ts" -import { fetchFxRates, convertPrice } from "./fx.ts" +import { fetchFxRates, convertPrice, convertSubsWithRates } from "./fx.ts" import type { FxRates } from "./fx.ts" import { renderBarChart } from "./timeline.ts" import { fail } from "./error.ts" @@ -147,11 +147,7 @@ export async function handleReport(options: ReportOptions = {}): Promise { try { rates = await fetchFxRates() displayCurrency = target - displaySubs = subs.map((s) => ({ - ...s, - price: Math.round(convertPrice(s.price, s.currency, target as Currency, rates!.rates)), - currency: target, - })) + displaySubs = convertSubsWithRates(subs, target as Currency, rates!) } catch { consola.warn("Failed to fetch exchange rates; reporting in original currencies") displayCurrency = null diff --git a/apps/subtrack/src/subscription.ts b/apps/subtrack/src/subscription.ts deleted file mode 100644 index 49f671f..0000000 --- a/apps/subtrack/src/subscription.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Subscription command handlers barrel. - * Re-exports from subscription/core.ts, subscription/add.ts, subscription/edit.ts. - * This file preserves backward compatibility for consumers importing from "./subscription.ts". - */ - -export { - handleList, - handleDelete, - handleTags, - handleClone, - handleArchive, - handleUnarchive, -} from "./subscription/core.ts" - -export { handleAdd, resolveAddOptions } from "./subscription/add.ts" - -export { handleEdit } from "./subscription/edit.ts" diff --git a/apps/subtrack/src/subscription/core.ts b/apps/subtrack/src/subscription/core.ts index 6dcde2d..d0dc905 100644 --- a/apps/subtrack/src/subscription/core.ts +++ b/apps/subtrack/src/subscription/core.ts @@ -21,6 +21,7 @@ import { import { formatPrice } from "../price.ts" import { spreadSubscription, showApiUsage } from "../display.ts" import { logAudit } from "../audit.ts" +import { runPreCommandHooks } from "../pre-command.ts" export async function handleList(options: { currency?: string @@ -41,12 +42,7 @@ export async function handleList(options: { maxPrice?: number }) { // Auto-scan for new suggestions (non-blocking on failure) - if (!options.json) { - const { autoScan } = await import("../suggest/scan.ts") - await autoScan() - const { showNotificationBanner } = await import("../notifications/banner.ts") - showNotificationBanner() - } + await runPreCommandHooks(options) const list = options.tags ? tagsSubscription(options.tags.split(",").map((t) => t.trim())) diff --git a/apps/subtrack/src/timeline.ts b/apps/subtrack/src/timeline.ts index 6c7a1c6..559125c 100644 --- a/apps/subtrack/src/timeline.ts +++ b/apps/subtrack/src/timeline.ts @@ -3,9 +3,9 @@ import { fail } from "./error.ts" import pc from "picocolors" import { getSubscriptions } from "./db.ts" import type { SharedArgs, Currency } from "./types.ts" -import { periodFactor } from "./date-utils.ts" +import { periodFactor, SHORT_MONTH_NAMES } from "./date-utils.ts" import { formatPrice } from "./price.ts" -import { fetchFxRates, convertPrice } from "./fx.ts" +import { fetchFxRates, convertSubsWithRates } from "./fx.ts" import type { FxRates } from "./fx.ts" export type TimelineOptions = { @@ -122,17 +122,12 @@ export function renderBarChart(totals: MonthTotal[], currency: string = "USD"): const labelWidth = 4 // "Dec " or "Jun " const lines: string[] = [] - const monthNames = [ - "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", - ] - lines.push(pc.bold("Monthly spending")) lines.push("─".repeat(barWidth + labelWidth + 16)) lines.push("") for (const t of totals) { - const shortMon = monthNames[t.month] + const shortMon = SHORT_MONTH_NAMES[t.month] const label = `${shortMon} ${String(t.year).slice(2)}`.padEnd(labelWidth + 3) const barLen = Math.round((t.total / max) * barWidth) const bar = "█".repeat(barLen) + "░".repeat(barWidth - barLen) @@ -161,10 +156,7 @@ function renderCategoryChart( currency: string = "USD", ): string { const lines: string[] = [] - const monthNames = [ - "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", - ] + const monthNames = SHORT_MONTH_NAMES lines.push(pc.bold("Monthly spending by category")) lines.push("") @@ -216,11 +208,7 @@ export async function handleTimeline(options: TimelineOptions = {}): Promise ({ - ...s, - price: Math.round(convertPrice(s.price, s.currency, displayCurrency as Currency, rates.rates)), - currency: displayCurrency, - })) + activeSubs = convertSubsWithRates(activeSubs, displayCurrency as Currency, rates) } catch { consola.warn("Failed to fetch exchange rates; showing in original currencies") displayCurrency = "USD" diff --git a/apps/subtrack/src/trial.ts b/apps/subtrack/src/trial.ts index e23df32..25f8aff 100644 --- a/apps/subtrack/src/trial.ts +++ b/apps/subtrack/src/trial.ts @@ -3,7 +3,7 @@ import { consola } from "consola" import { fail } from "./error.ts" import pc from "picocolors" import CliTable3 from "cli-table3" -import type { TrialEntry, AddTrialArgs, TrialAddFlags, Cycle } from "./types.ts" +import type { TrialEntry, AddTrialArgs, TrialAddFlags } from "./types.ts" import { writeTrial, getTrials, getTrial, deleteTrial, getTrialsExpiringSoon } from "./db.ts" import { formatPrice } from "./price.ts" import { TABLE_CHARS, TABLE_STYLE } from "./display-constants.ts" @@ -16,17 +16,10 @@ import { promptString, promptSelect, } from "./prompts.ts" +import { daysUntil } from "./date-utils.ts" // ── Helpers ──────────────────────────────────────────── -function daysUntil(dateStr: string): number { - const now = new Date() - now.setHours(0, 0, 0, 0) - const target = new Date(dateStr + "T00:00:00") - target.setHours(0, 0, 0, 0) - return Math.ceil((target.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)) -} - function formatPriceOrDash(price: number | null, currency: string | null, cycle: string | null): string { if (price === null || price === undefined) return "—" const ccy = currency ?? "USD" diff --git a/apps/subtrack/src/upcoming.ts b/apps/subtrack/src/upcoming.ts index 1a80469..716d3fd 100644 --- a/apps/subtrack/src/upcoming.ts +++ b/apps/subtrack/src/upcoming.ts @@ -1,16 +1,12 @@ import { consola } from "consola" import pc from "picocolors" import type { SharedArgs, Cycle, Currency } from "./types.ts" -import { getSubscriptions } from "./db.ts" +import { getSubscriptions, getNonCancelledSubscriptions } from "./db.ts" import { formatPrice } from "./price.ts" -import { fetchFxRates, convertPrice } from "./fx.ts" +import { fetchFxRates, tryConvert } from "./fx.ts" import type { FxRates } from "./fx.ts" - - -function toDate(dateStr: string): Date { - const [y, m, d] = dateStr.split("-").map(Number) - return new Date(y, m - 1, d) -} +import { toDate, formatDate, formatShortDate, dateWithClampedDay, daysUntil } from "./date-utils.ts" +import { runPreCommandHooks } from "./pre-command.ts" function getBillingDay(sub: SharedArgs): number { if (sub.billingDay) return sub.billingDay @@ -19,15 +15,6 @@ function getBillingDay(sub: SharedArgs): number { return created.getDate() } -/** - * Build a date with the given day clamped to the last day of the month, - * avoiding JS Date overflow (e.g. day 31 in February -> Feb 28). - */ -function dateWithClampedDay(year: number, month: number, day: number): Date { - const lastDay = new Date(year, month + 1, 0).getDate() - return new Date(year, month, Math.min(day, lastDay)) -} - /** * Date of the k-th period occurrence anchored on `anchorDate`, * billed on `day` (clamped to the month length). @@ -82,19 +69,6 @@ export function calculateNextBilling(sub: SharedArgs, fromDate: Date): Date { return nextDateForCycle(day, anchorDate, sub.cycle, fromDate) } -function formatDate(d: Date): string { - const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] - return `${months[d.getMonth()]} ${d.getDate()}` -} - -function daysUntil(d: Date): number { - const now = new Date() - now.setHours(0, 0, 0, 0) - const target = new Date(d) - target.setHours(0, 0, 0, 0) - return Math.ceil((target.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)) -} - export type UpcomingEntry = { sub: SharedArgs nextDate: Date @@ -102,7 +76,7 @@ export type UpcomingEntry = { } export function calcUpcoming(days: number = 7): UpcomingEntry[] { - const list = getSubscriptions().filter((s) => s.status !== "cancelled") + const list = getNonCancelledSubscriptions() if (list.length === 0) return [] const now = new Date() @@ -131,12 +105,10 @@ export async function calcUpcomingWithCurrency(days: number = 7, targetCurrency? try { const rates = await fetchFxRates() for (const entry of entries) { - try { - const converted = convertPrice(entry.amount, entry.sub.currency, targetCurrency as Currency, rates.rates) + const converted = tryConvert(entry.amount, entry.sub.currency, targetCurrency as Currency, rates.rates) + if (converted !== null) { entry.amount = Math.round(converted) entry.sub = { ...entry.sub, price: Math.round(converted), currency: targetCurrency } - } catch { - // Keep original currency } } } catch { @@ -159,7 +131,7 @@ export async function showUpcoming(days: number = 7, options: { currency?: strin const currencyTotals: Record = {} for (const entry of entries) { - const dateStr = formatDate(entry.nextDate) + const dateStr = formatShortDate(entry.nextDate) const dayLabel = daysUntil(entry.nextDate) === 0 ? " (today)" : daysUntil(entry.nextDate) === 1 ? " (tomorrow)" : "" consola.log( ` ${pc.cyan(dateStr)}${pc.dim(dayLabel)} ${pc.bold(entry.sub.name)} ${formatPrice(entry.sub.price, entry.sub.currency)}/${entry.sub.cycle} ${pc.dim(entry.sub.tags.length > 0 ? `[${entry.sub.tags.join(", ")}]` : "")}`, @@ -180,12 +152,7 @@ export async function showUpcoming(days: number = 7, options: { currency?: strin export async function handleUpcoming(days: number = 7, options: { json?: boolean; currency?: string } = {}): Promise { // Show notification banner for non-JSON output - if (!options.json) { - const { autoScan } = await import("./suggest/scan.ts") - await autoScan() - const { showNotificationBanner } = await import("./notifications/banner.ts") - showNotificationBanner() - } + await runPreCommandHooks(options) if (options.json) { const entries = options.currency ? await calcUpcomingWithCurrency(days, options.currency) : calcUpcoming(days) @@ -195,7 +162,7 @@ export async function handleUpcoming(days: number = 7, options: { json?: boolean price: e.sub.price, currency: e.sub.currency, cycle: e.sub.cycle, - nextDate: `${e.nextDate.getFullYear()}-${String(e.nextDate.getMonth() + 1).padStart(2, "0")}-${String(e.nextDate.getDate()).padStart(2, "0")}`, + nextDate: formatDate(e.nextDate), amount: Math.round(e.amount), tags: e.sub.tags, })) diff --git a/apps/subtrack/src/usage-import.ts b/apps/subtrack/src/usage-import.ts index 6d6db35..9c53f07 100644 --- a/apps/subtrack/src/usage-import.ts +++ b/apps/subtrack/src/usage-import.ts @@ -5,7 +5,8 @@ import { fail } from "./error.ts" import type { UsageImportFlags } from "./types.ts" import { addLlmUsageFromLog } from "./db.ts" import { safeJsonParse } from "./safe-json.ts" -import { resolveSafePath } from "./path-utils.ts" +import { safePath } from "./path-utils.ts" +import { today } from "./date-utils.ts" const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB const MAX_STDIN_SIZE = 10 * 1024 * 1024 // 10 MB (stdin is unbounded) @@ -19,13 +20,8 @@ import { // ── Helpers ────────────────────────────────────────────── -export function todayLocal(): string { - const now = new Date() - return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}` -} - function unixTsToDate(ts: number | undefined): string { - if (!ts) return todayLocal() + if (!ts) return today() return new Date(ts * 1000).toISOString().split("T")[0] } @@ -81,7 +77,7 @@ function parseResponseJson(obj: Record): ParsedLogEntry | null inputTokens, outputTokens, costCents, - date: todayLocal(), + date: today(), } } @@ -113,7 +109,7 @@ function parseResponseJson(obj: Record): ParsedLogEntry | null inputTokens, outputTokens, costCents: null, - date: todayLocal(), + date: today(), } } @@ -169,7 +165,7 @@ export async function handleUsageImport(flags: UsageImportFlags) { if (stdinDestroyed) return content = Buffer.concat(chunks).toString("utf-8") } else { - const safeFile = resolveSafePath([os.homedir(), os.tmpdir()], filePath) + const safeFile = safePath(filePath) if (!safeFile) { fail( `File not found or path not allowed — must be within home or temp directory`, diff --git a/apps/subtrack/src/usage-refresh.ts b/apps/subtrack/src/usage-refresh.ts index e70d026..5d00b82 100644 --- a/apps/subtrack/src/usage-refresh.ts +++ b/apps/subtrack/src/usage-refresh.ts @@ -3,7 +3,7 @@ import type { UsageRefreshFlags } from "./types.ts" import { batchAddLlmUsageFromLog } from "./db.ts" import { runAllScanners } from "./scanner.ts" import { currentMonthStart } from "./date-utils.ts" -import { todayLocal } from "./usage-import.ts" +import { today } from "./date-utils.ts" import { ensurePricingCache, lookupModelKey, @@ -15,7 +15,7 @@ import { export async function handleUsageRefresh(flags: UsageRefreshFlags = {}) { const from = flags.all ? undefined : (flags.from ?? currentMonthStart()) - const to = flags.all ? undefined : (flags.to ?? todayLocal()) + const to = flags.all ? undefined : (flags.to ?? today()) const result = runAllScanners(from, to) From c196992e46557eada99a6681a99cee92516ed3d3 Mon Sep 17 00:00:00 2001 From: nazozokc Date: Tue, 18 Aug 2026 14:49:03 +0900 Subject: [PATCH 16/19] docs: update architecture documentation --- .agents/skills/subtrack-rules/SKILL.md | 16 ++++++++++------ AGENTS.md | 6 ++++-- apps/subtrack/AGENTS.md | 10 +++++++--- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/.agents/skills/subtrack-rules/SKILL.md b/.agents/skills/subtrack-rules/SKILL.md index 67404d0..8fb4a48 100644 --- a/.agents/skills/subtrack-rules/SKILL.md +++ b/.agents/skills/subtrack-rules/SKILL.md @@ -29,7 +29,7 @@ subtrack is a Node.js CLI tool for managing subscription services from the termi ## Testing - Framework: `vitest` (`pnpm test` or `vitest run`) -- Test files are co-located next to source files as `*.test.ts` +- Test files live under `src/__tests__/` as `*.test.ts` (not co-located) - Use `pnpm test:watch` for watch mode - Use `__setDb()` from `db.ts` to inject an in-memory SQLite database for tests - Mock `consola` via `consola.mockTypes()` for output assertions (see `display.test.ts`) @@ -41,7 +41,7 @@ subtrack is a Node.js CLI tool for managing subscription services from the termi - Import: `import initSqlJs from "sql.js"` and `import type { Database, SqlValue, BindParams } from "sql.js"` - Database file location: `$SUBSC_CLI_DB_DIR` env var or `~/.config/subtrack/subtrack.db` - State is held in memory (`_db`) and persisted to disk via `saveDb()` on writes -- Schema has 3 tables: `subscriptions`, `tags`, `subscription_tags` (many-to-many) +- Schema has 8 tables: `subscriptions`, `tags`, `subscription_tags`, `llm_usage`, `trials`, `price_history`, `suggestions`, `audit_log` - Always use transactions for multi-step writes (`BEGIN TRANSACTION` / `COMMIT` / `ROLLBACK`) - Use `PRAGMA foreign_keys = ON` at connection time @@ -51,18 +51,22 @@ The source code (`subtrack/src/`) follows a 4-layer separation: | Layer | File | Responsibility | |---|---|---| -| Entry | `index.ts` | CLI definition (commander), command routing | -| Commands | `commands.ts` | Command handlers, workflow logic, user interaction | -| Database | `db.ts` | SQLite CRUD, schema, persistence, `__setDb()` for testing | +| Entry | `index.ts` | CLI definition (gunshi), command routing | +| Commands | `commands/` | gunshi command definitions (`define()` + `.run()`) | +| Handlers | `subscription/`, `menu.ts`, `search.ts`, `payment.ts`, … | Command handlers, workflow logic, user interaction | +| Database | `db.ts`, `db/` | SQLite CRUD, schema, persistence, `__setDb()` for testing | | Display | `display.ts` | Table rendering with cli-table3, FX rate conversion | | Prompts | `prompts.ts` | Input validation, interactive prompts, shared choices | +| FX | `fx.ts` | Exchange rate fetching & conversion (`fetchFxRates`, `convertPrice`, `convertSubsWithRates`, `tryConvert`) | +| Dates | `date-utils.ts` | Date helpers (`today`, `formatDate`, `daysUntil`, period ranges) | +| Path safety | `path-utils.ts` | `resolveSafePath` / `resolveSafeOutputPath` (+ `safePath` / `safeOutputPath` shortcuts) | Keep concerns separated. Don't put DB queries in display logic or prompt logic in command handlers. ## Import Style - Use `node:` prefix for Node.js built-ins: `import { readFileSync } from "node:fs"`, `import path from "node:path"`, `import { homedir } from "node:os"` -- Use `.ts` extension in local imports: `import { handleList } from "./commands.ts"` +- Use `.ts` extension in local imports: `import { handleList } from "./subscription/core.ts"` - Prefer native `fetch` for HTTP requests - Prefer native `WebSocket` for WebSocket connections (if needed) - Use `type` prefix for type-only imports: `import type { SharedArgs } from "./db.ts"` diff --git a/AGENTS.md b/AGENTS.md index 96ff677..f011ac7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,12 +49,14 @@ The CLI tool (`subtrack/`) uses `gunshi` for command routing and follows a multi | Layer | File | Responsibility | | ----------- | ------------------- | ----------------------------------- | | Entry | `src/index.ts` | Command definitions (gunshi), routing | -| Commands | `src/commands.ts` | Command handlers, workflow logic | -| Database | `src/db.ts` | SQLite CRUD, schema, persistence | +| Commands | `src/commands/` | gunshi command definitions (`define()` + `.run()`) | +| Handlers | `src/subscription/`, `src/*.ts` | Command handlers, workflow logic | +| Database | `src/db.ts`, `src/db/` | SQLite CRUD, schema, persistence | | Display | `src/display.ts` | Table rendering, formatting | | Prompts | `src/prompts.ts` | Input validation, interactive prompts | | Payment | `src/payment.ts` | Payment/summary calculations | | FX | `src/fx.ts` | Exchange rate fetching & conversion | +| Dates | `src/date-utils.ts` | Date helpers (today, formatDate, daysUntil, period ranges) | | Usage | `src/usage.ts` | LLM API usage tracking | For **detailed implementation guidance** (DB schema, testing patterns, import style, dependency reference), load the `subtrack-rules` skill. diff --git a/apps/subtrack/AGENTS.md b/apps/subtrack/AGENTS.md index d152486..86bf462 100644 --- a/apps/subtrack/AGENTS.md +++ b/apps/subtrack/AGENTS.md @@ -32,7 +32,7 @@ CLI tool to manage subscription services from the terminal. Node.js + TypeScript ## Testing - `pnpm test` (vitest) -- Test files co-located as `*.test.ts` +- Test files under `src/__tests__/` as `*.test.ts` - Use `__setDb()` from `db.ts` to inject in-memory SQLite for tests - Mock `consola` via `consola.mockTypes()` - Mock `globalThis.fetch` for FX rate API @@ -43,12 +43,16 @@ CLI tool to manage subscription services from the terminal. Node.js + TypeScript |---|---| | `src/index.ts` | CLI definition (gunshi), command routing | | `src/menu.ts` | Interactive main menu (@inquirer select, launched by bare `subtrack`) | -| `src/commands.ts` | Command handlers, workflow logic | -| `src/db.ts` | SQLite CRUD, schema, persistence | +| `src/commands/` | gunshi command definitions (`define()` + `.run()`) | +| `src/subscription/` | Core subscription handlers (list/add/edit/delete/clone/archive/tags) | +| `src/db.ts`, `src/db/` | SQLite CRUD, schema, persistence | | `src/display.ts` | Table rendering, formatting | | `src/prompts.ts` | Input validation, interactive prompts | | `src/payment.ts` | Payment/summary calculations | | `src/fx.ts` | Exchange rate fetching & conversion | +| `src/date-utils.ts` | Date helpers (today, formatDate, daysUntil, period ranges) | +| `src/pre-command.ts` | Pre-command hooks (auto-scan + notification banner) | +| `src/path-utils.ts` | Path safety validation (resolveSafePath / safeOutputPath) | | `src/usage.ts` | LLM API usage tracking | | `src/export.ts` | CSV/JSON/MD export | | `src/import-csv.ts` | CSV import | From 53ff92bb24933e2a94fe99fa54a47b1a2655c732 Mon Sep 17 00:00:00 2001 From: nazozokc Date: Tue, 18 Aug 2026 22:13:10 +0900 Subject: [PATCH 17/19] feat: add user-configurable display themes Add a named-color theme system driven by config: - theme presets (default, light, high-contrast, none) plus per-key overrides for border, header, zebra, and accent colors - tableZebra on/off, tableMinWidth, dateFormat, and default list columns (listShowNotes, listShowMethod) as config keys - unified section headings, status colors, total-row emphasis, USD cost formatting, and FX-failure messaging across modules - shared column-width calculation for all cli-table3 tables - menu header showing version, subscription count, and DB path - docs: document display config keys and themes --- apps/subtrack/src/__tests__/commands.test.ts | 15 +- apps/subtrack/src/__tests__/config.test.ts | 77 ++++++ .../src/__tests__/display-constants.test.ts | 203 +++++++++++++++ apps/subtrack/src/__tests__/display.test.ts | 15 +- apps/subtrack/src/analytics.ts | 4 +- apps/subtrack/src/audit.ts | 24 +- apps/subtrack/src/budget.ts | 6 +- apps/subtrack/src/calendar.ts | 9 +- apps/subtrack/src/cancel.ts | 6 +- apps/subtrack/src/color.ts | 34 +++ apps/subtrack/src/compare.ts | 21 +- apps/subtrack/src/config.ts | 65 ++++- apps/subtrack/src/currency.ts | 3 +- apps/subtrack/src/display-constants.ts | 236 +++++++++++++++++- apps/subtrack/src/display.ts | 118 ++------- apps/subtrack/src/export.ts | 2 +- apps/subtrack/src/forecast.ts | 28 ++- apps/subtrack/src/menu.ts | 19 +- apps/subtrack/src/notify.ts | 5 +- apps/subtrack/src/payment.ts | 12 +- apps/subtrack/src/price.ts | 8 + apps/subtrack/src/report.ts | 10 +- apps/subtrack/src/stats.ts | 3 +- apps/subtrack/src/subscription/core.ts | 6 +- apps/subtrack/src/trial.ts | 25 +- apps/subtrack/src/types.ts | 18 ++ apps/subtrack/src/usage-add.ts | 9 +- apps/subtrack/src/usage-total.ts | 12 +- apps/subtrack/src/usage.ts | 3 +- apps/subtrack/vitest.config.ts | 6 + docs/configuration.md | 36 ++- 31 files changed, 846 insertions(+), 192 deletions(-) create mode 100644 apps/subtrack/src/__tests__/display-constants.test.ts create mode 100644 apps/subtrack/src/color.ts diff --git a/apps/subtrack/src/__tests__/commands.test.ts b/apps/subtrack/src/__tests__/commands.test.ts index e17a745..dac9fc0 100644 --- a/apps/subtrack/src/__tests__/commands.test.ts +++ b/apps/subtrack/src/__tests__/commands.test.ts @@ -397,8 +397,8 @@ test("handleExport with currency falls back when fetch fails", async () => { const { handleExport } = await import("../commands.ts") await handleExport("csv", { currency: "USD" }) - expect(failMessages.length).toBeGreaterThan(0) - expect(failMessages[0]).toContain("Failed to fetch exchange rates") + expect(warnMessages.length).toBeGreaterThan(0) + expect(warnMessages[0]).toContain("Failed to fetch exchange rates") globalThis.fetch = async () => new Response(JSON.stringify({ base: "USD", rates: { JPY: 160, USD: 1 } })) @@ -1346,11 +1346,12 @@ test("handleUsageTotal display shows tokens and by model", async () => { const { handleUsageTotal } = await import("../usage-total.ts") handleUsageTotal({ from: "2026-06-01", to: "2026-06-30" }) const combined = logMessages.join("\n") - expect(combined).toContain("By provider") - expect(combined).toContain("By model") - expect(combined).toContain("gpt-4o") - expect(combined).toContain("Tokens: 100 in / 50 out") - expect(combined).toContain("Total: $0.01") // cost 1.0 = 1 cent + const plain = combined.replace(/\x1b\[[0-9;]*m/g, "") + expect(plain).toContain("By provider") + expect(plain).toContain("By model") + expect(plain).toContain("gpt-4o") + expect(plain).toContain("Tokens: 100 in / 50 out") + expect(plain).toContain("Total: $0.01") // cost 1.0 = 1 cent }) test("handleUsageTotal shows info when no usage in range", async () => { diff --git a/apps/subtrack/src/__tests__/config.test.ts b/apps/subtrack/src/__tests__/config.test.ts index d162670..da1949c 100644 --- a/apps/subtrack/src/__tests__/config.test.ts +++ b/apps/subtrack/src/__tests__/config.test.ts @@ -174,3 +174,80 @@ test("webhook secrets are masked in list, get, and set output", async () => { // The real value is still stored expect(loadConfig().slackWebhook).toBe(SECRET) }) + +// ── Display theme keys ─────────────────────────────── + +test("theme accepts preset names and rejects unknown", async () => { + const { handleConfigSet, handleConfigGet } = await import("../commands.ts") + const { loadConfig, resetConfig } = await import("../config.ts") + + resetConfig() + handleConfigSet("theme", "light") + expect(loadConfig().theme).toBe("light") + expect(successMessages.some((m) => m.includes("theme = light"))).toBe(true) + + resetConfig() + handleConfigSet("theme", "vaporwave") + expect(errorMessages.some((m) => m.includes("theme must be one of"))).toBe(true) + expect(loadConfig().theme).not.toBe("vaporwave") +}) + +test("color keys validate color names", async () => { + const { handleConfigSet } = await import("../commands.ts") + const { loadConfig, resetConfig } = await import("../config.ts") + + resetConfig() + handleConfigSet("tableBorderColor", "brightMagenta") + expect(loadConfig().tableBorderColor).toBe("brightMagenta") + + resetConfig() + handleConfigSet("accentColor", "notacolor") + expect(errorMessages.some((m) => m.includes("must be a color name"))).toBe(true) + expect(loadConfig().accentColor).toBeUndefined() + + resetConfig() + handleConfigSet("tableHeaderColor", "cyan") + expect(loadConfig().tableHeaderColor).toBe("cyan") +}) + +test("tableZebra and tableMinWidth validate values", async () => { + const { handleConfigSet } = await import("../commands.ts") + const { loadConfig, resetConfig } = await import("../config.ts") + + resetConfig() + handleConfigSet("tableZebra", "off") + expect(loadConfig().tableZebra).toBe("off") + + resetConfig() + handleConfigSet("tableZebra", "maybe") + expect(errorMessages.some((m) => m.includes("tableZebra must be"))).toBe(true) + + resetConfig() + handleConfigSet("tableMinWidth", "120") + expect(loadConfig().tableMinWidth).toBe(120) + + resetConfig() + handleConfigSet("tableMinWidth", "10") + expect(errorMessages.some((m) => m.includes("tableMinWidth must be"))).toBe(true) +}) + +test("dateFormat and listShow keys validate values", async () => { + const { handleConfigSet } = await import("../commands.ts") + const { loadConfig, resetConfig } = await import("../config.ts") + + resetConfig() + handleConfigSet("dateFormat", "short") + expect(loadConfig().dateFormat).toBe("short") + + resetConfig() + handleConfigSet("dateFormat", "long") + expect(errorMessages.some((m) => m.includes("dateFormat must be"))).toBe(true) + + resetConfig() + handleConfigSet("listShowNotes", "on") + expect(loadConfig().listShowNotes).toBe("on") + + resetConfig() + handleConfigSet("listShowMethod", "yes") + expect(errorMessages.some((m) => m.includes("must be 'on' or 'off'"))).toBe(true) +}) diff --git a/apps/subtrack/src/__tests__/display-constants.test.ts b/apps/subtrack/src/__tests__/display-constants.test.ts new file mode 100644 index 0000000..0657470 --- /dev/null +++ b/apps/subtrack/src/__tests__/display-constants.test.ts @@ -0,0 +1,203 @@ +import { test, expect, describe, beforeEach, afterEach } from "vitest" +import { unlinkSync } from "node:fs" +import { + statusColor, + sectionTitle, + divider, + zebraRow, + calcColumnWidths, + getDisplayTheme, + isPlainTheme, + getTableStyle, +} from "../display-constants.ts" +import { loadConfig, saveConfig, resetConfig, getConfigPath } from "../config.ts" + +const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "") + +beforeEach(() => { + resetConfig() + try { unlinkSync(getConfigPath()) } catch { /* no config yet */ } +}) + +afterEach(() => { + resetConfig() + try { unlinkSync(getConfigPath()) } catch { /* no config yet */ } +}) + +describe("statusColor", () => { + test("colors active green", () => { + expect(stripAnsi(statusColor("active"))).toBe("active") + expect(statusColor("active")).toContain("\x1b[32m") + }) + + test("colors paused yellow", () => { + expect(statusColor("paused")).toContain("\x1b[33m") + }) + + test("colors cancelled red", () => { + expect(statusColor("cancelled")).toContain("\x1b[31m") + }) + + test("colors archived gray", () => { + expect(statusColor("archived")).toContain("\x1b[90m") + }) +}) + +describe("sectionTitle", () => { + test("wraps text in bold cyan ── markers", () => { + const t = sectionTitle("Database Statistics") + expect(stripAnsi(t)).toBe("── Database Statistics ──") + expect(t).toContain("\x1b[1m") // bold + expect(t).toContain("\x1b[36m") // cyan + }) +}) + +describe("divider", () => { + test("renders a dim line with the given length", () => { + const d = divider(20) + expect(stripAnsi(d)).toBe("─".repeat(20)) + expect(d).toContain("\x1b[2m") // dim + }) + + test("defaults to 40 chars", () => { + expect(stripAnsi(divider())).toBe("─".repeat(40)) + }) +}) + +describe("zebraRow", () => { + test("wraps every cell with the zebra background", () => { + const cells = ["a", "b"] + const out = zebraRow(cells) + expect(out).toEqual([ + "\x1b[100ma\x1b[0m", + "\x1b[100mb\x1b[0m", + ]) + }) +}) + +describe("calcColumnWidths", () => { + const config = { + headers: ["name", "status", "cycle", "tags", "price"] as const, + minWidths: [10, 8, 6, 8, 8] as const, + maxWidths: [40, 12, 20, 60, 20] as const, + } + + test("respects min and max widths", () => { + const widths = calcColumnWidths( + [["a", "active", "monthly", "-", "$10.00"]], + config, + ) + expect(widths.length).toBe(5) + expect(widths[0]).toBeGreaterThanOrEqual(10) + expect(widths[0]).toBeLessThanOrEqual(40) + }) + + test("allocates more width to longer content", () => { + const rows = [ + ["a very long subscription name here", "active", "monthly", "-", "$10.00"], + ["x", "paused", "yearly", "tag1, tag2, tag3", "$999.99"], + ] + const widths = calcColumnWidths(rows, config) + // name column gets more room than the tiny status column + expect(widths[0]).toBeGreaterThan(widths[1]) + }) + + test("sum of widths stays within the terminal width", () => { + const rows = [ + ["name", "active", "monthly", "-", "$10.00"], + ["another subscription", "paused", "yearly", "a, b, c", "$99.00"], + ] + const widths = calcColumnWidths(rows, config) + const sum = widths.reduce((a, b) => a + b, 0) + const termWidth = process.stdout.columns ?? 80 + expect(sum).toBeLessThanOrEqual(termWidth) + }) + + test("honors tableMinWidth from config", () => { + saveConfig({ ...loadConfig(), tableMinWidth: 100 }) + const widths = calcColumnWidths( + [["a", "active", "monthly", "-", "$10.00"]], + config, + ) + expect(widths.reduce((a, b) => a + b, 0)).toBeGreaterThanOrEqual(80) + resetConfig() + }) +}) + +describe("getDisplayTheme", () => { + test("uses default preset when no config", () => { + const t = getDisplayTheme() + expect(t.border).toBe("gray") + expect(t.header).toBe("brightBlue") + expect(t.zebra).toBe("gray") + expect(t.accent).toBe("cyan") + expect(t.statusActive).toBe("green") + expect(t.statusArchived).toBe("gray") + expect(isPlainTheme()).toBe(false) + }) + + test("none theme resolves to plain output", () => { + saveConfig({ ...loadConfig(), theme: "none" }) + const t = getDisplayTheme() + expect(t.border).toBeNull() + expect(t.header).toBeNull() + expect(t.zebra).toBeNull() + expect(t.accent).toBeNull() + expect(t.statusActive).toBeNull() + expect(isPlainTheme()).toBe(true) + // Emitted helpers are uncolored + expect(zebraRow(["a"])).toEqual(["a"]) + expect(sectionTitle("X")).not.toContain("\x1b[") + expect(statusColor("active")).toBe("active") + resetConfig() + }) + + test("individual keys override the preset", () => { + saveConfig({ + ...loadConfig(), + theme: "light", + tableBorderColor: "brightMagenta", + accentColor: "yellow", + }) + const t = getDisplayTheme() + expect(t.border).toBe("brightMagenta") + expect(t.header).toBe("blue") // from light preset + expect(t.accent).toBe("yellow") + resetConfig() + }) + + test("tableZebra off disables striping", () => { + saveConfig({ ...loadConfig(), tableZebra: "off" }) + expect(getDisplayTheme().zebra).toBeNull() + expect(zebraRow(["a"])).toEqual(["a"]) + resetConfig() + }) + + test("invalid color names fall back to the preset", () => { + saveConfig({ ...loadConfig(), tableBorderColor: "notacolor" }) + expect(getDisplayTheme().border).toBe("gray") + resetConfig() + }) + + test("unknown theme falls back to default preset", () => { + saveConfig({ ...loadConfig(), theme: "vaporwave" }) + expect(getDisplayTheme().header).toBe("brightBlue") + resetConfig() + }) +}) + +describe("getTableStyle", () => { + test("emits border and head colors for the default theme", () => { + const style = getTableStyle() + expect(style.border).toEqual(["\x1b[90m", "\x1b[0m"]) + expect(style.head).toEqual(["\x1b[1m\x1b[94m", "\x1b[0m"]) + }) + + test("emits empty codes for the none theme", () => { + saveConfig({ ...loadConfig(), theme: "none" }) + const style = getTableStyle() + expect(style.border).toEqual(["", ""]) + expect(style.head).toEqual(["", ""]) + resetConfig() + }) +}) diff --git a/apps/subtrack/src/__tests__/display.test.ts b/apps/subtrack/src/__tests__/display.test.ts index 900d984..904314f 100644 --- a/apps/subtrack/src/__tests__/display.test.ts +++ b/apps/subtrack/src/__tests__/display.test.ts @@ -9,6 +9,7 @@ const logMessages: string[] = [] const infoMessages: string[] = [] const failMessages: string[] = [] const errorMessages: string[] = [] +const warnMessages: string[] = [] let originalFetch: typeof globalThis.fetch @@ -17,6 +18,7 @@ beforeEach(() => { infoMessages.length = 0 failMessages.length = 0 errorMessages.length = 0 + warnMessages.length = 0 const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "") @@ -28,6 +30,7 @@ beforeEach(() => { if (_type === "info") infoMessages.push(clean) if (_type === "fail") failMessages.push(clean) if (_type === "error") errorMessages.push(clean) + if (_type === "warn") warnMessages.push(clean) } }) @@ -247,9 +250,9 @@ test("currency falls back when fetch fails", async () => { "JPY", ) - // Should log fail message - expect(failMessages.length).toBeGreaterThan(0) - expect(failMessages[0]).toContain("Failed to fetch exchange rates") + // Should log warn message + expect(warnMessages.length).toBeGreaterThan(0) + expect(warnMessages[0]).toContain("Failed to fetch exchange rates") // Should fall back to grouped-by-currency display const table = logMessages.filter(Boolean).join("\n") @@ -481,9 +484,9 @@ test("showPayment --currency falls back when fetch fails", async () => { makeSub({ name: "US", price: 10, currency: "USD" }), ]) - // Should log fail message - expect(failMessages.length).toBeGreaterThan(0) - expect(failMessages[0]).toContain("Failed to fetch exchange rates") + // Should log warn message + expect(warnMessages.length).toBeGreaterThan(0) + expect(warnMessages[0]).toContain("Failed to fetch exchange rates") // Should fall back to per-currency display const combined = logMessages.join("\n") diff --git a/apps/subtrack/src/analytics.ts b/apps/subtrack/src/analytics.ts index 800fd6b..32ccdf2 100644 --- a/apps/subtrack/src/analytics.ts +++ b/apps/subtrack/src/analytics.ts @@ -24,7 +24,7 @@ export async function handleAnalytics(options: AnalyticsOptions = {}): Promise = {} if (rates) { @@ -69,7 +69,7 @@ export async function showAnalytics(options: AnalyticsOptions = {}): Promise [String(e.id), formatAction(e.action), e.target_type ?? "", e.details ?? ""]), AUDIT_COLS) const table = new CliTable3({ chars: { ...TABLE_CHARS }, - style: { ...TABLE_STYLE }, + style: getTableStyle(), colWidths: colWidths, - head: headers, + head: [...headers], colAligns: ["right", "left", "left", "left"], }) @@ -112,7 +112,7 @@ export function handleAuditList(options: { const ts = formatTimestamp(e.created_at) const row = [String(e.id), action, target, `${ts} ${details}`] if (i % 2 === 0) { - table.push(row.map((cell) => `\x1b[48;5;236m${cell}\x1b[0m`)) + table.push(zebraRow(row)) } else { table.push(row) } diff --git a/apps/subtrack/src/budget.ts b/apps/subtrack/src/budget.ts index cdd358b..3b8789b 100644 --- a/apps/subtrack/src/budget.ts +++ b/apps/subtrack/src/budget.ts @@ -134,7 +134,7 @@ export async function handleBudget(options: BudgetOptions = {}): Promise { try { rates = await fetchFxRates() } catch { - consola.warn("Failed to fetch exchange rates; comparing in original currencies") + consola.warn("Failed to fetch exchange rates; showing in original currencies") } } @@ -225,10 +225,10 @@ export async function handleBudget(options: BudgetOptions = {}): Promise { const budgetLabel = budget.name ? `Budget (${budget.name})` : "Budget" consola.log( - `${periodName} spending: ${pc.bold(formatPrice(Math.round(spending), currency))}/${periodLabel}`, + `${periodName} spending: ${pc.bold(pc.yellow(formatPrice(Math.round(spending), currency)))}/${periodLabel}`, ) consola.log( - `${budgetLabel}: ${pc.bold(formatPrice(Math.round(budgetDisplay), currency))}/${periodLabel}` + + `${budgetLabel}: ${pc.bold(pc.yellow(formatPrice(Math.round(budgetDisplay), currency)))}/${periodLabel}` + (budget.currency !== currency ? ` (${budget.currency})` : ""), ) if (over) { diff --git a/apps/subtrack/src/calendar.ts b/apps/subtrack/src/calendar.ts index be8a62d..645db0b 100644 --- a/apps/subtrack/src/calendar.ts +++ b/apps/subtrack/src/calendar.ts @@ -2,10 +2,11 @@ import { consola } from "consola" import pc from "picocolors" import { getNonCancelledSubscriptions } from "./db.ts" import { formatPrice } from "./price.ts" -import type { SharedArgs, Currency } from "./types.ts" +import type { SharedArgs, Currency, Status } from "./types.ts" import { fetchFxRates, tryConvert } from "./fx.ts" import type { FxRates } from "./fx.ts" import { toDate, clampDay, daysInMonth } from "./date-utils.ts" +import { statusColor } from "./display-constants.ts" /** Options for the calendar command */ export type CalendarOptions = { @@ -24,7 +25,7 @@ export type CalendarEntry = { /** Day of month (1-31) */ day: number /** Subscriptions billing on this day */ - subs: { name: string; price: number; currency: string; status: string; id: number }[] + subs: { name: string; price: number; currency: string; status: Status; id: number }[] } /** @@ -201,10 +202,8 @@ export async function showCalendar(options: CalendarOptions): Promise { for (const entry of entries) { for (const sub of entry.subs) { - const statusStyle = - sub.status === "active" ? pc.green : sub.status === "paused" ? pc.yellow : pc.dim consola.log( - ` ${pc.cyan(`Day ${String(entry.day).padStart(2)}`)} ${pc.bold(sub.name)} ${formatPrice(sub.price, sub.currency)} ${statusStyle(sub.status)}`, + ` ${pc.cyan(`Day ${String(entry.day).padStart(2)}`)} ${pc.bold(sub.name)} ${formatPrice(sub.price, sub.currency)} ${statusColor(sub.status)}`, ) currencyTotals[sub.currency] = (currencyTotals[sub.currency] ?? 0) + sub.price totalSubs++ diff --git a/apps/subtrack/src/cancel.ts b/apps/subtrack/src/cancel.ts index ac38adc..73e0bc9 100644 --- a/apps/subtrack/src/cancel.ts +++ b/apps/subtrack/src/cancel.ts @@ -7,9 +7,10 @@ import path from "node:path" import { getSubscription, updateSubscription } from "./db.ts" import { logAudit } from "./audit.ts" import { fail } from "./error.ts" +import { loadConfig } from "./config.ts" import { formatPrice } from "./price.ts" import { calculateNextBilling } from "./upcoming.ts" -import { today, formatDate } from "./date-utils.ts" +import { today, formatDate, formatShortDate } from "./date-utils.ts" import { exportCsv } from "./export.ts" import { safeOutputPath } from "./path-utils.ts" import type { AddSharedArgs } from "./types.ts" @@ -67,7 +68,8 @@ export async function handleCancel(id: number, options: CancelOptions = {}): Pro consola.log(pc.bold(`Cancelling: ${sub.name}`)) consola.log(` Price: ${formatPrice(sub.price, sub.currency)}/${sub.cycle}`) - consola.log(` Next billing: ${formatDate(nextBilling)}`) + const fmt = loadConfig().dateFormat === "short" ? formatShortDate : formatDate + consola.log(` Next billing: ${fmt(nextBilling)}`) consola.log("") let noteCancellationDate = false diff --git a/apps/subtrack/src/color.ts b/apps/subtrack/src/color.ts new file mode 100644 index 0000000..2f8dfe3 --- /dev/null +++ b/apps/subtrack/src/color.ts @@ -0,0 +1,34 @@ +/** + * Named ANSI color system for display theming. + * Shared by display-constants (theme resolution) and config (validation). + */ + +/** Named ANSI colors configurable by the user. */ +export type ColorName = + | "black" | "red" | "green" | "yellow" | "blue" | "magenta" | "cyan" | "white" + | "gray" | "brightRed" | "brightGreen" | "brightYellow" | "brightBlue" + | "brightMagenta" | "brightCyan" | "brightWhite" + +const FG_CODES: Record = { + black: 30, red: 31, green: 32, yellow: 33, blue: 34, magenta: 35, cyan: 36, white: 37, + gray: 90, brightRed: 91, brightGreen: 92, brightYellow: 93, brightBlue: 94, + brightMagenta: 95, brightCyan: 96, brightWhite: 97, +} + +const BG_CODES: Record = Object.fromEntries( + Object.entries(FG_CODES).map(([name, code]) => [name, code + 10]), +) as Record + +export function isColorName(value: string): value is ColorName { + return value in FG_CODES +} + +/** Foreground ANSI prefix for a color name, or "" when null. */ +export function fgCode(name: ColorName | null): string { + return name ? `\x1b[${FG_CODES[name]}m` : "" +} + +/** Background ANSI prefix for a color name, or "" when null. */ +export function bgCode(name: ColorName | null): string { + return name ? `\x1b[${BG_CODES[name]}m` : "" +} \ No newline at end of file diff --git a/apps/subtrack/src/compare.ts b/apps/subtrack/src/compare.ts index 8bf966b..abf8a3b 100644 --- a/apps/subtrack/src/compare.ts +++ b/apps/subtrack/src/compare.ts @@ -8,7 +8,8 @@ import { formatPrice } from "./price.ts" import { fetchFxRates, convertPrice } from "./fx.ts" import type { FxRates } from "./fx.ts" import { calcSubTotal } from "./payment.ts" -import { TABLE_CHARS, TABLE_STYLE } from "./display-constants.ts" +import { TABLE_CHARS, getTableStyle, calcColumnWidths } from "./display-constants.ts" +import type { ColumnConfig } from "./display-constants.ts" type PeriodLabel = string @@ -60,10 +61,22 @@ function renderCompareTable( currentLabel: string, previousLabel: string, ): void { + const headers = ["", currentLabel, previousLabel, "Change"] as const + const COMPARE_COLS: ColumnConfig = { + headers, + minWidths: [10, 12, 12, 16] as const, + maxWidths: [40, 20, 20, 30] as const, + } + const colWidths = calcColumnWidths( + rows.map((r) => [r.label, r.current, r.previous, r.change]), + COMPARE_COLS, + ) + const table = new CliTable3({ chars: { ...TABLE_CHARS }, - style: { ...TABLE_STYLE }, - head: ["", currentLabel, previousLabel, "Change"], + style: getTableStyle(), + colWidths, + head: [...headers], colAligns: ["left", "right", "right", "right"], }) @@ -112,7 +125,7 @@ export async function showCompare( try { rates = await fetchFxRates() } catch { - consola.fail("Failed to fetch exchange rates; showing per-currency totals") + consola.warn("Failed to fetch exchange rates; showing in original currencies") } } diff --git a/apps/subtrack/src/config.ts b/apps/subtrack/src/config.ts index ee91967..7bee105 100644 --- a/apps/subtrack/src/config.ts +++ b/apps/subtrack/src/config.ts @@ -6,6 +6,7 @@ import { safeJsonParse } from "./safe-json.ts" import { encryptBuffer, decryptBuffer, hasEncryptionKey } from "./crypto.ts" import { logAudit } from "./audit.ts" import { fail } from "./error.ts" +import { isColorName } from "./color.ts" import type { SubtrackConfig } from "./types.ts" export const CONFIG_KEYS = [ @@ -17,6 +18,15 @@ export const CONFIG_KEYS = [ "notifyChannels", "slackWebhook", "webhookUrl", + "tableBorderColor", + "tableHeaderColor", + "tableZebraColor", + "accentColor", + "tableZebra", + "tableMinWidth", + "dateFormat", + "listShowNotes", + "listShowMethod", ] as const /** IMAP-related config keys (not stored directly on SubtrackConfig). */ @@ -131,9 +141,57 @@ export function setConfig(key: string, value: string): boolean { config.budgets = parsed break } - case "theme": + case "theme": { + const validThemes = ["default", "light", "high-contrast", "none"] + if (!validThemes.includes(value)) { + fail(`theme must be one of: ${validThemes.join(", ")}`) + return false + } config.theme = value break + } + case "tableBorderColor": + case "tableHeaderColor": + case "tableZebraColor": + case "accentColor": { + if (!isColorName(value)) { + fail(`"${key}" must be a color name: black, red, green, yellow, blue, magenta, cyan, white, gray, brightRed, brightGreen, brightYellow, brightBlue, brightMagenta, brightCyan, brightWhite`) + return false + } + config[key] = value + break + } + case "tableZebra": + if (value !== "on" && value !== "off") { + fail("tableZebra must be 'on' or 'off'") + return false + } + config.tableZebra = value + break + case "tableMinWidth": { + const num = Number(value) + if (isNaN(num) || num < 20 || num > 200 || !Number.isInteger(num)) { + fail("tableMinWidth must be an integer between 20 and 200") + return false + } + config.tableMinWidth = num + break + } + case "dateFormat": + if (value !== "iso" && value !== "short") { + fail("dateFormat must be 'iso' or 'short'") + return false + } + config.dateFormat = value + break + case "listShowNotes": + case "listShowMethod": + if (value !== "on" && value !== "off") { + fail(`"${key}" must be 'on' or 'off'`) + return false + } + config[key] = value + break case "notifyDays": { const num = Number(value) if (isNaN(num) || num < 0 || !Number.isInteger(num)) { @@ -339,7 +397,10 @@ function getConfigDisplayValue(key: string, config: SubtrackConfig): string { case "notifyChannels": return config.notifyChannels?.length ? config.notifyChannels.join(",") : "(not set)" case "slackWebhook": return maskSecret(config.slackWebhook) case "webhookUrl": return maskSecret(config.webhookUrl) - default: return String((config as Record)[key] ?? "") + default: { + const v = (config as Record)[key] + return v === undefined || v === null || v === "" ? "(not set)" : String(v) + } } } diff --git a/apps/subtrack/src/currency.ts b/apps/subtrack/src/currency.ts index 67ca530..efdf66b 100644 --- a/apps/subtrack/src/currency.ts +++ b/apps/subtrack/src/currency.ts @@ -3,6 +3,7 @@ */ import { consola } from "consola" +import { sectionTitle } from "./display-constants.ts" import { CURRENCY_CHOICES } from "./prompts.ts" export type CurrencyListOptions = { @@ -15,7 +16,7 @@ export function handleCurrencyList(options: CurrencyListOptions = {}): void { return } - consola.log("── Supported Currencies ──") + consola.log(sectionTitle("Supported Currencies")) for (const c of CURRENCY_CHOICES) { consola.log(` ${c.value.padEnd(5)} ${c.name}`) } diff --git a/apps/subtrack/src/display-constants.ts b/apps/subtrack/src/display-constants.ts index d711b48..0a9390f 100644 --- a/apps/subtrack/src/display-constants.ts +++ b/apps/subtrack/src/display-constants.ts @@ -1,8 +1,17 @@ /** * Shared table rendering constants for cli-table3. * Extracted to avoid duplication across display, trial, forecast, compare, etc. + * Colors are theme-driven: resolved from config with preset fallbacks. */ +import type { Status } from "./types.ts" +import { loadConfig } from "./config.ts" +import type { ColorName } from "./color.ts" +import { isColorName, fgCode, bgCode } from "./color.ts" + +export type { ColorName } from "./color.ts" +export { isColorName, fgCode, bgCode } from "./color.ts" + export const TABLE_CHARS = { top: "─", "top-mid": "┬", @@ -21,10 +30,223 @@ export const TABLE_CHARS = { middle: "│", } as const -export const TABLE_STYLE = { - border: ["\x1b[90m", "\x1b[0m"], - head: ["\x1b[1m\x1b[38;5;75m", "\x1b[0m"], - "padding-left": 1, - "padding-right": 1, - compact: false, -} satisfies Record +/** Resolved display theme (all colors resolved, ready to emit). */ +export type ResolvedTheme = { + border: ColorName | null + header: ColorName | null + zebra: ColorName | null + accent: ColorName | null + statusActive: ColorName | null + statusPaused: ColorName | null + statusCancelled: ColorName | null + statusArchived: ColorName | null +} + +const PRESET_THEMES: Record = { + default: { + border: "gray", + header: "brightBlue", + zebra: "gray", + accent: "cyan", + statusActive: "green", + statusPaused: "yellow", + statusCancelled: "red", + statusArchived: "gray", + }, + light: { + border: "gray", + header: "blue", + zebra: "brightWhite", + accent: "blue", + statusActive: "green", + statusPaused: "yellow", + statusCancelled: "red", + statusArchived: "gray", + }, + "high-contrast": { + border: "white", + header: "white", + zebra: null, + accent: "white", + statusActive: "green", + statusPaused: "yellow", + statusCancelled: "red", + statusArchived: "white", + }, + none: { + border: null, + header: null, + zebra: null, + accent: null, + statusActive: null, + statusPaused: null, + statusCancelled: null, + statusArchived: null, + }, +} + +/** + * Resolve the display theme from config. + * Priority: individual color keys > preset (`theme`) > default preset. + * `tableZebra: "off"` disables zebra striping entirely. + */ +export function getDisplayTheme(): ResolvedTheme { + const config = loadConfig() + const preset = PRESET_THEMES[config.theme] ?? PRESET_THEMES.default + return { + border: config.tableBorderColor && isColorName(config.tableBorderColor) + ? config.tableBorderColor + : preset.border, + header: config.tableHeaderColor && isColorName(config.tableHeaderColor) + ? config.tableHeaderColor + : preset.header, + zebra: config.tableZebra === "off" + ? null + : config.tableZebraColor && isColorName(config.tableZebraColor) + ? config.tableZebraColor + : preset.zebra, + accent: config.accentColor && isColorName(config.accentColor) + ? config.accentColor + : preset.accent, + statusActive: preset.statusActive, + statusPaused: preset.statusPaused, + statusCancelled: preset.statusCancelled, + statusArchived: preset.statusArchived, + } +} + +/** True when the resolved theme emits no colors at all. */ +export function isPlainTheme(): boolean { + const t = getDisplayTheme() + return !t.border && !t.header && !t.zebra && !t.accent +} + +/** cli-table3 style object for the resolved theme. */ +export function getTableStyle(): Record { + const t = getDisplayTheme() + return { + border: t.border ? [fgCode(t.border), "\x1b[0m"] : ["", ""], + head: t.header ? [`\x1b[1m${fgCode(t.header)}`, "\x1b[0m"] : ["", ""], + "padding-left": 1, + "padding-right": 1, + compact: false, + } +} + +/** + * Wrap table cells with the zebra background for striping. + * Apply to every even-indexed row (i % 2 === 0). + * Returns cells unchanged when zebra is disabled by the theme. + */ +export function zebraRow(cells: string[]): string[] { + const zebra = getDisplayTheme().zebra + if (!zebra) return cells + return cells.map((cell) => `${bgCode(zebra)}${cell}\x1b[0m`) +} + +/** + * Colorize a subscription status consistently across all views. + * Canonical definition — do not redefine per module. + */ +export function statusColor(status: Status): string { + const t = getDisplayTheme() + const name = status === "active" ? t.statusActive + : status === "paused" ? t.statusPaused + : status === "cancelled" ? t.statusCancelled + : status === "archived" ? t.statusArchived + : null + if (!name) return status + return `${fgCode(name)}${status}\x1b[0m` +} + +/** + * Section heading used to separate report sections. + * e.g. `── Database Statistics ──` + */ +export function sectionTitle(text: string): string { + const t = getDisplayTheme() + if (!t.accent) return `── ${text} ──` + return `\x1b[1m${fgCode(t.accent)}── ${text} ──\x1b[0m` +} + +/** + * Horizontal divider line with a consistent width. + * Dimmed, except in plain (none) themes where it stays uncolored. + */ +export function divider(length = 40): string { + if (isPlainTheme()) return "─".repeat(length) + return `\x1b[2m${"─".repeat(length)}\x1b[0m` +} + +export type ColumnConfig = { + headers: readonly string[] + minWidths: readonly number[] + maxWidths: readonly number[] + /** Minimum available width (default 40) */ + minAvail?: number +} + +/** Border + left/right padding overhead of a cli-table3 table. */ +export const BORDER_AND_PADDING = 16 + +/** + * Calculate column widths that fit the terminal width. + * Allocates available width proportionally to content weight, clamped to + * min/max widths per column, then adjusts to exactly fit. + */ +export function calcColumnWidths(rows: string[][], config: ColumnConfig): number[] { + const termWidth = process.stdout.columns ?? 80 + const avail = Math.max( + config.minAvail ?? loadConfig().tableMinWidth ?? 40, + termWidth - BORDER_AND_PADDING, + ) + + const weights = config.headers.map((hdr, i) => { + let max = hdr.length + for (const row of rows) { + const len = row[i].length + if (len > max) max = len + } + return Math.min(max, config.maxWidths[i]) + }) + + const totalWeight = weights.reduce((a, b) => a + b, 0) + const widths = weights.map((w, i) => + Math.max( + config.minWidths[i], + Math.min(config.maxWidths[i], Math.round((avail * w) / totalWeight)), + ), + ) + + // Adjust to exactly fit avail + let sum = widths.reduce((a, b) => a + b, 0) + let diff = sum - avail + let iterations = 0 + + while (diff > 0 && iterations < 100) { + let idx = -1 + for (let i = 0; i < widths.length; i++) { + if (widths[i] > config.minWidths[i] && (idx === -1 || widths[i] > widths[idx])) + idx = i + } + if (idx === -1) break + widths[idx]-- + diff-- + iterations++ + } + + iterations = 0 + while (diff < 0 && iterations < 100) { + let idx = -1 + for (let i = 0; i < widths.length; i++) { + if (widths[i] < config.maxWidths[i] && (idx === -1 || weights[i] > weights[idx])) + idx = i + } + if (idx === -1) break + widths[idx]++ + diff++ + iterations++ + } + + return widths +} \ No newline at end of file diff --git a/apps/subtrack/src/display.ts b/apps/subtrack/src/display.ts index d11e89e..c8a5162 100644 --- a/apps/subtrack/src/display.ts +++ b/apps/subtrack/src/display.ts @@ -1,23 +1,21 @@ import { consola } from "consola" import pc from "picocolors" import CliTable3 from "cli-table3" -import type { SharedArgs, Currency, LlmUsageEntry, Status } from "./types.ts" +import type { SharedArgs, Currency, LlmUsageEntry } from "./types.ts" import { getSubscriptions } from "./db.ts" import { fetchFxRates, convertPrice } from "./fx.ts" import type { FxRates } from "./fx.ts" -import { formatPrice } from "./price.ts" -import { TABLE_CHARS, TABLE_STYLE } from "./display-constants.ts" - -function statusColor(status: Status): string { - switch (status) { - case "active": return pc.green("active") - case "paused": return pc.yellow("paused") - case "cancelled": return pc.red("cancelled") - case "archived": return pc.dim("archived") - default: return status - } -} +import { formatPrice, formatUsdCost } from "./price.ts" +import { + TABLE_CHARS, + getTableStyle, + statusColor, + zebraRow, + sectionTitle, + calcColumnWidths, +} from "./display-constants.ts" +import type { ColumnConfig } from "./display-constants.ts" function buildRow(sub: SharedArgs, price: string, showNotes: boolean, showMethod: boolean, showContract?: boolean, showVendor?: boolean): string[] { const row = [ @@ -62,14 +60,6 @@ function buildRow(sub: SharedArgs, price: string, showNotes: boolean, showMethod return row } -type ColumnConfig = { - headers: readonly string[] - minWidths: readonly number[] - maxWidths: readonly number[] - /** Minimum available width (default 40) */ - minAvail?: number -} - const BASE_COLS: ColumnConfig = { headers: ["name", "status", "cycle", "tags", "price"] as const, minWidths: [10, 8, 6, 8, 8] as const, @@ -118,62 +108,6 @@ const ALL_EXTRA_COLS: ColumnConfig = { maxWidths: [30, 12, 20, 50, 30, 30, 10, 30, 20, 50, 20] as const, } -const BORDER_AND_PADDING = 16 - -function calcColumnWidths(rows: string[][], config: ColumnConfig): number[] { - const termWidth = process.stdout.columns ?? 80 - const avail = Math.max(config.minAvail ?? 40, termWidth - BORDER_AND_PADDING) - - const weights = config.headers.map((hdr, i) => { - let max = hdr.length - for (const row of rows) { - const len = row[i].length - if (len > max) max = len - } - return Math.min(max, config.maxWidths[i]) - }) - - const totalWeight = weights.reduce((a, b) => a + b, 0) - const widths = weights.map((w, i) => - Math.max( - config.minWidths[i], - Math.min(config.maxWidths[i], Math.round((avail * w) / totalWeight)), - ), - ) - - // Adjust to exactly fit avail - let sum = widths.reduce((a, b) => a + b, 0) - let diff = sum - avail - let iterations = 0 - - while (diff > 0 && iterations < 100) { - let idx = -1 - for (let i = 0; i < widths.length; i++) { - if (widths[i] > config.minWidths[i] && (idx === -1 || widths[i] > widths[idx])) - idx = i - } - if (idx === -1) break - widths[idx]-- - diff-- - iterations++ - } - - iterations = 0 - while (diff < 0 && iterations < 100) { - let idx = -1 - for (let i = 0; i < widths.length; i++) { - if (widths[i] < config.maxWidths[i] && (idx === -1 || weights[i] > weights[idx])) - idx = i - } - if (idx === -1) break - widths[idx]++ - diff++ - iterations++ - } - - return widths -} - function renderTable(rows: string[][], config: ColumnConfig): string { const widths = calcColumnWidths(rows, config) const colAligns = config.headers.map((h, i) => @@ -182,7 +116,7 @@ function renderTable(rows: string[][], config: ColumnConfig): string { const table = new CliTable3({ chars: { ...TABLE_CHARS }, - style: { ...TABLE_STYLE }, + style: getTableStyle(), colWidths: widths, head: [...config.headers], wordWrap: true, @@ -205,7 +139,7 @@ function renderTable(rows: string[][], config: ColumnConfig): string { })) } else { if (i % 2 === 0) { - table.push(row.map(cell => `\x1b[48;5;236m${cell}\x1b[0m`)) + table.push(zebraRow(row)) } else { table.push(row) } @@ -255,8 +189,8 @@ export const spreadSubscription = async ( consola.info("Fetching the latest exchange rates...") rates = await fetchFxRates() consola.success("Exchange rates updated") - } catch (e) { - consola.fail(`Failed to fetch exchange rates: ${e}`) + } catch { + consola.warn("Failed to fetch exchange rates; showing in original currencies") } if (rates) { @@ -345,7 +279,7 @@ function renderUsageTableBody( ): string { const table = new CliTable3({ chars: { ...TABLE_CHARS }, - style: { ...TABLE_STYLE }, + style: getTableStyle(), colWidths: widths, head: [...USAGE_HEADERS], wordWrap: true, @@ -360,12 +294,12 @@ function renderUsageTableBody( e.model, e.input_tokens.toLocaleString(), e.output_tokens.toLocaleString(), - `$${(e.cost / 100).toFixed(4)}`, + formatUsdCost(e.cost), e.date, e.description ?? "", ] if (i % 2 === 0) { - table.push(row.map((cell) => `\x1b[48;5;236m${cell}\x1b[0m`)) + table.push(zebraRow(row)) } else { table.push(row) } @@ -393,7 +327,7 @@ export function renderUsageTable(entries: LlmUsageEntry[]): void { e.model, e.input_tokens.toLocaleString(), e.output_tokens.toLocaleString(), - `$${(e.cost / 100).toFixed(4)}`, + formatUsdCost(e.cost), e.date, e.description ?? "", ]) @@ -407,7 +341,7 @@ export function renderUsageTable(entries: LlmUsageEntry[]): void { "", "", "", - `$${(totalCost / 100).toFixed(2)}`, + formatUsdCost(totalCost, 2), "", `(${entries.length} entr${entries.length === 1 ? "y" : "ies"})`, ] as UsageRow, @@ -419,7 +353,7 @@ export function renderUsageTable(entries: LlmUsageEntry[]): void { // Render TOTAL footer row const table = new CliTable3({ chars: { ...TABLE_CHARS_FOOTER }, - style: { ...TABLE_STYLE }, + style: getTableStyle(), colWidths: widths, colAligns: ["left", "left", "right", "right", "right", "left", "left"], }) @@ -428,7 +362,7 @@ export function renderUsageTable(entries: LlmUsageEntry[]): void { "", "", "", - pc.bold(pc.yellow(`$${(totalCost / 100).toFixed(2)}`)), + pc.bold(pc.yellow(formatUsdCost(totalCost, 2))), "", pc.dim(`(${entries.length} entr${entries.length === 1 ? "y" : "ies"})`), ]) @@ -443,7 +377,7 @@ export function showApiUsage( periodLabel: string, ): void { consola.log("") - consola.log(pc.bold(pc.cyan(`── API Usage (${periodLabel}) ──`))) + consola.log(sectionTitle(`API Usage (${periodLabel})`)) if (total <= 0) { consola.info("No API usage found for this month") @@ -452,17 +386,17 @@ export function showApiUsage( const apiTable = new CliTable3({ chars: { ...TABLE_CHARS }, - style: { ...TABLE_STYLE }, + style: getTableStyle(), head: ["Provider", "Cost"], colAligns: ["left", "right"], }) for (const p of byProvider) { - apiTable.push([p.provider, `$${(p.total / 100).toFixed(2)}`]) + apiTable.push([p.provider, formatUsdCost(p.total, 2)]) } apiTable.push([ pc.bold(pc.yellow("Total")), - pc.bold(pc.yellow(`$${(total / 100).toFixed(2)}`)), + pc.bold(pc.yellow(formatUsdCost(total, 2))), ]) consola.log(apiTable.toString()) diff --git a/apps/subtrack/src/export.ts b/apps/subtrack/src/export.ts index 6601d5e..df35ce9 100644 --- a/apps/subtrack/src/export.ts +++ b/apps/subtrack/src/export.ts @@ -277,7 +277,7 @@ export async function handleExport( const targetCurrency = options.currency as Currency list = convertSubsWithRates(list, targetCurrency, rates) } catch (e) { - consola.fail(`Failed to fetch exchange rates; exporting in original currencies: ${String(e)}`) + consola.warn("Failed to fetch exchange rates; showing in original currencies") } } diff --git a/apps/subtrack/src/forecast.ts b/apps/subtrack/src/forecast.ts index 00912bf..ce6a387 100644 --- a/apps/subtrack/src/forecast.ts +++ b/apps/subtrack/src/forecast.ts @@ -3,7 +3,8 @@ import { consola } from "consola" import pc from "picocolors" import CliTable3 from "cli-table3" import type { SharedArgs, Currency, Cycle } from "./types.ts" -import { TABLE_CHARS, TABLE_STYLE } from "./display-constants.ts" +import { TABLE_CHARS, getTableStyle, sectionTitle, calcColumnWidths, zebraRow } from "./display-constants.ts" +import type { ColumnConfig } from "./display-constants.ts" import { periodFactor } from "./date-utils.ts" import { getSubscriptions, getNonCancelledSubscriptions } from "./db.ts" import { formatPrice } from "./price.ts" @@ -132,7 +133,7 @@ export async function handleForecast( try { rates = await fetchFxRates() } catch { - consola.fail("Failed to fetch exchange rates; showing original currencies") + consola.warn("Failed to fetch exchange rates; showing in original currencies") targetCurrency = undefined } } @@ -194,7 +195,7 @@ export async function handleForecast( for (const [ccy, group] of Object.entries(currencyGroups).sort()) { if (Object.keys(currencyGroups).length > 1) { consola.log("") - consola.log(pc.bold(pc.cyan(`── ${ccy} ──`))) + consola.log(sectionTitle(ccy)) } const entriesForTable = group.entries @@ -205,20 +206,21 @@ export async function handleForecast( const displayEntries = entriesForTable.slice(0, maxRows) const overflow = entriesForTable.length - displayEntries.length - const headers = ["Subscription", `Monthly`, periodLabel] + const headers = ["Subscription", `Monthly`, periodLabel] as const const colAligns: ("left" | "right")[] = ["left", "right", "right"] - const width = process.stdout.columns ?? 80 - const nameWidth = Math.max(16, Math.round(width * 0.35)) - const priceWidth = Math.max(10, Math.round(width * 0.2)) - const totalWidth = Math.max(10, Math.round(width * 0.2)) - const colWidths: number[] = [nameWidth, priceWidth, totalWidth] + const FORECAST_COLS: ColumnConfig = { + headers, + minWidths: [16, 10, 10] as const, + maxWidths: [60, 20, 20] as const, + } + const colWidths = calcColumnWidths(displayEntries.map((e) => [e.name, formatPrice(e.monthly, e.currency), formatPrice(Math.round(e.monthly * months), e.currency)]), FORECAST_COLS) const table = new CliTable3({ chars: { ...TABLE_CHARS }, - style: { ...TABLE_STYLE }, + style: getTableStyle(), colWidths: colWidths, - head: headers, + head: [...headers], colAligns, }) @@ -231,7 +233,7 @@ export async function handleForecast( formatPrice(monthlyTotal, e.currency), ] if (i % 2 === 0) { - table.push(row.map((cell) => `\x1b[48;5;236m${cell}\x1b[0m`)) + table.push(zebraRow(row)) } else { table.push(row) } @@ -247,7 +249,7 @@ export async function handleForecast( // Divider table.push([ - pc.dim("─".repeat(nameWidth - 2)), + pc.dim("─".repeat(colWidths[0] - 2)), pc.dim("─"), pc.dim("─"), ]) diff --git a/apps/subtrack/src/menu.ts b/apps/subtrack/src/menu.ts index ec1c02f..a8a416f 100644 --- a/apps/subtrack/src/menu.ts +++ b/apps/subtrack/src/menu.ts @@ -8,7 +8,8 @@ import { checkbox, confirm, input, select } from "@inquirer/prompts" import { consola } from "consola" -import { getAllTags, getSubscriptions } from "./db.ts" +import pc from "picocolors" +import { getAllTags, getSubscriptions, getDbPath } from "./db.ts" import { handleList, handleDelete, handleTags, handleClone, handleArchive, handleUnarchive } from "./subscription/core.ts" import { handleAdd } from "./subscription/add.ts" import { handleEdit } from "./subscription/edit.ts" @@ -46,11 +47,27 @@ import { handleCurrencyList } from "./currency.ts" import { handleMcp } from "./commands.ts" import { CYCLE_CHOICES } from "./prompts.ts" import { formatPrice } from "./price.ts" +import { divider } from "./display-constants.ts" import type { Cycle, Status } from "./types.ts" type MainChoice = "view" | "add" | "manage" | "report" | "data" | "config" | "system" | "quit" +/** Show the subtrack header (title, version, subscription count, DB path). */ +function showMenuHeader(): void { + const pkg = require("../package.json") as { version: string } + const count = getSubscriptions().length + console.log(pc.bold(pc.cyan(`subtrack v${pkg.version}`))) + console.log( + pc.dim( + ` ${count} subscription${count === 1 ? "" : "s"} · ${getDbPath()}`, + ), + ) + console.log(divider(52)) + console.log("") +} + export async function handleMenu(): Promise { + showMenuHeader() while (true) { const choice = await select({ message: "subtrack — choose a category", diff --git a/apps/subtrack/src/notify.ts b/apps/subtrack/src/notify.ts index e1bde54..3bfe657 100644 --- a/apps/subtrack/src/notify.ts +++ b/apps/subtrack/src/notify.ts @@ -3,7 +3,7 @@ import { calcUpcoming } from "./upcoming.ts" import { formatPrice } from "./price.ts" import { loadConfig } from "./config.ts" import type { NotifyChannel } from "./types.ts" -import { formatDate } from "./date-utils.ts" +import { formatDate, formatShortDate } from "./date-utils.ts" export type NotifyOptions = { days?: number @@ -57,8 +57,9 @@ export async function handleNotify(options: NotifyOptions = {}): Promise { if (options.dryRun) { consola.info(`Upcoming bills (next ${days} day${days > 1 ? "s" : ""}):`) + const fmt = loadConfig().dateFormat === "short" ? formatShortDate : formatDate for (const e of entries) { - const date = formatDate(e.nextDate) + const date = fmt(e.nextDate) consola.log(` ${date} ${e.sub.name} ${formatPrice(e.sub.price, e.sub.currency)}/${e.sub.cycle}`) } return diff --git a/apps/subtrack/src/payment.ts b/apps/subtrack/src/payment.ts index 05fa90d..7afa19a 100644 --- a/apps/subtrack/src/payment.ts +++ b/apps/subtrack/src/payment.ts @@ -3,7 +3,7 @@ import pc from "picocolors" import type { SharedArgs, Currency, Cycle } from "./types.ts" import { periodFactor, getPeriodDateRange } from "./date-utils.ts" import { getSubscriptions, getNonCancelledSubscriptions, getLlmUsageTotal, getLlmUsageTotalByProvider, getAllPriceChanges } from "./db.ts" -import { formatPrice } from "./price.ts" +import { formatPrice, formatUsdCost } from "./price.ts" import { fetchFxRates, convertPrice } from "./fx.ts" import type { FxRates } from "./fx.ts" import { runPreCommandHooks } from "./pre-command.ts" @@ -50,7 +50,7 @@ export const showPayment = async ( try { rates = await fetchFxRates() } catch { - consola.fail("Failed to fetch exchange rates; falling back to per-currency display") + consola.warn("Failed to fetch exchange rates; showing in original currencies") } if (rates) { @@ -88,7 +88,7 @@ export const showPayment = async ( } const grandTotal = subTotal + apiConverted consola.log( - `${formatPrice(Math.round(subTotal), currency)}/${fmtPeriod} ${pc.dim(`+ API ${formatPrice(Math.round(apiConverted), currency)} = ${pc.bold(formatPrice(Math.round(grandTotal), currency))}/${fmtPeriod}`)}`, + `${formatPrice(Math.round(subTotal), currency)}/${fmtPeriod} ${pc.dim(`+ API ${formatPrice(Math.round(apiConverted), currency)} = ${pc.bold(pc.yellow(formatPrice(Math.round(grandTotal), currency)))}/${fmtPeriod}`)}`, ) } else { consola.log(`${formatPrice(Math.round(subTotal), currency)}/${fmtPeriod}`) @@ -139,11 +139,11 @@ export const showPayment = async ( } else { // Show API usage in USD with provider breakdown const providerDetails = apiByProvider - .map((p) => `${p.provider}: $${(p.total / 100).toFixed(2)}`) + .map((p) => `${p.provider}: ${formatUsdCost(p.total, 2)}`) .join(", ") consola.log( pc.dim( - `${pc.bold("API usage:")} $${(apiTotal / 100).toFixed(2)}/${fmtPeriod} ${pc.dim(`(${providerDetails})`)}`, + `${pc.bold("API usage:")} ${formatUsdCost(apiTotal, 2)}/${fmtPeriod} ${pc.dim(`(${providerDetails})`)}`, ), ) } @@ -345,7 +345,7 @@ export async function handlePayment( let subTotal = 0 if (targetCurrency) { - try { rates = await fetchFxRates() } catch { consola.fail("Failed to fetch exchange rates; reporting in original currencies") } + try { rates = await fetchFxRates() } catch { consola.warn("Failed to fetch exchange rates; showing in original currencies") } if (rates) { for (const entry of entries) { try { subTotal += convertPrice(entry.convertedPrice, entry.currency, targetCurrency, rates.rates) } diff --git a/apps/subtrack/src/price.ts b/apps/subtrack/src/price.ts index b85cc11..fb84461 100644 --- a/apps/subtrack/src/price.ts +++ b/apps/subtrack/src/price.ts @@ -17,3 +17,11 @@ export function formatPrice(price: number, currency: string): string { return `${currency} ${price}` } } + +/** + * Format an API usage cost (stored in cents) as a USD string. + * Defaults to 4 decimal places because LLM API costs are small. + */ +export function formatUsdCost(cents: number, digits = 4): string { + return `$${(cents / 100).toFixed(digits)}` +} diff --git a/apps/subtrack/src/report.ts b/apps/subtrack/src/report.ts index f9e6fff..47a7dac 100644 --- a/apps/subtrack/src/report.ts +++ b/apps/subtrack/src/report.ts @@ -149,7 +149,7 @@ export async function handleReport(options: ReportOptions = {}): Promise { displayCurrency = target displaySubs = convertSubsWithRates(subs, target as Currency, rates!) } catch { - consola.warn("Failed to fetch exchange rates; reporting in original currencies") + consola.warn("Failed to fetch exchange rates; showing in original currencies") displayCurrency = null } } @@ -236,20 +236,20 @@ export async function handleReport(options: ReportOptions = {}): Promise { return } - consola.log(pc.bold(`📊 Subscription Report — ${year}`)) + consola.log(pc.bold(pc.cyan(`📊 Subscription Report — ${year}`))) consola.log("") // Total spending consola.log(pc.bold("Total spending:")) if (displayCurrency) { - consola.log(` ${formatPrice(Math.round(total), displayCurrency)}`) + consola.log(` ${pc.bold(pc.yellow(formatPrice(Math.round(total), displayCurrency)))}`) } else if (Object.keys(byCurrency).length === 1) { const [ccy, amount] = Object.entries(byCurrency)[0]! - consola.log(` ${formatPrice(amount, ccy)}`) + consola.log(` ${pc.bold(pc.yellow(formatPrice(amount, ccy)))}`) } else { const parts = Object.entries(byCurrency) .sort(([a], [b]) => a.localeCompare(b)) - .map(([ccy, amount]) => `${formatPrice(amount, ccy)}`) + .map(([ccy, amount]) => `${pc.bold(pc.yellow(formatPrice(amount, ccy)))}`) consola.log(` ${parts.join(" + ")}`) } diff --git a/apps/subtrack/src/stats.ts b/apps/subtrack/src/stats.ts index c81a791..ce04072 100644 --- a/apps/subtrack/src/stats.ts +++ b/apps/subtrack/src/stats.ts @@ -3,6 +3,7 @@ */ import { consola } from "consola" +import { sectionTitle } from "./display-constants.ts" import { getDb, getDbPath } from "./db.ts" import type { Status } from "./types.ts" import { execObjs } from "./db/connection.ts" @@ -72,7 +73,7 @@ export function handleStats(options: { json?: boolean } = {}): void { return } - consola.log("── Database Statistics ──") + consola.log(sectionTitle("Database Statistics")) consola.log(` Subscriptions: ${total}`) consola.log(` Active: ${active}`) consola.log(` Paused: ${paused}`) diff --git a/apps/subtrack/src/subscription/core.ts b/apps/subtrack/src/subscription/core.ts index d0dc905..dc26d85 100644 --- a/apps/subtrack/src/subscription/core.ts +++ b/apps/subtrack/src/subscription/core.ts @@ -6,6 +6,7 @@ import { checkbox, confirm, select } from "@inquirer/prompts" import { consola } from "consola" import { fail } from "../error.ts" +import { loadConfig } from "../config.ts" import type { Currency, SharedArgs, AddFlags } from "../types.ts" import { getSubscriptions, @@ -61,7 +62,10 @@ export async function handleList(options: { process.stdout.write(JSON.stringify(list, null, 2) + "\n") return } - await spreadSubscription(list, options.currency as Currency | undefined, options.notes, options.method, options.showContract, options.showVendor) + // Flag > config > default (off) + const showNotes = options.notes ?? loadConfig().listShowNotes === "on" + const showMethod = options.method ?? loadConfig().listShowMethod === "on" + await spreadSubscription(list, options.currency as Currency | undefined, showNotes, showMethod, options.showContract, options.showVendor) if (options.api) { const now = new Date() diff --git a/apps/subtrack/src/trial.ts b/apps/subtrack/src/trial.ts index 25f8aff..6a95aea 100644 --- a/apps/subtrack/src/trial.ts +++ b/apps/subtrack/src/trial.ts @@ -6,7 +6,13 @@ import CliTable3 from "cli-table3" import type { TrialEntry, AddTrialArgs, TrialAddFlags } from "./types.ts" import { writeTrial, getTrials, getTrial, deleteTrial, getTrialsExpiringSoon } from "./db.ts" import { formatPrice } from "./price.ts" -import { TABLE_CHARS, TABLE_STYLE } from "./display-constants.ts" +import { + TABLE_CHARS, + getTableStyle, + calcColumnWidths, + zebraRow, +} from "./display-constants.ts" +import type { ColumnConfig } from "./display-constants.ts" import { CURRENCY_CHOICES, CYCLE_CHOICES, @@ -240,7 +246,7 @@ export async function handleTrialDelete(ids?: number[]): Promise { // ── Table rendering ──────────────────────────────────── function renderTrialTable(trials: TrialEntry[]): void { - const headers = ["ID", "Name", "Expires", "Days", "Price", "Notes"] + const headers = ["ID", "Name", "Expires", "Days", "Price", "Notes"] as const const rows: string[][] = trials.map((t) => { const days = daysUntil(t.expiresAt) const statusLabel = trialStatusLabel(days) @@ -255,14 +261,17 @@ function renderTrialTable(trials: TrialEntry[]): void { ] }) - const termWidth = process.stdout.columns ?? 80 - const avail = Math.max(50, termWidth - 14) - const weights = [4, 20, 12, 6, 16, 20] - const widths = weights.map((w) => Math.min(w, Math.round((avail * w) / weights.reduce((a, b) => a + b, 0)))) + const TRIAL_COLS: ColumnConfig = { + headers, + minWidths: [4, 16, 10, 6, 10, 8] as const, + maxWidths: [10, 40, 14, 10, 20, 40] as const, + minAvail: 50, + } + const widths = calcColumnWidths(rows, TRIAL_COLS) const table = new CliTable3({ chars: { ...TABLE_CHARS }, - style: { ...TABLE_STYLE }, + style: getTableStyle(), colWidths: widths, head: [...headers], colAligns: ["right", "left", "left", "left", "right", "left"], @@ -270,7 +279,7 @@ function renderTrialTable(trials: TrialEntry[]): void { for (let i = 0; i < rows.length; i++) { if (i % 2 === 0) { - table.push(rows[i].map((cell) => `\x1b[48;5;236m${cell}\x1b[0m`)) + table.push(zebraRow(rows[i])) } else { table.push(rows[i]) } diff --git a/apps/subtrack/src/types.ts b/apps/subtrack/src/types.ts index a906ca7..bf4d7b4 100644 --- a/apps/subtrack/src/types.ts +++ b/apps/subtrack/src/types.ts @@ -241,4 +241,22 @@ export type SubtrackConfig = { slackWebhook?: string /** Generic webhook URL for notifications */ webhookUrl?: string + /** Display theme preset name (default | light | high-contrast | none) */ + tableBorderColor?: string + /** Override border color (ColorName) */ + tableHeaderColor?: string + /** Override table header color (ColorName) */ + tableZebraColor?: string + /** Override zebra stripe background color (ColorName) */ + accentColor?: string + /** Override accent color used for headings (ColorName) */ + tableZebra?: "on" | "off" + /** Enable/disable zebra striping */ + tableMinWidth?: number + /** Minimum table width in columns (default 40) */ + dateFormat?: "iso" | "short" + /** Show notes column in `subtrack list` by default */ + listShowNotes?: "on" | "off" + /** Show payment method column in `subtrack list` by default */ + listShowMethod?: "on" | "off" } diff --git a/apps/subtrack/src/usage-add.ts b/apps/subtrack/src/usage-add.ts index f48a946..f473cfc 100644 --- a/apps/subtrack/src/usage-add.ts +++ b/apps/subtrack/src/usage-add.ts @@ -10,6 +10,7 @@ import { validateDate, } from "./prompts.ts" import { today } from "./date-utils.ts" +import { formatUsdCost } from "./price.ts" import { ensurePricingCache, searchPricingModels, @@ -179,8 +180,8 @@ async function resolveUsageAddOptions(flags: UsageAddFlags) { // Confirm (only when interactive) if (prompted) { const costDisplay = manualCost - ? `$${((costCents ?? 0) / 100).toFixed(2)} (manual)` - : `$${((costCents ?? 0) / 100).toFixed(4)}` + ? `${formatUsdCost(costCents ?? 0, 2)} (manual)` + : `${formatUsdCost(costCents ?? 0)}` consola.info( `Cost: ${costDisplay} (${(inputTokens ?? 0).toLocaleString()} in / ${(outputTokens ?? 0).toLocaleString()} out)`, ) @@ -214,10 +215,10 @@ export async function handleUsageAdd(flags: UsageAddFlags) { addLlmUsage(result) logAudit("usage.add", { targetType: "usage", - details: `${result.provider}/${result.model}: $${(result.cost / 100).toFixed(4)} on ${result.date}`, + details: `${result.provider}/${result.model}: ${formatUsdCost(result.cost)} on ${result.date}`, }) consola.success( - `Added usage: ${result.provider}/${result.model} — $${(result.cost / 100).toFixed(4)} on ${result.date}`, + `Added usage: ${result.provider}/${result.model} — ${formatUsdCost(result.cost)} on ${result.date}`, ) } catch (error) { fail(`Failed to add usage entry: ${error instanceof Error ? error.message : String(error)}`) diff --git a/apps/subtrack/src/usage-total.ts b/apps/subtrack/src/usage-total.ts index 85c7867..e6dc32d 100644 --- a/apps/subtrack/src/usage-total.ts +++ b/apps/subtrack/src/usage-total.ts @@ -4,6 +4,7 @@ import { consola } from "consola" import pc from "picocolors" +import { sectionTitle, divider } from "./display-constants.ts" import { getLlmUsageTotal, getLlmUsageTokenTotal, @@ -12,6 +13,7 @@ import { } from "./db.ts" import { getPeriodDateRange } from "./date-utils.ts" import type { Cycle } from "./types.ts" +import { formatUsdCost } from "./price.ts" export type UsageTotalOptions = { from?: string @@ -55,23 +57,23 @@ export function handleUsageTotal(options: UsageTotalOptions = {}): void { return } - consola.log(`── LLM API Usage (${from} → ${to}) ──`) + consola.log(sectionTitle(`LLM API Usage (${from} → ${to})`)) consola.log(pc.bold(" By provider:")) for (const p of byProvider) { - consola.log(` ${p.provider}: $${(p.total / 100).toFixed(2)}`) + consola.log(` ${p.provider}: ${formatUsdCost(p.total, 2)}`) } if (byModel.length > 0) { consola.log(pc.bold(" By model:")) for (const m of byModel) { consola.log( - ` ${m.model}: $${(m.total / 100).toFixed(2)} ` + + ` ${m.model}: ${formatUsdCost(m.total, 2)} ` + `(${m.inputTokens.toLocaleString()} in / ${m.outputTokens.toLocaleString()} out)`, ) } } - consola.log(` ${"─".repeat(20)}`) + consola.log(` ${divider(20)}`) consola.log( ` Tokens: ${tokens.inputTokens.toLocaleString()} in / ${tokens.outputTokens.toLocaleString()} out`, ) - consola.log(` Total: $${(total / 100).toFixed(2)}`) + consola.log(` Total: ${pc.bold(pc.yellow(formatUsdCost(total, 2)))}`) } \ No newline at end of file diff --git a/apps/subtrack/src/usage.ts b/apps/subtrack/src/usage.ts index 765291c..a9d0968 100644 --- a/apps/subtrack/src/usage.ts +++ b/apps/subtrack/src/usage.ts @@ -5,6 +5,7 @@ import type { LlmUsageEntry } from "./types.ts" import { getLlmUsage, deleteLlmUsage } from "./db.ts" import { logAudit } from "./audit.ts" import { renderUsageTable } from "./display.ts" +import { formatUsdCost } from "./price.ts" export { handleUsageAdd } from "./usage-add.ts" export { handleUsageImport } from "./usage-import.ts" @@ -58,7 +59,7 @@ export async function handleUsageDelete(ids?: number[]) { const selected = await checkbox({ message: "Select usage entries to delete", choices: all.map((e: LlmUsageEntry) => ({ - name: `${e.date} ${e.provider}/${e.model} ${e.input_tokens.toLocaleString()} in / ${e.output_tokens.toLocaleString()} out $${(e.cost / 100).toFixed(4)}${e.description ? ` — ${e.description}` : ""}`, + name: `${e.date} ${e.provider}/${e.model} ${e.input_tokens.toLocaleString()} in / ${e.output_tokens.toLocaleString()} out ${formatUsdCost(e.cost)}${e.description ? ` — ${e.description}` : ""}`, value: e, })), loop: false, diff --git a/apps/subtrack/vitest.config.ts b/apps/subtrack/vitest.config.ts index 3f89d26..c1af15b 100644 --- a/apps/subtrack/vitest.config.ts +++ b/apps/subtrack/vitest.config.ts @@ -4,6 +4,12 @@ export default defineConfig({ test: { // Scanner tests need time for sql.js WASM initialization at module level testTimeout: 15_000, + // Force ANSI colors so display helpers can be tested for styling + env: { + FORCE_COLOR: "1", + // Isolate config.json so tests never read the user's real config + SUBSC_CLI_DB_DIR: "/tmp/subtrack-vitest-config", + }, coverage: { provider: "v8", reporter: ["text", "lcov"], diff --git a/docs/configuration.md b/docs/configuration.md index ebb9342..92336f1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -77,7 +77,7 @@ See the [Commands reference](/commands#config) for full details. |-----|-------------|---------| | `defaultCurrency` | Default currency for display and analytics | `USD` | | `monthlyBudget` | Monthly spending budget in USD (0 = disabled) | `0` | -| `theme` | Display theme | `default` | +| `theme` | Display theme preset (`default`, `light`, `high-contrast`, `none`) | `default` | | `notifyDays` | Default look-ahead days for `subtrack notify` | `7` | | `notifyChannels` | Notification channels (comma-separated: `os`, `slack`, `webhook`) | `os` | | `slackWebhook` | Slack webhook URL for Slack notifications | — | @@ -86,6 +86,15 @@ See the [Commands reference](/commands#config) for full details. | `profiles` | Saved filter profiles (stored as JSON object) | `{}` | | `activeProfile` | Currently active filter profile name | — | | `budgets` | Multiple named budgets for budget-vs-actual tracking | — | +| `tableBorderColor` | Table border color override (color name) | theme default | +| `tableHeaderColor` | Table header color override (color name) | theme default | +| `tableZebraColor` | Zebra stripe background color override (color name) | theme default | +| `accentColor` | Accent color override for headings (color name) | theme default | +| `tableZebra` | Enable/disable zebra striping (`on`/`off`) | `on` | +| `tableMinWidth` | Minimum table width in columns (20–200) | `40` | +| `dateFormat` | Date display format (`iso` or `short`) | `iso` | +| `listShowNotes` | Show notes column in `subtrack list` by default (`on`/`off`) | `off` | +| `listShowMethod` | Show payment method column in `subtrack list` by default (`on`/`off`) | `off` | Set values with: @@ -96,6 +105,31 @@ subtrack config set defaultCurrency JPY The `config set` command validates input (e.g., currency codes must be ISO 4217, budget must be non-negative). +### Display themes + +The `theme` key switches between color presets: + +| Preset | Best for | +|--------|----------| +| `default` | Dark terminal backgrounds (default) | +| `light` | Light terminal backgrounds | +| `high-contrast` | Accessibility / high ambient light | +| `none` | Plain monochrome output (piping, screen readers) | + +Individual color keys (`tableBorderColor`, `tableHeaderColor`, `tableZebraColor`, `accentColor`) override the preset. Valid color names: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`, `gray`, and the `bright*` variants. + +```bash +# Switch to the light theme +subtrack config set theme light + +# Or fine-tune a preset +subtrack config set theme high-contrast +subtrack config set accentColor yellow + +# Disable zebra striping +subtrack config set tableZebra off +``` + ## No config file subtrack does not use configuration files (`.subtrackrc`, `subtrack.json`, etc.). All settings are controlled via the `config` command, environment variables, or CLI flags. This keeps the tool simple and predictable. From 889a895830f2af53f593af4e4c9ab5dd3febb8e9 Mon Sep 17 00:00:00 2001 From: nazozokc Date: Tue, 18 Aug 2026 22:21:52 +0900 Subject: [PATCH 18/19] fix: normalize db dir separators for Windows compatibility getDbDir() returned SUBSC_CLI_DB_DIR verbatim, so on Windows path.join() produced backslash paths from a forward-slash env value (e.g. \tmp\... from /tmp/...), breaking path containment checks in tests and causing inconsistent paths at runtime. --- apps/subtrack/src/db/connection.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/subtrack/src/db/connection.ts b/apps/subtrack/src/db/connection.ts index c80d41f..e9c72f7 100644 --- a/apps/subtrack/src/db/connection.ts +++ b/apps/subtrack/src/db/connection.ts @@ -155,7 +155,9 @@ process.on("exit", releaseLock) export function getDbDir(): string { const dir = process.env.SUBSC_CLI_DB_DIR ?? path.join(homedir(), ".config", "subtrack") validateDbDir(dir) - return dir + // Normalize separators so downstream path.join() produces consistent + // paths on all platforms (e.g. "/tmp/x" on Windows becomes "\tmp\x") + return path.normalize(dir) } export function getDefaultBackupDir(): string { From 4ee13c30d7eeb3cf6bdd314abce4606a66eae12e Mon Sep 17 00:00:00 2001 From: nazozokc Date: Tue, 18 Aug 2026 22:36:43 +0900 Subject: [PATCH 19/19] docs: document display themes and display config keys - commands.md: add new display config keys to the config table and describe theme presets, color overrides, dateFormat, and list default columns - guides.md: add a Customizing the display section with examples --- docs/commands.md | 24 ++++++++++++++++- docs/guides.md | 69 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/docs/commands.md b/docs/commands.md index ede84a7..f9e0ae9 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -670,7 +670,16 @@ Manages subtrack configuration. Configuration is stored in `~/.config/subtrack/c | `monthlyBudget` | Monthly spending budget in USD (0 = disabled) | `0` | | `yearlyBudget` | Yearly spending budget (used by `budget --period yearly` and `report`) | — | | `budgets` | JSON array of named budgets (see below) | — | -| `theme` | Display theme | `default` | +| `theme` | Display theme preset: `default`, `light`, `high-contrast`, `none` | `default` | +| `tableBorderColor` | Table border color override (color name) | theme default | +| `tableHeaderColor` | Table header color override (color name) | theme default | +| `tableZebraColor` | Zebra stripe background color override (color name) | theme default | +| `accentColor` | Accent color override for headings (color name) | theme default | +| `tableZebra` | Zebra striping on/off | `on` | +| `tableMinWidth` | Minimum table width in columns (20–200) | `40` | +| `dateFormat` | Date display format: `iso` or `short` | `iso` | +| `listShowNotes` | Show notes column in `list` by default | `off` | +| `listShowMethod` | Show payment method column in `list` by default | `off` | | `notifyDays` | Notification look-ahead in days | `7` | | `notifyChannels` | Comma-separated channels: `os`, `slack`, `webhook` | `os` | | `slackWebhook` | Slack webhook URL for `slack` notifications | — | @@ -706,12 +715,25 @@ subtrack config set defaultCurrency JPY subtrack config set notifyChannels os,slack subtrack config set slackWebhook https://hooks.slack.com/services/... +# Switch to the light theme (for light terminal backgrounds) +subtrack config set theme light + +# Override a single color on top of a preset +subtrack config set accentColor yellow + +# Disable zebra striping entirely +subtrack config set tableZebra off + # Reset all config to defaults subtrack config reset ``` The `config set` command validates input (e.g., currency codes must be ISO 4217, budget must be non-negative, channels must be one of `os`/`slack`/`webhook`). +The `theme` key switches between four presets: `default` (dark backgrounds), `light`, `high-contrast`, and `none` (plain monochrome). Individual color keys (`tableBorderColor`, `tableHeaderColor`, `tableZebraColor`, `accentColor`) override the preset. Valid color names: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`, `gray`, and the `bright*` variants. + +`dateFormat` controls human-facing dates in `cancel` and `notify` (`short` uses `MM/DD` style); machine-readable output (JSON) always uses ISO 8601. `listShowNotes` / `listShowMethod` enable the notes and payment method columns in `subtrack list` by default — CLI flags still take precedence. + ## `usage` Tracks LLM API usage costs. Costs are auto-calculated from model pricing when available, with manual fallback. diff --git a/docs/guides.md b/docs/guides.md index 0a373a2..fef83a4 100644 --- a/docs/guides.md +++ b/docs/guides.md @@ -553,6 +553,75 @@ subtrack stats Shows subscription counts by status, tag count, trial count, usage entry count, database file size, and price range information. +## Customizing the display + +subtrack's tables and headings are themeable via `subtrack config set`. Everything is stored in `config.json` and validated on write, so an invalid value never breaks your terminal. + +### Choosing a theme preset + +The `theme` key picks a preset tuned for your terminal: + +```bash +# Dark terminal backgrounds (default) +subtrack config set theme default + +# Light terminal backgrounds +subtrack config set theme light + +# Maximum contrast for accessibility or bright environments +subtrack config set theme high-contrast + +# Plain monochrome — ideal for piping output or screen readers +subtrack config set theme none +``` + +### Overriding individual colors + +Any color key overrides the active preset. Valid color names are `black`, `red`, +`green`, `yellow`, `blue`, `magenta`, `cyan`, `white`, `gray`, plus the `bright*` +variants (`brightRed`, `brightGreen`, ...). + +```bash +# Accent color used for section headings +subtrack config set accentColor brightCyan + +# Table chrome: border, header, and zebra stripe background +subtrack config set tableBorderColor blue +subtrack config set tableHeaderColor brightBlue +subtrack config set tableZebraColor gray +``` + +### Table sizing and zebra stripes + +```bash +# Turn zebra striping off (or back on) +subtrack config set tableZebra off + +# Minimum table width in columns (20–200); tables still grow +# to fit the terminal width when content requires it +subtrack config set tableMinWidth 60 +``` + +### Dates and list columns + +```bash +# Compact MM/DD dates in cancel/notify messages +# (JSON output always stays ISO 8601) +subtrack config set dateFormat short + +# Show notes / payment method columns in `subtrack list` by default +subtrack config set listShowNotes on +subtrack config set listShowMethod on +``` + +CLI flags (`subtrack list --notes --method`) always take precedence over these defaults. + +### Seeing the current configuration + +```bash +subtrack config list +``` + ## MCP integration subtrack runs an MCP (Model Context Protocol) server, allowing AI assistants to manage your subscriptions: