diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aba7097..1c17d1b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + cookie: '>=0.7.0' + importers: .: {} @@ -1144,9 +1147,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - cookie@0.6.0: - resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} - engines: {node: '>= 0.6'} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} @@ -2554,7 +2557,7 @@ snapshots: '@sveltejs/vite-plugin-svelte': 7.1.2(svelte@5.56.3)(vite@6.4.3(@types/node@26.0.0)(tsx@4.22.4)(yaml@2.9.0)) '@types/cookie': 0.6.0 acorn: 8.17.0 - cookie: 0.6.0 + cookie: 1.1.1 devalue: 5.8.1 esm-env: 1.2.2 kleur: 4.1.5 @@ -2784,7 +2787,7 @@ snapshots: convert-source-map@2.0.0: {} - cookie@0.6.0: {} + cookie@1.1.1: {} cross-spawn@7.0.6: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 40d774d..7370915 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,3 +7,8 @@ packages: # postinstall/preinstall scripts as a supply chain security measure. allowBuilds: esbuild: true + +# Force cookie >= 0.7.0 to fix CVE-2024-47764 +# @sveltejs/kit specifies ^0.6.0 which resolves to the vulnerable 0.6.0 +overrides: + cookie: ">=0.7.0" diff --git a/subtrack/src/__tests__/upcoming.test.ts b/subtrack/src/__tests__/upcoming.test.ts new file mode 100644 index 0000000..50db309 --- /dev/null +++ b/subtrack/src/__tests__/upcoming.test.ts @@ -0,0 +1,114 @@ +import { test, expect, beforeEach, afterEach, beforeAll } from "vitest" +import { consola } from "consola" +import initSqlJs from "sql.js" +import type { Database } from "sql.js" + +const logMessages: string[] = [] +const infoMessages: string[] = [] + +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')) + )`) + 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") + + logMessages.length = 0 + infoMessages.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) + } + }) +}) + +afterEach(() => { + consola.mockTypes() +}) + +test("showUpcoming shows info when no subscriptions", async () => { + const { showUpcoming } = await import("../upcoming.ts") + showUpcoming(7) + expect(infoMessages.some((m) => m.includes("No active subscriptions"))).toBe(true) +}) + +test("showUpcoming shows info when no upcoming bills", async () => { + const db = await import("../db.ts") + // Create a subscription with billing far in the future + db.writeSubscription({ name: "Yearly", price: 1000, currency: "USD", cycle: "yearly", tags: [], status: "active", createdAt: "2025-01-01", billingDay: 1 }) + + const { showUpcoming } = await import("../upcoming.ts") + showUpcoming(7) + expect(infoMessages.some((m) => m.includes("No upcoming bills"))).toBe(true) +}) + +test("showUpcoming shows upcoming monthly subscription", async () => { + const db = await import("../db.ts") + // Create a subscription with billing day = 25 (tomorrow-ish) + const today = new Date() + const billingDay = today.getDate() + 1 > 28 ? 28 : today.getDate() + 1 + db.writeSubscription({ name: "Netflix", price: 1500, currency: "JPY", cycle: "monthly", tags: ["video"], status: "active", billingDay, createdAt: "2026-01-15" }) + + const { showUpcoming } = await import("../upcoming.ts") + showUpcoming(30) + expect(logMessages.length).toBeGreaterThan(0) + const output = logMessages.join("\n") + expect(output).toContain("Netflix") + expect(output).toContain("¥1,500") +}) + +test("showUpcoming excludes cancelled subscriptions", async () => { + const db = await import("../db.ts") + db.writeSubscription({ name: "Active", price: 100, currency: "USD", cycle: "monthly", tags: [], status: "active", billingDay: 28, createdAt: "2026-01-01" }) + db.writeSubscription({ name: "Cancelled", price: 200, currency: "USD", cycle: "monthly", tags: [], status: "cancelled", billingDay: 28, createdAt: "2026-01-01" }) + + const { showUpcoming } = await import("../upcoming.ts") + showUpcoming(30) + const output = logMessages.join("\n") + expect(output).toContain("Active") + expect(output).not.toContain("Cancelled") +}) diff --git a/subtrack/src/analytics.ts b/subtrack/src/analytics.ts new file mode 100644 index 0000000..509fa4a --- /dev/null +++ b/subtrack/src/analytics.ts @@ -0,0 +1,77 @@ +import { consola } from "consola" +import pc from "picocolors" +import type { SharedArgs } from "./types.ts" +import { getSubscriptions } from "./db.ts" +import { formatPrice } from "./display.ts" +import { calcSummary } from "./payment.ts" +import { loadConfig } from "./config.ts" +import { periodFactor } from "./types.ts" + +export function showAnalytics(): void { + const list = getSubscriptions().filter((s) => s.status !== "cancelled") + if (list.length === 0) { + consola.info("No active subscriptions found") + return + } + + const config = loadConfig() + const data = calcSummary(list) + + // Header + consola.log(pc.bold("📊 Subscription Analytics")) + consola.log("") + + // Overview + consola.log(pc.bold("Overview:")) + consola.log(` Total subscriptions: ${pc.bold(String(data.totalCount))}`) + consola.log(` Status breakdown:`) + const activeCount = list.filter((s) => s.status === "active").length + const pausedCount = list.filter((s) => s.status === "paused").length + const cancelledCount = getSubscriptions().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}`)}`) + + if (data.mostExpensive) { + const me = data.mostExpensive + consola.log(` Most expensive: ${pc.bold(me.name)} (${formatPrice(me.price, me.currency)}/${me.cycle})`) + } + + // Monthly spending + consola.log("") + consola.log(pc.bold("Monthly spending:")) + for (const [ccy, total] of Object.entries(data.monthlyByCurrency).sort()) { + consola.log(` ${ccy} ${formatPrice(Math.round(total), ccy)}`) + } + + // Budget + if (config.monthlyBudget > 0) { + const defaultCurrency = config.defaultCurrency || "USD" + // Sum all monthly costs in their original currencies + const monthlyTotal = list.reduce((sum, sub) => sum + sub.price * periodFactor(sub.cycle, "monthly"), 0) + // For budget display, use default currency as reference + const budgetDisplay = formatPrice(config.monthlyBudget, defaultCurrency) + const spentDisplay = formatPrice(monthlyTotal, "USD") + consola.log(` ${pc.dim("─".repeat(30))}`) + consola.log(` Budget: ${pc.bold(budgetDisplay)}`) + const remaining = config.monthlyBudget - monthlyTotal + const remainingDisplay = formatPrice(remaining, "USD") + if (remaining >= 0) { + consola.log(` Remaining: ${pc.green(remainingDisplay)}`) + } else { + consola.log(` Over budget: ${pc.red(remainingDisplay.replace("-", ""))}`) + } + } + + // Tags breakdown + if (Object.keys(data.monthlyByTag).length > 0) { + consola.log("") + consola.log(pc.bold("Monthly by tag:")) + const sorted = Object.entries(data.monthlyByTag).sort((a, b) => b[1].monthly - a[1].monthly) + for (const [tag, info] of sorted) { + consola.log( + ` ${tag.padEnd(16)} ${formatPrice(Math.round(info.monthly), "USD")}/month (${info.count} sub${info.count > 1 ? "s" : ""})`, + ) + } + } +} diff --git a/subtrack/src/codex-scanner.ts b/subtrack/src/codex-scanner.ts index 5d81176..d66447c 100644 --- a/subtrack/src/codex-scanner.ts +++ b/subtrack/src/codex-scanner.ts @@ -38,10 +38,11 @@ export function scanCodexCli(from?: string, to?: string): ScanResult { db = new _SQL.Database(data) let sql = `SELECT id, tokens_used, model, model_provider, created_at_ms FROM threads WHERE tokens_used > 0` - if (from) sql += ` AND created_at_ms >= ${dateToStartOfDayMs(from)}` - if (to) sql += ` AND created_at_ms <= ${dateToEndOfDayMs(to)}` + const params: (number | string)[] = [] + if (from) { sql += ` AND created_at_ms >= ?`; params.push(dateToStartOfDayMs(from)) } + if (to) { sql += ` AND created_at_ms <= ?`; params.push(dateToEndOfDayMs(to)) } - const results = db.exec(sql) + const results = db.exec(sql, params) if (results.length === 0) { consola.info("No usage data found in Codex CLI DB") diff --git a/subtrack/src/config.ts b/subtrack/src/config.ts new file mode 100644 index 0000000..88a0c60 --- /dev/null +++ b/subtrack/src/config.ts @@ -0,0 +1,97 @@ +import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs" +import { homedir } from "node:os" +import path from "node:path" +import { consola } from "consola" +import { safeJsonParse } from "./safe-json.ts" +import type { SubtrackConfig } from "./types.ts" + +export const CONFIG_KEYS = [ + "defaultCurrency", + "monthlyBudget", + "theme", +] as const + +export type ConfigKey = (typeof CONFIG_KEYS)[number] + +const DEFAULT_CONFIG: SubtrackConfig = { + defaultCurrency: "USD", + monthlyBudget: 0, + theme: "default", +} + +function getConfigDir(): string { + return process.env.SUBSC_CLI_DB_DIR ?? path.join(homedir(), ".config", "subtrack") +} + +function getConfigPath(): string { + return path.join(getConfigDir(), "config.json") +} + +let _config: SubtrackConfig | null = null + +export function loadConfig(): SubtrackConfig { + if (_config) return _config + + const configPath = getConfigPath() + if (existsSync(configPath)) { + try { + const raw = readFileSync(configPath, "utf-8") + const parsed = safeJsonParse>(raw) + _config = { ...DEFAULT_CONFIG, ...parsed } + return _config + } catch { + // corrupt config — use defaults + } + } + + _config = { ...DEFAULT_CONFIG } + return _config +} + +export function resetConfig(): void { + _config = null +} + +export function setConfig(key: ConfigKey, value: string): boolean { + const config = loadConfig() + + switch (key) { + case "defaultCurrency": { + if (!/^[A-Z]{3}$/.test(value)) { + consola.error(`Invalid currency code: "${value}"`) + return false + } + config.defaultCurrency = value + break + } + case "monthlyBudget": { + const num = Number(value) + if (isNaN(num) || num < 0) { + consola.error("monthlyBudget must be a non-negative number") + return false + } + config.monthlyBudget = num + break + } + case "theme": + config.theme = value + break + default: + consola.error(`Unknown config key: "${key}"`) + return false + } + + saveConfig(config) + consola.success(`Set ${key} = ${value}`) + return true +} + +function saveConfig(config: SubtrackConfig): void { + const configPath = getConfigPath() + const dir = path.dirname(configPath) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o700 }) + } + writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 }) + _config = config +} diff --git a/subtrack/src/cursor-scanner.ts b/subtrack/src/cursor-scanner.ts index 97b6597..48bf396 100644 --- a/subtrack/src/cursor-scanner.ts +++ b/subtrack/src/cursor-scanner.ts @@ -102,20 +102,16 @@ export function scanCursor(from?: string, to?: string): ScanResult { db = new _SQL.Database(data) // Try both possible table names - let tableName = "cursorDiskKV" const tables = db.exec("SELECT name FROM sqlite_master WHERE type='table'") - if (tables.length > 0) { - const tableNames = tables[0].values.map((r) => String(r[0])) - if (tableNames.includes("ItemTable")) { - tableName = "ItemTable" // Older Cursor versions - } else if (!tableNames.includes(tableName)) { - consola.info("No known Cursor KV table found") - return { source: "cursor", entries: [] } - } + const tableNames = tables.length > 0 ? tables[0].values.map((r) => String(r[0])) : [] + const knownTables = ["cursorDiskKV", "ItemTable"] + const tableName = knownTables.find((t) => tableNames.includes(t)) + if (!tableName) { + consola.info("No known Cursor KV table found") + return { source: "cursor", entries: [] } } - const sql = `SELECT key, value FROM ${tableName} WHERE key LIKE 'bubbleId:%'` - const results = db.exec(sql) + const results = db.exec(`SELECT key, value FROM "${tableName}" WHERE key LIKE 'bubbleId:%'`) if (results.length === 0) { consola.info("No usage data found in Cursor DB") diff --git a/subtrack/src/display.ts b/subtrack/src/display.ts index f767a27..04ecf2e 100644 --- a/subtrack/src/display.ts +++ b/subtrack/src/display.ts @@ -1,7 +1,7 @@ import { consola } from "consola" import pc from "picocolors" import CliTable3 from "cli-table3" -import type { SharedArgs, Currency, LlmUsageEntry } from "./types.ts" +import type { SharedArgs, Currency, LlmUsageEntry, Status } from "./types.ts" import { getSubscriptions } from "./db.ts" import { fetchFxRates, convertPrice } from "./fx.ts" import type { FxRates } from "./fx.ts" @@ -15,19 +15,28 @@ export function formatPrice(price: number, currency: string): string { }).format(price) } -function buildRow(sub: SharedArgs, price: string): [string, string, string, string] { +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") + } +} + +function buildRow(sub: SharedArgs, price: string): [string, string, string, string, string] { return [ String(sub.name), + statusColor(sub.status), String(sub.cycle), sub.tags.length > 0 ? sub.tags.join(", ") : "-", price, ] } -const HEADERS = ["name", "cycle", "tags", "price"] as const -const MIN_WIDTHS = [10, 6, 8, 8] as const -const MAX_WIDTHS = [40, 20, 60, 20] as const -const BORDER_AND_PADDING = 13 +const HEADERS = ["name", "status", "cycle", "tags", "price"] as const +const MIN_WIDTHS = [10, 8, 6, 8, 8] as const +const MAX_WIDTHS = [40, 12, 20, 60, 20] as const +const BORDER_AND_PADDING = 16 function calcColumnWidths(rows: string[][]): number[] { const termWidth = process.stdout.columns ?? 80 @@ -119,15 +128,16 @@ function renderTable(rows: string[][]): string { head: [...HEADERS], wordWrap: true, wrapOnWordBoundary: true, - colAligns: ["left", "left", "left", "right"], + colAligns: ["left", "left", "left", "left", "right"], }) for (let i = 0; i < rows.length; i++) { const row = rows[i] - const isTotal = row[2].endsWith("TOTAL") + // Total rows have empty strings in first three columns (name, status, cycle) + const isTotal = row[0] === "" && row[1] === "" && row[2] === "" if (isTotal) { table.push(row.map((cell, j) => { - if (j === 2 || j === 3) return pc.bold(pc.yellow(cell)) + if (j === 3 || j === 4) return pc.bold(pc.yellow(cell)) return cell })) } else { @@ -188,7 +198,7 @@ export const spreadSubscription = async ( } } - rows.push(["", "", `${currency} TOTAL`, formatPrice(Math.round(total), currency)]) + rows.push(["", "", "", `${currency} TOTAL`, formatPrice(Math.round(total), currency)]) if (hasMissingRate) { consola.warn( @@ -219,6 +229,7 @@ export const spreadSubscription = async ( total += sub.price } groupRows.push([ + "", "", "", `${currencyCode} TOTAL`, diff --git a/subtrack/src/opencode-scanner.ts b/subtrack/src/opencode-scanner.ts index da9409b..780a9f8 100644 --- a/subtrack/src/opencode-scanner.ts +++ b/subtrack/src/opencode-scanner.ts @@ -108,15 +108,18 @@ export function scanOpenCodeDb(from?: string, to?: string): ScanResult { db = new _SQL.Database(data) let sql = `SELECT id, data FROM message WHERE json_extract(data, '$.tokens.input') IS NOT NULL` + const params: (number | string)[] = [] if (from) { - sql += ` AND json_extract(data, '$.time.created') >= ${dateToStartOfDayMs(from)}` + sql += ` AND json_extract(data, '$.time.created') >= ?` + params.push(dateToStartOfDayMs(from)) } if (to) { - sql += ` AND json_extract(data, '$.time.created') <= ${dateToEndOfDayMs(to)}` + sql += ` AND json_extract(data, '$.time.created') <= ?` + params.push(dateToEndOfDayMs(to)) } - const results = db.exec(sql) + const results = db.exec(sql, params) if (results.length === 0) { consola.info("No token usage data found in OpenCode DB") diff --git a/subtrack/src/upcoming.ts b/subtrack/src/upcoming.ts new file mode 100644 index 0000000..6c404d5 --- /dev/null +++ b/subtrack/src/upcoming.ts @@ -0,0 +1,153 @@ +import { consola } from "consola" +import pc from "picocolors" +import type { SharedArgs, Cycle } from "./types.ts" +import { getSubscriptions } from "./db.ts" +import { formatPrice } from "./display.ts" +import { periodFactor } from "./types.ts" + +type UpcomingEntry = { + sub: SharedArgs + nextDate: Date + amount: number +} + +function toDate(dateStr: string): Date { + const [y, m, d] = dateStr.split("-").map(Number) + return new Date(y, m - 1, d) +} + +function getBillingDay(sub: SharedArgs): number { + if (sub.billingDay) return sub.billingDay + // Fall back to created_at day + const created = toDate(sub.createdAt) + return created.getDate() +} + +function addMonths(date: Date, n: number): Date { + const result = new Date(date) + result.setMonth(result.getMonth() + n) + return result +} + +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) + if (candidate > fromDate) return candidate + // Move to next month + return new Date(fromDate.getFullYear(), fromDate.getMonth() + 1, anchorDay) + } + case "yearly": { + const candidate = new Date(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) + } + 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) + } + 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) + } + } +} + +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) +} + +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 function showUpcoming(days: number = 7): void { + const list = getSubscriptions().filter((s) => s.status !== "cancelled") + if (list.length === 0) { + consola.info("No active subscriptions found") + return + } + + const now = new Date() + now.setHours(0, 0, 0, 0) + const endDate = new Date(now) + endDate.setDate(endDate.getDate() + days) + + const entries: UpcomingEntry[] = [] + + for (const sub of list) { + const next = calculateNextBilling(sub, now) + if (next >= now && next <= endDate) { + // Calculate amount for this period + const amount = sub.price * periodFactor(sub.cycle, "monthly") + entries.push({ sub, nextDate: next, amount }) + } + } + + entries.sort((a, b) => a.nextDate.getTime() - b.nextDate.getTime()) + + if (entries.length === 0) { + consola.info(`No upcoming bills in the next ${days} day${days > 1 ? "s" : ""}`) + return + } + + consola.log(pc.bold(`Upcoming bills (next ${days} day${days > 1 ? "s" : ""}):`)) + consola.log("") + + const currencyTotals: Record = {} + for (const entry of entries) { + const dateStr = formatDate(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(", ")}]` : "")}`, + ) + currencyTotals[entry.sub.currency] = (currencyTotals[entry.sub.currency] ?? 0) + entry.sub.price + } + + if (entries.length > 1) { + consola.log("") + const totalParts = Object.entries(currencyTotals) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([ccy, total]) => formatPrice(Math.round(total), ccy)) + consola.log(` ${pc.bold("Total:")} ${totalParts.join(" + ")} (across ${entries.length} subscription${entries.length > 1 ? "s" : ""})`) + } +} diff --git a/subtrack/src/windsurf-scanner.ts b/subtrack/src/windsurf-scanner.ts index 253f654..aa26a67 100644 --- a/subtrack/src/windsurf-scanner.ts +++ b/subtrack/src/windsurf-scanner.ts @@ -95,20 +95,16 @@ export function scanWindsurf(from?: string, to?: string): ScanResult { db = new _SQL.Database(data) const tables = db.exec("SELECT name FROM sqlite_master WHERE type='table'") - let tableName = "windsurfDiskKV" - if (tables.length > 0) { - const tableNames = tables[0].values.map((r) => String(r[0])) - if (tableNames.includes("ItemTable")) { - tableName = "ItemTable" - } else if (!tableNames.includes(tableName)) { - consola.info("No known Windsurf KV table found") - return { source: "windsurf", entries: [] } - } + const tableNames = tables.length > 0 ? tables[0].values.map((r) => String(r[0])) : [] + const knownTables = ["windsurfDiskKV", "ItemTable"] + const tableName = knownTables.find((t) => tableNames.includes(t)) + if (!tableName) { + consola.info("No known Windsurf KV table found") + return { source: "windsurf", entries: [] } } // Fetch all key-value pairs that might contain usage data - const sql = `SELECT key, value FROM ${tableName}` - const results = db.exec(sql) + const results = db.exec(`SELECT key, value FROM "${tableName}"`) if (results.length === 0) { consola.info("No data found in Windsurf DB")