2026-03-17 15:31:56 -04:00
|
|
|
import { NextResponse } from 'next/server';
|
2026-05-07 08:04:47 -04:00
|
|
|
import { requireAuth } from '@/lib/auth-utils';
|
2026-03-17 15:31:56 -04:00
|
|
|
import { postgresClient } from '@/lib/services/postgres-client';
|
2026-05-07 08:04:47 -04:00
|
|
|
import { getUserTimezone } from '@/lib/services/user-timezone';
|
2026-03-17 15:31:56 -04:00
|
|
|
|
|
|
|
|
export async function GET() {
|
2026-05-07 08:04:47 -04:00
|
|
|
// 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);
|
|
|
|
|
|
2026-07-18 06:34:57 -04:00
|
|
|
const [summary, aging, topCustomers, overdueInvoices, recentPayments, monthlyRevenue, credits] = await Promise.all([
|
2026-05-07 08:04:47 -04:00
|
|
|
postgresClient.query(
|
|
|
|
|
`
|
2026-03-17 15:31:56 -04:00
|
|
|
SELECT
|
2026-07-18 06:34:57 -04:00
|
|
|
SUM(balance) FILTER (WHERE status IN ('Open','Overdue')) as total_ar_gross,
|
2026-03-17 15:31:56 -04:00
|
|
|
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,
|
2026-05-14 21:58:35 -04:00
|
|
|
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
|
2026-03-17 15:31:56 -04:00
|
|
|
FROM qbo_invoices
|
2026-07-18 06:34:57 -04:00
|
|
|
WHERE is_deleted = false
|
2026-05-07 08:04:47 -04:00
|
|
|
`,
|
|
|
|
|
[tz],
|
|
|
|
|
),
|
|
|
|
|
postgresClient.query(
|
|
|
|
|
`
|
2026-03-17 15:31:56 -04:00
|
|
|
SELECT
|
2026-05-14 21:58:35 -04:00
|
|
|
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
|
2026-03-17 15:31:56 -04:00
|
|
|
FROM qbo_invoices
|
2026-07-18 06:34:57 -04:00
|
|
|
WHERE is_deleted = false
|
2026-05-07 08:04:47 -04:00
|
|
|
`,
|
|
|
|
|
[tz],
|
|
|
|
|
),
|
2026-07-18 06:34:57 -04:00
|
|
|
// Top customers — net out any unapplied payment credits the customer is
|
|
|
|
|
// sitting on, so the number matches QBO's customer A/R view.
|
2026-03-17 15:31:56 -04:00
|
|
|
postgresClient.query(`
|
2026-07-18 06:34:57 -04:00
|
|
|
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
|
2026-03-17 15:31:56 -04:00
|
|
|
`),
|
2026-05-07 08:04:47 -04:00
|
|
|
postgresClient.query(
|
|
|
|
|
`
|
2026-03-17 15:31:56 -04:00
|
|
|
SELECT id, doc_number, txn_date, due_date, customer_ref_name, total_amt, balance, status,
|
2026-05-14 21:58:35 -04:00
|
|
|
(NOW() AT TIME ZONE $1)::date - due_date::date as days_overdue
|
2026-03-17 15:31:56 -04:00
|
|
|
FROM qbo_invoices
|
2026-07-18 06:34:57 -04:00
|
|
|
WHERE is_deleted = false
|
|
|
|
|
AND status IN ('Open','Overdue')
|
2026-03-17 15:31:56 -04:00
|
|
|
ORDER BY status DESC, balance DESC LIMIT 30
|
2026-05-07 08:04:47 -04:00
|
|
|
`,
|
|
|
|
|
[tz],
|
|
|
|
|
),
|
2026-03-17 15:31:56 -04:00
|
|
|
postgresClient.query(`
|
|
|
|
|
SELECT id, txn_date, customer_ref_name, total_amt
|
|
|
|
|
FROM qbo_payments
|
|
|
|
|
ORDER BY txn_date DESC LIMIT 10
|
|
|
|
|
`),
|
2026-05-07 08:04:47 -04:00
|
|
|
// monthlyRevenue: rolling 12-month window — NOT a calendar boundary;
|
|
|
|
|
// tz does not apply.
|
2026-03-17 15:31:56 -04:00
|
|
|
postgresClient.query(`
|
|
|
|
|
SELECT DATE_TRUNC('month', txn_date) as month,
|
|
|
|
|
SUM(total_amt) as revenue, COUNT(*) as invoice_count
|
|
|
|
|
FROM qbo_invoices
|
2026-07-18 06:34:57 -04:00
|
|
|
WHERE is_deleted = false
|
|
|
|
|
AND status = 'Paid'
|
|
|
|
|
AND txn_date >= NOW() - INTERVAL '12 months'
|
2026-03-17 15:31:56 -04:00
|
|
|
GROUP BY 1 ORDER BY 1 ASC
|
|
|
|
|
`),
|
2026-07-18 06:34:57 -04:00
|
|
|
// 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
|
|
|
|
|
`),
|
2026-03-17 15:31:56 -04:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const s = summary.rows[0];
|
|
|
|
|
const a = aging.rows[0];
|
2026-07-18 06:34:57 -04:00
|
|
|
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);
|
2026-03-17 15:31:56 -04:00
|
|
|
return NextResponse.json({
|
|
|
|
|
summary: {
|
2026-07-18 06:34:57 -04:00
|
|
|
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),
|
2026-03-17 15:31:56 -04:00
|
|
|
},
|
|
|
|
|
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),
|
|
|
|
|
})),
|
|
|
|
|
});
|
|
|
|
|
}
|