2026-05-30 17:27:15 -05:00
|
|
|
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,
|
|
|
|
|
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
|
2026-05-30 21:20:51 -05:00
|
|
|
(user_id, source_type, account_id, posted_date, amount, currency, description, payee, match_status, ignored)
|
|
|
|
|
VALUES (?, 'manual', ?, ?, ?, 'USD', ?, ?, 'unmatched', 0)
|
2026-05-30 17:27:15 -05:00
|
|
|
`).run(
|
|
|
|
|
userId,
|
2026-05-30 21:20:51 -05:00
|
|
|
overrides.account_id || null,
|
2026-05-30 17:27:15 -05:00
|
|
|
overrides.posted_date || new Date().toISOString().slice(0, 10),
|
|
|
|
|
overrides.amount ?? -1599,
|
|
|
|
|
overrides.description || 'NETFLIX.COM',
|
|
|
|
|
overrides.payee || 'NETFLIX.COM',
|
|
|
|
|
).lastInsertRowid;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 21:20:51 -05:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 17:27:15 -05:00
|
|
|
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');
|
2026-05-30 21:52:02 -05:00
|
|
|
const accountId = createAccount(db, userId, true);
|
|
|
|
|
createTransaction(db, userId, { account_id: accountId });
|
2026-05-30 17:27:15 -05:00
|
|
|
|
|
|
|
|
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);
|
2026-05-30 21:52:02 -05:00
|
|
|
assert.deepEqual(netflix.accounts, ['Checking']);
|
2026-05-30 17:27:15 -05:00
|
|
|
assert.match(netflix.reasons.join(' '), /Matches known service: Netflix/);
|
2026-06-06 20:44:54 -05:00
|
|
|
assert.equal(netflix.evidence.identity.type, 'bank_descriptor');
|
|
|
|
|
assert.equal(netflix.evidence.amount.match, 'monthly_plausible');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-06 21:05:01 -05:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-06 20:44:54 -05:00
|
|
|
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);
|
2026-05-30 17:27:15 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
});
|
2026-05-30 17:57:34 -05:00
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
});
|
2026-05-30 21:20:51 -05:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|