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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
114 changes: 114 additions & 0 deletions subtrack/src/__tests__/upcoming.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
Comment on lines +73 to +114

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Verify whether upcoming tests control system time.
# Expectation after the fix: this should find fake-timer setup such as useFakeTimers/setSystemTime/useRealTimers.
rg -n 'useFakeTimers|setSystemTime|useRealTimers' subtrack/src/__tests__/upcoming.test.ts

Repository: nazozokc/subtrack

Length of output: 155


Freeze time in these tests to prevent flakiness.

The assertions in "no upcoming bills" and "upcoming monthly subscription" rely on new Date(), causing instability near billing thresholds. Implement jest.useFakeTimers() with a fixed date in beforeEach and jest.useRealTimers() in afterEach to ensure deterministic execution.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@subtrack/src/__tests__/upcoming.test.ts` around lines 73 - 114, The upcoming
subscription tests are time-sensitive because they depend on new Date() and can
flap around billing thresholds. In upcoming.test.ts, add deterministic time
control in the test setup by using jest.useFakeTimers() with a fixed system date
in beforeEach and restoring with jest.useRealTimers() in afterEach, so
showUpcoming and the monthly billing-day calculations behave consistently across
runs.

77 changes: 77 additions & 0 deletions subtrack/src/analytics.ts
Original file line number Diff line number Diff line change
@@ -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}`)}`)
Comment on lines +11 to +33

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fix the SharedArgs/query contract for status (currently breaks typecheck).

Line 11 (and Lines 28-30) reads s.status, but CI fails with TS2339 and getSubscriptions’s shown query shape does not include status. This is a blocker: add status to the returned row shape (and SharedArgs typing), or stop using status here.

🧰 Tools
🪛 GitHub Actions: app-ci / 1_check _ check.txt

[error] 11-11: TypeScript (tsc --noEmit) failed: TS2339 Property 'status' does not exist on type 'SharedArgs'.

🪛 GitHub Actions: app-ci / check _ check

[error] 11-11: TypeScript (tsc --noEmit) error TS2339: Property 'status' does not exist on type 'SharedArgs'.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@subtrack/src/analytics.ts` around lines 11 - 33, The analytics flow in
`getSubscriptions`/`calcSummary` is reading `s.status`, but the current query
result and `SharedArgs` contract do not expose a `status` field, causing the
typecheck failure. Update the shared row/query typing so `status` is included in
the returned subscription shape, or refactor `analytics.ts` to avoid accessing
`status` in the `list.filter(...)` and `cancelledCount` logic; make sure the
types for `getSubscriptions`, `SharedArgs`, and any related subscription model
stay consistent.

Source: Pipeline failures


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")
Comment on lines +48 to +58

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Budget/tag totals are mixing currencies and hardcoding USD output.

Line 51 aggregates monthly totals across all currencies, then Lines 54/58/73 format as "USD" regardless of actual currency. This yields incorrect analytics in multi-currency data. Group by currency (like monthlyByCurrency) or apply explicit FX conversion before producing a single-currency budget/tag figure.

Also applies to: 67-74

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@subtrack/src/analytics.ts` around lines 48 - 58, The budget/tag analytics in
analytics.ts are mixing amounts from different currencies and hardcoding USD in
the display path. Update the budget summary logic around the monthly aggregation
and the tag totals so values are either grouped per currency (for example using
a monthlyByCurrency-style breakdown) or explicitly converted to one reference
currency before formatting. Make sure the Budget, Spent, Remaining, and tag
output all use the same currency source instead of passing "USD" directly into
formatPrice.

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" : ""})`,
)
}
}
}
7 changes: 4 additions & 3 deletions subtrack/src/codex-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
97 changes: 97 additions & 0 deletions subtrack/src/config.ts
Original file line number Diff line number Diff line change
@@ -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<Partial<SubtrackConfig>>(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
}
18 changes: 7 additions & 11 deletions subtrack/src/cursor-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading