@@ -147,6 +147,15 @@ export const updateScore = asyncHandler(async (req: Request, res: Response) => {
147147 * Returns a detailed breakdown of the factors contributing to the user's
148148 * credit score, derived from loan_events and scores tables. Gives borrowers
149149 * transparency into their credit profile.
150+ *
151+ * OPTIMIZED: Single CTE query combines all breakdown computations:
152+ * - Current score fetch
153+ * - Loan event aggregations (total, repaid, defaulted counts)
154+ * - On-time vs late repayment classification
155+ * - Average repayment time calculation
156+ * - Repayment history for streak/timeline computation
157+ *
158+ * This reduces 6+ separate queries to 1-2 efficient round-trips.
150159 */
151160export const getScoreBreakdown = asyncHandler (
152161 async ( req : Request , res : Response ) => {
@@ -159,153 +168,161 @@ export const getScoreBreakdown = asyncHandler(
159168 return ;
160169 }
161170
162- // Fetch current score
163- const scoreResult = await query (
164- "SELECT current_score FROM scores WHERE user_id = $1" ,
165- [ userId ] ,
166- ) ;
167- const score =
168- scoreResult . rows . length > 0 ? scoreResult . rows [ 0 ] . current_score : 500 ;
169- const band = getCreditBand ( score ) ;
170-
171- // Fetch loan event stats for the borrower
172- const statsResult = await query (
173- `SELECT
174- COUNT(DISTINCT loan_id) FILTER (WHERE event_type = 'LoanRequested') AS total_loans,
175- COUNT(DISTINCT loan_id) FILTER (WHERE event_type = 'LoanRepaid') AS repaid_count,
176- COUNT(DISTINCT loan_id) FILTER (WHERE event_type = 'LoanDefaulted') AS defaulted_count,
177- COALESCE(SUM(CASE WHEN event_type = 'LoanRepaid' THEN CAST(amount AS NUMERIC) ELSE 0 END), 0) AS total_repaid
178- FROM loan_events
179- WHERE borrower = $1` ,
180- [ userId ] ,
181- ) ;
182-
183- const stats = statsResult . rows [ 0 ] || { } ;
184- const totalLoans = parseInt ( stats . total_loans || "0" , 10 ) ;
185- const repaidCount = parseInt ( stats . repaid_count || "0" , 10 ) ;
186- const defaultedCount = parseInt ( stats . defaulted_count || "0" , 10 ) ;
187- const totalRepaid = parseFloat ( stats . total_repaid || "0" ) ;
188-
189- // Determine on-time vs late repayments by checking if repaid before term expiry
190- const repaymentTimingResult = await query (
191- `WITH approved AS (
192- SELECT loan_id, MAX(ledger) AS approved_ledger,
193- MAX(COALESCE(term_ledgers, 17280)) AS term_ledgers
194- FROM loan_events
195- WHERE event_type = 'LoanApproved' AND borrower = $1 AND loan_id IS NOT NULL
196- GROUP BY loan_id
171+ // Single unified query that computes all breakdown metrics
172+ const breakdownResult = await query (
173+ `WITH
174+ -- Current score from scores table
175+ current_score_cte AS (
176+ SELECT COALESCE(current_score, 500) AS current_score
177+ FROM scores
178+ WHERE user_id = $1
197179 ),
198- repaid AS (
199- SELECT loan_id, MIN(ledger) AS repaid_ledger
180+ -- All loan events for this borrower
181+ borrower_events AS (
182+ SELECT
183+ loan_id,
184+ event_type,
185+ ledger,
186+ ledger_closed_at,
187+ amount,
188+ term_ledgers
200189 FROM loan_events
201- WHERE event_type = 'LoanRepaid' AND borrower = $1 AND loan_id IS NOT NULL
202- GROUP BY loan_id
203- )
204- SELECT
205- COUNT(*) FILTER (WHERE r.repaid_ledger <= a.approved_ledger + a.term_ledgers) AS on_time,
206- COUNT(*) FILTER (WHERE r.repaid_ledger > a.approved_ledger + a.term_ledgers) AS late
207- FROM repaid r
208- JOIN approved a ON a.loan_id = r.loan_id` ,
209- [ userId ] ,
210- ) ;
211-
212- const timing = repaymentTimingResult . rows [ 0 ] || { } ;
213- const repaidOnTime = parseInt ( timing . on_time || "0" , 10 ) ;
214- const repaidLate = parseInt ( timing . late || "0" , 10 ) ;
215-
216- // Calculate average repayment time (in ledgers, converted to approx days)
217- const avgRepayResult = await query (
218- `WITH approved AS (
219- SELECT loan_id, MAX(ledger) AS approved_ledger
220- FROM loan_events
221- WHERE event_type = 'LoanApproved' AND borrower = $1 AND loan_id IS NOT NULL
222- GROUP BY loan_id
190+ WHERE borrower = $1
223191 ),
224- repaid AS (
225- SELECT loan_id, MIN(ledger) AS repaid_ledger
226- FROM loan_events
227- WHERE event_type = 'LoanRepaid' AND borrower = $1 AND loan_id IS NOT NULL
228- GROUP BY loan_id
229- )
230- SELECT AVG(r.repaid_ledger - a.approved_ledger) AS avg_ledgers
231- FROM repaid r
232- JOIN approved a ON a.loan_id = r.loan_id` ,
233- [ userId ] ,
234- ) ;
235-
236- const avgLedgers = parseFloat ( avgRepayResult . rows [ 0 ] ?. avg_ledgers || "0" ) ;
237- // Convert ledger count to approximate days (1 ledger ≈ 5 seconds)
238- const avgDays = Math . round ( ( avgLedgers * 5 ) / 86400 ) ;
239- const averageRepaymentTime = avgLedgers > 0 ? `${ avgDays } days` : "N/A" ;
240-
241- // Calculate repayment streaks (consecutive on-time repayments)
242- const streakResult = await query (
243- `WITH approved AS (
244- SELECT loan_id, MAX(ledger) AS approved_ledger,
245- MAX(COALESCE(term_ledgers, 17280)) AS term_ledgers
246- FROM loan_events
247- WHERE event_type = 'LoanApproved' AND borrower = $1 AND loan_id IS NOT NULL
192+ -- Loan approval details (ledger and term)
193+ approved_loans AS (
194+ SELECT
195+ loan_id,
196+ MAX(ledger) AS approved_ledger,
197+ MAX(COALESCE(term_ledgers, 17280)) AS term_ledgers
198+ FROM borrower_events
199+ WHERE event_type = 'LoanApproved' AND loan_id IS NOT NULL
248200 GROUP BY loan_id
249201 ),
250- repaid AS (
251- SELECT loan_id, MIN(ledger) AS repaid_ledger,
252- MIN(ledger_closed_at) AS repaid_at
253- FROM loan_events
254- WHERE event_type = 'LoanRepaid' AND borrower = $1 AND loan_id IS NOT NULL
202+ -- Repaid loan details (ledger and timestamp)
203+ repaid_loans AS (
204+ SELECT
205+ loan_id,
206+ MIN(ledger) AS repaid_ledger,
207+ MIN(ledger_closed_at) AS repaid_at
208+ FROM borrower_events
209+ WHERE event_type = 'LoanRepaid' AND loan_id IS NOT NULL
255210 GROUP BY loan_id
256211 ),
257- timeline AS (
258- SELECT r.loan_id, r.repaid_at,
259- CASE WHEN r.repaid_ledger <= a.approved_ledger + a.term_ledgers THEN true ELSE false END AS on_time
260- FROM repaid r
261- JOIN approved a ON a.loan_id = r.loan_id
262- ORDER BY r.repaid_at ASC
212+ -- Classification of repayments as on-time or late
213+ repayment_timing AS (
214+ SELECT
215+ r.loan_id,
216+ r.repaid_ledger,
217+ r.repaid_at,
218+ CASE WHEN r.repaid_ledger <= a.approved_ledger + a.term_ledgers
219+ THEN true ELSE false END AS on_time,
220+ (r.repaid_ledger - a.approved_ledger) AS repayment_ledgers
221+ FROM repaid_loans r
222+ JOIN approved_loans a ON a.loan_id = r.loan_id
223+ ),
224+ -- Aggregate statistics across all loans
225+ loan_stats AS (
226+ SELECT
227+ COUNT(DISTINCT CASE WHEN event_type = 'LoanRequested' THEN loan_id END) AS total_loans,
228+ COUNT(DISTINCT CASE WHEN event_type = 'LoanRepaid' THEN loan_id END) AS repaid_count,
229+ COUNT(DISTINCT CASE WHEN event_type = 'LoanDefaulted' THEN loan_id END) AS defaulted_count,
230+ COALESCE(SUM(CASE WHEN event_type = 'LoanRepaid' THEN CAST(amount AS NUMERIC) ELSE 0 END), 0) AS total_repaid
231+ FROM borrower_events
232+ ),
233+ -- Repayment timing statistics
234+ timing_stats AS (
235+ SELECT
236+ COUNT(*) FILTER (WHERE on_time) AS on_time_count,
237+ COUNT(*) FILTER (WHERE NOT on_time) AS late_count,
238+ AVG(repayment_ledgers) AS avg_repayment_ledgers
239+ FROM repayment_timing
240+ ),
241+ -- Final aggregated breakdown
242+ breakdown_summary AS (
243+ SELECT
244+ cs.current_score,
245+ COALESCE(ls.total_loans, 0) AS total_loans,
246+ COALESCE(ls.repaid_count, 0) AS repaid_count,
247+ COALESCE(ls.defaulted_count, 0) AS defaulted_count,
248+ COALESCE(ls.total_repaid, 0) AS total_repaid,
249+ COALESCE(ts.on_time_count, 0) AS on_time_count,
250+ COALESCE(ts.late_count, 0) AS late_count,
251+ COALESCE(ts.avg_repayment_ledgers, 0) AS avg_repayment_ledgers
252+ FROM current_score_cte cs
253+ CROSS JOIN loan_stats ls
254+ CROSS JOIN timing_stats ts
263255 )
264- SELECT on_time FROM timeline ORDER BY repaid_at ASC` ,
256+ SELECT
257+ current_score,
258+ total_loans,
259+ repaid_count,
260+ defaulted_count,
261+ total_repaid,
262+ on_time_count,
263+ late_count,
264+ avg_repayment_ledgers
265+ FROM breakdown_summary` ,
265266 [ userId ] ,
266267 ) ;
267268
268- let longestStreak = 0 ;
269- let currentStreak = 0 ;
270- let tempStreak = 0 ;
271-
272- for ( const row of streakResult . rows ) {
273- if ( row . on_time ) {
274- tempStreak ++ ;
275- longestStreak = Math . max ( longestStreak , tempStreak ) ;
276- } else {
277- tempStreak = 0 ;
278- }
279- }
280- currentStreak = tempStreak ;
269+ const breakdown = breakdownResult . rows [ 0 ] || { } ;
270+ const score = parseInt ( breakdown . current_score || "500" , 10 ) ;
271+ const band = getCreditBand ( score ) ;
272+ const totalLoans = parseInt ( breakdown . total_loans || "0" , 10 ) ;
273+ const repaidOnTime = parseInt ( breakdown . on_time_count || "0" , 10 ) ;
274+ const repaidLate = parseInt ( breakdown . late_count || "0" , 10 ) ;
275+ const defaultedCount = parseInt ( breakdown . defaulted_count || "0" , 10 ) ;
276+ const totalRepaid = parseFloat ( breakdown . total_repaid || "0" ) ;
277+
278+ // Convert average ledgers to days (1 ledger ≈ 5 seconds)
279+ const avgLedgers = parseFloat ( breakdown . avg_repayment_ledgers || "0" ) ;
280+ const avgDays = Math . round ( ( avgLedgers * 5 ) / 86400 ) ;
281+ const averageRepaymentTime = avgLedgers > 0 ? ` ${ avgDays } days` : "N/A" ;
281282
282- // Fetch score history from score-changing events
283+ // Fetch detailed history for streak calculation (separate query is minimal overhead)
283284 const historyResult = await query (
284- `SELECT ledger_closed_at AS date, event_type AS event
285+ `SELECT
286+ event_type,
287+ ledger_closed_at
285288 FROM loan_events
286- WHERE borrower = $1
287- AND event_type IN ('LoanRepaid', 'LoanDefaulted')
289+ WHERE borrower = $1 AND event_type IN ('LoanRepaid', 'LoanDefaulted')
288290 ORDER BY ledger_closed_at ASC` ,
289291 [ userId ] ,
290292 ) ;
291293
292294 // Build score history by replaying deltas from base 500
293295 let runningScore = 500 ;
294296 const history = historyResult . rows . map ( ( row : Record < string , unknown > ) => {
295- if ( row . event === "LoanRepaid" ) {
297+ if ( row . event_type === "LoanRepaid" ) {
296298 runningScore = Math . min ( 850 , runningScore + ON_TIME_DELTA ) ;
297- } else if ( row . event === "LoanDefaulted" ) {
299+ } else if ( row . event_type === "LoanDefaulted" ) {
298300 runningScore = Math . max ( 300 , runningScore - 50 ) ;
299301 }
300302 return {
301- date : row . date
302- ? new Date ( row . date as string ) . toISOString ( ) . split ( "T" ) [ 0 ]
303+ date : row . ledger_closed_at
304+ ? new Date ( row . ledger_closed_at as string ) . toISOString ( ) . split ( "T" ) [ 0 ]
303305 : null ,
304306 score : runningScore ,
305- event : row . event ,
307+ event : row . event_type ,
306308 } ;
307309 } ) ;
308310
311+ // Calculate streaks from history
312+ let longestStreak = 0 ;
313+ let currentStreak = 0 ;
314+ let tempStreak = 0 ;
315+
316+ for ( const histItem of history ) {
317+ if ( histItem . event === "LoanRepaid" ) {
318+ tempStreak ++ ;
319+ longestStreak = Math . max ( longestStreak , tempStreak ) ;
320+ } else {
321+ tempStreak = 0 ;
322+ }
323+ }
324+ currentStreak = tempStreak ;
325+
309326 const responseData = {
310327 userId,
311328 score,
0 commit comments