/** * GET /api/qbo/diagnose-ar * One-off reconciliation diagnostic — pulls QBO's Aged Receivable Detail * report live and diffs it against Pulse's qbo_invoices to identify the * gap between Pulse's headline A/R and what QBO reports. * * Returns per-invoice mismatches in three buckets: * - in_qbo_not_in_pulse — QBO knows about it, Pulse doesn't (sync miss) * - in_pulse_not_in_qbo — Pulse has live A/R for it, QBO doesn't * (likely a tombstone candidate) * - balance_diff — both have it but the balance differs */ import { NextResponse } from 'next/server'; import { requireAdmin } from '@/lib/auth-utils'; import postgresClient from '@/lib/services/postgres-client'; import { QboClient } from '@/lib/services/qbo-client'; // QBO report rows are deeply nested — walk them and yield every leaf "Data" // row. Each leaf carries an array of column values matching the report's // column header (Customer | Date | Transaction Type | Num | Due Date | Aging | // Open Balance, etc.). function* walkRows(node: unknown): Generator<{ values: string[]; group?: string }> { if (!node || typeof node !== 'object') return; const n = node as Record; if (Array.isArray(n.Row)) { for (const child of n.Row as unknown[]) yield* walkRows(child); return; } if (n.Rows) { yield* walkRows(n.Rows); return; } if (n.type === 'Data' && n.ColData && Array.isArray(n.ColData)) { const values = (n.ColData as Array<{ value?: string }>).map((c) => c?.value ?? ''); yield { values, group: typeof n.group === 'string' ? n.group : undefined }; } if (n.Header || n.Summary) { // Section node — recurse into its rows if (n.Rows) yield* walkRows(n.Rows); } } export async function GET() { const { error } = await requireAdmin(); if (error) return error; const client = new QboClient(); const report = await client.getAgedReceivableDetail(); // Map columns by ColTitle so we don't rely on positional order const cols: Array<{ ColTitle?: string; ColType?: string }> = (report.Columns?.Column ?? []) as Array<{ ColTitle?: string; ColType?: string }>; const idx = (title: string) => cols.findIndex((c) => (c.ColTitle ?? '').toLowerCase() === title.toLowerCase()); const iNum = idx('Num'); const iCust = idx('Customer'); const iBalance = idx('Open Balance'); const iType = idx('Transaction Type'); const iAging = idx('Aging'); type QboLine = { docNumber: string; customer: string; type: string; aging: string; balance: number; }; const qboInvoices: QboLine[] = []; let qboTotal = 0; for (const row of walkRows(report.Rows)) { const v = row.values; if (!v.length) continue; const balanceStr = iBalance >= 0 ? v[iBalance] : ''; const balance = balanceStr ? parseFloat(balanceStr) : 0; const docNumber = iNum >= 0 ? v[iNum] : ''; const customer = iCust >= 0 ? v[iCust] : ''; const type = iType >= 0 ? v[iType] : ''; const aging = iAging >= 0 ? v[iAging] : ''; // Skip total/summary rows (no doc number, no customer) if (!docNumber && !customer) continue; qboInvoices.push({ docNumber, customer, type, aging, balance }); qboTotal += balance; } const pulseRes = await postgresClient.query<{ id: string; doc_number: string | null; customer_ref_name: string | null; balance: string; status: string; }>( `SELECT id, doc_number, customer_ref_name, balance::text, status FROM qbo_invoices WHERE is_deleted = false AND status IN ('Open','Overdue') AND balance <> 0`, ); const pulseByDoc = new Map(); let pulseTotal = 0; for (const r of pulseRes.rows) { const balance = parseFloat(r.balance); pulseTotal += balance; if (r.doc_number) { pulseByDoc.set(r.doc_number, { id: r.id, customer: r.customer_ref_name ?? '', balance, status: r.status, }); } } // Diff const inQboNotInPulse: QboLine[] = []; const balanceDiff: Array<{ docNumber: string; customer: string; qboBalance: number; pulseBalance: number; delta: number; }> = []; const seenDocs = new Set(); for (const inv of qboInvoices) { if (!inv.docNumber) continue; seenDocs.add(inv.docNumber); const pulse = pulseByDoc.get(inv.docNumber); if (!pulse) { inQboNotInPulse.push(inv); continue; } const delta = Math.round((inv.balance - pulse.balance) * 100) / 100; if (Math.abs(delta) > 0.005) { balanceDiff.push({ docNumber: inv.docNumber, customer: inv.customer || pulse.customer, qboBalance: inv.balance, pulseBalance: pulse.balance, delta, }); } } const inPulseNotInQbo: Array<{ docNumber: string; customer: string; balance: number; status: string; }> = []; for (const [docNumber, p] of pulseByDoc) { if (!seenDocs.has(docNumber)) { inPulseNotInQbo.push({ docNumber, customer: p.customer, balance: p.balance, status: p.status, }); } } const sumOf = (arr: T[], key: 'balance' | 'delta') => Math.round(arr.reduce((s, x) => s + (x[key] ?? 0), 0) * 100) / 100; return NextResponse.json({ summary: { qbo_aging_total: Math.round(qboTotal * 100) / 100, pulse_gross_ar: Math.round(pulseTotal * 100) / 100, qbo_invoice_count: qboInvoices.length, pulse_invoice_count: pulseRes.rows.length, gap: Math.round((pulseTotal - qboTotal) * 100) / 100, }, differences: { in_qbo_not_in_pulse: { count: inQboNotInPulse.length, sum: sumOf(inQboNotInPulse, 'balance'), rows: inQboNotInPulse }, in_pulse_not_in_qbo: { count: inPulseNotInQbo.length, sum: sumOf(inPulseNotInQbo, 'balance'), rows: inPulseNotInQbo }, balance_diff: { count: balanceDiff.length, sum: sumOf(balanceDiff, 'delta'), rows: balanceDiff }, }, report_meta: { report_name: report.Header?.ReportName, end_period: report.Header?.EndPeriod, time: report.Header?.Time, report_basis: report.Header?.ReportBasis, }, }); }