const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const dbPath = path.join(os.tmpdir(), `bill-tracker-subscription-service-test-${process.pid}.sqlite`); process.env.DB_PATH = dbPath; const { getDb, closeDb } = require('../db/database'); const { getSubscriptionRecommendations, recordSubscriptionFeedback, searchSubscriptionTransactions, } = require('../services/subscriptionService'); function createUser(db, suffix) { return db.prepare(` INSERT INTO users (username, password_hash, role, active, email, created_at, updated_at) VALUES (?, 'x', 'user', 1, ?, datetime('now'), datetime('now')) `).run(`subscription-user-${suffix}`, `subscription-user-${suffix}@local`).lastInsertRowid; } function createTransaction(db, userId, overrides = {}) { return db.prepare(` INSERT INTO transactions (user_id, source_type, account_id, posted_date, amount, currency, description, payee, match_status, ignored) VALUES (?, 'manual', ?, ?, ?, 'USD', ?, ?, 'unmatched', 0) `).run( userId, overrides.account_id || null, overrides.posted_date || new Date().toISOString().slice(0, 10), overrides.amount ?? -1599, overrides.description || 'NETFLIX.COM', overrides.payee || 'NETFLIX.COM', ).lastInsertRowid; } function createAccount(db, userId, monitored = true) { const sourceId = db.prepare(` INSERT INTO data_sources (user_id, type, provider, name, status) VALUES (?, 'provider_sync', 'simplefin', 'SimpleFIN', 'active') `).run(userId).lastInsertRowid; return db.prepare(` INSERT INTO financial_accounts (user_id, data_source_id, provider_account_id, name, currency, monitored) VALUES (?, ?, ?, 'Checking', 'USD', ?) `).run(userId, sourceId, `acct-${sourceId}`, monitored ? 1 : 0).lastInsertRowid; } function createBill(db, userId, overrides = {}) { return db.prepare(` INSERT INTO bills (user_id, name, due_day, expected_amount, is_subscription, cycle_type, billing_cycle) VALUES (?, ?, ?, ?, ?, ?, ?) `).run( userId, overrides.name || 'Netflix', overrides.due_day || 8, overrides.expected_amount ?? 1599, overrides.is_subscription ?? 1, overrides.cycle_type || 'monthly', overrides.billing_cycle || 'monthly', ).lastInsertRowid; } test.after(() => { closeDb(); for (const suffix of ['', '-wal', '-shm']) { fs.rmSync(`${dbPath}${suffix}`, { force: true }); } }); test('known catalog services appear as high-confidence subscription recommendations', () => { const db = getDb(); const userId = createUser(db, 'recommendation'); const accountId = createAccount(db, userId, true); createTransaction(db, userId, { account_id: accountId }); const recommendations = getSubscriptionRecommendations(db, userId); const netflix = recommendations.find(item => item.catalog_match?.name === 'Netflix'); assert.ok(netflix, 'Netflix catalog match should be recommended from one known charge'); assert.equal(netflix.subscription_type, 'streaming'); assert.equal(netflix.confidence >= 90, true); assert.deepEqual(netflix.accounts, ['Checking']); assert.match(netflix.reasons.join(' '), /Matches known service: Netflix/); assert.equal(netflix.evidence.identity.type, 'bank_descriptor'); assert.equal(netflix.evidence.amount.match, 'monthly_plausible'); }); test('existing tracked bills are recommended for linking instead of tracking again', () => { const db = getDb(); const userId = createUser(db, 'existing-bill'); const accountId = createAccount(db, userId, true); const billId = createBill(db, userId, { name: 'Netflix', due_day: 12, expected_amount: 1599, is_subscription: 1, }); createTransaction(db, userId, { account_id: accountId, description: 'NETFLIX.COM', payee: 'NETFLIX.COM', amount: -1599, posted_date: '2026-04-12', }); const recommendations = getSubscriptionRecommendations(db, userId); const netflix = recommendations.find(item => item.catalog_match?.name === 'Netflix'); assert.ok(netflix, 'Netflix should still be recommended when an existing bill is present'); assert.equal(netflix.recommended_action, 'link_existing_bill'); assert.equal(netflix.existing_bill_match.id, billId); assert.equal(netflix.existing_bill_match.strong, true); }); test('subscription recommendation feedback boosts future matching for the user', () => { const db = getDb(); const userId = createUser(db, 'feedback-boost'); const accountId = createAccount(db, userId, true); createTransaction(db, userId, { account_id: accountId, description: 'NETFLIX.COM', payee: 'NETFLIX.COM', amount: -1599, posted_date: '2026-04-12', }); const before = getSubscriptionRecommendations(db, userId).find(item => item.catalog_match?.name === 'Netflix'); assert.ok(before, 'Netflix should be recommended before feedback'); recordSubscriptionFeedback(db, userId, { action: 'accept_track_new', catalog_id: before.catalog_match.id, merchant: before.merchant, confidence: before.confidence, }); const after = getSubscriptionRecommendations(db, userId).find(item => item.catalog_match?.name === 'Netflix'); assert.ok(after, 'Netflix should still be recommended after feedback'); assert.equal(after.confidence > before.confidence, true); assert.equal(after.evidence.feedback.positive_count > 0, true); }); test('weak one-off known service names stay below the recommendation threshold', () => { const db = getDb(); const userId = createUser(db, 'weak-known'); const accountId = createAccount(db, userId, true); createTransaction(db, userId, { account_id: accountId, description: 'MAX', payee: 'MAX', amount: -35000, }); const recommendations = getSubscriptionRecommendations(db, userId); assert.equal(recommendations.some(item => item.catalog_match?.name === 'Max'), false); }); test('broad Apple, Amazon, and Google one-off purchases with odd amounts are not recommended', () => { const db = getDb(); const userId = createUser(db, 'broad-one-offs'); const accountId = createAccount(db, userId, true); createTransaction(db, userId, { account_id: accountId, description: 'APPLE.COM/BILL', payee: 'APPLE.COM/BILL', amount: -19999, posted_date: '2026-04-02', }); createTransaction(db, userId, { account_id: accountId, description: 'AMAZON MKTPLACE PMTS', payee: 'AMAZON MKTPLACE PMTS', amount: -8700, posted_date: '2026-04-03', }); createTransaction(db, userId, { account_id: accountId, description: 'GOOGLE *STORE', payee: 'GOOGLE *STORE', amount: -64999, posted_date: '2026-04-04', }); const recommendations = getSubscriptionRecommendations(db, userId); const names = recommendations.map(item => item.catalog_match?.name || item.name).join(' '); assert.doesNotMatch(names, /Apple|Amazon|Google/); }); test('annual known-service charges can be recommended when catalog annual pricing matches', () => { const db = getDb(); const userId = createUser(db, 'annual-known'); const accountId = createAccount(db, userId, true); db.prepare("UPDATE subscription_catalog SET starting_annual_usd = 191.88 WHERE name = 'Netflix'").run(); createTransaction(db, userId, { account_id: accountId, description: 'NETFLIX.COM', payee: 'NETFLIX.COM', amount: -19188, posted_date: '2026-04-12', }); const recommendations = getSubscriptionRecommendations(db, userId); const netflix = recommendations.find(item => item.catalog_match?.name === 'Netflix'); assert.ok(netflix, 'Annual Netflix charge should be recommended from exact descriptor and annual price'); assert.equal(netflix.cycle_type, 'annual'); assert.equal(netflix.evidence.amount.match, 'annual_close'); }); test('exact user descriptors produce high-confidence one-off recommendations', () => { const db = getDb(); const userId = createUser(db, 'custom-descriptor'); const accountId = createAccount(db, userId, true); const netflixCatalog = db.prepare("SELECT id FROM subscription_catalog WHERE name = 'Netflix'").get(); db.prepare(` INSERT INTO user_catalog_descriptors (user_id, catalog_id, descriptor) VALUES (?, ?, 'NFX CUSTOM BILL') `).run(userId, netflixCatalog.id); createTransaction(db, userId, { account_id: accountId, description: 'NFX CUSTOM BILL', payee: 'NFX CUSTOM BILL', amount: -1599, posted_date: '2026-04-12', }); const recommendations = getSubscriptionRecommendations(db, userId); const netflix = recommendations.find(item => item.catalog_match?.name === 'Netflix'); assert.ok(netflix, 'Custom descriptor should produce a Netflix recommendation'); assert.equal(netflix.confidence >= 90, true); assert.equal(netflix.evidence.identity.type, 'user_descriptor'); }); test('ambiguous recurring known service names are flagged for review', () => { const db = getDb(); const userId = createUser(db, 'ambiguous-known'); const accountId = createAccount(db, userId, true); createTransaction(db, userId, { account_id: accountId, description: 'MAX', payee: 'MAX', amount: -1099, posted_date: '2026-01-08', }); createTransaction(db, userId, { account_id: accountId, description: 'MAX', payee: 'MAX', amount: -1099, posted_date: '2026-02-08', }); createTransaction(db, userId, { account_id: accountId, description: 'MAX', payee: 'MAX', amount: -1099, posted_date: '2026-03-08', }); const recommendations = getSubscriptionRecommendations(db, userId); const max = recommendations.find(item => item.catalog_match?.name === 'Max'); assert.ok(max, 'Recurring Max charges should still be recommended'); assert.equal(max.confidence >= 90, true); assert.equal(max.evidence.ambiguity.ambiguous, true); assert.equal(max.evidence.ambiguity.penalty > 0, true); assert.equal(max.transactions.length, 3); }); test('unknown recurring patterns do not appear as known-service recommendations', () => { const db = getDb(); const userId = createUser(db, 'unknown-pattern'); const accountId = createAccount(db, userId, true); createTransaction(db, userId, { account_id: accountId, description: 'LOCAL CLUB MEMBERSHIP', payee: 'LOCAL CLUB MEMBERSHIP', amount: -2500, posted_date: '2026-01-05', }); createTransaction(db, userId, { account_id: accountId, description: 'LOCAL CLUB MEMBERSHIP', payee: 'LOCAL CLUB MEMBERSHIP', amount: -2500, posted_date: '2026-02-05', }); createTransaction(db, userId, { account_id: accountId, description: 'LOCAL CLUB MEMBERSHIP', payee: 'LOCAL CLUB MEMBERSHIP', amount: -2500, posted_date: '2026-03-05', }); const recommendations = getSubscriptionRecommendations(db, userId); assert.equal(recommendations.some(item => item.merchant === 'local club membership'), false); }); test('subscription transaction search annotates known catalog matches', () => { const db = getDb(); const userId = createUser(db, 'search'); const transactionId = createTransaction(db, userId, { description: 'NETFLIX.COM 866-579-7172', payee: 'NETFLIX.COM', }); const matches = searchSubscriptionTransactions(db, userId, { q: 'netflix', limit: 10 }); const match = matches.find(item => item.id === transactionId); assert.ok(match, 'transaction should be returned by subscription search'); assert.equal(match.is_known_subscription, true); assert.equal(match.catalog_match.name, 'Netflix'); assert.equal(match.catalog_match.subscription_type, 'streaming'); }); test('Claude.ai catalog seed matches Anthropic transaction descriptors', () => { const db = getDb(); const userId = createUser(db, 'claude'); const transactionId = createTransaction(db, userId, { description: 'ANTHROPIC CLAUDE PRO', payee: 'ANTHROPIC', amount: -2000, }); const matches = searchSubscriptionTransactions(db, userId, { q: 'anthropic', limit: 10 }); const match = matches.find(item => item.id === transactionId); assert.ok(match, 'Anthropic transaction should be returned by subscription search'); assert.equal(match.is_known_subscription, true); assert.equal(match.catalog_match.name, 'Claude.ai'); assert.equal(match.catalog_match.subscription_type, 'software'); }); test('subscription recommendations and search ignore unmonitored SimpleFIN accounts', () => { const db = getDb(); const userId = createUser(db, 'unmonitored'); const accountId = createAccount(db, userId, false); const transactionId = createTransaction(db, userId, { account_id: accountId, description: 'NETFLIX.COM 866-579-7172', payee: 'NETFLIX.COM', }); const recommendations = getSubscriptionRecommendations(db, userId); assert.equal(recommendations.some(item => item.catalog_match?.name === 'Netflix'), false); const matches = searchSubscriptionTransactions(db, userId, { q: 'netflix', limit: 10 }); assert.equal(matches.some(item => item.id === transactionId), false); });