wulf-pulse/lib/services/qbo-sync-service.ts
lorentz 672f17b7f9 chore: check in pending work — queue preferences, QBO AR diagnostics, mobile engagement fixes, ops scripts
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
2026-07-18 06:34:57 -04:00

564 lines
22 KiB
TypeScript

/**
* QuickBooks Online Sync Service
* Fetches invoices, payments, deposits, transactions, and financial reports
* from QBO and upserts them into PostgreSQL.
*/
import postgresClient from './postgres-client';
import { QboClient, getQboClient } from './qbo-client';
import {
QboInvoice,
QboPayment,
QboDeposit,
QboPurchase,
QboJournalEntry,
QboReport,
QboSyncResult,
QboEntitySyncResult,
} from '@/lib/types/qbo';
export class QboSyncService {
private client: QboClient;
private isSyncing = false;
constructor(client?: QboClient) {
this.client = client || getQboClient();
}
isSyncInProgress(): boolean {
return this.isSyncing;
}
async fullSync(triggeredBy = 'system'): Promise<QboSyncResult> {
return this.executeSync('full', triggeredBy);
}
async incrementalSync(triggeredBy = 'system'): Promise<QboSyncResult> {
return this.executeSync('incremental', triggeredBy);
}
private async executeSync(
syncType: 'full' | 'incremental',
triggeredBy: string
): Promise<QboSyncResult> {
if (this.isSyncing) {
throw new Error('QBO sync already in progress');
}
this.isSyncing = true;
const syncId = `qbo_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const startedAt = new Date();
const entities: QboEntitySyncResult[] = [];
const errors: string[] = [];
const realmId = process.env.QBO_REALM_ID || '';
console.log(`[QboSync] Starting ${syncType} sync (${syncId}) triggered by ${triggeredBy}`);
try {
// Determine updatedSince for incremental
let updatedSince: Date | undefined;
if (syncType === 'incremental') {
updatedSince = await this.getLastSyncTime();
}
// Sync invoices. Pass syncType so a full sync can tombstone any
// rows QBO no longer returns (voided / deleted there).
entities.push(await this.syncInvoices(realmId, updatedSince, syncType));
// Sync payments
entities.push(await this.syncPayments(realmId, updatedSince));
// Sync deposits
entities.push(await this.syncDeposits(realmId, updatedSince));
// Sync purchases (expenses/credit card charges)
entities.push(await this.syncPurchases(realmId, updatedSince));
// Sync journal entries
entities.push(await this.syncJournalEntries(realmId, updatedSince));
// Sync reports — always fetch current + last 12 months on full, current month on incremental
if (syncType === 'full') {
entities.push(await this.syncReports(realmId, 12));
} else {
entities.push(await this.syncReports(realmId, 1));
}
// Record sync history
await this.recordSyncHistory(syncId, syncType, triggeredBy, startedAt, 'completed', entities);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
errors.push(msg);
console.error(`[QboSync] Sync failed: ${msg}`);
await this.recordSyncHistory(syncId, syncType, triggeredBy, startedAt, 'failed', entities, msg);
} finally {
this.isSyncing = false;
}
const completedAt = new Date();
return {
syncId,
realmId,
status: errors.length === 0 ? 'completed' : 'failed',
startedAt,
completedAt,
duration: completedAt.getTime() - startedAt.getTime(),
entities,
errors,
};
}
// ─── Entity Sync Methods ───────────────────────────────────────────────────
private async syncInvoices(
realmId: string,
updatedSince?: Date,
syncType: 'full' | 'incremental' = 'incremental',
): Promise<QboEntitySyncResult> {
const start = Date.now();
try {
const invoices = await this.client.getInvoices(updatedSince);
console.log(`[QboSync] Fetched ${invoices.length} invoices`);
let upserted = 0;
const seenIds: string[] = [];
for (const inv of invoices) {
if (inv.Id) seenIds.push(inv.Id);
const status = this.deriveInvoiceStatus(inv);
await postgresClient.query(
`INSERT INTO qbo_invoices
(id, realm_id, doc_number, txn_date, due_date, customer_ref_id, customer_ref_name,
email_address, total_amt, balance, status, currency_code, line_items, linked_txns,
sync_token, qbo_created_at, qbo_updated_at, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,NOW())
ON CONFLICT (id) DO UPDATE SET
doc_number = EXCLUDED.doc_number,
txn_date = EXCLUDED.txn_date,
due_date = EXCLUDED.due_date,
customer_ref_id = EXCLUDED.customer_ref_id,
customer_ref_name = EXCLUDED.customer_ref_name,
email_address = EXCLUDED.email_address,
total_amt = EXCLUDED.total_amt,
balance = EXCLUDED.balance,
status = EXCLUDED.status,
currency_code = EXCLUDED.currency_code,
line_items = EXCLUDED.line_items,
linked_txns = EXCLUDED.linked_txns,
sync_token = EXCLUDED.sync_token,
qbo_updated_at = EXCLUDED.qbo_updated_at,
synced_at = NOW(),
is_deleted = false,
deleted_at = NULL`,
[
inv.Id, realmId, inv.DocNumber ?? null,
inv.TxnDate ?? null, inv.DueDate ?? null,
inv.CustomerRef?.value ?? null, inv.CustomerRef?.name ?? null,
inv.BillEmail?.Address ?? null,
inv.TotalAmt ?? null, inv.Balance ?? null,
status, inv.CurrencyRef?.value ?? 'USD',
JSON.stringify(inv.Line ?? []),
JSON.stringify(inv.LinkedTxn ?? []),
inv.SyncToken,
inv.MetaData?.CreateTime ?? null,
inv.MetaData?.LastUpdatedTime ?? null,
]
);
upserted++;
}
// Tombstone pass — full syncs only.
//
// QBO's Invoice query never reports deletions; voided/deleted invoices
// just stop appearing. A full sync just fetched every live invoice
// for this realm, so any Pulse row not in `seenIds` is an orphan and
// should be soft-deleted so it stops counting toward A/R.
//
// Incremental syncs are skipped here — they only see recently-changed
// rows, so applying the same logic would tombstone the entire ledger.
let tombstoned = 0;
if (syncType === 'full' && seenIds.length > 0) {
const tombstoneRes = await postgresClient.query<{ id: string }>(
`UPDATE qbo_invoices
SET is_deleted = true,
deleted_at = NOW()
WHERE realm_id = $1
AND is_deleted = false
AND id <> ALL($2::text[])
RETURNING id`,
[realmId, seenIds],
);
tombstoned = tombstoneRes.rowCount ?? tombstoneRes.rows.length;
if (tombstoned > 0) {
console.log(`[QboSync] Tombstoned ${tombstoned} invoice(s) absent from full-sync response`);
}
}
return {
entity: 'invoices',
success: true,
recordsUpserted: upserted,
recordsDeleted: tombstoned,
duration: Date.now() - start,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[QboSync] Invoice sync failed: ${msg}`);
return { entity: 'invoices', success: false, recordsUpserted: 0, duration: Date.now() - start, error: msg };
}
}
private async syncPayments(realmId: string, updatedSince?: Date): Promise<QboEntitySyncResult> {
const start = Date.now();
try {
const payments = await this.client.getPayments(updatedSince);
console.log(`[QboSync] Fetched ${payments.length} payments`);
let upserted = 0;
for (const pay of payments) {
await postgresClient.query(
`INSERT INTO qbo_payments
(id, realm_id, txn_date, customer_ref_id, customer_ref_name, total_amt, unapplied_amt,
currency_code, payment_method_ref, deposit_account_ref, linked_txns,
sync_token, qbo_created_at, qbo_updated_at, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,NOW())
ON CONFLICT (id) DO UPDATE SET
txn_date = EXCLUDED.txn_date,
customer_ref_id = EXCLUDED.customer_ref_id,
customer_ref_name = EXCLUDED.customer_ref_name,
total_amt = EXCLUDED.total_amt,
unapplied_amt = EXCLUDED.unapplied_amt,
currency_code = EXCLUDED.currency_code,
payment_method_ref = EXCLUDED.payment_method_ref,
deposit_account_ref = EXCLUDED.deposit_account_ref,
linked_txns = EXCLUDED.linked_txns,
sync_token = EXCLUDED.sync_token,
qbo_updated_at = EXCLUDED.qbo_updated_at,
synced_at = NOW()`,
[
pay.Id, realmId, pay.TxnDate ?? null,
pay.CustomerRef?.value ?? null, pay.CustomerRef?.name ?? null,
pay.TotalAmt ?? null, pay.UnappliedAmt ?? null,
pay.CurrencyRef?.value ?? 'USD',
pay.PaymentMethodRef?.name ?? null,
pay.DepositToAccountRef?.name ?? null,
JSON.stringify(pay.Line ?? []),
pay.SyncToken,
pay.MetaData?.CreateTime ?? null,
pay.MetaData?.LastUpdatedTime ?? null,
]
);
upserted++;
}
return { entity: 'payments', success: true, recordsUpserted: upserted, duration: Date.now() - start };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[QboSync] Payment sync failed: ${msg}`);
return { entity: 'payments', success: false, recordsUpserted: 0, duration: Date.now() - start, error: msg };
}
}
private async syncDeposits(realmId: string, updatedSince?: Date): Promise<QboEntitySyncResult> {
const start = Date.now();
try {
const deposits = await this.client.getDeposits(updatedSince);
console.log(`[QboSync] Fetched ${deposits.length} deposits`);
let upserted = 0;
for (const dep of deposits) {
await postgresClient.query(
`INSERT INTO qbo_deposits
(id, realm_id, txn_date, deposit_to_account_ref_id, deposit_to_account_ref_name,
total_amt, line_items, sync_token, qbo_created_at, qbo_updated_at, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW())
ON CONFLICT (id) DO UPDATE SET
txn_date = EXCLUDED.txn_date,
deposit_to_account_ref_id = EXCLUDED.deposit_to_account_ref_id,
deposit_to_account_ref_name = EXCLUDED.deposit_to_account_ref_name,
total_amt = EXCLUDED.total_amt,
line_items = EXCLUDED.line_items,
sync_token = EXCLUDED.sync_token,
qbo_updated_at = EXCLUDED.qbo_updated_at,
synced_at = NOW()`,
[
dep.Id, realmId, dep.TxnDate ?? null,
dep.DepositToAccountRef?.value ?? null,
dep.DepositToAccountRef?.name ?? null,
dep.TotalAmt ?? null,
JSON.stringify(dep.Line ?? []),
dep.SyncToken,
dep.MetaData?.CreateTime ?? null,
dep.MetaData?.LastUpdatedTime ?? null,
]
);
upserted++;
}
return { entity: 'deposits', success: true, recordsUpserted: upserted, duration: Date.now() - start };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[QboSync] Deposit sync failed: ${msg}`);
return { entity: 'deposits', success: false, recordsUpserted: 0, duration: Date.now() - start, error: msg };
}
}
private async syncPurchases(realmId: string, updatedSince?: Date): Promise<QboEntitySyncResult> {
const start = Date.now();
try {
const purchases = await this.client.getPurchases(updatedSince);
console.log(`[QboSync] Fetched ${purchases.length} purchases`);
let upserted = 0;
for (const p of purchases) {
await postgresClient.query(
`INSERT INTO qbo_transactions
(id, txn_type, realm_id, txn_date, doc_number, entity_ref_id, entity_ref_name,
entity_type, account_ref_id, account_ref_name, total_amt, currency_code,
private_note, line_items, sync_token, qbo_created_at, qbo_updated_at, synced_at)
VALUES ($1,'Purchase',$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,NOW())
ON CONFLICT (id, txn_type) DO UPDATE SET
txn_date = EXCLUDED.txn_date,
doc_number = EXCLUDED.doc_number,
entity_ref_id = EXCLUDED.entity_ref_id,
entity_ref_name = EXCLUDED.entity_ref_name,
entity_type = EXCLUDED.entity_type,
account_ref_id = EXCLUDED.account_ref_id,
account_ref_name = EXCLUDED.account_ref_name,
total_amt = EXCLUDED.total_amt,
currency_code = EXCLUDED.currency_code,
private_note = EXCLUDED.private_note,
line_items = EXCLUDED.line_items,
sync_token = EXCLUDED.sync_token,
qbo_updated_at = EXCLUDED.qbo_updated_at,
synced_at = NOW()`,
[
p.Id, realmId, p.TxnDate ?? null, p.DocNumber ?? null,
p.EntityRef?.value ?? null, p.EntityRef?.name ?? null,
p.EntityRef?.type ?? null,
p.AccountRef?.value ?? null, p.AccountRef?.name ?? null,
p.TotalAmt ?? null, p.CurrencyRef?.value ?? 'USD',
p.PrivateNote ?? null,
JSON.stringify(p.Line ?? []),
p.SyncToken,
p.MetaData?.CreateTime ?? null,
p.MetaData?.LastUpdatedTime ?? null,
]
);
upserted++;
}
return { entity: 'purchases', success: true, recordsUpserted: upserted, duration: Date.now() - start };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[QboSync] Purchase sync failed: ${msg}`);
return { entity: 'purchases', success: false, recordsUpserted: 0, duration: Date.now() - start, error: msg };
}
}
private async syncJournalEntries(realmId: string, updatedSince?: Date): Promise<QboEntitySyncResult> {
const start = Date.now();
try {
const entries = await this.client.getJournalEntries(updatedSince);
console.log(`[QboSync] Fetched ${entries.length} journal entries`);
let upserted = 0;
for (const je of entries) {
await postgresClient.query(
`INSERT INTO qbo_transactions
(id, txn_type, realm_id, txn_date, doc_number, total_amt, currency_code,
private_note, line_items, sync_token, qbo_created_at, qbo_updated_at, synced_at)
VALUES ($1,'JournalEntry',$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,NOW())
ON CONFLICT (id, txn_type) DO UPDATE SET
txn_date = EXCLUDED.txn_date,
doc_number = EXCLUDED.doc_number,
total_amt = EXCLUDED.total_amt,
currency_code = EXCLUDED.currency_code,
private_note = EXCLUDED.private_note,
line_items = EXCLUDED.line_items,
sync_token = EXCLUDED.sync_token,
qbo_updated_at = EXCLUDED.qbo_updated_at,
synced_at = NOW()`,
[
je.Id, realmId, je.TxnDate ?? null, je.DocNumber ?? null,
je.TotalAmt ?? null, je.CurrencyRef?.value ?? 'USD',
je.PrivateNote ?? null,
JSON.stringify(je.Line ?? []),
je.SyncToken,
je.MetaData?.CreateTime ?? null,
je.MetaData?.LastUpdatedTime ?? null,
]
);
upserted++;
}
return { entity: 'journal_entries', success: true, recordsUpserted: upserted, duration: Date.now() - start };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[QboSync] Journal entry sync failed: ${msg}`);
return { entity: 'journal_entries', success: false, recordsUpserted: 0, duration: Date.now() - start, error: msg };
}
}
private async syncReports(realmId: string, monthsBack: number): Promise<QboEntitySyncResult> {
const start = Date.now();
let upserted = 0;
let skipped = 0;
const errors: string[] = [];
try {
// Fetch company accounting preference once
const { reportBasis } = await this.client.getPreferences();
console.log(`[QboSync] Report sync using accounting method: ${reportBasis}`);
const now = new Date();
for (let i = 0; i < monthsBack; i++) {
const periodEnd = new Date(now.getFullYear(), now.getMonth() - i + 1, 0);
const periodStart = new Date(now.getFullYear(), now.getMonth() - i, 1);
const startStr = this.formatDate(periodStart);
const endStr = this.formatDate(periodEnd);
// P&L
try {
const pl = await this.client.getProfitAndLoss(startStr, endStr, reportBasis);
if (pl?.Header?.NoReportData === 'true') {
console.log(`[QboSync] P&L ${startStr}: no data, skipping`);
skipped++;
} else {
await this.upsertReport(realmId, 'ProfitAndLoss', periodStart, periodEnd, pl);
console.log(`[QboSync] P&L ${startStr}: upserted`);
upserted++;
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
errors.push(`P&L ${startStr}: ${msg}`);
}
// Balance Sheet
try {
const bs = await this.client.getBalanceSheet(startStr, endStr, reportBasis);
if (bs?.Header?.NoReportData === 'true') {
console.log(`[QboSync] BalanceSheet ${startStr}: no data, skipping`);
skipped++;
} else {
await this.upsertReport(realmId, 'BalanceSheet', periodStart, periodEnd, bs);
console.log(`[QboSync] BalanceSheet ${startStr}: upserted`);
upserted++;
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
errors.push(`BalanceSheet ${startStr}: ${msg}`);
}
// Cash Flow
try {
const cf = await this.client.getCashFlow(startStr, endStr);
if (cf?.Header?.NoReportData === 'true') {
console.log(`[QboSync] CashFlow ${startStr}: no data, skipping`);
skipped++;
} else {
await this.upsertReport(realmId, 'CashFlow', periodStart, periodEnd, cf);
console.log(`[QboSync] CashFlow ${startStr}: upserted`);
upserted++;
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
errors.push(`CashFlow ${startStr}: ${msg}`);
}
}
console.log(`[QboSync] Reports: ${upserted} upserted, ${skipped} skipped, ${errors.length} errors`);
if (errors.length > 0) {
console.warn(`[QboSync] Report errors: ${errors.join('; ')}`);
}
return {
entity: 'reports',
success: errors.length === 0,
recordsUpserted: upserted,
duration: Date.now() - start,
error: errors.length > 0 ? errors.join('; ') : undefined,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[QboSync] Report sync failed: ${msg}`);
return { entity: 'reports', success: false, recordsUpserted: upserted, duration: Date.now() - start, error: msg };
}
}
private async upsertReport(
realmId: string,
reportType: string,
periodStart: Date,
periodEnd: Date,
data: QboReport
): Promise<void> {
await postgresClient.query(
`INSERT INTO qbo_reports (realm_id, report_type, period_start, period_end, report_data, synced_at)
VALUES ($1, $2, $3, $4, $5, NOW())
ON CONFLICT (realm_id, report_type, period_start, period_end) DO UPDATE SET
report_data = EXCLUDED.report_data,
synced_at = NOW()`,
[realmId, reportType, periodStart, periodEnd, JSON.stringify(data)]
);
}
// ─── Helpers ────────────────────────────────────────────────────────────────
private deriveInvoiceStatus(inv: QboInvoice): string {
if ((inv.Balance ?? 0) === 0 && (inv.TotalAmt ?? 0) > 0) return 'Paid';
if ((inv.Balance ?? 0) > 0 && inv.DueDate && new Date(inv.DueDate) < new Date()) return 'Overdue';
if ((inv.Balance ?? 0) > 0) return 'Open';
return 'Unknown';
}
private formatDate(d: Date): string {
return d.toISOString().split('T')[0];
}
private async getLastSyncTime(): Promise<Date | undefined> {
try {
const result = await postgresClient.query<{ synced_at: Date }>(
`SELECT MAX(synced_at) as synced_at FROM qbo_invoices`
);
return result.rows[0]?.synced_at ?? undefined;
} catch {
return undefined;
}
}
private async recordSyncHistory(
_syncId: string,
syncType: string,
triggeredBy: string,
startedAt: Date,
status: string,
entities: QboEntitySyncResult[],
errorMessage?: string
): Promise<void> {
try {
const totalRecords = entities.reduce((sum, e) => sum + e.recordsUpserted, 0);
const normalizedType = syncType === 'incremental' ? 'incremental' : 'full';
await postgresClient.query(
`INSERT INTO sync_history
(entity_type, sync_type, status, records_added, started_at, completed_at, triggered_by, error_message)
VALUES ('qbo', $1, $2, $3, $4, NOW(), $5, $6)`,
[normalizedType, status, totalRecords, startedAt, triggeredBy, errorMessage ?? null]
);
} catch (err) {
console.warn('[QboSync] Failed to record sync history:', err);
}
}
}
let _instance: QboSyncService | null = null;
export function getQboSyncService(): QboSyncService {
if (!_instance) {
_instance = new QboSyncService();
}
return _instance;
}