Bundles several in-progress efforts that were sitting uncommitted: - User queue-preferences (migration 087, API route, popover component) - QBO invoice soft-delete (migration 088) and AR diagnostics route - Dashboard/mobile engagement route and page adjustments - Docker Compose log-rotation config - One-off ticket/RMM investigation scripts (scripts/) - Planning docs: phase verification/pattern notes, mobile shell design spec - .gitignore: exclude local scratch financial/inventory data and Claude Code worktree/local-settings runtime state (never meant for version control) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY
182 lines
6.2 KiB
TypeScript
182 lines
6.2 KiB
TypeScript
/**
|
|
* 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,
|
|
},
|
|
});
|
|
}
|