wulf-pulse/app/api/qbo/diagnose-ar/route.ts

183 lines
6.2 KiB
TypeScript
Raw Normal View History

/**
* 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<string, unknown>;
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<string, { id: string; customer: string; balance: number; status: string }>();
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<string>();
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 = <T extends { balance?: number; delta?: number }>(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,
},
});
}