2026-07-06 11:26:50 -05:00
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req , Res , Next } from '../types/http' ;
2026-05-03 19:51:57 -05:00
const express = require ( 'express' ) ;
const router = express . Router ( ) ;
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 <noreply@anthropic.com>
2026-07-06 11:34:08 -05:00
const { getDb , ensureUserDefaultCategories } = require ( '../db/database.cts' ) ;
2026-05-16 15:38:28 -05:00
const {
auditBillsForUser ,
categoryBelongsToUser ,
insertBill ,
2026-06-11 20:12:31 -05:00
serializeBill ,
2026-05-16 15:38:28 -05:00
parseTemplateData ,
sanitizeTemplateData ,
validateBillData ,
computeBalanceDelta ,
2026-06-06 16:34:20 -05:00
applyBalanceDelta ,
2026-07-06 11:12:06 -05:00
} = require ( '../services/billsService.cts' ) ;
refactor(server): migrate 10 services to TypeScript (.cts)
Continues the incremental server TS migration (after utils/*.mts and
paymentValidation.cts): converts 10 services to .cts (CommonJS TypeScript,
run natively via Node type-stripping — no build step), type-checked by
tsconfig.server.json.
Migrated: totpService, amountSuggestionService, encryptionService,
paymentAccountingService, statusService, aprService, driftService,
analyticsService, spendingService, billMerchantRuleService.
- types/db.d.ts: shared minimal better-sqlite3 Db/Statement types (the lib
ships none), reused across the migrated modules.
- Every require of a migrated service updated to the explicit .cts extension
(extensionless require does not resolve .cts) across routes / services /
db migrations / server.js / workers / tests — require-only changes.
- package.json: check:server find pattern now includes *.cts (it silently
skipped .cts before, so paymentValidation.cts had no node --check gate).
Behavior-preserving (types only). Verified: typecheck:server 0,
check:server 0, full server suite 226/226, real boot → /api/health 200.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:35:29 -05:00
const { amortizationSchedule , debtAprSnapshot } = require ( '../services/aprService.cts' ) ;
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 <noreply@anthropic.com>
2026-07-06 11:34:08 -05:00
const { standardizeError } = require ( '../middleware/errorFormatter.cts' ) ;
2026-07-05 13:51:37 -05:00
const { validatePaymentInput , serializePayment } = require ( '../services/paymentValidation.cts' ) ;
2026-07-06 14:23:53 -05:00
const {
addMerchantRule ,
syncBillPaymentsFromSimplefin ,
merchantMatches ,
} = require ( '../services/billMerchantRuleService.cts' ) ;
2026-07-06 11:22:44 -05:00
const { normalizeMerchant } = require ( '../services/subscriptionService.cts' ) ;
2026-07-06 10:54:10 -05:00
const { decorateTransaction } = require ( '../services/transactionService.cts' ) ;
2026-06-07 01:05:48 -05:00
const {
accountingActiveSql ,
applyBankPaymentAsSourceOfTruth ,
refactor(server): migrate 10 services to TypeScript (.cts)
Continues the incremental server TS migration (after utils/*.mts and
paymentValidation.cts): converts 10 services to .cts (CommonJS TypeScript,
run natively via Node type-stripping — no build step), type-checked by
tsconfig.server.json.
Migrated: totpService, amountSuggestionService, encryptionService,
paymentAccountingService, statusService, aprService, driftService,
analyticsService, spendingService, billMerchantRuleService.
- types/db.d.ts: shared minimal better-sqlite3 Db/Statement types (the lib
ships none), reused across the migrated modules.
- Every require of a migrated service updated to the explicit .cts extension
(extensionless require does not resolve .cts) across routes / services /
db migrations / server.js / workers / tests — require-only changes.
- package.json: check:server find pattern now includes *.cts (it silently
skipped .cts before, so paymentValidation.cts had no node --check gate).
Behavior-preserving (types only). Verified: typecheck:server 0,
check:server 0, full server suite 226/226, real boot → /api/health 200.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:35:29 -05:00
} = require ( '../services/paymentAccountingService.cts' ) ;
2026-07-05 13:48:44 -05:00
const { localDateString , todayLocal } = require ( '../utils/dates.mts' ) ;
2026-07-05 13:44:04 -05:00
const { roundMoney , sumMoney , toCents , fromCents } = require ( '../utils/money.mts' ) ;
2026-05-03 19:51:57 -05:00
// ── GET /api/bills ────────────────────────────────────────────────────────────
2026-07-06 11:26:50 -05:00
router . get ( '/' , ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
const db = getDb ( ) ;
ensureUserDefaultCategories ( req . user . id ) ;
const includeInactive = req . query . inactive === 'true' ;
2026-06-06 23:04:53 -05:00
// LEFT JOIN on pre-grouped subqueries — one query instead of N+1 correlated EXISTS lookups.
2026-07-06 14:23:53 -05:00
const bills = db
. prepare (
`
2026-05-03 19:51:57 -05:00
SELECT b . * , c . name AS category_name ,
2026-06-06 23:04:53 -05:00
CASE WHEN hr . bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_history_ranges ,
CASE WHEN mr . bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_merchant_rule ,
CASE WHEN lt . matched_bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_linked_transactions
2026-05-03 19:51:57 -05:00
FROM bills b
2026-05-16 10:34:32 -05:00
LEFT JOIN categories c ON b . category_id = c . id AND c . deleted_at IS NULL
2026-06-03 22:25:30 -05:00
LEFT JOIN ( SELECT DISTINCT bill_id FROM bill_history_ranges ) hr ON hr . bill_id = b . id
2026-06-06 23:04:53 -05:00
LEFT JOIN ( SELECT DISTINCT bill_id FROM bill_merchant_rules ) mr ON mr . bill_id = b . id
LEFT JOIN ( SELECT DISTINCT matched_bill_id FROM transactions WHERE match_status = 'matched' ) lt ON lt . matched_bill_id = b . id
2026-05-03 19:51:57 -05:00
WHERE b . user_id = ?
2026-05-16 10:34:32 -05:00
AND b . deleted_at IS NULL
2026-05-03 19:51:57 -05:00
$ { includeInactive ? '' : 'AND b.active = 1' }
2026-05-30 16:13:37 -05:00
ORDER BY CASE WHEN b . sort_order IS NULL THEN 1 ELSE 0 END , b . sort_order ASC , b . due_day ASC , b . name ASC
2026-07-06 14:23:53 -05:00
` ,
)
. all ( req . user . id ) ;
2026-06-11 20:12:31 -05:00
res . json ( bills . map ( serializeBill ) ) ;
2026-05-16 10:56:56 -05:00
} ) ;
2026-07-03 12:56:45 -05:00
// ── GET /api/bills/deleted ────────────────────────────────────────────────────
// Soft-deleted bills still inside the 30-day recovery window (before the
// retention GC purges them), newest deletion first. Powers the "Recently
// deleted" restore view. Must be declared before GET /:id.
const BILL_RETENTION_DAYS = 30 ; // matches pruneSoftDeletedFinancialRecords()
2026-07-06 11:26:50 -05:00
router . get ( '/deleted' , ( req : Req , res : Res ) = > {
2026-07-03 12:56:45 -05:00
const db = getDb ( ) ;
2026-07-06 14:23:53 -05:00
const rows = db
. prepare (
`
2026-07-03 12:56:45 -05:00
SELECT b . * , c . name AS category_name ,
CAST ( julianday ( b . deleted_at , '+${BILL_RETENTION_DAYS} days' ) - julianday ( 'now' ) AS INTEGER ) AS days_left
FROM bills b
LEFT JOIN categories c ON b . category_id = c . id AND c . deleted_at IS NULL
WHERE b . user_id = ?
AND b . deleted_at IS NOT NULL
AND b . deleted_at >= datetime ( 'now' , '-${BILL_RETENTION_DAYS} days' )
ORDER BY b . deleted_at DESC
2026-07-06 14:23:53 -05:00
` ,
)
. all ( req . user . id ) ;
res . json (
rows . map ( ( row ) = > ( {
. . . serializeBill ( row ) ,
category_name : row.category_name ,
deleted_at : row.deleted_at ,
days_left : Math.max ( 0 , row . days_left ) ,
} ) ) ,
) ;
2026-07-03 12:56:45 -05:00
} ) ;
2026-05-30 16:13:37 -05:00
// ── PUT /api/bills/reorder ───────────────────────────────────────────────────
2026-07-06 11:26:50 -05:00
router . put ( '/reorder' , ( req : Req , res : Res ) = > {
2026-05-30 16:13:37 -05:00
const db = getDb ( ) ;
const entries = Object . entries ( req . body || { } ) . map ( ( [ billId , sortOrder ] ) = > ( {
billId : Number ( billId ) ,
sortOrder : Number ( sortOrder ) ,
} ) ) ;
if ( entries . length === 0 ) {
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json ( standardizeError ( 'At least one bill order is required' , 'VALIDATION_ERROR' , 'reorder' ) ) ;
2026-05-30 16:13:37 -05:00
}
2026-07-06 14:23:53 -05:00
const invalid = entries . find (
( { billId , sortOrder } ) = >
! Number . isInteger ( billId ) || billId <= 0 || ! Number . isInteger ( sortOrder ) || sortOrder < 0 ,
) ;
2026-05-30 16:13:37 -05:00
if ( invalid ) {
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json (
standardizeError (
'Reorder payload must map bill ids to non-negative integer positions' ,
'VALIDATION_ERROR' ,
'reorder' ,
) ,
) ;
2026-05-30 16:13:37 -05:00
}
2026-07-06 14:23:53 -05:00
const ids = entries . map ( ( item ) = > item . billId ) ;
2026-05-30 16:13:37 -05:00
const placeholders = ids . map ( ( ) = > '?' ) . join ( ',' ) ;
2026-07-06 14:23:53 -05:00
const owned = db
. prepare (
`
2026-05-30 16:13:37 -05:00
SELECT id
FROM bills
WHERE user_id = ? AND deleted_at IS NULL AND id IN ( $ { placeholders } )
2026-07-06 14:23:53 -05:00
` ,
)
. all ( req . user . id , . . . ids ) ;
2026-05-30 16:13:37 -05:00
if ( owned . length !== ids . length ) {
2026-07-06 14:23:53 -05:00
return res
. status ( 404 )
. json ( standardizeError ( 'One or more bills were not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
2026-05-30 16:13:37 -05:00
}
2026-07-06 14:23:53 -05:00
const update = db . prepare (
"UPDATE bills SET sort_order = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?" ,
) ;
2026-05-30 16:13:37 -05:00
const applyOrder = db . transaction ( ( items ) = > {
for ( const item of items ) update . run ( item . sortOrder , item . billId , req . user . id ) ;
} ) ;
applyOrder ( entries ) ;
2026-07-06 14:23:53 -05:00
const bills = db
. prepare (
`
2026-05-30 16:13:37 -05:00
SELECT b . * , c . name AS category_name
FROM bills b
LEFT JOIN categories c ON b . category_id = c . id AND c . deleted_at IS NULL
WHERE b . user_id = ? AND b . deleted_at IS NULL AND b . active = 1
ORDER BY CASE WHEN b . sort_order IS NULL THEN 1 ELSE 0 END , b . sort_order ASC , b . due_day ASC , b . name ASC
2026-07-06 14:23:53 -05:00
` ,
)
. all ( req . user . id ) ;
2026-05-30 16:13:37 -05:00
2026-06-11 20:12:31 -05:00
res . json ( { success : true , bills : bills.map ( serializeBill ) } ) ;
2026-05-30 16:13:37 -05:00
} ) ;
2026-05-16 10:56:56 -05:00
// ── GET /api/bills/audit?inactive=true ───────────────────────────────────────
2026-07-06 11:26:50 -05:00
router . get ( '/audit' , ( req : Req , res : Res ) = > {
2026-05-16 10:56:56 -05:00
const db = getDb ( ) ;
ensureUserDefaultCategories ( req . user . id ) ;
const includeInactive = req . query . inactive === 'true' ;
2026-05-16 15:38:28 -05:00
res . json ( auditBillsForUser ( db , req . user . id , includeInactive ) ) ;
} ) ;
2026-05-30 14:33:55 -05:00
// ── GET /api/bills/drift-report ──────────────────────────────────────────────
2026-07-06 11:26:50 -05:00
router . get ( '/drift-report' , ( req : Req , res : Res ) = > {
refactor(server): migrate 10 services to TypeScript (.cts)
Continues the incremental server TS migration (after utils/*.mts and
paymentValidation.cts): converts 10 services to .cts (CommonJS TypeScript,
run natively via Node type-stripping — no build step), type-checked by
tsconfig.server.json.
Migrated: totpService, amountSuggestionService, encryptionService,
paymentAccountingService, statusService, aprService, driftService,
analyticsService, spendingService, billMerchantRuleService.
- types/db.d.ts: shared minimal better-sqlite3 Db/Statement types (the lib
ships none), reused across the migrated modules.
- Every require of a migrated service updated to the explicit .cts extension
(extensionless require does not resolve .cts) across routes / services /
db migrations / server.js / workers / tests — require-only changes.
- package.json: check:server find pattern now includes *.cts (it silently
skipped .cts before, so paymentValidation.cts had no node --check gate).
Behavior-preserving (types only). Verified: typecheck:server 0,
check:server 0, full server suite 226/226, real boot → /api/health 200.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:35:29 -05:00
const { getDriftReport } = require ( '../services/driftService.cts' ) ;
2026-05-30 14:33:55 -05:00
try {
res . json ( getDriftReport ( req . user . id ) ) ;
} catch ( err ) {
res . status ( 500 ) . json ( { error : 'Failed to compute drift report' } ) ;
}
} ) ;
2026-06-04 20:45:11 -05:00
// GET /api/bills/merchant-rules — all rules for this user across all bills
2026-07-06 11:26:50 -05:00
router . get ( '/merchant-rules' , ( req : Req , res : Res ) = > {
2026-06-04 20:45:11 -05:00
try {
2026-07-06 14:23:53 -05:00
const rules = getDb ( )
. prepare (
`
2026-06-04 20:45:11 -05:00
SELECT bmr . id , bmr . merchant , bmr . auto_attribute_late , bmr . created_at ,
b . id AS bill_id , b . name AS bill_name
FROM bill_merchant_rules bmr
JOIN bills b ON b . id = bmr . bill_id AND b . deleted_at IS NULL
WHERE bmr . user_id = ?
ORDER BY b . name COLLATE NOCASE ASC , LENGTH ( bmr . merchant ) DESC , bmr . merchant ASC
2026-07-06 14:23:53 -05:00
` ,
)
. all ( req . user . id ) ;
2026-06-04 20:45:11 -05:00
res . json ( { rules } ) ;
} catch ( err ) {
console . error ( '[bills/merchant-rules GET]' , err . message ) ;
res . status ( 500 ) . json ( { error : 'Failed to load merchant rules' } ) ;
}
} ) ;
2026-05-30 14:33:55 -05:00
// ── POST /api/bills/:id/snooze-drift ─────────────────────────────────────────
// Registered early (before /:id) but path has suffix so no conflict
2026-07-06 11:26:50 -05:00
router . post ( '/:id/snooze-drift' , ( req : Req , res : Res ) = > {
2026-05-30 14:33:55 -05:00
const db = getDb ( ) ;
const id = parseInt ( req . params . id , 10 ) ;
if ( ! Number . isInteger ( id ) || id <= 0 ) return res . status ( 400 ) . json ( { error : 'Invalid id' } ) ;
2026-07-06 14:23:53 -05:00
const bill = db
. prepare ( 'SELECT id, user_id FROM bills WHERE id = ? AND deleted_at IS NULL' )
. get ( id ) ;
2026-05-30 14:33:55 -05:00
if ( ! bill || bill . user_id !== req . user . id ) return res . status ( 404 ) . json ( { error : 'Not found' } ) ;
const until = new Date ( ) ;
until . setDate ( until . getDate ( ) + 30 ) ;
2026-06-10 19:42:51 -05:00
const untilStr = localDateString ( until ) ;
2026-05-30 14:33:55 -05:00
db . prepare ( 'UPDATE bills SET drift_snoozed_until = ? WHERE id = ?' ) . run ( untilStr , id ) ;
res . json ( { ok : true , drift_snoozed_until : untilStr } ) ;
} ) ;
2026-06-11 20:12:31 -05:00
// Bill templates store money fields (expected_amount, current_balance, minimum_payment)
// in integer cents, matching validateBillData's normalized output. Convert back to
// dollars for API responses, mirroring serializeBill.
function serializeTemplateData ( data ) {
if ( ! data ) return data ;
const out = { . . . data } ;
if ( out . expected_amount != null ) out . expected_amount = fromCents ( out . expected_amount ) ;
if ( out . current_balance != null ) out . current_balance = fromCents ( out . current_balance ) ;
if ( out . minimum_payment != null ) out . minimum_payment = fromCents ( out . minimum_payment ) ;
return out ;
}
2026-05-16 15:38:28 -05:00
// ── GET /api/bills/templates ─────────────────────────────────────────────────
2026-07-06 11:26:50 -05:00
router . get ( '/templates' , ( req : Req , res : Res ) = > {
2026-05-16 15:38:28 -05:00
const db = getDb ( ) ;
2026-07-06 14:23:53 -05:00
const rows = db
. prepare (
`
2026-05-16 15:38:28 -05:00
SELECT id , name , data , created_at , updated_at
FROM bill_templates
WHERE user_id = ?
ORDER BY name COLLATE NOCASE ASC
2026-07-06 14:23:53 -05:00
` ,
)
. all ( req . user . id ) ;
res . json (
rows . map ( ( row ) = > ( {
. . . row ,
data : serializeTemplateData ( parseTemplateData ( row . data ) ) ,
} ) ) ,
) ;
2026-05-16 15:38:28 -05:00
} ) ;
2026-05-16 10:56:56 -05:00
2026-05-16 15:38:28 -05:00
// ── POST /api/bills/templates ────────────────────────────────────────────────
2026-07-06 11:26:50 -05:00
router . post ( '/templates' , ( req : Req , res : Res ) = > {
2026-05-16 15:38:28 -05:00
const db = getDb ( ) ;
const name = String ( req . body . name || '' ) . trim ( ) ;
if ( name . length < 2 ) {
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json (
standardizeError ( 'Template name must be at least 2 characters' , 'VALIDATION_ERROR' , 'name' ) ,
) ;
2026-05-16 15:38:28 -05:00
}
2026-05-16 10:56:56 -05:00
2026-05-16 15:38:28 -05:00
const data = sanitizeTemplateData ( req . body . data || { } ) ;
if ( Object . keys ( data ) . length === 0 ) {
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json ( standardizeError ( 'Template data is required' , 'VALIDATION_ERROR' , 'data' ) ) ;
2026-05-16 15:38:28 -05:00
}
const validation = validateBillData ( data ) ;
if ( validation . errors . length > 0 ) {
const firstError = validation . errors [ 0 ] ;
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json ( standardizeError ( firstError . message , 'VALIDATION_ERROR' , ` data. ${ firstError . field } ` ) ) ;
2026-05-16 15:38:28 -05:00
}
if ( ! categoryBelongsToUser ( db , validation . normalized . category_id , req . user . id ) ) {
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json (
standardizeError (
'category_id is invalid for this user' ,
'VALIDATION_ERROR' ,
'data.category_id' ,
) ,
) ;
2026-05-16 15:38:28 -05:00
}
const normalizedData = sanitizeTemplateData ( validation . normalized ) ;
2026-05-16 10:56:56 -05:00
2026-07-06 14:23:53 -05:00
const result = db
. prepare (
`
2026-05-16 15:38:28 -05:00
INSERT INTO bill_templates ( user_id , name , data , updated_at )
VALUES ( ? , ? , ? , datetime ( 'now' ) )
ON CONFLICT ( user_id , name ) DO UPDATE SET
data = excluded . data ,
updated_at = datetime ( 'now' )
2026-07-06 14:23:53 -05:00
` ,
)
. run ( req . user . id , name , JSON . stringify ( normalizedData ) ) ;
2026-05-16 15:38:28 -05:00
2026-07-06 14:23:53 -05:00
const template = db
. prepare (
`
2026-05-16 15:38:28 -05:00
SELECT id , name , data , created_at , updated_at
FROM bill_templates
WHERE user_id = ? AND name = ?
2026-07-06 14:23:53 -05:00
` ,
)
. get ( req . user . id , name ) ;
2026-05-16 15:38:28 -05:00
res . status ( result . changes > 0 ? 201 : 200 ) . json ( {
. . . template ,
2026-06-11 20:12:31 -05:00
data : serializeTemplateData ( parseTemplateData ( template . data ) ) ,
2026-05-16 10:56:56 -05:00
} ) ;
2026-05-03 19:51:57 -05:00
} ) ;
2026-05-16 15:38:28 -05:00
// ── DELETE /api/bills/templates/:templateId ──────────────────────────────────
2026-07-06 11:26:50 -05:00
router . delete ( '/templates/:templateId' , ( req : Req , res : Res ) = > {
2026-05-16 15:38:28 -05:00
const db = getDb ( ) ;
const templateId = parseInt ( req . params . templateId , 10 ) ;
if ( ! Number . isInteger ( templateId ) ) {
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json ( standardizeError ( 'template_id must be an integer' , 'VALIDATION_ERROR' , 'template_id' ) ) ;
2026-05-16 15:38:28 -05:00
}
2026-07-06 14:23:53 -05:00
const result = db
. prepare ( 'DELETE FROM bill_templates WHERE id = ? AND user_id = ?' )
. run ( templateId , req . user . id ) ;
if ( result . changes === 0 )
return res . status ( 404 ) . json ( standardizeError ( 'Template not found' , 'NOT_FOUND' , 'template_id' ) ) ;
2026-05-16 15:38:28 -05:00
res . json ( { success : true } ) ;
} ) ;
// ── POST /api/bills/:id/duplicate ────────────────────────────────────────────
2026-07-06 11:26:50 -05:00
router . post ( '/:id/duplicate' , ( req : Req , res : Res ) = > {
2026-05-16 15:38:28 -05:00
const db = getDb ( ) ;
const body = req . body || { } ;
const billId = parseInt ( req . params . id , 10 ) ;
if ( ! Number . isInteger ( billId ) ) {
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json ( standardizeError ( 'bill_id must be an integer' , 'VALIDATION_ERROR' , 'bill_id' ) ) ;
2026-05-16 15:38:28 -05:00
}
2026-07-06 14:23:53 -05:00
const source = db
. prepare ( 'SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' )
. get ( billId , req . user . id ) ;
if ( ! source )
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
2026-05-16 15:38:28 -05:00
const draft = {
2026-06-11 20:12:31 -05:00
. . . sanitizeTemplateData ( serializeBill ( source ) ) ,
2026-05-16 15:38:28 -05:00
. . . sanitizeTemplateData ( body ) ,
name : String ( body . name || ` ${ source . name } (Copy) ` ) . trim ( ) ,
} ;
const validation = validateBillData ( draft ) ;
if ( validation . errors . length > 0 ) {
const firstError = validation . errors [ 0 ] ;
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json ( standardizeError ( firstError . message , 'VALIDATION_ERROR' , firstError . field ) ) ;
2026-05-16 15:38:28 -05:00
}
const { normalized } = validation ;
if ( ! categoryBelongsToUser ( db , normalized . category_id , req . user . id ) ) {
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json (
standardizeError ( 'category_id is invalid for this user' , 'VALIDATION_ERROR' , 'category_id' ) ,
) ;
2026-05-16 15:38:28 -05:00
}
2026-06-11 20:12:31 -05:00
res . status ( 201 ) . json ( serializeBill ( insertBill ( db , req . user . id , normalized ) ) ) ;
2026-05-16 15:38:28 -05:00
} ) ;
2026-05-03 19:51:57 -05:00
// ── GET /api/bills/:id/monthly-state?year=&month= ─────────────────────────────
2026-07-06 11:26:50 -05:00
router . get ( '/:id/monthly-state' , ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
const db = getDb ( ) ;
const billId = parseInt ( req . params . id , 10 ) ;
2026-07-06 14:23:53 -05:00
if (
! db
. prepare ( 'SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' )
. get ( billId , req . user . id )
)
2026-05-09 13:03:36 -05:00
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
2026-05-03 19:51:57 -05:00
2026-07-06 14:23:53 -05:00
const year = parseInt ( req . query . year , 10 ) ;
2026-05-03 19:51:57 -05:00
const month = parseInt ( req . query . month , 10 ) ;
2026-07-06 14:23:53 -05:00
if ( isNaN ( year ) || year < 2000 || year > 2100 )
return res
. status ( 400 )
. json (
standardizeError (
'year must be a 4-digit integer between 2000 and 2100' ,
'VALIDATION_ERROR' ,
'year' ,
) ,
) ;
if ( isNaN ( month ) || month < 1 || month > 12 )
return res
. status ( 400 )
. json (
standardizeError ( 'month must be an integer between 1 and 12' , 'VALIDATION_ERROR' , 'month' ) ,
) ;
const mbs = db
. prepare (
'SELECT actual_amount, notes, is_skipped FROM monthly_bill_state WHERE bill_id=? AND year=? AND month=?' ,
)
. get ( billId , year , month ) ;
2026-05-03 19:51:57 -05:00
res . json ( {
2026-07-06 14:23:53 -05:00
bill_id : billId ,
2026-05-03 19:51:57 -05:00
year ,
month ,
2026-06-11 20:12:31 -05:00
actual_amount : fromCents ( mbs ? . actual_amount ) ,
2026-07-06 14:23:53 -05:00
notes : mbs?.notes ? ? null ,
is_skipped : ! ! mbs ? . is_skipped ,
2026-05-03 19:51:57 -05:00
} ) ;
} ) ;
// ── PUT /api/bills/:id/monthly-state ──────────────────────────────────────────
2026-07-06 11:26:50 -05:00
router . put ( '/:id/monthly-state' , ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
const db = getDb ( ) ;
const billId = parseInt ( req . params . id , 10 ) ;
2026-07-06 14:23:53 -05:00
if (
! db
. prepare ( 'SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' )
. get ( billId , req . user . id )
)
2026-05-09 13:03:36 -05:00
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
2026-05-03 19:51:57 -05:00
2026-05-30 13:19:09 -05:00
const { year , month , actual_amount , notes , is_skipped , snoozed_until } = req . body ;
2026-05-03 19:51:57 -05:00
2026-07-06 14:23:53 -05:00
const y = parseInt ( year , 10 ) ;
2026-05-03 19:51:57 -05:00
const m = parseInt ( month , 10 ) ;
if ( isNaN ( y ) || y < 2000 || y > 2100 )
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json (
standardizeError (
'year must be a 4-digit integer between 2000 and 2100' ,
'VALIDATION_ERROR' ,
'year' ,
) ,
) ;
2026-05-03 19:51:57 -05:00
if ( isNaN ( m ) || m < 1 || m > 12 )
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json (
standardizeError ( 'month must be an integer between 1 and 12' , 'VALIDATION_ERROR' , 'month' ) ,
) ;
2026-05-03 19:51:57 -05:00
if ( actual_amount !== undefined && actual_amount !== null ) {
const amt = parseFloat ( actual_amount ) ;
if ( isNaN ( amt ) || amt < 0 )
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json (
standardizeError (
'actual_amount must be a non-negative number or null' ,
'VALIDATION_ERROR' ,
'actual_amount' ,
) ,
) ;
2026-05-03 19:51:57 -05:00
}
2026-05-30 13:19:09 -05:00
if ( snoozed_until !== undefined && snoozed_until !== null ) {
if ( ! /^\d{4}-\d{2}-\d{2}$/ . test ( snoozed_until ) )
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json (
standardizeError (
'snoozed_until must be an ISO date string (YYYY-MM-DD) or null' ,
'VALIDATION_ERROR' ,
'snoozed_until' ,
) ,
) ;
2026-05-30 13:19:09 -05:00
}
2026-06-10 19:28:54 -05:00
// Partial-update semantics: fields omitted from the request keep their
// existing values instead of being wiped by the upsert.
2026-07-06 14:23:53 -05:00
const existing = db
. prepare (
'SELECT actual_amount, notes, is_skipped, snoozed_until FROM monthly_bill_state WHERE bill_id=? AND year=? AND month=?' ,
)
. get ( billId , y , m ) ;
const amt =
actual_amount !== undefined
? actual_amount === null
? null
: toCents ( actual_amount )
: ( existing ? . actual_amount ? ? null ) ;
const noteVal = notes !== undefined ? notes || null : ( existing ? . notes ? ? null ) ;
const skipVal = is_skipped !== undefined ? ( is_skipped ? 1 : 0 ) : ( existing ? . is_skipped ? ? 0 ) ;
const snoozeVal =
snoozed_until !== undefined ? snoozed_until || null : ( existing ? . snoozed_until ? ? null ) ;
db . prepare (
`
2026-05-30 13:19:09 -05:00
INSERT INTO monthly_bill_state ( bill_id , year , month , actual_amount , notes , is_skipped , snoozed_until , updated_at )
VALUES ( ? , ? , ? , ? , ? , ? , ? , datetime ( 'now' ) )
2026-05-03 19:51:57 -05:00
ON CONFLICT ( bill_id , year , month ) DO UPDATE SET
actual_amount = excluded . actual_amount ,
notes = excluded . notes ,
is_skipped = excluded . is_skipped ,
2026-05-30 13:19:09 -05:00
snoozed_until = excluded . snoozed_until ,
2026-05-03 19:51:57 -05:00
updated_at = datetime ( 'now' )
2026-07-06 14:23:53 -05:00
` ,
) . run ( billId , y , m , amt , noteVal , skipVal , snoozeVal ) ;
2026-05-03 19:51:57 -05:00
2026-07-06 14:23:53 -05:00
const saved = db
. prepare ( 'SELECT * FROM monthly_bill_state WHERE bill_id=? AND year=? AND month=?' )
. get ( billId , y , m ) ;
2026-05-03 19:51:57 -05:00
res . json ( {
2026-07-06 14:23:53 -05:00
bill_id : saved.bill_id ,
year : saved.year ,
month : saved.month ,
2026-06-11 20:12:31 -05:00
actual_amount : fromCents ( saved . actual_amount ) ,
2026-07-06 14:23:53 -05:00
notes : saved.notes ,
is_skipped : ! ! saved . is_skipped ,
2026-05-30 13:19:09 -05:00
snoozed_until : saved.snoozed_until ? ? null ,
2026-07-06 14:23:53 -05:00
created_at : saved.created_at ,
updated_at : saved.updated_at ,
2026-05-03 19:51:57 -05:00
} ) ;
} ) ;
// ── GET /api/bills/:id ────────────────────────────────────────────────────────
2026-07-06 11:26:50 -05:00
router . get ( '/:id' , ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
const db = getDb ( ) ;
2026-07-06 14:23:53 -05:00
const bill = db
. prepare (
`
2026-05-03 19:51:57 -05:00
SELECT b . * , c . name AS category_name ,
CASE WHEN EXISTS (
SELECT 1 FROM bill_history_ranges WHERE bill_id = b . id
2026-05-29 04:19:20 -05:00
) THEN 1 ELSE 0 END AS has_history_ranges ,
CASE WHEN EXISTS (
SELECT 1 FROM bill_merchant_rules WHERE bill_id = b . id AND user_id = b . user_id
2026-06-06 23:04:53 -05:00
) THEN 1 ELSE 0 END AS has_merchant_rule ,
CASE WHEN EXISTS (
SELECT 1 FROM transactions WHERE matched_bill_id = b . id AND match_status = 'matched' AND user_id = b . user_id
) THEN 1 ELSE 0 END AS has_linked_transactions
2026-05-03 19:51:57 -05:00
FROM bills b
2026-05-16 10:34:32 -05:00
LEFT JOIN categories c ON b . category_id = c . id AND c . deleted_at IS NULL
WHERE b . id = ? AND b . user_id = ? AND b . deleted_at IS NULL
2026-07-06 14:23:53 -05:00
` ,
)
. get ( req . params . id , req . user . id ) ;
if ( ! bill )
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
2026-06-07 14:49:39 -05:00
let autopay_stats = null ;
if ( bill . autopay_enabled ) {
2026-07-06 14:23:53 -05:00
autopay_stats = db
. prepare (
`
2026-06-07 14:49:39 -05:00
SELECT COUNT ( * ) AS total ,
SUM ( autopay_failure ) AS failures ,
MAX ( CASE WHEN autopay_failure = 1 THEN paid_date END ) AS last_failure_date ,
MAX ( CASE WHEN autopay_failure = 1 THEN notes END ) AS last_failure_notes
FROM payments
WHERE bill_id = ? AND deleted_at IS NULL AND paid_date >= date ( 'now' , '-12 months' )
2026-07-06 14:23:53 -05:00
` ,
)
. get ( bill . id ) ;
2026-06-07 14:49:39 -05:00
autopay_stats = {
total : autopay_stats.total || 0 ,
failures : autopay_stats.failures || 0 ,
last_failure_date : autopay_stats.last_failure_date || null ,
last_failure_notes : autopay_stats.last_failure_notes || null ,
} ;
}
2026-06-11 20:12:31 -05:00
res . json ( { . . . serializeBill ( bill ) , autopay_stats } ) ;
2026-06-07 14:49:39 -05:00
} ) ;
// ── POST /api/bills/:id/verify-autopay ───────────────────────────────────────
2026-07-06 11:26:50 -05:00
router . post ( '/:id/verify-autopay' , ( req : Req , res : Res ) = > {
2026-06-07 14:49:39 -05:00
const db = getDb ( ) ;
const id = parseInt ( req . params . id , 10 ) ;
if ( ! Number . isInteger ( id ) || id <= 0 ) {
return res . status ( 400 ) . json ( standardizeError ( 'Invalid id' , 'VALIDATION_ERROR' , 'bill_id' ) ) ;
}
2026-07-06 14:23:53 -05:00
const bill = db
. prepare (
'SELECT id, autopay_enabled FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ,
)
. get ( id , req . user . id ) ;
if ( ! bill )
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
2026-06-07 14:49:39 -05:00
if ( ! bill . autopay_enabled ) {
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json (
standardizeError (
'Bill does not have autopay enabled' ,
'VALIDATION_ERROR' ,
'autopay_enabled' ,
) ,
) ;
2026-06-07 14:49:39 -05:00
}
const now = new Date ( ) . toISOString ( ) ;
2026-07-06 14:23:53 -05:00
db . prepare (
'UPDATE bills SET autopay_verified_at = ?, updated_at = ? WHERE id = ? AND user_id = ?' ,
) . run ( now , now , id , req . user . id ) ;
2026-06-07 14:49:39 -05:00
res . json ( { ok : true , autopay_verified_at : now } ) ;
2026-05-03 19:51:57 -05:00
} ) ;
// ── POST /api/bills ───────────────────────────────────────────────────────────
2026-07-06 11:26:50 -05:00
router . post ( '/' , ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
const db = getDb ( ) ;
2026-05-16 15:38:28 -05:00
const body = req . body || { } ;
let payload = body ;
2026-07-06 14:23:53 -05:00
if (
body . source_bill_id !== undefined &&
body . source_bill_id !== null &&
body . source_bill_id !== ''
) {
2026-05-16 15:38:28 -05:00
const sourceBillId = parseInt ( body . source_bill_id , 10 ) ;
if ( ! Number . isInteger ( sourceBillId ) ) {
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json (
standardizeError (
'source_bill_id must be an integer' ,
'VALIDATION_ERROR' ,
'source_bill_id' ,
) ,
) ;
2026-05-16 15:38:28 -05:00
}
2026-07-06 14:23:53 -05:00
const source = db
. prepare ( 'SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' )
. get ( sourceBillId , req . user . id ) ;
if ( ! source )
return res
. status ( 404 )
. json ( standardizeError ( 'Source bill not found' , 'NOT_FOUND' , 'source_bill_id' ) ) ;
2026-05-16 15:38:28 -05:00
payload = {
2026-06-11 20:12:31 -05:00
. . . sanitizeTemplateData ( serializeBill ( source ) ) ,
2026-05-16 15:38:28 -05:00
. . . sanitizeTemplateData ( body ) ,
name : String ( body . name || ` ${ source . name } (Copy) ` ) . trim ( ) ,
} ;
}
2026-05-03 19:51:57 -05:00
2026-05-11 12:12:31 -05:00
// Validate and normalize bill data
2026-05-16 15:38:28 -05:00
const validation = validateBillData ( payload ) ;
2026-05-11 12:12:31 -05:00
if ( validation . errors . length > 0 ) {
const firstError = validation . errors [ 0 ] ;
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json ( standardizeError ( firstError . message , 'VALIDATION_ERROR' , firstError . field ) ) ;
2026-05-03 19:51:57 -05:00
}
2026-05-11 12:12:31 -05:00
const { normalized } = validation ;
2026-05-03 19:51:57 -05:00
2026-05-11 12:12:31 -05:00
// Validate category_id exists for this user
2026-05-16 15:38:28 -05:00
if ( ! categoryBelongsToUser ( db , normalized . category_id , req . user . id ) ) {
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json (
standardizeError ( 'category_id is invalid for this user' , 'VALIDATION_ERROR' , 'category_id' ) ,
) ;
2026-05-03 19:51:57 -05:00
}
2026-06-11 20:12:31 -05:00
res . status ( 201 ) . json ( serializeBill ( insertBill ( db , req . user . id , normalized ) ) ) ;
2026-05-03 19:51:57 -05:00
} ) ;
// ── PUT /api/bills/:id ────────────────────────────────────────────────────────
2026-07-06 11:26:50 -05:00
router . put ( '/:id' , ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
const db = getDb ( ) ;
2026-07-06 14:23:53 -05:00
const existing = db
. prepare ( 'SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' )
. get ( req . params . id , req . user . id ) ;
if ( ! existing )
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
2026-05-03 19:51:57 -05:00
2026-05-11 12:12:31 -05:00
// Validate and normalize bill data
const validation = validateBillData ( req . body , existing ) ;
if ( validation . errors . length > 0 ) {
const firstError = validation . errors [ 0 ] ;
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json ( standardizeError ( firstError . message , 'VALIDATION_ERROR' , firstError . field ) ) ;
2026-05-03 19:51:57 -05:00
}
2026-05-11 12:12:31 -05:00
const { normalized } = validation ;
2026-05-03 19:51:57 -05:00
2026-05-11 12:12:31 -05:00
// Validate category_id exists for this user if changed
2026-05-16 15:38:28 -05:00
if ( ! categoryBelongsToUser ( db , normalized . category_id , req . user . id ) ) {
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json (
standardizeError ( 'category_id is invalid for this user' , 'VALIDATION_ERROR' , 'category_id' ) ,
) ;
2026-05-10 00:39:11 -05:00
}
2026-07-06 14:23:53 -05:00
const inactiveReason =
typeof req . body . inactive_reason === 'string' ? req . body . inactive_reason . trim ( ) || null : null ;
2026-06-07 14:49:39 -05:00
const wasActive = existing . active === 1 ;
const nowActive = normalized . active === 1 ;
2026-07-06 14:23:53 -05:00
const inactivatedAt =
! wasActive || nowActive
? existing . inactivated_at
: inactiveReason
? todayLocal ( )
: existing . inactivated_at ;
db . prepare (
`
2026-05-03 19:51:57 -05:00
UPDATE bills SET
name = ? , category_id = ? , due_day = ? , override_due_date = ? , bucket = ? ,
2026-05-16 15:38:28 -05:00
expected_amount = ? , interest_rate = ? , billing_cycle = ? , autopay_enabled = ? , autodraft_status = ? , auto_mark_paid = ? ,
2026-05-03 19:51:57 -05:00
website = ? , username = ? , account_info = ? , has_2fa = ? , notes = ? , active = ? ,
2026-05-10 00:39:11 -05:00
history_visibility = ? , cycle_type = ? , cycle_day = ? ,
2026-05-14 03:00:01 -05:00
current_balance = ? , minimum_payment = ? , snowball_order = ? , snowball_include = ? , snowball_exempt = ? ,
2026-05-28 22:54:07 -05:00
is_subscription = ? , subscription_type = ? , reminder_days_before = ? , subscription_source = ? , subscription_detected_at = ? ,
2026-06-07 14:49:39 -05:00
inactive_reason = ? , inactivated_at = ? ,
2026-05-03 19:51:57 -05:00
updated_at = datetime ( 'now' )
WHERE id = ? AND user_id = ?
2026-07-06 14:23:53 -05:00
` ,
) . run (
2026-05-11 12:12:31 -05:00
normalized . name ,
normalized . category_id ,
normalized . due_day ,
normalized . override_due_date ,
normalized . bucket ,
normalized . expected_amount ,
normalized . interest_rate ,
normalized . billing_cycle ,
normalized . autopay_enabled ,
normalized . autodraft_status ,
2026-05-16 15:38:28 -05:00
normalized . auto_mark_paid ,
2026-05-11 12:12:31 -05:00
normalized . website ,
normalized . username ,
normalized . account_info ,
normalized . has_2fa ,
normalized . notes ,
normalized . active ,
normalized . history_visibility ,
normalized . cycle_type ,
normalized . cycle_day ,
2026-05-14 02:11:54 -05:00
normalized . current_balance ,
normalized . minimum_payment ,
normalized . snowball_order ,
normalized . snowball_include ,
2026-05-14 03:00:01 -05:00
normalized . snowball_exempt ,
2026-05-28 22:54:07 -05:00
normalized . is_subscription ,
normalized . subscription_type ,
normalized . reminder_days_before ,
normalized . subscription_source ,
normalized . subscription_detected_at ,
2026-06-07 14:49:39 -05:00
inactiveReason ,
inactivatedAt ,
2026-05-03 19:51:57 -05:00
req . params . id ,
req . user . id ,
) ;
2026-07-06 14:23:53 -05:00
const updated = db
. prepare ( 'SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' )
. get ( req . params . id , req . user . id ) ;
2026-06-11 20:12:31 -05:00
res . json ( serializeBill ( updated ) ) ;
2026-05-03 19:51:57 -05:00
} ) ;
2026-05-30 16:13:37 -05:00
// ── PUT /api/bills/:id/archived ──────────────────────────────────────────────
2026-07-06 11:26:50 -05:00
router . put ( '/:id/archived' , ( req : Req , res : Res ) = > {
2026-05-30 16:13:37 -05:00
const db = getDb ( ) ;
const id = parseInt ( req . params . id , 10 ) ;
if ( ! Number . isInteger ( id ) || id <= 0 ) {
return res . status ( 400 ) . json ( standardizeError ( 'Invalid id' , 'VALIDATION_ERROR' , 'bill_id' ) ) ;
}
2026-07-06 14:23:53 -05:00
const bill = db
. prepare ( 'SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' )
. get ( id , req . user . id ) ;
if ( ! bill )
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
2026-05-30 16:13:37 -05:00
const archived = ! ! req . body ? . archived ;
2026-07-06 14:23:53 -05:00
db . prepare (
"UPDATE bills SET active = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?" ,
) . run ( archived ? 0 : 1 , id , req . user . id ) ;
2026-05-30 16:13:37 -05:00
2026-07-06 14:23:53 -05:00
const updated = db
. prepare ( 'SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' )
. get ( id , req . user . id ) ;
2026-06-11 20:12:31 -05:00
res . json ( { . . . serializeBill ( updated ) , archived : ! updated . active } ) ;
2026-05-30 16:13:37 -05:00
} ) ;
2026-05-16 10:34:32 -05:00
// ── DELETE /api/bills/:id — soft delete for 30-day recovery ───────────────────
2026-07-06 11:26:50 -05:00
router . delete ( '/:id' , ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
const db = getDb ( ) ;
2026-07-06 14:23:53 -05:00
const bill = db
. prepare ( 'SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' )
. get ( req . params . id , req . user . id ) ;
if ( ! bill )
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
2026-05-03 19:51:57 -05:00
2026-07-06 14:23:53 -05:00
db . prepare (
"UPDATE bills SET deleted_at = datetime('now'), active = 0, updated_at = datetime('now') WHERE id = ? AND user_id = ?" ,
) . run ( req . params . id , req . user . id ) ;
2026-05-03 19:51:57 -05:00
res . json ( {
success : true ,
2026-07-06 14:23:53 -05:00
deleted_bill_id : bill.id ,
2026-05-03 19:51:57 -05:00
deleted_bill_name : bill.name ,
2026-05-16 10:34:32 -05:00
recoverable_until_days : 30 ,
2026-05-03 19:51:57 -05:00
} ) ;
} ) ;
2026-05-16 10:34:32 -05:00
// POST /api/bills/:id/restore — undo bill soft delete
2026-07-06 11:26:50 -05:00
router . post ( '/:id/restore' , ( req : Req , res : Res ) = > {
2026-05-16 10:34:32 -05:00
const db = getDb ( ) ;
2026-07-06 14:23:53 -05:00
const bill = db
. prepare ( 'SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL' )
. get ( req . params . id , req . user . id ) ;
if ( ! bill )
return res . status ( 404 ) . json ( standardizeError ( 'Deleted bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
db . prepare (
"UPDATE bills SET deleted_at = NULL, active = 1, updated_at = datetime('now') WHERE id = ? AND user_id = ?" ,
) . run ( req . params . id , req . user . id ) ;
res . json (
serializeBill (
db
. prepare ( 'SELECT * FROM bills WHERE id = ? AND user_id = ?' )
. get ( req . params . id , req . user . id ) ,
) ,
) ;
2026-05-16 10:34:32 -05:00
} ) ;
2026-05-29 04:19:20 -05:00
// POST /api/bills/:id/sync-simplefin-payments
// Scan unmatched SimpleFIN transactions for this bill's merchant rules and
// backfill any missing payments.
2026-07-06 11:26:50 -05:00
router . post ( '/:id/sync-simplefin-payments' , ( req : Req , res : Res ) = > {
2026-05-29 04:19:20 -05:00
const billId = parseInt ( req . params . id , 10 ) ;
2026-07-06 14:23:53 -05:00
if ( ! Number . isInteger ( billId ) )
return res . status ( 400 ) . json ( standardizeError ( 'Invalid bill id' , 'VALIDATION_ERROR' ) ) ;
2026-05-29 04:19:20 -05:00
const db = getDb ( ) ;
2026-07-06 14:23:53 -05:00
const bill = db
. prepare ( 'SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' )
. get ( billId , req . user . id ) ;
2026-05-29 04:19:20 -05:00
if ( ! bill ) return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' ) ) ;
try {
const result = syncBillPaymentsFromSimplefin ( db , req . user . id , billId ) ;
res . json ( result ) ;
} catch ( err ) {
res . status ( 500 ) . json ( standardizeError ( err . message || 'Sync failed' , 'SYNC_ERROR' ) ) ;
}
} ) ;
2026-05-03 19:51:57 -05:00
// ── GET /api/bills/:id/payments?page=1&limit=20 ───────────────────────────────
2026-07-06 11:26:50 -05:00
router . get ( '/:id/payments' , ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
const db = getDb ( ) ;
2026-07-06 14:23:53 -05:00
const bill = db
. prepare ( 'SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' )
. get ( req . params . id , req . user . id ) ;
if ( ! bill )
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
2026-05-03 19:51:57 -05:00
2026-07-06 14:23:53 -05:00
const limit = Math . min ( parseInt ( req . query . limit || '20' , 10 ) , 100 ) ;
const page = Math . max ( parseInt ( req . query . page || '1' , 10 ) , 1 ) ;
2026-05-03 19:51:57 -05:00
const offset = ( page - 1 ) * limit ;
2026-07-06 14:23:53 -05:00
const total = db
. prepare ( 'SELECT COUNT(*) AS n FROM payments WHERE bill_id = ? AND deleted_at IS NULL' )
. get ( req . params . id ) . n ;
2026-05-03 19:51:57 -05:00
2026-07-06 14:23:53 -05:00
const items = db
. prepare (
'SELECT * FROM payments WHERE bill_id = ? AND deleted_at IS NULL ORDER BY paid_date DESC LIMIT ? OFFSET ?' ,
)
. all ( req . params . id , limit , offset ) ;
2026-05-03 19:51:57 -05:00
res . json ( {
2026-07-06 14:23:53 -05:00
bill_id : parseInt ( req . params . id , 10 ) ,
bill_name : bill.name ,
2026-05-03 19:51:57 -05:00
total ,
page ,
limit ,
2026-07-06 14:23:53 -05:00
pages : Math.ceil ( total / limit ) ,
payments : items.map ( serializePayment ) ,
2026-05-03 19:51:57 -05:00
} ) ;
} ) ;
2026-05-16 21:36:04 -05:00
// ── GET /api/bills/:id/transactions ──────────────────────────────────────────
2026-07-06 11:26:50 -05:00
router . get ( '/:id/transactions' , ( req : Req , res : Res ) = > {
2026-05-16 21:36:04 -05:00
const db = getDb ( ) ;
const billId = parseInt ( req . params . id , 10 ) ;
if ( ! Number . isInteger ( billId ) ) {
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json ( standardizeError ( 'bill_id must be an integer' , 'VALIDATION_ERROR' , 'bill_id' ) ) ;
2026-05-16 21:36:04 -05:00
}
2026-07-06 14:23:53 -05:00
const bill = db
. prepare ( 'SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' )
. get ( billId , req . user . id ) ;
if ( ! bill )
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
2026-05-16 21:36:04 -05:00
2026-07-06 14:23:53 -05:00
const rows = db
. prepare (
`
2026-05-16 21:36:04 -05:00
SELECT
t . id , t . user_id , t . data_source_id , t . account_id , t . provider_transaction_id ,
t . source_type , t . transaction_type , t . posted_date , t . transacted_at , t . amount ,
t . currency , t . description , t . payee , t . memo , t . category , t . matched_bill_id ,
t . match_status , t . ignored , t . created_at , t . updated_at ,
ds . type AS data_source_type , ds . provider AS data_source_provider ,
ds . name AS data_source_name , ds . status AS data_source_status ,
fa . name AS account_name , fa . org_name AS account_org_name ,
fa . account_type AS account_type ,
b . name AS matched_bill_name ,
p . id AS linked_payment_id ,
p . amount AS linked_payment_amount ,
p . paid_date AS linked_payment_date ,
p . payment_source AS linked_payment_source ,
p . method AS linked_payment_method
FROM transactions t
LEFT JOIN data_sources ds ON ds . id = t . data_source_id AND ds . user_id = t . user_id
LEFT JOIN financial_accounts fa ON fa . id = t . account_id AND fa . user_id = t . user_id
LEFT JOIN bills b ON b . id = t . matched_bill_id AND b . user_id = t . user_id AND b . deleted_at IS NULL
2026-05-16 21:41:13 -05:00
JOIN payments p ON p . transaction_id = t . id AND p . bill_id = ? AND p . deleted_at IS NULL
2026-05-16 21:36:04 -05:00
WHERE t . user_id = ?
AND t . matched_bill_id = ?
AND t . match_status = 'matched'
AND t . ignored = 0
ORDER BY COALESCE ( t . posted_date , substr ( t . transacted_at , 1 , 10 ) , t . created_at ) DESC , t . id DESC
2026-07-06 14:23:53 -05:00
` ,
)
. all ( billId , req . user . id , billId ) ;
const transactions = rows . map ( ( row ) = >
decorateTransaction ( {
. . . row ,
linked_payment : row.linked_payment_id
? {
id : row.linked_payment_id ,
amount : fromCents ( row . linked_payment_amount ) ,
paid_date : row.linked_payment_date ,
payment_source : row.linked_payment_source ,
method : row.linked_payment_method ,
}
: null ,
} ) ,
) ;
2026-05-16 21:36:04 -05:00
res . json ( {
bill_id : billId ,
bill_name : bill.name ,
total : transactions.length ,
transactions ,
} ) ;
} ) ;
2026-05-09 13:03:36 -05:00
// ── POST /api/bills/:id/toggle-paid — toggle Paid/Unpaid status ──────────────
2026-07-06 11:26:50 -05:00
router . post ( '/:id/toggle-paid' , ( req : Req , res : Res ) = > {
2026-05-09 13:03:36 -05:00
const db = getDb ( ) ;
const billId = parseInt ( req . params . id , 10 ) ;
2026-05-10 15:25:47 -05:00
// Get bill - always scope to the requesting user
2026-07-06 14:23:53 -05:00
const bill = db
. prepare (
'SELECT id, expected_amount, user_id, due_day, current_balance, interest_rate, autopay_enabled FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ,
)
. get ( billId , req . user . id ) ;
2026-05-09 13:03:36 -05:00
2026-07-06 14:23:53 -05:00
if ( ! bill )
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
2026-05-09 13:03:36 -05:00
2026-05-11 11:56:49 -05:00
// Scope to year/month if provided
2026-07-06 14:23:53 -05:00
const year = req . body . year !== undefined ? parseInt ( req . body . year , 10 ) : null ;
2026-05-11 11:56:49 -05:00
const month = req . body . month !== undefined ? parseInt ( req . body . month , 10 ) : null ;
2026-05-15 22:45:38 -05:00
if ( ( year === null ) !== ( month === null ) ) {
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json (
standardizeError (
'year and month must both be provided or both omitted' ,
'VALIDATION_ERROR' ,
'year' ,
) ,
) ;
2026-05-15 22:45:38 -05:00
}
if ( year !== null && ( Number . isNaN ( year ) || year < 2000 || year > 2100 ) ) {
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json (
standardizeError (
'year must be a 4-digit integer between 2000 and 2100' ,
'VALIDATION_ERROR' ,
'year' ,
) ,
) ;
2026-05-15 22:45:38 -05:00
}
if ( month !== null && ( Number . isNaN ( month ) || month < 1 || month > 12 ) ) {
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json (
standardizeError ( 'month must be an integer between 1 and 12' , 'VALIDATION_ERROR' , 'month' ) ,
) ;
2026-05-15 22:45:38 -05:00
}
2026-05-11 11:56:49 -05:00
let currentPayment ;
if ( year !== null && month !== null ) {
2026-07-06 14:23:53 -05:00
currentPayment = db
. prepare (
` SELECT * FROM payments WHERE bill_id = ? AND deleted_at IS NULL AND ${ accountingActiveSql ( ) } AND strftime('%Y', paid_date) = ? AND strftime('%m', paid_date) = ? ORDER BY paid_date DESC LIMIT 1 ` ,
)
. get ( billId , String ( year ) , String ( month ) . padStart ( 2 , '0' ) ) ;
2026-05-11 11:56:49 -05:00
} else {
2026-07-06 14:23:53 -05:00
currentPayment = db
. prepare (
` SELECT * FROM payments WHERE bill_id = ? AND deleted_at IS NULL AND ${ accountingActiveSql ( ) } ORDER BY paid_date DESC LIMIT 1 ` ,
)
. get ( billId ) ;
2026-05-11 11:56:49 -05:00
}
2026-05-09 13:03:36 -05:00
// If paid (has payment), remove it → Unpaid
if ( currentPayment ) {
2026-05-14 02:11:54 -05:00
// Reverse any balance delta that was applied when this payment was created
if ( currentPayment . balance_delta != null ) {
const freshBill = db . prepare ( 'SELECT current_balance FROM bills WHERE id = ?' ) . get ( billId ) ;
if ( freshBill ? . current_balance != null ) {
2026-06-11 20:12:31 -05:00
const restored = Math . max ( 0 , freshBill . current_balance - currentPayment . balance_delta ) ;
2026-07-06 14:23:53 -05:00
db . prepare (
"UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?" ,
) . run ( restored , billId ) ;
2026-05-14 02:11:54 -05:00
}
}
2026-07-06 14:23:53 -05:00
db . prepare ( "UPDATE payments SET deleted_at = datetime('now') WHERE id = ?" ) . run (
currentPayment . id ,
) ;
2026-05-09 13:03:36 -05:00
res . json ( {
success : true ,
isPaid : false ,
action : 'removed_payment' ,
paymentId : currentPayment.id ,
} ) ;
return ;
}
// If unpaid, create payment → Paid
// Use expected_amount if no amount provided
2026-06-11 20:12:31 -05:00
const amount = req . body . amount !== undefined ? req.body.amount : fromCents ( bill . expected_amount ) ;
2026-07-06 14:23:53 -05:00
2026-05-11 11:56:49 -05:00
// Determine paid_date
let paidDate = req . body . paid_date ;
if ( ! paidDate && year !== null && month !== null ) {
// Calculate paid_date from bill's due_day clamped to the month's days
const daysInMonth = new Date ( year , month , 0 ) . getDate ( ) ;
const day = Math . min ( Math . max ( Number ( bill . due_day ) , 1 ) , daysInMonth ) ;
paidDate = ` ${ year } - ${ String ( month ) . padStart ( 2 , '0' ) } - ${ String ( day ) . padStart ( 2 , '0' ) } ` ;
} else if ( ! paidDate ) {
2026-06-10 19:42:51 -05:00
paidDate = todayLocal ( ) ;
2026-05-11 11:56:49 -05:00
}
2026-07-06 14:23:53 -05:00
2026-05-09 13:03:36 -05:00
const method = req . body . method || null ;
const notes = req . body . notes || null ;
2026-05-15 22:45:38 -05:00
const paymentValidation = validatePaymentInput (
2026-05-16 20:26:09 -05:00
{ amount , paid_date : paidDate , payment_source : req.body.payment_source ? ? 'manual' } ,
2026-05-15 22:45:38 -05:00
{ requireBillId : false } ,
) ;
if ( paymentValidation . error ) {
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json ( standardizeError ( paymentValidation . error , 'VALIDATION_ERROR' , paymentValidation . field ) ) ;
2026-05-09 13:03:36 -05:00
}
2026-05-15 22:45:38 -05:00
const payment = paymentValidation . normalized ;
2026-05-09 13:03:36 -05:00
2026-05-14 02:11:54 -05:00
// Compute balance delta for debt bills before inserting
2026-05-15 22:45:38 -05:00
const balCalc = computeBalanceDelta ( bill , payment . amount ) ;
2026-05-14 02:11:54 -05:00
2026-07-06 14:23:53 -05:00
const result = db
. prepare (
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' ,
)
. run (
billId ,
payment . amount ,
payment . paid_date ,
method ,
notes ,
balCalc ? . balance_delta ? ? null ,
balCalc ? . interest_delta ? ? null ,
payment . payment_source ,
) ;
2026-05-14 02:11:54 -05:00
2026-06-06 16:34:20 -05:00
applyBalanceDelta ( db , billId , balCalc ) ;
2026-05-09 13:03:36 -05:00
res . status ( 201 ) . json ( {
success : true ,
isPaid : true ,
action : 'created_payment' ,
2026-07-06 14:23:53 -05:00
payment : serializePayment (
db . prepare ( 'SELECT * FROM payments WHERE id = ?' ) . get ( result . lastInsertRowid ) ,
) ,
2026-05-09 13:03:36 -05:00
} ) ;
} ) ;
2026-05-03 19:51:57 -05:00
// ── GET /api/bills/:id/history-ranges ────────────────────────────────────────
2026-07-06 11:26:50 -05:00
router . get ( '/:id/history-ranges' , ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
const db = getDb ( ) ;
2026-07-06 14:23:53 -05:00
if (
! db
. prepare ( 'SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' )
. get ( req . params . id , req . user . id )
)
2026-05-09 13:03:36 -05:00
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
2026-05-03 19:51:57 -05:00
2026-07-06 14:23:53 -05:00
const ranges = db
. prepare (
'SELECT * FROM bill_history_ranges WHERE bill_id = ? ORDER BY start_year ASC, start_month ASC' ,
)
. all ( req . params . id ) ;
2026-05-03 19:51:57 -05:00
2026-07-06 14:23:53 -05:00
const bill = db
. prepare ( 'SELECT history_visibility FROM bills WHERE id = ? AND deleted_at IS NULL' )
. get ( req . params . id ) ;
2026-05-03 19:51:57 -05:00
2026-07-06 14:23:53 -05:00
res . json ( {
bill_id : parseInt ( req . params . id , 10 ) ,
history_visibility : bill.history_visibility ,
ranges ,
} ) ;
2026-05-03 19:51:57 -05:00
} ) ;
// ── POST /api/bills/:id/history-ranges ───────────────────────────────────────
2026-07-06 11:26:50 -05:00
router . post ( '/:id/history-ranges' , ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
const db = getDb ( ) ;
2026-07-06 14:23:53 -05:00
if (
! db
. prepare ( 'SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' )
. get ( req . params . id , req . user . id )
)
2026-05-09 13:03:36 -05:00
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
2026-05-03 19:51:57 -05:00
const { start_year , start_month , end_year , end_month , label } = req . body ;
const sy = parseInt ( start_year , 10 ) ;
const sm = parseInt ( start_month , 10 ) ;
if ( isNaN ( sy ) || sy < 2000 || sy > 2100 )
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json (
standardizeError (
'start_year must be between 2000 and 2100' ,
'VALIDATION_ERROR' ,
'start_year' ,
) ,
) ;
2026-05-03 19:51:57 -05:00
if ( isNaN ( sm ) || sm < 1 || sm > 12 )
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json (
standardizeError ( 'start_month must be between 1 and 12' , 'VALIDATION_ERROR' , 'start_month' ) ,
) ;
let ey = null ,
em = null ;
2026-05-03 19:51:57 -05:00
if ( end_year != null ) {
ey = parseInt ( end_year , 10 ) ;
if ( isNaN ( ey ) || ey < 2000 || ey > 2100 )
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json (
standardizeError (
'end_year must be between 2000 and 2100' ,
'VALIDATION_ERROR' ,
'end_year' ,
) ,
) ;
2026-05-03 19:51:57 -05:00
}
if ( end_month != null ) {
em = parseInt ( end_month , 10 ) ;
if ( isNaN ( em ) || em < 1 || em > 12 )
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json (
standardizeError ( 'end_month must be between 1 and 12' , 'VALIDATION_ERROR' , 'end_month' ) ,
) ;
2026-05-03 19:51:57 -05:00
}
if ( ( ey == null ) !== ( em == null ) ) {
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json (
standardizeError (
'end_year and end_month must both be provided or both omitted' ,
'VALIDATION_ERROR' ,
'end_year' ,
) ,
) ;
2026-05-03 19:51:57 -05:00
}
if ( ey != null ) {
const startVal = sy * 12 + sm ;
2026-07-06 14:23:53 -05:00
const endVal = ey * 12 + em ;
2026-05-03 19:51:57 -05:00
if ( endVal < startVal )
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json (
standardizeError (
'end date must be on or after start date' ,
'VALIDATION_ERROR' ,
'end_year' ,
) ,
) ;
2026-05-03 19:51:57 -05:00
}
2026-07-06 14:23:53 -05:00
const result = db
. prepare (
`
2026-05-03 19:51:57 -05:00
INSERT INTO bill_history_ranges ( bill_id , start_year , start_month , end_year , end_month , label )
VALUES ( ? , ? , ? , ? , ? , ? )
2026-07-06 14:23:53 -05:00
` ,
)
. run ( req . params . id , sy , sm , ey , em , label || null ) ;
2026-05-03 19:51:57 -05:00
2026-07-06 14:23:53 -05:00
const created = db
. prepare ( 'SELECT * FROM bill_history_ranges WHERE id = ?' )
. get ( result . lastInsertRowid ) ;
2026-05-03 19:51:57 -05:00
res . status ( 201 ) . json ( created ) ;
} ) ;
// ── PUT /api/bills/:id/history-ranges/:rangeId ───────────────────────────────
2026-07-06 11:26:50 -05:00
router . put ( '/:id/history-ranges/:rangeId' , ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
const db = getDb ( ) ;
2026-07-06 14:23:53 -05:00
if (
! db
. prepare ( 'SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' )
. get ( req . params . id , req . user . id )
)
2026-05-09 13:03:36 -05:00
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
2026-05-03 19:51:57 -05:00
2026-07-06 14:23:53 -05:00
const range = db
. prepare ( 'SELECT * FROM bill_history_ranges WHERE id = ? AND bill_id = ?' )
2026-05-03 19:51:57 -05:00
. get ( req . params . rangeId , req . params . id ) ;
2026-07-06 14:23:53 -05:00
if ( ! range )
return res
. status ( 404 )
. json ( standardizeError ( 'History range not found' , 'NOT_FOUND' , 'rangeId' ) ) ;
2026-05-03 19:51:57 -05:00
const { start_year , start_month , end_year , end_month , label } = req . body ;
const sy = start_year != null ? parseInt ( start_year , 10 ) : range . start_year ;
const sm = start_month != null ? parseInt ( start_month , 10 ) : range . start_month ;
if ( isNaN ( sy ) || sy < 2000 || sy > 2100 )
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json (
standardizeError (
'start_year must be between 2000 and 2100' ,
'VALIDATION_ERROR' ,
'start_year' ,
) ,
) ;
2026-05-03 19:51:57 -05:00
if ( isNaN ( sm ) || sm < 1 || sm > 12 )
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json (
standardizeError ( 'start_month must be between 1 and 12' , 'VALIDATION_ERROR' , 'start_month' ) ,
) ;
2026-05-03 19:51:57 -05:00
let ey = range . end_year ;
let em = range . end_month ;
if ( end_year !== undefined ) ey = end_year != null ? parseInt ( end_year , 10 ) : null ;
if ( end_month !== undefined ) em = end_month != null ? parseInt ( end_month , 10 ) : null ;
if ( ey != null && ( isNaN ( ey ) || ey < 2000 || ey > 2100 ) )
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json (
standardizeError ( 'end_year must be between 2000 and 2100' , 'VALIDATION_ERROR' , 'end_year' ) ,
) ;
2026-05-03 19:51:57 -05:00
if ( em != null && ( isNaN ( em ) || em < 1 || em > 12 ) )
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json (
standardizeError ( 'end_month must be between 1 and 12' , 'VALIDATION_ERROR' , 'end_month' ) ,
) ;
2026-05-03 19:51:57 -05:00
if ( ( ey == null ) !== ( em == null ) )
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json (
standardizeError (
'end_year and end_month must both be provided or both omitted' ,
'VALIDATION_ERROR' ,
'end_year' ,
) ,
) ;
if ( ey != null && ey * 12 + em < sy * 12 + sm )
return res
. status ( 400 )
. json (
standardizeError ( 'end date must be on or after start date' , 'VALIDATION_ERROR' , 'end_year' ) ,
) ;
db . prepare (
`
2026-05-03 19:51:57 -05:00
UPDATE bill_history_ranges
SET start_year = ? , start_month = ? , end_year = ? , end_month = ? , label = ? ,
updated_at = datetime ( 'now' )
WHERE id = ? AND bill_id = ?
2026-07-06 14:23:53 -05:00
` ,
) . run (
sy ,
sm ,
ey ,
em ,
label !== undefined ? label || null : range . label ,
req . params . rangeId ,
req . params . id ,
) ;
2026-05-03 19:51:57 -05:00
2026-07-06 14:23:53 -05:00
const updated = db
. prepare ( 'SELECT * FROM bill_history_ranges WHERE id = ?' )
. get ( req . params . rangeId ) ;
2026-05-03 19:51:57 -05:00
res . json ( updated ) ;
} ) ;
// ── DELETE /api/bills/:id/history-ranges/:rangeId ────────────────────────────
2026-07-06 11:26:50 -05:00
router . delete ( '/:id/history-ranges/:rangeId' , ( req : Req , res : Res ) = > {
2026-05-03 19:51:57 -05:00
const db = getDb ( ) ;
2026-07-06 14:23:53 -05:00
if (
! db
. prepare ( 'SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' )
. get ( req . params . id , req . user . id )
)
2026-05-09 13:03:36 -05:00
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
2026-05-03 19:51:57 -05:00
2026-07-06 14:23:53 -05:00
const range = db
. prepare ( 'SELECT id FROM bill_history_ranges WHERE id = ? AND bill_id = ?' )
2026-05-03 19:51:57 -05:00
. get ( req . params . rangeId , req . params . id ) ;
2026-07-06 14:23:53 -05:00
if ( ! range )
return res
. status ( 404 )
. json ( standardizeError ( 'History range not found' , 'NOT_FOUND' , 'rangeId' ) ) ;
2026-05-03 19:51:57 -05:00
2026-07-06 14:23:53 -05:00
db . prepare ( 'DELETE FROM bill_history_ranges WHERE id = ? AND bill_id = ?' ) . run (
req . params . rangeId ,
req . params . id ,
) ;
2026-05-03 19:51:57 -05:00
res . json ( { success : true } ) ;
} ) ;
2026-05-15 00:03:32 -05:00
// ── GET /api/bills/:id/amortization — full month-by-month schedule for a debt bill ──
2026-07-06 11:26:50 -05:00
router . get ( '/:id/amortization' , ( req : Req , res : Res ) = > {
2026-07-06 14:23:53 -05:00
const db = getDb ( ) ;
2026-05-15 00:03:32 -05:00
const billId = parseInt ( req . params . id , 10 ) ;
2026-07-06 14:23:53 -05:00
const bill = db
. prepare ( 'SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' )
. get ( billId , req . user . id ) ;
if ( ! bill )
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
2026-05-15 00:03:32 -05:00
2026-07-06 14:23:53 -05:00
const balance = fromCents ( Number ( bill . current_balance ) ) ;
const apr = Number ( bill . interest_rate ) || 0 ;
const minPmt = fromCents ( Number ( bill . minimum_payment ) || 0 ) ;
2026-05-15 00:03:32 -05:00
// Optional override: ?payment=X lets callers model "what if I pay more?"
let payment = minPmt ;
if ( req . query . payment !== undefined ) {
const qp = parseFloat ( req . query . payment ) ;
if ( ! Number . isFinite ( qp ) || qp <= 0 ) {
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json ( standardizeError ( 'payment must be a positive number' , 'VALIDATION_ERROR' , 'payment' ) ) ;
2026-05-15 00:03:32 -05:00
}
payment = qp ;
}
// Optional ?max_months=N (default 360, hard cap 600)
let maxMonths = 360 ;
if ( req . query . max_months !== undefined ) {
const qm = parseInt ( req . query . max_months , 10 ) ;
if ( Number . isInteger ( qm ) && qm > 0 ) maxMonths = Math . min ( qm , 600 ) ;
}
if ( ! Number . isFinite ( balance ) || balance <= 0 ) {
2026-07-06 14:23:53 -05:00
return res . json ( {
bill_id : billId ,
schedule : [ ] ,
apr_snapshot : null ,
error : 'No current balance set' ,
} ) ;
2026-05-15 00:03:32 -05:00
}
2026-07-06 14:23:53 -05:00
const schedule = amortizationSchedule ( balance , apr , payment , maxMonths ) ;
const apr_snapshot = debtAprSnapshot ( {
. . . bill ,
current_balance : balance ,
minimum_payment : minPmt ,
} ) ;
2026-05-15 00:03:32 -05:00
const total_interest = schedule . reduce ( ( s , r ) = > s + r . interest , 0 ) ;
res . json ( {
2026-07-06 14:23:53 -05:00
bill_id : billId ,
2026-05-15 00:03:32 -05:00
balance ,
apr ,
payment ,
schedule ,
summary : {
2026-07-06 14:23:53 -05:00
months : schedule.length ,
total_interest : roundMoney ( total_interest ) ,
total_paid : sumMoney ( schedule , ( r ) = > r . payment ) ,
capped : schedule.length >= maxMonths && schedule [ schedule . length - 1 ] ? . balance > 0 ,
2026-05-15 00:03:32 -05:00
} ,
apr_snapshot ,
} ) ;
} ) ;
2026-05-14 19:33:23 -05:00
// ── PATCH /api/bills/:id/snowball — update only snowball_include / snowball_exempt ──
2026-07-06 11:26:50 -05:00
router . patch ( '/:id/snowball' , ( req : Req , res : Res ) = > {
2026-05-14 19:33:23 -05:00
const db = getDb ( ) ;
const billId = parseInt ( req . params . id , 10 ) ;
2026-07-06 14:23:53 -05:00
if (
! db
. prepare ( 'SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' )
. get ( billId , req . user . id )
) {
2026-05-14 19:33:23 -05:00
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
}
2026-07-06 14:23:53 -05:00
const include =
req . body . snowball_include !== undefined ? ( req . body . snowball_include ? 1 : 0 ) : undefined ;
const exempt =
req . body . snowball_exempt !== undefined ? ( req . body . snowball_exempt ? 1 : 0 ) : undefined ;
2026-05-14 19:33:23 -05:00
const parts = [ ] ;
2026-07-06 14:23:53 -05:00
const vals = [ ] ;
if ( include !== undefined ) {
parts . push ( 'snowball_include = ?' ) ;
vals . push ( include ) ;
}
if ( exempt !== undefined ) {
parts . push ( 'snowball_exempt = ?' ) ;
vals . push ( exempt ) ;
}
if ( parts . length === 0 )
return res . status ( 400 ) . json ( standardizeError ( 'Nothing to update' , 'VALIDATION_ERROR' ) ) ;
2026-05-14 19:33:23 -05:00
parts . push ( "updated_at = datetime('now')" ) ;
2026-07-06 14:23:53 -05:00
db . prepare ( ` UPDATE bills SET ${ parts . join ( ', ' ) } WHERE id = ? AND user_id = ? ` ) . run (
. . . vals ,
billId ,
req . user . id ,
) ;
2026-05-14 19:33:23 -05:00
res . json ( { id : billId , snowball_include : include , snowball_exempt : exempt } ) ;
} ) ;
2026-05-14 02:11:54 -05:00
// ── PATCH /api/bills/:id/balance — lightweight balance-only update ────────────
2026-07-06 11:26:50 -05:00
router . patch ( '/:id/balance' , ( req : Req , res : Res ) = > {
2026-05-14 02:11:54 -05:00
const db = getDb ( ) ;
const billId = parseInt ( req . params . id , 10 ) ;
2026-07-06 14:23:53 -05:00
if (
! db
. prepare ( 'SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' )
. get ( billId , req . user . id )
) {
2026-05-14 02:11:54 -05:00
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
}
const raw = req . body . current_balance ;
let val = null ;
if ( raw !== null && raw !== '' && raw !== undefined ) {
val = parseFloat ( raw ) ;
if ( ! Number . isFinite ( val ) || val < 0 ) {
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json (
standardizeError (
'current_balance must be a non-negative number' ,
'VALIDATION_ERROR' ,
'current_balance' ,
) ,
) ;
2026-05-14 02:11:54 -05:00
}
2026-06-10 20:14:13 -05:00
val = roundMoney ( val ) ;
2026-05-14 02:11:54 -05:00
}
2026-07-06 14:23:53 -05:00
db . prepare ( "UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?" ) . run (
toCents ( val ) ,
billId ,
) ;
2026-05-14 02:11:54 -05:00
res . json ( { id : billId , current_balance : val } ) ;
} ) ;
2026-06-03 21:21:38 -05:00
// ── Merchant rule helpers ─────────────────────────────────────────────────────
function requireBill ( db , billId , userId ) {
2026-07-06 14:23:53 -05:00
return db
. prepare ( 'SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' )
. get ( billId , userId ) ;
2026-06-03 21:21:38 -05:00
}
// Count unmatched transactions that would match a normalized merchant string.
function previewMatchCount ( db , userId , normalized ) {
if ( ! normalized || normalized . length < 2 ) return 0 ;
2026-07-06 14:23:53 -05:00
const txRows = db
. prepare (
`
2026-06-03 21:21:38 -05:00
SELECT t . payee , t . description , t . memo
FROM transactions t
LEFT JOIN financial_accounts fa ON fa . id = t . account_id AND fa . user_id = t . user_id
WHERE t . user_id = ?
AND t . match_status = 'unmatched'
AND t . ignored = 0
AND t . amount < 0
AND ( t . account_id IS NULL OR fa . id IS NULL OR fa . monitored = 1 )
2026-07-06 14:23:53 -05:00
` ,
)
. all ( userId ) ;
return txRows . filter ( ( tx ) = > {
2026-06-03 21:21:38 -05:00
const txMerchant = normalizeMerchant ( tx . payee || tx . description || tx . memo || '' ) ;
2026-06-04 02:20:30 -05:00
return txMerchant && merchantMatches ( txMerchant , normalized ) ;
2026-06-03 21:21:38 -05:00
} ) . length ;
}
// Find bills (other than this one) that already claim this merchant.
function findConflicts ( db , userId , billId , normalized ) {
2026-07-06 14:23:53 -05:00
return db
. prepare (
`
2026-06-03 21:21:38 -05:00
SELECT b . id , b . name
FROM bill_merchant_rules bmr
JOIN bills b ON b . id = bmr . bill_id AND b . user_id = bmr . user_id AND b . deleted_at IS NULL
WHERE bmr . user_id = ? AND bmr . merchant = ? AND bmr . bill_id != ?
2026-07-06 14:23:53 -05:00
` ,
)
. all ( userId , normalized , billId ) ;
2026-06-03 21:21:38 -05:00
}
// ── GET /api/bills/:id/merchant-rules ────────────────────────────────────────
2026-07-06 11:26:50 -05:00
router . get ( '/:id/merchant-rules' , ( req : Req , res : Res ) = > {
2026-06-03 21:21:38 -05:00
const db = getDb ( ) ;
const billId = parseInt ( req . params . id , 10 ) ;
if ( ! Number . isInteger ( billId ) || billId < 1 )
return res . status ( 400 ) . json ( standardizeError ( 'Invalid bill id' , 'VALIDATION_ERROR' ) ) ;
if ( ! requireBill ( db , billId , req . user . id ) )
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' ) ) ;
2026-07-06 14:23:53 -05:00
const rules = db
. prepare (
`
2026-06-04 02:36:36 -05:00
SELECT id , merchant , auto_attribute_late , created_at FROM bill_merchant_rules
2026-06-03 21:21:38 -05:00
WHERE user_id = ? AND bill_id = ?
ORDER BY created_at ASC
2026-07-06 14:23:53 -05:00
` ,
)
. all ( req . user . id , billId ) ;
2026-06-03 21:21:38 -05:00
// Suggest recent unmatched transactions as quick-pick options
2026-07-06 14:23:53 -05:00
const suggestions = db
. prepare (
`
2026-06-03 21:21:38 -05:00
SELECT t . id , t . payee , t . description , t . memo , t . amount , t . posted_date , t . transacted_at
FROM transactions t
LEFT JOIN financial_accounts fa ON fa . id = t . account_id AND fa . user_id = t . user_id
WHERE t . user_id = ?
AND t . match_status = 'unmatched'
AND t . ignored = 0
AND t . amount < 0
AND ( t . account_id IS NULL OR fa . id IS NULL OR fa . monitored = 1 )
ORDER BY COALESCE ( t . posted_date , substr ( t . transacted_at , 1 , 10 ) ) DESC
LIMIT 30
2026-07-06 14:23:53 -05:00
` ,
)
. all ( req . user . id )
. map ( ( tx ) = > {
const raw = tx . payee || tx . description || tx . memo || '' ;
const normalized = normalizeMerchant ( raw ) ;
return {
id : tx.id ,
label : raw.trim ( ) ,
normalized ,
amount : tx.amount ,
date : tx.posted_date || String ( tx . transacted_at || '' ) . slice ( 0 , 10 ) ,
} ;
} )
. filter ( ( s ) = > s . normalized . length >= 2 ) ;
2026-06-03 21:21:38 -05:00
res . json ( { rules , suggestions } ) ;
} ) ;
// ── GET /api/bills/:id/merchant-rules/preview ─────────────────────────────────
2026-07-06 11:26:50 -05:00
router . get ( '/:id/merchant-rules/preview' , ( req : Req , res : Res ) = > {
2026-06-03 21:21:38 -05:00
const db = getDb ( ) ;
const billId = parseInt ( req . params . id , 10 ) ;
if ( ! Number . isInteger ( billId ) || billId < 1 )
return res . status ( 400 ) . json ( standardizeError ( 'Invalid bill id' , 'VALIDATION_ERROR' ) ) ;
if ( ! requireBill ( db , billId , req . user . id ) )
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' ) ) ;
const raw = String ( req . query . merchant || '' ) . trim ( ) ;
const normalized = normalizeMerchant ( raw ) ;
if ( ! normalized || normalized . length < 2 )
return res . json ( { match_count : 0 , conflicts : [ ] , normalized : '' } ) ;
const match_count = previewMatchCount ( db , req . user . id , normalized ) ;
2026-07-06 14:23:53 -05:00
const conflicts = findConflicts ( db , req . user . id , billId , normalized ) ;
2026-06-03 21:21:38 -05:00
res . json ( { match_count , conflicts , normalized } ) ;
} ) ;
// ── POST /api/bills/:id/merchant-rules ───────────────────────────────────────
2026-07-06 11:26:50 -05:00
router . post ( '/:id/merchant-rules' , ( req : Req , res : Res ) = > {
2026-06-03 21:21:38 -05:00
const db = getDb ( ) ;
const billId = parseInt ( req . params . id , 10 ) ;
if ( ! Number . isInteger ( billId ) || billId < 1 )
return res . status ( 400 ) . json ( standardizeError ( 'Invalid bill id' , 'VALIDATION_ERROR' ) ) ;
if ( ! requireBill ( db , billId , req . user . id ) )
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' ) ) ;
const raw = String ( req . body ? . merchant || '' ) . trim ( ) ;
const normalized = normalizeMerchant ( raw ) ;
if ( ! normalized || normalized . length < 2 )
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json (
standardizeError (
'merchant must be at least 2 characters after normalisation' ,
'VALIDATION_ERROR' ,
'merchant' ,
) ,
) ;
2026-06-03 21:21:38 -05:00
const conflicts = findConflicts ( db , req . user . id , billId , normalized ) ;
try {
2026-07-06 14:23:53 -05:00
db . prepare (
`
2026-06-03 21:21:38 -05:00
INSERT INTO bill_merchant_rules ( user_id , bill_id , merchant )
VALUES ( ? , ? , ? )
ON CONFLICT ( user_id , bill_id , merchant ) DO NOTHING
2026-07-06 14:23:53 -05:00
` ,
) . run ( req . user . id , billId , normalized ) ;
2026-06-03 21:21:38 -05:00
} catch ( err ) {
return res . status ( 500 ) . json ( standardizeError ( 'Failed to save rule' , 'DB_ERROR' ) ) ;
}
// Retroactively apply the new rule to existing unmatched transactions
const { added } = syncBillPaymentsFromSimplefin ( db , req . user . id , billId ) ;
2026-07-06 14:23:53 -05:00
const rule = db
. prepare (
'SELECT id, merchant, created_at FROM bill_merchant_rules WHERE user_id = ? AND bill_id = ? AND merchant = ?' ,
)
2026-06-03 21:21:38 -05:00
. get ( req . user . id , billId , normalized ) ;
res . status ( 201 ) . json ( { rule , retroactive_matches : added , conflicts } ) ;
} ) ;
// ── DELETE /api/bills/:id/merchant-rules/:ruleId ──────────────────────────────
2026-06-04 02:05:15 -05:00
// ── GET /api/bills/:id/merchant-rules/candidates ─────────────────────────────
// All transactions matching this bill's merchant rules — any match_status.
// Each item includes the current status so the user knows what will happen.
2026-07-06 11:26:50 -05:00
router . get ( '/:id/merchant-rules/candidates' , ( req : Req , res : Res ) = > {
2026-06-04 02:05:15 -05:00
const db = getDb ( ) ;
const billId = parseInt ( req . params . id , 10 ) ;
if ( ! Number . isInteger ( billId ) || billId < 1 )
return res . status ( 400 ) . json ( standardizeError ( 'Invalid bill id' , 'VALIDATION_ERROR' ) ) ;
const bill = requireBill ( db , billId , req . user . id ) ;
if ( ! bill ) return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' ) ) ;
2026-07-06 14:23:53 -05:00
const rules = db
. prepare ( 'SELECT merchant FROM bill_merchant_rules WHERE user_id = ? AND bill_id = ?' )
. all ( req . user . id , billId )
. map ( ( r ) = > r . merchant ) ;
2026-06-04 02:05:15 -05:00
if ( rules . length === 0 ) return res . json ( { candidates : [ ] } ) ;
// Fetch all negative transactions for this user — any match status
let txRows ;
try {
2026-07-06 14:23:53 -05:00
txRows = db
. prepare (
`
2026-06-04 02:05:15 -05:00
SELECT t . id , t . amount , t . payee , t . description , t . memo ,
t . posted_date , t . transacted_at , t . match_status ,
t . matched_bill_id ,
b . name AS matched_bill_name
FROM transactions t
LEFT JOIN bills b ON b . id = t . matched_bill_id AND b . user_id = t . user_id AND b . deleted_at IS NULL
WHERE t . user_id = ? AND t . amount < 0 AND t . ignored = 0
ORDER BY COALESCE ( t . posted_date , substr ( t . transacted_at , 1 , 10 ) ) DESC
LIMIT 500
2026-07-06 14:23:53 -05:00
` ,
)
. all ( req . user . id ) ;
2026-06-04 02:05:15 -05:00
} catch {
return res . json ( { candidates : [ ] } ) ;
}
// Existing payments for this bill keyed by transaction_id
const existingPayments = new Set (
2026-07-06 14:23:53 -05:00
db
. prepare (
'SELECT transaction_id FROM payments WHERE bill_id = ? AND transaction_id IS NOT NULL AND deleted_at IS NULL' ,
)
. all ( billId )
. map ( ( r ) = > r . transaction_id ) ,
2026-06-04 02:05:15 -05:00
) ;
const candidates = [ ] ;
for ( const tx of txRows ) {
const txMerchant = normalizeMerchant ( tx . payee || tx . description || tx . memo || '' ) ;
if ( ! txMerchant ) continue ;
2026-07-06 14:23:53 -05:00
const matches = rules . some ( ( r ) = > merchantMatches ( txMerchant , r ) ) ;
2026-06-04 02:05:15 -05:00
if ( ! matches ) continue ;
2026-07-06 14:23:53 -05:00
const paidDate =
tx . posted_date || ( tx . transacted_at ? String ( tx . transacted_at ) . slice ( 0 , 10 ) : null ) ;
2026-06-04 02:05:15 -05:00
if ( ! paidDate ) continue ;
let status ;
if ( existingPayments . has ( tx . id ) ) {
status = 'payment_exists' ;
} else if ( tx . match_status === 'matched' && tx . matched_bill_id === billId ) {
status = 'matched_this_bill' ;
} else if ( tx . match_status === 'matched' && tx . matched_bill_id !== billId ) {
status = 'matched_other_bill' ;
} else {
status = 'unmatched' ;
}
candidates . push ( {
2026-07-06 14:23:53 -05:00
id : tx.id ,
payee : tx.payee || tx . description || '(no description)' ,
amount : Math.round ( Math . abs ( tx . amount ) ) / 100 ,
paid_date : paidDate ,
2026-06-04 02:05:15 -05:00
status ,
matched_bill_name : tx.matched_bill_name || null ,
} ) ;
}
res . json ( { candidates } ) ;
} ) ;
// ── POST /api/bills/:id/merchant-rules/import-historical ──────────────────────
// Import a specific list of transaction IDs as payments for this bill.
2026-07-06 11:26:50 -05:00
router . post ( '/:id/merchant-rules/import-historical' , ( req : Req , res : Res ) = > {
2026-06-04 02:05:15 -05:00
const db = getDb ( ) ;
const billId = parseInt ( req . params . id , 10 ) ;
if ( ! Number . isInteger ( billId ) || billId < 1 )
return res . status ( 400 ) . json ( standardizeError ( 'Invalid bill id' , 'VALIDATION_ERROR' ) ) ;
const bill = requireBill ( db , billId , req . user . id ) ;
if ( ! bill ) return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' ) ) ;
const ids = req . body ? . transaction_ids ;
if ( ! Array . isArray ( ids ) || ids . length === 0 )
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json ( standardizeError ( 'transaction_ids must be a non-empty array' , 'VALIDATION_ERROR' ) ) ;
2026-06-04 02:05:15 -05:00
2026-07-06 14:23:53 -05:00
const validIds = ids . filter ( ( id ) = > Number . isInteger ( id ) && id > 0 ) ;
2026-06-04 02:05:15 -05:00
if ( validIds . length === 0 )
2026-07-06 14:23:53 -05:00
return res
. status ( 400 )
. json ( standardizeError ( 'No valid transaction ids provided' , 'VALIDATION_ERROR' ) ) ;
2026-06-04 02:05:15 -05:00
2026-07-06 14:23:53 -05:00
const getBill = db . prepare ( 'SELECT * FROM bills WHERE id = ? AND deleted_at IS NULL' ) ;
const getTx = db . prepare (
'SELECT * FROM transactions WHERE id = ? AND user_id = ? AND amount < 0' ,
) ;
2026-06-04 02:05:15 -05:00
const insertPayment = db . prepare ( `
2026-06-07 01:05:48 -05:00
INSERT OR IGNORE INTO payments ( bill_id , amount , paid_date , payment_source , transaction_id )
VALUES ( ? , ? , ? , 'provider_sync' , ? )
2026-06-04 02:05:15 -05:00
` );
2026-07-06 14:23:53 -05:00
const updateTx = db . prepare ( `
2026-06-04 02:05:15 -05:00
UPDATE transactions SET matched_bill_id = ? , match_status = 'matched' , updated_at = datetime ( 'now' ) WHERE id = ?
` );
let imported = 0 ;
const lateAttributions = [ ] ;
try {
db . transaction ( ( ) = > {
for ( const txId of validIds ) {
const tx = getTx . get ( txId , req . user . id ) ;
if ( ! tx ) continue ;
2026-07-06 14:23:53 -05:00
const paidDate =
tx . posted_date || ( tx . transacted_at ? String ( tx . transacted_at ) . slice ( 0 , 10 ) : null ) ;
2026-06-04 02:05:15 -05:00
if ( ! paidDate ) continue ;
2026-06-11 20:12:31 -05:00
const amountCents = Math . round ( Math . abs ( tx . amount ) ) ;
2026-07-06 14:23:53 -05:00
const amount = fromCents ( amountCents ) ;
2026-06-04 02:05:15 -05:00
const billRow = getBill . get ( billId ) ;
2026-06-11 20:12:31 -05:00
const result = insertPayment . run ( billId , amountCents , paidDate , txId ) ;
2026-06-04 02:05:15 -05:00
if ( result . changes > 0 ) {
2026-07-06 14:23:53 -05:00
const insertedPayment = db
. prepare (
'SELECT * FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL' ,
)
. get ( txId , billId ) ;
2026-06-04 02:05:15 -05:00
updateTx . run ( billId , txId ) ;
imported ++ ;
// Check for late attribution
const { normalizeMerchant : nm , . . . _ } = { normalizeMerchant } ;
const rules2 = db . prepare ( 'SELECT due_day FROM bills WHERE id = ?' ) . get ( billId ) ;
if ( rules2 ? . due_day ) {
refactor(server): migrate 10 services to TypeScript (.cts)
Continues the incremental server TS migration (after utils/*.mts and
paymentValidation.cts): converts 10 services to .cts (CommonJS TypeScript,
run natively via Node type-stripping — no build step), type-checked by
tsconfig.server.json.
Migrated: totpService, amountSuggestionService, encryptionService,
paymentAccountingService, statusService, aprService, driftService,
analyticsService, spendingService, billMerchantRuleService.
- types/db.d.ts: shared minimal better-sqlite3 Db/Statement types (the lib
ships none), reused across the migrated modules.
- Every require of a migrated service updated to the explicit .cts extension
(extensionless require does not resolve .cts) across routes / services /
db migrations / server.js / workers / tests — require-only changes.
- package.json: check:server find pattern now includes *.cts (it silently
skipped .cts before, so paymentValidation.cts had no node --check gate).
Behavior-preserving (types only). Verified: typecheck:server 0,
check:server 0, full server suite 226/226, real boot → /api/health 200.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:35:29 -05:00
const { lateAttributionCandidate } = require ( '../services/billMerchantRuleService.cts' ) ;
2026-06-04 02:05:15 -05:00
// inline check
const paid = new Date ( paidDate + 'T00:00:00' ) ;
2026-07-06 14:23:53 -05:00
const dom = paid . getDate ( ) ;
2026-06-04 02:05:15 -05:00
if ( dom <= 5 ) {
const prevEnd = new Date ( paid . getFullYear ( ) , paid . getMonth ( ) , 0 ) ;
if ( rules2 . due_day <= prevEnd . getDate ( ) ) {
2026-06-10 19:42:51 -05:00
const suggested = localDateString ( prevEnd ) ;
2026-06-07 01:05:48 -05:00
if ( insertedPayment ) {
2026-07-06 14:23:53 -05:00
lateAttributions . push ( {
payment_id : insertedPayment.id ,
bill_name : bill.name ,
original_date : paidDate ,
suggested_date : suggested ,
amount ,
} ) ;
2026-06-04 02:05:15 -05:00
}
}
}
}
2026-07-06 14:23:53 -05:00
if ( billRow && insertedPayment )
applyBankPaymentAsSourceOfTruth ( db , billRow , insertedPayment ) ;
2026-06-04 02:05:15 -05:00
}
}
} ) ( ) ;
} catch ( err ) {
console . error ( '[import-historical] Transaction failed:' , err . message ) ;
return res . status ( 500 ) . json ( standardizeError ( 'Import failed' , 'DB_ERROR' ) ) ;
}
res . json ( { imported , late_attributions : lateAttributions } ) ;
} ) ;
2026-06-04 02:36:36 -05:00
// PATCH /api/bills/:id/merchant-rules/:ruleId/auto-attribute
// Toggle the auto_attribute_late flag for a single merchant rule.
2026-07-06 11:26:50 -05:00
router . patch ( '/:id/merchant-rules/:ruleId/auto-attribute' , ( req : Req , res : Res ) = > {
2026-06-04 02:36:36 -05:00
const db = getDb ( ) ;
2026-07-06 14:23:53 -05:00
const billId = parseInt ( req . params . id , 10 ) ;
const ruleId = parseInt ( req . params . ruleId , 10 ) ;
2026-06-04 02:36:36 -05:00
if ( ! Number . isInteger ( billId ) || billId < 1 || ! Number . isInteger ( ruleId ) || ruleId < 1 )
return res . status ( 400 ) . json ( standardizeError ( 'Invalid id' , 'VALIDATION_ERROR' ) ) ;
if ( ! requireBill ( db , billId , req . user . id ) )
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' ) ) ;
const enabled = req . body ? . enabled ? 1 : 0 ;
2026-07-06 14:23:53 -05:00
const changes = db
. prepare (
'UPDATE bill_merchant_rules SET auto_attribute_late = ? WHERE id = ? AND user_id = ? AND bill_id = ?' ,
)
. run ( enabled , ruleId , req . user . id , billId ) . changes ;
2026-06-04 02:36:36 -05:00
if ( changes === 0 ) return res . status ( 404 ) . json ( standardizeError ( 'Rule not found' , 'NOT_FOUND' ) ) ;
res . json ( { id : ruleId , auto_attribute_late : enabled === 1 } ) ;
} ) ;
2026-07-06 11:26:50 -05:00
router . delete ( '/:id/merchant-rules/:ruleId' , ( req : Req , res : Res ) = > {
2026-06-03 21:21:38 -05:00
const db = getDb ( ) ;
2026-07-06 14:23:53 -05:00
const billId = parseInt ( req . params . id , 10 ) ;
const ruleId = parseInt ( req . params . ruleId , 10 ) ;
2026-06-03 21:21:38 -05:00
if ( ! Number . isInteger ( billId ) || billId < 1 || ! Number . isInteger ( ruleId ) || ruleId < 1 )
return res . status ( 400 ) . json ( standardizeError ( 'Invalid id' , 'VALIDATION_ERROR' ) ) ;
if ( ! requireBill ( db , billId , req . user . id ) )
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' ) ) ;
2026-07-06 14:23:53 -05:00
const changes = db
. prepare ( 'DELETE FROM bill_merchant_rules WHERE id = ? AND user_id = ? AND bill_id = ?' )
2026-06-03 21:21:38 -05:00
. run ( ruleId , req . user . id , billId ) . changes ;
2026-07-06 14:23:53 -05:00
if ( changes === 0 ) return res . status ( 404 ) . json ( standardizeError ( 'Rule not found' , 'NOT_FOUND' ) ) ;
2026-06-03 21:21:38 -05:00
res . json ( { success : true } ) ;
} ) ;
2026-05-03 19:51:57 -05:00
module .exports = router ;