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
564 lines
22 KiB
TypeScript
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;
|
|
}
|