2026-07-05 13:03:32 -05:00
|
|
|
'use strict';
|
|
|
|
|
|
|
|
|
|
// Phase 1 — billMerchantRuleService.syncBillPaymentsFromSimplefin is a money-
|
|
|
|
|
// CREATING path: it turns matching unmatched bank transactions into payments
|
|
|
|
|
// (payment_source='provider_sync'). Untested until now. Pins: a rule-matched
|
|
|
|
|
// transaction becomes exactly one payment (amount in cents, tx marked matched),
|
|
|
|
|
// it's idempotent (re-running creates no duplicate — match-status filter + the
|
|
|
|
|
// UNIQUE(transaction_id) index), and a non-matching transaction is left alone.
|
|
|
|
|
const test = require('node:test');
|
|
|
|
|
const assert = require('node:assert/strict');
|
|
|
|
|
const os = require('node:os');
|
|
|
|
|
const path = require('node:path');
|
|
|
|
|
const fs = require('node:fs');
|
|
|
|
|
|
|
|
|
|
const dbPath = path.join(os.tmpdir(), `bill-tracker-merchantrule-${process.pid}.sqlite`);
|
|
|
|
|
process.env.DB_PATH = dbPath;
|
|
|
|
|
|
refactor(server): migrate middleware, workers, and db layer to TypeScript (.cts)
- middleware/ (5): requireAuth, securityHeaders, errorFormatter, csrf,
rateLimiter — fully typed against the shared http Req/Res/Next types.
- workers/dailyWorker — fully typed.
- db/ (4): database, subscriptionCatalogSeed, migrations/versionedMigrations,
migrations/legacyReconcileMigrations — large dynamic schema/migration infra,
converted with @ts-nocheck (typing deferred). All requires updated to .cts.
Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 11:34:08 -05:00
|
|
|
const { getDb, closeDb } = require('../db/database.cts');
|
2026-07-06 14:23:53 -05:00
|
|
|
const {
|
|
|
|
|
addMerchantRule,
|
|
|
|
|
syncBillPaymentsFromSimplefin,
|
|
|
|
|
} = require('../services/billMerchantRuleService.cts');
|
2026-07-05 13:03:32 -05:00
|
|
|
|
|
|
|
|
let db, userId, billId;
|
2026-07-06 14:23:53 -05:00
|
|
|
const insertTx = (payee, amountCents, postedDate) =>
|
|
|
|
|
db
|
|
|
|
|
.prepare(
|
|
|
|
|
`
|
2026-07-05 13:03:32 -05:00
|
|
|
INSERT INTO transactions (user_id, source_type, amount, payee, posted_date, match_status, ignored, pending)
|
|
|
|
|
VALUES (?, 'simplefin', ?, ?, ?, 'unmatched', 0, 0)
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
|
|
|
|
)
|
|
|
|
|
.run(userId, amountCents, payee, postedDate).lastInsertRowid;
|
|
|
|
|
const activePayments = () =>
|
|
|
|
|
db.prepare('SELECT * FROM payments WHERE bill_id = ? AND deleted_at IS NULL').all(billId);
|
2026-07-05 13:03:32 -05:00
|
|
|
|
|
|
|
|
test.before(() => {
|
|
|
|
|
db = getDb();
|
2026-07-06 14:23:53 -05:00
|
|
|
userId = db
|
|
|
|
|
.prepare(
|
|
|
|
|
"INSERT INTO users (username, password_hash, role, active) VALUES ('sync-user','x','user',1)",
|
|
|
|
|
)
|
|
|
|
|
.run().lastInsertRowid;
|
|
|
|
|
billId = db
|
|
|
|
|
.prepare(
|
|
|
|
|
"INSERT INTO bills (user_id, name, due_day, expected_amount, current_balance, interest_rate, active) VALUES (?, 'Netflix', 15, 1500, 50000, 0, 1)",
|
|
|
|
|
)
|
|
|
|
|
.run(userId).lastInsertRowid;
|
2026-07-05 13:03:32 -05:00
|
|
|
addMerchantRule(db, userId, billId, 'netflix');
|
|
|
|
|
});
|
|
|
|
|
test.after(() => {
|
|
|
|
|
closeDb();
|
2026-07-06 14:23:53 -05:00
|
|
|
for (const s of ['', '-wal', '-shm']) {
|
|
|
|
|
try {
|
|
|
|
|
fs.rmSync(dbPath + s);
|
|
|
|
|
} catch {}
|
|
|
|
|
}
|
2026-07-05 13:03:32 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a rule-matched unmatched transaction becomes exactly one provider_sync payment', () => {
|
|
|
|
|
const txId = insertTx('NETFLIX', -1500, '2026-07-15'); // $15 charge (cents, negative = money out)
|
|
|
|
|
const result = syncBillPaymentsFromSimplefin(db, userId, billId);
|
|
|
|
|
assert.equal(result.added, 1, 'one payment created');
|
|
|
|
|
|
|
|
|
|
const pays = activePayments();
|
|
|
|
|
assert.equal(pays.length, 1);
|
|
|
|
|
assert.equal(pays[0].amount, 1500, 'amount stored in cents');
|
|
|
|
|
assert.equal(pays[0].payment_source, 'provider_sync');
|
|
|
|
|
assert.equal(pays[0].transaction_id, txId, 'linked to the source transaction');
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const tx = db
|
|
|
|
|
.prepare('SELECT match_status, matched_bill_id FROM transactions WHERE id = ?')
|
|
|
|
|
.get(txId);
|
2026-07-05 13:03:32 -05:00
|
|
|
assert.equal(tx.match_status, 'matched', 'transaction marked matched');
|
|
|
|
|
assert.equal(tx.matched_bill_id, billId);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('re-running is idempotent — no duplicate payment', () => {
|
|
|
|
|
const result = syncBillPaymentsFromSimplefin(db, userId, billId);
|
|
|
|
|
assert.equal(result.added, 0, 'nothing new to add on a second sync');
|
|
|
|
|
assert.equal(activePayments().length, 1, 'still exactly one payment');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a non-matching transaction is left untouched', () => {
|
|
|
|
|
const spotifyTx = insertTx('SPOTIFY USA', -999, '2026-07-16');
|
|
|
|
|
const result = syncBillPaymentsFromSimplefin(db, userId, billId);
|
|
|
|
|
assert.equal(result.added, 0, 'Spotify does not match the netflix rule');
|
|
|
|
|
assert.equal(activePayments().length, 1);
|
2026-07-06 14:23:53 -05:00
|
|
|
assert.equal(
|
|
|
|
|
db.prepare('SELECT match_status FROM transactions WHERE id = ?').get(spotifyTx).match_status,
|
|
|
|
|
'unmatched',
|
|
|
|
|
'left unmatched',
|
|
|
|
|
);
|
2026-07-05 13:03:32 -05:00
|
|
|
});
|