import { NextResponse } from 'next/server'; import { requireAuth } from '@/lib/auth-utils'; import { postgresClient } from '@/lib/services/postgres-client'; import { getUserTimezone } from '@/lib/services/user-timezone'; export async function GET() { // Auth gate (Phase 7.1): aligns this route with every other // /api/mobile/* handler and lets us resolve the caller's tz from // the session. Browser callers carry the Better Auth session // cookie automatically, so the existing /mobile/finance page works // unchanged. const { session, error } = await requireAuth(); if (error) return error; const tz = getUserTimezone(session); const [summary, aging, topCustomers, overdueInvoices, recentPayments, monthlyRevenue, credits] = await Promise.all([ postgresClient.query( ` SELECT SUM(balance) FILTER (WHERE status IN ('Open','Overdue')) as total_ar_gross, COUNT(*) FILTER (WHERE status IN ('Open','Overdue')) as total_ar_count, SUM(balance) FILTER (WHERE status = 'Open') as current_balance, COUNT(*) FILTER (WHERE status = 'Open') as current_count, SUM(balance) FILTER (WHERE status = 'Overdue') as overdue_balance, COUNT(*) FILTER (WHERE status = 'Overdue') as overdue_count, SUM(total_amt) FILTER (WHERE status = 'Paid' AND (txn_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= DATE_TRUNC('month', NOW() AT TIME ZONE $1)) as paid_mtd, SUM(total_amt) FILTER (WHERE status = 'Paid' AND (txn_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= DATE_TRUNC('year', NOW() AT TIME ZONE $1)) as paid_ytd FROM qbo_invoices WHERE is_deleted = false `, [tz], ), postgresClient.query( ` SELECT SUM(balance) FILTER (WHERE status = 'Overdue' AND due_date >= (NOW() AT TIME ZONE $1)::date - 30) as days_1_30, COUNT(*) FILTER (WHERE status = 'Overdue' AND due_date >= (NOW() AT TIME ZONE $1)::date - 30) as cnt_1_30, SUM(balance) FILTER (WHERE status = 'Overdue' AND due_date < (NOW() AT TIME ZONE $1)::date - 30 AND due_date >= (NOW() AT TIME ZONE $1)::date - 60) as days_31_60, COUNT(*) FILTER (WHERE status = 'Overdue' AND due_date < (NOW() AT TIME ZONE $1)::date - 30 AND due_date >= (NOW() AT TIME ZONE $1)::date - 60) as cnt_31_60, SUM(balance) FILTER (WHERE status = 'Overdue' AND due_date < (NOW() AT TIME ZONE $1)::date - 60) as days_60_plus, COUNT(*) FILTER (WHERE status = 'Overdue' AND due_date < (NOW() AT TIME ZONE $1)::date - 60) as cnt_60_plus FROM qbo_invoices WHERE is_deleted = false `, [tz], ), // Top customers — net out any unapplied payment credits the customer is // sitting on, so the number matches QBO's customer A/R view. postgresClient.query(` WITH inv AS ( SELECT customer_ref_id, customer_ref_name, SUM(balance) AS gross_balance, COUNT(*) AS invoice_count FROM qbo_invoices WHERE is_deleted = false AND status IN ('Open','Overdue') GROUP BY customer_ref_id, customer_ref_name ), cred AS ( SELECT customer_ref_id, SUM(unapplied_amt) AS unapplied FROM qbo_payments WHERE unapplied_amt > 0 GROUP BY customer_ref_id ) SELECT inv.customer_ref_name, GREATEST(inv.gross_balance - COALESCE(cred.unapplied, 0), 0) AS balance, inv.invoice_count FROM inv LEFT JOIN cred ON cred.customer_ref_id = inv.customer_ref_id ORDER BY balance DESC LIMIT 8 `), postgresClient.query( ` SELECT id, doc_number, txn_date, due_date, customer_ref_name, total_amt, balance, status, (NOW() AT TIME ZONE $1)::date - due_date::date as days_overdue FROM qbo_invoices WHERE is_deleted = false AND status IN ('Open','Overdue') ORDER BY status DESC, balance DESC LIMIT 30 `, [tz], ), postgresClient.query(` SELECT id, txn_date, customer_ref_name, total_amt FROM qbo_payments ORDER BY txn_date DESC LIMIT 10 `), // monthlyRevenue: rolling 12-month window — NOT a calendar boundary; // tz does not apply. postgresClient.query(` SELECT DATE_TRUNC('month', txn_date) as month, SUM(total_amt) as revenue, COUNT(*) as invoice_count FROM qbo_invoices WHERE is_deleted = false AND status = 'Paid' AND txn_date >= NOW() - INTERVAL '12 months' GROUP BY 1 ORDER BY 1 ASC `), // Unapplied customer credits — payments not yet linked to an invoice. // QBO nets these against A/R in its customer balance reports. postgresClient.query(` SELECT COALESCE(SUM(unapplied_amt), 0) AS total_unapplied FROM qbo_payments WHERE unapplied_amt > 0 `), ]); const s = summary.rows[0]; const a = aging.rows[0]; const unappliedCredits = parseFloat(credits.rows[0]?.total_unapplied ?? 0); const grossAr = parseFloat(s.total_ar_gross ?? 0); // QBO's A/R reports net unapplied customer credits against the gross // invoice balance. Mirror that so the dashboard headline matches QBO. const netAr = Math.max(grossAr - unappliedCredits, 0); return NextResponse.json({ summary: { total_ar: netAr, total_ar_gross: grossAr, unapplied_credits: unappliedCredits, total_ar_count: parseInt(s.total_ar_count ?? 0), current_balance: parseFloat(s.current_balance ?? 0), current_count: parseInt(s.current_count ?? 0), overdue_balance: parseFloat(s.overdue_balance ?? 0), overdue_count: parseInt(s.overdue_count ?? 0), paid_mtd: parseFloat(s.paid_mtd ?? 0), paid_ytd: parseFloat(s.paid_ytd ?? 0), }, aging: { days_1_30: { balance: parseFloat(a.days_1_30 ?? 0), count: parseInt(a.cnt_1_30 ?? 0) }, days_31_60: { balance: parseFloat(a.days_31_60 ?? 0), count: parseInt(a.cnt_31_60 ?? 0) }, days_60_plus:{ balance: parseFloat(a.days_60_plus ?? 0),count: parseInt(a.cnt_60_plus ?? 0) }, }, top_customers: topCustomers.rows, open_invoices: overdueInvoices.rows, recent_payments: recentPayments.rows, monthly_revenue: monthlyRevenue.rows.map(r => ({ month: r.month, revenue: parseFloat(r.revenue), count: parseInt(r.invoice_count), })), }); }