@ -1,5 +1,7 @@
'use strict' ;
import type { Db } from '../types/db' ;
const { getDb } = require ( '../db/database' ) ;
const { buildTrackerRow , getCycleRange , resolveDueDate , isPaidStatus } = require ( './statusService.cts' ) ;
const { getUserSettings } = require ( './userSettings.cts' ) ;
@ -8,22 +10,24 @@ const { computeAmountSuggestionsBatch } = require('./amountSuggestionService.cts
const { accountingActiveSql } = require ( './paymentAccountingService.cts' ) ;
const { normalizeMerchant } = require ( './subscriptionService' ) ;
const { localDateString } = require ( '../utils/dates.mts' ) ;
// Aggregation-heavy: money results flow through many .filter/.sort/arith, so a
// plain require (→ any) avoids Dollars|null friction (cf. analyticsService).
const { sumMoney , roundMoney , fromCents } = require ( '../utils/money.mts' ) ;
type Row = Record < string , any > ;
const DEFAULT_PENDING_DAYS = 3 ;
// Word-boundary match — same semantics as billMerchantRuleService.merchantMatches.
function txMerchantMatches ( txNorm , ruleMerchant ) {
function txMerchantMatches ( txNorm : any , ruleMerchant : any ) : boolean {
if ( ! txNorm || ! ruleMerchant ) return false ;
if ( txNorm === ruleMerchant ) return true ;
const esc = s = > s . replace ( /[.*+?^${}()|[\]\\]/g , '\\$&' ) ;
const wb = s = > new RegExp ( ` (^| \\ s) ${ esc ( s ) } ( \\ s| $ ) ` ) ;
const esc = ( s : string ) = > s . replace ( /[.*+?^${}()|[\]\\]/g , '\\$&' ) ;
const wb = ( s : string ) = > new RegExp ( ` (^| \\ s) ${ esc ( s ) } ( \\ s| $ ) ` ) ;
return wb ( ruleMerchant ) . test ( txNorm ) || wb ( txNorm ) . test ( ruleMerchant ) ;
}
// For bills that have merchant rules, count how many of that user's pending bank
// transactions match each bill. Only bills with at least one rule are checked.
function fetchBankPendingCounts ( db , userId , billIds ) {
function fetchBankPendingCounts ( db : Db , userId : number , billIds : any [ ] ) : Record < string , number > {
if ( billIds . length === 0 ) return { } ;
const ph = billIds . map ( ( ) = > '?' ) . join ( ',' ) ;
const rules = db . prepare ( `
@ -45,18 +49,18 @@ function fetchBankPendingCounts(db, userId, billIds) {
` ).all(userId);
if ( pendingTxs . length === 0 ) return { } ;
const rulesByBill = { } ;
const rulesByBill : Record < string , string [ ] > = { } ;
for ( const rule of rules ) {
if ( ! rulesByBill [ rule . bill_id ] ) rulesByBill [ rule . bill_id ] = [ ] ;
rulesByBill [ rule . bill_id ] . push ( rule . merchant ) ;
}
const counts = { } ;
const counts : Record < string , number > = { } ;
for ( const tx of pendingTxs ) {
const txNorm = normalizeMerchant ( tx . payee || tx . description || tx . memo || '' ) ;
if ( ! txNorm ) continue ;
for ( const [ billId , merchants ] of Object . entries ( rulesByBill ) ) {
if ( merchants . some ( m = > txMerchantMatches ( txNorm , m ) ) ) {
if ( merchants . some ( ( m : string ) = > txMerchantMatches ( txNorm , m ) ) ) {
counts [ billId ] = ( counts [ billId ] || 0 ) + 1 ;
}
}
@ -64,11 +68,7 @@ function fetchBankPendingCounts(db, userId, billIds) {
return counts ;
}
// `gatedUnpaidThisMonth` (dollars) is the occurrence-gated unpaid total the
// caller already computed from the tracker rows (which honor resolveDueDate).
// When provided we use it instead of the ungated SQL below, so annual/quarterly/
// off-month bills don't inflate `unpaid_this_month` → the bank `remaining`.
function buildBankTracking ( db , userId , year , month , gatedUnpaidThisMonth = null ) {
function buildBankTracking ( db : Db , userId : number , year : number , month : number , gatedUnpaidThisMonth : any = null ) : any {
try {
const settings = getUserSettings ( userId ) ;
if ( settings . bank_tracking_enabled !== 'true' ) return { enabled : false } ;
@ -85,9 +85,6 @@ function buildBankTracking(db, userId, year, month, gatedUnpaidThisMonth = null)
const pendingDays = parseInt ( settings . bank_tracking_pending_days , 10 ) ;
const days = Number . isInteger ( pendingDays ) && pendingDays >= 0 ? pendingDays : DEFAULT_PENDING_DAYS ;
// Only count manually-entered payments as pending — bank-synced payments
// (provider_sync) are already reflected in the live bank balance, so
// including them would double-deduct.
const pendingRow = days > 0
? db . prepare ( `
SELECT COALESCE ( SUM ( p . amount ) , 0 ) AS pending_total
@ -100,9 +97,6 @@ function buildBankTracking(db, userId, year, month, gatedUnpaidThisMonth = null)
` ).get(userId, days)
: { pending_total : 0 } ;
// Fallback (only when the caller didn't pass a gated total, e.g. a direct
// call): the ungated SQL sum. This overcounts off-month bills — prefer the
// gated total from getTracker's rows.
let unpaid ;
if ( gatedUnpaidThisMonth != null ) {
unpaid = roundMoney ( gatedUnpaidThisMonth ) ;
@ -145,13 +139,13 @@ function buildBankTracking(db, userId, year, month, gatedUnpaidThisMonth = null)
remaining : roundMoney ( effective - unpaid ) ,
last_updated : account.updated_at ,
} ;
} catch ( err ) {
} catch ( err : any ) {
console . error ( '[buildBankTracking] Error computing bank tracking data:' , err . message ) ;
return { enabled : false } ;
}
}
function validateTrackerMonth ( query = { } , now = new Date ( ) ) {
function validateTrackerMonth ( query : any = { } , now : Date = new Date ( ) ) : any {
const year = parseInt ( query . year || now . getFullYear ( ) , 10 ) ;
const month = parseInt ( query . month || now . getMonth ( ) + 1 , 10 ) ;
@ -164,14 +158,14 @@ function validateTrackerMonth(query = {}, now = new Date()) {
return { year , month } ;
}
function previousMonthFor ( year , month ) {
function previousMonthFor ( year : number , month : number ) {
return {
year : month === 1 ? year - 1 : year ,
month : month === 1 ? 12 : month - 1 ,
} ;
}
function monthOffset ( year , month , offset ) {
function monthOffset ( year : number , month : number , offset : number ) {
let y = year ;
let m = month + offset ;
while ( m <= 0 ) { m += 12 ; y -= 1 ; }
@ -179,12 +173,12 @@ function monthOffset(year, month, offset) {
return { year : y , month : m } ;
}
const FETCH_BILLS_ORDER = {
const FETCH_BILLS_ORDER : Record < string , string > = {
due_day : 'CASE WHEN b.sort_order IS NULL THEN 1 ELSE 0 END, b.sort_order ASC, b.due_day ASC, b.name ASC' ,
id : 'b.id ASC' ,
} ;
function fetchActiveBills ( db , userId , orderKey = 'due_day' ) {
function fetchActiveBills ( db : Db , userId : number , orderKey = 'due_day' ) : any [ ] {
const orderBy = FETCH_BILLS_ORDER [ orderKey ] ? ? FETCH_BILLS_ORDER . due_day ;
return db . prepare ( `
SELECT b . * , c . name AS category_name ,
@ -199,7 +193,7 @@ function fetchActiveBills(db, userId, orderKey = 'due_day') {
` ).all(userId);
}
function fetchMonthlyStates ( db , billIds , year , month ) {
function fetchMonthlyStates ( db : Db , billIds : any [ ] , year : number , month : number ) : Record < string , any > {
if ( billIds . length === 0 ) return { } ;
const placeholders = billIds . map ( ( ) = > '?' ) . join ( ',' ) ;
const rows = db . prepare ( `
@ -207,10 +201,10 @@ function fetchMonthlyStates(db, billIds, year, month) {
FROM monthly_bill_state
WHERE bill_id IN ( $ { placeholders } ) AND year = ? AND month = ?
` ).all(...billIds, year, month);
return Object . fromEntries ( rows . map ( row = > [ row . bill_id , row ] ) ) ;
return Object . fromEntries ( rows . map ( ( row : Row ) = > [ row . bill_id , row ] ) ) ;
}
function fetchPaymentsForBillCycle ( db , bill , year , month ) {
function fetchPaymentsForBillCycle ( db : Db , bill : Row , year : number , month : number ) : any [ ] {
const range = getCycleRange ( year , month , bill ) ;
if ( ! range ) return [ ] ;
return db . prepare ( `
@ -223,14 +217,10 @@ function fetchPaymentsForBillCycle(db, bill, year, month) {
` ).all(bill.id, range.start, range.end);
}
// Batched form of fetchPaymentsForBillCycle: one query for every bill's cycle
// payments (grouped in JS by each bill's own range) instead of one query per
// bill. Returns Map<billId, payments[]> with each list still ordered paid_date
// DESC — identical to calling fetchPaymentsForBillCycle per bill.
function fetchPaymentsForBillsInMonth ( db , bills , year , month ) {
const ranges = new Map ( ) ; // billId → { start, end } (only bills due this month)
let minStart = null ;
let maxEnd = null ;
function fetchPaymentsForBillsInMonth ( db : Db , bills : any [ ] , year : number , month : number ) : Map < any , any > {
const ranges = new Map < any , any > ( ) ; // billId → { start, end } (only bills due this month)
let minStart : any = null ;
let maxEnd : any = null ;
for ( const bill of bills ) {
const range = getCycleRange ( year , month , bill ) ;
if ( ! range ) continue ;
@ -239,7 +229,7 @@ function fetchPaymentsForBillsInMonth(db, bills, year, month) {
if ( maxEnd === null || range . end > maxEnd ) maxEnd = range . end ;
}
const byBill = new Map () ;
const byBill = new Map <any , any > () ;
if ( minStart === null ) return byBill ;
const ids = [ . . . ranges . keys ( ) ] ;
@ -263,7 +253,7 @@ function fetchPaymentsForBillsInMonth(db, bills, year, month) {
return byBill ;
}
function fetchPreviousMonthPaid ( db , billIds , range ) {
function fetchPreviousMonthPaid ( db : Db , billIds : any [ ] , range : any ) : Record < string , any > {
if ( billIds . length === 0 ) return { } ;
const placeholders = billIds . map ( ( ) = > '?' ) . join ( ',' ) ;
const rows = db . prepare ( `
@ -274,20 +264,20 @@ function fetchPreviousMonthPaid(db, billIds, range) {
AND $ { accountingActiveSql ( ) }
GROUP BY bill_id
` ).all(...billIds, range.start, range.end);
return Object . fromEntries ( rows . map ( row = > [ row . bill_id , row . total_paid ] ) ) ;
return Object . fromEntries ( rows . map ( ( row : Row ) = > [ row . bill_id , row . total_paid ] ) ) ;
}
function fetchDismissedSuggestions ( db , userId , billIds , year , month ) {
function fetchDismissedSuggestions ( db : Db , userId : number , billIds : any [ ] , year : number , month : number ) : Set < any > {
if ( billIds . length === 0 ) return new Set ( ) ;
const rows = db . prepare ( `
SELECT bill_id
FROM autopay_suggestion_dismissals
WHERE user_id = ? AND year = ? AND month = ?
` ).all(userId, year, month);
return new Set ( rows . map ( row = > row . bill_id ) ) ;
return new Set ( rows . map ( ( row : Row ) = > row . bill_id ) ) ;
}
function fetchSparklines ( db , billIds ) {
function fetchSparklines ( db : Db , billIds : any [ ] ) : Record < string , any > {
if ( billIds . length === 0 ) return { } ;
const ph = billIds . map ( ( ) = > '?' ) . join ( ',' ) ;
const rows = db . prepare ( `
@ -299,7 +289,7 @@ function fetchSparklines(db, billIds) {
GROUP BY bill_id , month_str
ORDER BY bill_id , month_str
` ).all(...billIds);
const out = { } ;
const out : Record < string , any > = { } ;
for ( const r of rows ) {
if ( ! out [ r . bill_id ] ) out [ r . bill_id ] = [ ] ;
out [ r . bill_id ] . push ( fromCents ( r . total ) ) ;
@ -307,7 +297,7 @@ function fetchSparklines(db, billIds) {
return out ;
}
function fetchAutopayStats ( db , billIds ) {
function fetchAutopayStats ( db : Db , billIds : any [ ] ) : Record < string , any > {
if ( billIds . length === 0 ) return { } ;
const ph = billIds . map ( ( ) = > '?' ) . join ( ',' ) ;
const rows = db . prepare ( `
@ -325,7 +315,7 @@ function fetchAutopayStats(db, billIds) {
AND p . paid_date >= date ( 'now' , '-12 months' )
GROUP BY p . bill_id
` ).all(...billIds);
return Object . fromEntries ( rows . map ( r = > [ r . bill_id , {
return Object . fromEntries ( rows . map ( ( r : Row ) = > [ r . bill_id , {
total : r.total ,
failures : r.failures || 0 ,
last_failure_date : r.last_failure_date || null ,
@ -333,65 +323,53 @@ function fetchAutopayStats(db, billIds) {
} ] ) ) ;
}
function rowDueAmount ( row ) {
function rowDueAmount ( row : Row ) : number {
const amount = Number ( row . actual_amount ? ? row . expected_amount ) ;
return Number . isFinite ( amount ) ? amount : 0 ;
}
function rowPaidTowardDue ( row ) {
function rowPaidTowardDue ( row : Row ) : number {
const cappedPaid = Number ( row . paid_toward_due ) ;
if ( Number . isFinite ( cappedPaid ) ) return cappedPaid ;
return Math . min ( Number ( row . total_paid ) || 0 , rowDueAmount ( row ) ) ;
}
function rowOutstanding ( row ) {
function rowOutstanding ( row : Row ) : number {
return Math . max ( Number ( row . balance ) || 0 , 0 ) ;
}
/ * *
* Safe - to - spend projection for the current 1 st / 15 th pay period .
*
* Pure function ( no DB ) so it is unit - testable : takes serialized tracker rows
* ( dollar - denominated , post buildTrackerRow ) plus the cash available for the
* period , and answers : " after every bill still due before the next payday is
* covered — including overdue carry - over — what is left to spend ? "
*
* The next payday is the next bucket boundary ( the 15 th , or the 1 st of the
* following month ) , matching the app ' s semi - monthly bucket model .
* Safe - to - spend projection for the current 1 st / 15 th pay period . Pure ( no DB ) .
* /
function buildSafeToSpend ( { activeRows , available , todayStr , year , month , dayOfMonth } ) {
function buildSafeToSpend ( { activeRows , available , todayStr , year , month , dayOfMonth } : { activeRows : any [ ] ; available : any ; todayStr : string ; year : number ; month : number ; dayOfMonth : number } ) : any {
const nextPayday = dayOfMonth < 15
? ` ${ year } - ${ String ( month ) . padStart ( 2 , '0' ) } -15 `
: ( month === 12 ? ` ${ year + 1 } -01-01 ` : ` ${ year } - ${ String ( month + 1 ) . padStart ( 2 , '0' ) } -01 ` ) ;
// Both strings are YYYY-MM-DD → Date.parse treats them as UTC midnight,
// so the difference is an exact whole number of days.
const daysUntilPayday = Math . max ( 0 , Math . round ( ( Date . parse ( nextPayday ) - Date . parse ( todayStr ) ) / 86400000 ) ) ;
const stillDueRows = activeRows
. filter ( r = > ! isPaidStatus ( r . status ) )
. filter ( r = > rowOutstanding ( r ) > 0 )
. filter ( r = > r . due_date < nextPayday )
. sort ( ( a , b ) = > a . due_date . localeCompare ( b . due_date ) || String ( a . name ) . localeCompare ( String ( b . name ) ) ) ;
. filter ( ( r : Row ) = > ! isPaidStatus ( r . status ) )
. filter ( ( r : Row ) = > rowOutstanding ( r ) > 0 )
. filter ( ( r : Row ) = > r . due_date < nextPayday )
. sort ( ( a : Row , b : Row ) = > a . due_date . localeCompare ( b . due_date ) || String ( a . name ) . localeCompare ( String ( b . name ) ) ) ;
const stillDueTotal = sumMoney ( stillDueRows , rowOutstanding ) ;
const safeToSpend = roundMoney ( available - stillDueTotal ) ;
// Daily projection from today to payday: balance steps down as bills hit.
// Overdue bills land on "today" — they need to be paid now, not in the past.
const byDate = new Map ( ) ;
const byDate = new Map < any , any > ( ) ;
for ( const r of stillDueRows ) {
const date = r . due_date > todayStr ? r.due_date : todayStr ;
if ( ! byDate . has ( date ) ) byDate . set ( date , [ ] ) ;
byDate . get ( date ) . push ( { id : r.id , name : r.name , amount : rowOutstanding ( r ) } ) ;
}
const timeline = [ ] ;
const timeline : any [ ] = [ ] ;
let running = roundMoney ( available ) ;
if ( ! byDate . has ( todayStr ) ) timeline . push ( { date : todayStr , balance : running , bills : [ ] } ) ;
for ( const date of [ . . . byDate . keys ( ) ] . sort ( ) ) {
const bills = byDate . get ( date ) ;
running = roundMoney ( running - sumMoney ( bills , b = > b . amount ) ) ;
running = roundMoney ( running - sumMoney ( bills , ( b : Row ) = > b . amount ) ) ;
timeline . push ( { date , balance : running , bills } ) ;
}
if ( timeline . length === 0 || timeline [ timeline . length - 1 ] . date !== nextPayday ) {
@ -405,7 +383,7 @@ function buildSafeToSpend({ activeRows, available, todayStr, year, month, dayOfM
safe_to_spend : safeToSpend ,
still_due_total : stillDueTotal ,
still_due_count : stillDueRows.length ,
upcoming : stillDueRows.slice ( 0 , 8 ) . map ( r = > ( {
upcoming : stillDueRows.slice ( 0 , 8 ) . map ( ( r : Row ) = > ( {
id : r.id ,
name : r.name ,
due_date : r.due_date ,
@ -416,7 +394,7 @@ function buildSafeToSpend({ activeRows, available, todayStr, year, month, dayOfM
} ;
}
function applyAutopaySuggestions ( db , bill , payments , mbs , year , month , todayStr , dismissedSuggestions ) {
function applyAutopaySuggestions ( db : Db , bill : Row , payments : any [ ] , mbs : any , year : number , month : number , todayStr : string , dismissedSuggestions : Set < any > ) : any {
const dueDate = resolveDueDate ( bill , year , month ) ;
if ( ! dueDate ) return null ;
@ -452,9 +430,6 @@ function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr,
}
const balCalc = computeBalanceDelta ( bill , suggestedAmount ) ;
// Atomic: the auto-mark payment INSERT and its balance update must both land
// or neither. This runs on a GET /tracker, so a mid-way failure would
// otherwise leave a payment without its balance adjustment (or vice versa).
const insertAutoPayment = db . transaction ( ( ) = > {
const r = db . prepare ( `
INSERT INTO payments ( bill_id , amount , paid_date , method , notes , balance_delta , interest_delta , payment_source )
@ -492,7 +467,7 @@ function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr,
} ;
}
function buildThreeMonthTrend ( db , userId , year , month , end , currentMonthPaid ) {
function buildThreeMonthTrend ( db : Db , userId : number , year : number , month : number , end : any , currentMonthPaid : any ) : any {
const threeMonthsAgo = monthOffset ( year , month , - 2 ) ;
const threeMonthStart = getCycleRange ( threeMonthsAgo . year , threeMonthsAgo . month ) . start ;
const rows = db . prepare ( `
@ -505,12 +480,12 @@ function buildThreeMonthTrend(db, userId, year, month, end, currentMonthPaid) {
GROUP BY strftime ( '%Y-%m' , p . paid_date )
` ).all(userId, threeMonthStart, end);
const monthlyPaymentsMap = new Map () ;
rows . forEach ( payment = > {
const monthlyPaymentsMap = new Map <any , any > () ;
rows . forEach ( ( payment : Row ) = > {
monthlyPaymentsMap . set ( payment . month_key , payment . total_paid ) ;
} ) ;
const months = [ ] ;
const months : any [ ] = [ ] ;
for ( let i = 2 ; i >= 0 ; i -- ) {
const date = new Date ( year , month - 1 - i ) ;
const monthKey = ` ${ date . getFullYear ( ) } - ${ String ( date . getMonth ( ) + 1 ) . padStart ( 2 , '0' ) } ` ;
@ -522,7 +497,7 @@ function buildThreeMonthTrend(db, userId, year, month, end, currentMonthPaid) {
} ) ;
}
const threeMonthAvg = sumMoney ( months , m = > m . payment ) / 3 ;
const threeMonthAvg = sumMoney ( months , ( m : Row ) = > m . payment ) / 3 ;
let percentChange = 0 ;
let direction = 'flat' ;
if ( threeMonthAvg > 0 ) {
@ -539,11 +514,11 @@ function buildThreeMonthTrend(db, userId, year, month, end, currentMonthPaid) {
} ;
}
function getTracker ( userId , quer y = { } , now = new Date ( ) ) {
function getTracker ( userId : number , quer y: an y = { } , now : Date = new Date ( ) ) : any {
const parsed = validateTrackerMonth ( query , now ) ;
if ( parsed . error ) return parsed ;
const db = getDb ( ) ;
const db : Db = getDb ( ) ;
const { year , month } = parsed ;
const todayStr = localDateString ( now ) ;
const userSettings = getUserSettings ( userId ) ;
@ -553,18 +528,16 @@ function getTracker(userId, query = {}, now = new Date()) {
const prevMonthRange = getCycleRange ( previousMonth . year , previousMonth . month ) ;
const bills = fetchActiveBills ( db , userId ) ;
const billIds = bills . map ( bill = > bill . id ) ;
const billIds = bills . map ( ( bill : Row ) = > bill . id ) ;
const monthlyStates = fetchMonthlyStates ( db , billIds , year , month ) ;
const prevMonthPayments = fetchPreviousMonthPaid ( db , billIds , prevMonthRange ) ;
const dismissedSuggestions = fetchDismissedSuggestions ( db , userId , billIds , year , month ) ;
const sparklines = fetchSparklines ( db , billIds ) ;
const autopayStatsMap = fetchAutopayStats ( db , billIds ) ;
// Batched to avoid an N+1 across bills (was ~2– 3 queries × N bills every load):
// one query for all cycle payments, two for all amount suggestions.
const paymentsByBill = fetchPaymentsForBillsInMonth ( db , bills , year , month ) ;
const amountSuggestions = computeAmountSuggestionsBatch ( db , billIds , year , month ) ;
const rows = bills . map ( bill = > {
const rows = bills . map ( ( bill : Row ) = > {
bill . sparkline = sparklines [ bill . id ] ? ? null ;
bill . autopay_stats = autopayStatsMap [ bill . id ] ? ? null ;
if ( ! resolveDueDate ( bill , year , month ) ) return null ;
@ -572,14 +545,7 @@ function getTracker(userId, query = {}, now = new Date()) {
const payments = paymentsByBill . get ( bill . id ) || [ ] ;
const mbs = monthlyStates [ bill . id ] ;
const autopaySuggestion = applyAutopaySuggestions (
db ,
bill ,
payments ,
mbs ,
year ,
month ,
todayStr ,
dismissedSuggestions ,
db , bill , payments , mbs , year , month , todayStr , dismissedSuggestions ,
) ;
const billForStatus = mbs ? . actual_amount != null
@ -599,7 +565,7 @@ function getTracker(userId, query = {}, now = new Date()) {
return row ;
} ) . filter ( Boolean ) ;
const activeRows = rows . filter ( r = > ! r . is_skipped ) ;
const activeRows = rows . filter ( ( r : Row ) = > ! r . is_skipped ) ;
const startingAmounts = db . prepare ( `
SELECT COALESCE ( first_amount , 0 ) AS first_amount ,
COALESCE ( fifteenth_amount , 0 ) AS fifteenth_amount ,
@ -618,7 +584,7 @@ function getTracker(userId, query = {}, now = new Date()) {
const dayOfMonth = now . getDate ( ) ;
const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th' ;
const periodRows = activeRows . filter ( r = > r . bucket === activeRemainingPeriod ) ;
const periodRows = activeRows . filter ( ( r : Row ) = > r . bucket === activeRemainingPeriod ) ;
const periodPaidTowardDue = sumMoney ( periodRows , rowPaidTowardDue ) ;
const periodOutstandingBalance = sumMoney ( periodRows , rowOutstanding ) ;
const periodStartingAmount = activeRemainingPeriod === '1st'
@ -626,11 +592,7 @@ function getTracker(userId, query = {}, now = new Date()) {
: ( startingAmounts ? . fifteenth_amount || 0 ) ;
const periodLabel = activeRemainingPeriod === '1st' ? '1st balance' : '15th balance' ;
// Occurrence-gated unpaid total for this month: the still-owed amount across
// bills actually due this month (activeRows already honor resolveDueDate),
// netting partial payments. Feeds the bank card so its unpaid/remaining agree
// with the rows instead of over-counting annual/off-month bills.
const activeMonthUnpaid = sumMoney ( activeRows , r = > Math . max ( rowDueAmount ( r ) - rowPaidTowardDue ( r ) , 0 ) ) ;
const activeMonthUnpaid = sumMoney ( activeRows , ( r : Row ) = > Math . max ( rowDueAmount ( r ) - rowPaidTowardDue ( r ) , 0 ) ) ;
const bankTracking = buildBankTracking ( db , userId , year , month , activeMonthUnpaid ) ;
const bankPendingCounts = bankTracking . enabled ? fetchBankPendingCounts ( db , userId , billIds ) : { } ;
const totalStarting = bankTracking . enabled
@ -638,47 +600,34 @@ function getTracker(userId, query = {}, now = new Date()) {
: ( startingAmounts ? . combined_amount || 0 ) ;
const hasStartingAmounts = bankTracking . enabled || ! ! startingAmounts ;
// ── Cash flow projection ───────────────────────────────────────────────────
// "Projected" means starting minus ALL bills due — paid or not.
// This tells the user what they'll have left after everything clears,
// not just what remains after what they've already paid.
const lastDayOfMonth = new Date ( year , month , 0 ) . getDate ( ) ;
const periodEndDay = activeRemainingPeriod === '1st' ? 14 : lastDayOfMonth ;
const periodEndLabel = ` ${ [ 'Jan' , 'Feb' , 'Mar' , 'Apr' , 'May' , 'Jun' , 'Jul' , 'Aug' , 'Sep' , 'Oct' , 'Nov' , 'Dec' ] [ month - 1 ] } ${ periodEndDay } ` ;
const activeTotalPaid = sumMoney ( activeRows , r = > r . total_paid ) ;
const activeTotalPaid = sumMoney ( activeRows , ( r : Row ) = > r . total_paid ) ;
const activePaidTowardDue = sumMoney ( activeRows , rowPaidTowardDue ) ;
const activeTotalExpected = sumMoney ( activeRows , rowDueAmount ) ;
const activeOutstandingBalance = sumMoney ( activeRows , rowOutstanding ) ;
const periodBillsTotal = sumMoney ( periodRows , rowDueAmount ) ;
const periodPaidCount = periodRows . filter ( r = > isPaidStatus ( r . status ) ) . length ;
const periodPaidCount = periodRows . filter ( ( r : Row ) = > isPaidStatus ( r . status ) ) . length ;
const periodTotalCount = periodRows . length ;
// When bank tracking is on use the effective balance as the period starting point
const periodCashStart = bankTracking . enabled
? bankTracking . effective_balance
: periodStartingAmount ;
const periodProjected = roundMoney ( periodCashStart - periodBillsTotal ) ;
const monthBillsTotal = activeTotalExpected ;
const monthPaidCount = activeRows . filter ( r = > isPaidStatus ( r . status ) ) . length ;
const monthPaidCount = activeRows . filter ( ( r : Row ) = > isPaidStatus ( r . status ) ) . length ;
const monthTotalCount = activeRows . length ;
const monthProjected = roundMoney ( totalStarting - monthBillsTotal ) ;
// Safe to spend: cash on hand for this period after already-made payments
// (bank mode: effective balance already nets them out), minus everything
// still due before the next payday.
const availableNow = bankTracking . enabled
? bankTracking . effective_balance
: roundMoney ( periodStartingAmount - periodPaidTowardDue ) ;
const safeToSpend = buildSafeToSpend ( {
activeRows ,
available : availableNow ,
todayStr ,
year ,
month ,
dayOfMonth ,
activeRows , available : availableNow , todayStr , year , month , dayOfMonth ,
} ) ;
const cashflow = {
@ -701,9 +650,9 @@ function getTracker(userId, query = {}, now = new Date()) {
month_projected : monthProjected ,
} ;
const totalOverdue = sumMoney (
rows . filter ( r = > ! r . is_skipped && ( r . status === 'late' || r . status === 'missed' ) ) ,
r = > r . balance ) ;
const previousMonthTotal = sumMoney ( activeRows , r = > r . previous_month_paid ) ;
rows . filter ( ( r : Row ) = > ! r . is_skipped && ( r . status === 'late' || r . status === 'missed' ) ) ,
( r : Row ) = > r . balance ) ;
const previousMonthTotal = sumMoney ( activeRows , ( r : Row ) = > r . previous_month_paid ) ;
return {
year ,
@ -715,11 +664,6 @@ function getTracker(userId, query = {}, now = new Date()) {
has_starting_amounts : hasStartingAmounts ,
total_paid : activeTotalPaid ,
paid_toward_due : activePaidTowardDue ,
// In bank mode the effective bank balance is a single pool (no per-period
// split), so both remaining figures use the bank card's own remaining
// (effective_balance − gated unpaid) — keeping the summary consistent with
// safe-to-spend and the bank card instead of showing a different number
// derived from manual starting amounts.
remaining : roundMoney ( bankTracking . enabled
? bankTracking . remaining
: ( hasStartingAmounts ? periodStartingAmount - periodPaidTowardDue : periodOutstandingBalance ) ) ,
@ -732,23 +676,19 @@ function getTracker(userId, query = {}, now = new Date()) {
? ` ${ periodLabel } : ${ periodStartingAmount . toFixed ( 2 ) } starting minus ${ periodPaidTowardDue . toFixed ( 2 ) } paid toward due `
: ` ${ periodLabel } : unpaid bills due in this period ` ,
overdue : totalOverdue ,
count_paid : activeRows.filter ( r = > r . status === 'paid' ) . length ,
count_upcoming : activeRows.filter ( r = > r . status === 'upcoming' || r . status === 'due_soon' ) . length ,
count_late : activeRows.filter ( r = > r . status === 'late' || r . status === 'missed' ) . length ,
count_autodraft : activeRows.filter ( r = > r . status === 'autodraft' ) . length ,
count_paid : activeRows.filter ( ( r : Row ) = > r . status === 'paid' ) . length ,
count_upcoming : activeRows.filter ( ( r : Row ) = > r . status === 'upcoming' || r . status === 'due_soon' ) . length ,
count_late : activeRows.filter ( ( r : Row ) = > r . status === 'late' || r . status === 'missed' ) . length ,
count_autodraft : activeRows.filter ( ( r : Row ) = > r . status === 'autodraft' ) . length ,
previous_month_total : previousMonthTotal ,
trend : buildThreeMonthTrend ( db , userId , year , month , end , activeTotalPaid ) ,
} ,
bank_tracking : bankTracking ,
cashflow ,
rows : bankTracking.enabled
? rows . map ( r = > {
? rows . map ( ( r : Row ) = > {
const bank_pending_count = bankPendingCounts [ r . id ] || 0 ;
// Only flag manually-entered payments as pending-cleared — bank-synced
// or bank-matched payments are already settled so they don't need the badge.
const isManualPayment = r . payment_source !== 'provider_sync' && r . payment_source !== 'transaction_match' ;
// Amber "Pending" only makes sense for bills linked to the bank — for unlinked bills
// there's no bank clearing to wait for, so they should just show "Paid".
const isBankLinked = ! ! ( r . has_merchant_rule || r . has_linked_transactions ) ;
if ( r . status === 'paid' && r . last_paid_date && isManualPayment && isBankLinked ) {
const cutoff = new Date ( ) ;
@ -762,8 +702,8 @@ function getTracker(userId, query = {}, now = new Date()) {
} ;
}
function getUpcomingBills ( userId , quer y = { } , now = new Date ( ) ) {
const db = getDb ( ) ;
function getUpcomingBills ( userId : number , quer y: an y = { } , now : Date = new Date ( ) ) : any {
const db : Db = getDb ( ) ;
const days = Math . max ( 1 , Math . min ( parseInt ( query . days || '30' , 10 ) || 30 , 365 ) ) ;
const todayStr = localDateString ( now ) ;
const userSettings = getUserSettings ( userId ) ;
@ -773,8 +713,8 @@ function getUpcomingBills(userId, query = {}, now = new Date()) {
const cutoff = new Date ( now ) ;
cutoff . setDate ( cutoff . getDate ( ) + days ) ;
const cutoffStr = localDateString ( cutoff ) ;
const upcoming = [ ] ;
const seen = new Set () ;
const upcoming : any [ ] = [ ] ;
const seen = new Set <any > () ;
const monthCount = ( cutoff . getFullYear ( ) - now . getFullYear ( ) ) * 12
+ ( cutoff . getMonth ( ) - now . getMonth ( ) ) + 1 ;
@ -792,10 +732,7 @@ function getUpcomingBills(userId, query = {}, now = new Date()) {
const row = buildTrackerRow (
bill ,
fetchPaymentsForBillCycle ( db , bill , target . year , target . month ) ,
target . year ,
target . month ,
todayStr ,
rowOptions ,
target . year , target . month , todayStr , rowOptions ,
) ;
if ( ! row || row . status === 'paid' ) continue ;
@ -806,17 +743,17 @@ function getUpcomingBills(userId, query = {}, now = new Date()) {
due_date : dueDate ,
expected_amount : row.expected_amount ,
status : row.status ,
days_until_due : Math.floor ( ( new Date ( ` ${ dueDate } T00:00:00 ` ) - new Date ( ` ${ todayStr } T00:00:00 ` ) ) / 86400000 ) ,
days_until_due : Math.floor ( ( new Date ( ` ${ dueDate } T00:00:00 ` ) . getTime ( ) - new Date ( ` ${ todayStr } T00:00:00 ` ) . getTime ( ) ) / 86400000 ) ,
} ) ;
}
}
upcoming . sort ( ( a , b ) = > a . due_date . localeCompare ( b . due_date ) ) ;
upcoming . sort ( ( a : Row , b : Row ) = > a . due_date . localeCompare ( b . due_date ) ) ;
return { days , today : todayStr , upcoming } ;
}
function getOverdueCount ( userId , now = new Date ( ) ) {
const db = getDb ( ) ;
function getOverdueCount ( userId : number , now : Date = new Date ( ) ) : any {
const db : Db = getDb ( ) ;
const todayStr = localDateString ( now ) ;
const year = now . getFullYear ( ) ;
const month = now . getMonth ( ) + 1 ;
@ -842,14 +779,13 @@ function getOverdueCount(userId, now = new Date()) {
` ).all(year, month, rangeStart, rangeEnd, userId);
let count = 0 ;
const overdueNames = [ ] ;
const overdueNames : any [ ] = [ ] ;
for ( const bill of bills ) {
if ( bill . is_skipped ) continue ;
if ( bill . snoozed_until && bill . snoozed_until > todayStr ) continue ;
if ( bill . autopay_enabled && bill . autodraft_status === 'assumed_paid' ) continue ;
const dueDate = resolveDueDate ( bill , year , month ) ;
// Use >= so bills due TODAY are not counted as overdue — only strictly past dates
if ( ! dueDate || dueDate >= todayStr ) continue ;
const threshold = bill . actual_amount != null ? bill.actual_amount : bill.expected_amount ;