wulf-pulse/app/api/mobile/finance/route.ts
lorentz 5f4ccb9c56 fix(dashboard): correct NOW() timezone conversion for KPI/trend queries
NOW() returns TIMESTAMPTZ. The pattern
  (NOW() AT TIME ZONE 'UTC' AT TIME ZONE $userTz)::date
double-converts: first strips the tz designation (keeping UTC wall-clock as
naive TIMESTAMP), then re-interprets that wall-clock as user-local
(pushing UTC into the user-tz's UTC equivalent). For non-UTC users this
gives the WRONG date — e.g. NY user at 9pm sees "today = tomorrow's UTC
date", so opened-today returns 0.

The column-side pattern ((col AT TIME ZONE 'UTC') AT TIME ZONE $userTz)
is correct because the columns are TIMESTAMP without TZ (stored as UTC) —
only the NOW() side was buggy. Replace with (NOW() AT TIME ZONE $userTz)
everywhere.

Affects: dashboard overview/trends, mobile dashboard/engagement/finance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:58:35 -04:00

107 lines
4.9 KiB
TypeScript

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] = await Promise.all([
postgresClient.query(
`
SELECT
SUM(balance) FILTER (WHERE status IN ('Open','Overdue')) as total_ar,
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
`,
[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
`,
[tz],
),
postgresClient.query(`
SELECT customer_ref_name, SUM(balance) as balance, COUNT(*) as invoice_count
FROM qbo_invoices
WHERE status IN ('Open','Overdue')
GROUP BY customer_ref_name
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 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 status = 'Paid' AND txn_date >= NOW() - INTERVAL '12 months'
GROUP BY 1 ORDER BY 1 ASC
`),
]);
const s = summary.rows[0];
const a = aging.rows[0];
return NextResponse.json({
summary: {
total_ar: parseFloat(s.total_ar ?? 0),
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),
})),
});
}