diff --git a/lib/services/pax8-sync-service.ts b/lib/services/pax8-sync-service.ts index fe990e0..a8ea89d 100644 --- a/lib/services/pax8-sync-service.ts +++ b/lib/services/pax8-sync-service.ts @@ -16,10 +16,41 @@ import type { Pax8Company, Pax8Subscription, Pax8Product, + Pax8Invoice, + Pax8InvoiceItem, Pax8EntitySyncResult, Pax8SyncResult, } from '@/lib/types/pax8'; +/** + * Resolves the four dual-cost columns for a single invoice item, branching on + * `item.type` per 12-02-SUMMARY.md's live spot-check verdicts. All three + * observed types (subscription, prorate, one-time) were confirmed CONFIRM + * against the default mapping — no divergence was found — so this switch + * currently collapses to a single default branch. It's kept as a named, + * reviewable seam (not inline ternaries in the upsert call) so any future + * divergence discovered for a new item type has an obvious place to branch. + */ +function resolveCostColumns(item: Pax8InvoiceItem): { + unitPrice: number | null; + lineTotal: number | null; + partnerCost: number | null; + partnerCostTotal: number | null; +} { + switch (item.type) { + // 12-02-SUMMARY.md: subscription, prorate, and one-time all verified + // CONFIRM against the default mapping below — no per-type divergence to + // encode yet. + default: + return { + unitPrice: item.price ?? null, + lineTotal: item.amountDue ?? null, + partnerCost: item.cost ?? null, + partnerCostTotal: item.costTotal ?? null, + }; + } +} + export class Pax8SyncService { private client: Pax8Client; private syncing = false; @@ -324,6 +355,151 @@ export class Pax8SyncService { } } + /** + * Historical invoice/line-item sync (PAX8-06). Nested per-parent fetch + * (12-RESEARCH.md Pattern 1): page all invoice headers once, then for each + * header page its own items — there is no flat `/invoice-items` endpoint. + * Per-company cost lives ONLY on the item; `companyId` is always NULL on + * the header for this single-tenant reseller account (12-RESEARCH.md + * Pitfall 1), so `pax8_orders.pax8_company_id` intentionally stays NULL + * and all per-company cost joins go through `pax8_order_items` instead. + */ + private async syncOrders(): Promise { + const start = Date.now(); + try { + const invoices = await this.client.listAllInvoices(); + console.log(`[Pax8Sync] Fetched ${invoices.length} invoices`); + + let ordersUpserted = 0; + let itemsUpserted = 0; + const seenOrderIds: string[] = []; + const seenItemIds: string[] = []; + + for (const invoice of invoices) { + if (!invoice.id) continue; + seenOrderIds.push(invoice.id); + + // pax8_company_id stays NULL — see method doc / Pitfall 1. + await postgresClient.query( + `INSERT INTO pax8_orders + (id, pax8_company_id, order_date, total, status, currency, + raw_payload, synced_at, is_deleted, deleted_at) + VALUES ($1, NULL, $2, $3, $4, $5, $6, NOW(), false, NULL) + ON CONFLICT (id) DO UPDATE SET + order_date = EXCLUDED.order_date, + total = EXCLUDED.total, + status = EXCLUDED.status, + currency = EXCLUDED.currency, + raw_payload = EXCLUDED.raw_payload, + synced_at = NOW(), + is_deleted = false, + deleted_at = NULL`, + [ + invoice.id, + invoice.invoiceDate ?? null, + invoice.total ?? null, + invoice.status ?? null, + invoice.currencyCode ?? 'USD', + JSON.stringify(invoice), + ] + ); + ordersUpserted++; + + const items = await this.client.listAllInvoiceItems(invoice.id); + for (const item of items) { + if (!item.id) continue; + seenItemIds.push(item.id); + + const { unitPrice, lineTotal, partnerCost, partnerCostTotal } = resolveCostColumns(item); + + await postgresClient.query( + `INSERT INTO pax8_order_items + (id, order_id, product_id, quantity, unit_price, line_total, currency, + raw_payload, synced_at, is_deleted, deleted_at, + pax8_company_id, subscription_id, item_type, sku, description, + start_period, end_period, partner_cost, partner_cost_total) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8, NOW(), false, NULL, + $9,$10,$11,$12,$13,$14,$15,$16,$17) + ON CONFLICT (id) DO UPDATE SET + order_id = EXCLUDED.order_id, + product_id = EXCLUDED.product_id, + quantity = EXCLUDED.quantity, + unit_price = EXCLUDED.unit_price, + line_total = EXCLUDED.line_total, + currency = EXCLUDED.currency, + raw_payload = EXCLUDED.raw_payload, + synced_at = NOW(), + is_deleted = false, + deleted_at = NULL, + pax8_company_id = EXCLUDED.pax8_company_id, + subscription_id = EXCLUDED.subscription_id, + item_type = EXCLUDED.item_type, + sku = EXCLUDED.sku, + description = EXCLUDED.description, + start_period = EXCLUDED.start_period, + end_period = EXCLUDED.end_period, + partner_cost = EXCLUDED.partner_cost, + partner_cost_total = EXCLUDED.partner_cost_total`, + [ + item.id, + invoice.id, + item.productId ?? null, + item.quantity ?? null, + unitPrice, + lineTotal, + item.currencyCode ?? 'USD', + JSON.stringify(item), + item.companyId ?? null, + item.subscriptionId ?? null, + item.type ?? null, + item.sku ?? null, + item.description ?? null, + item.startPeriod ?? null, + item.endPeriod ?? null, + partnerCost, + partnerCostTotal, + ] + ); + itemsUpserted++; + } + } + + // Child-then-parent tombstoning to respect the order_items -> orders FK. + const itemsTombstoned = seenItemIds.length === 0 + ? 0 + : (await postgresClient.query( + `UPDATE pax8_order_items SET is_deleted = true, deleted_at = NOW() + WHERE is_deleted = false AND id <> ALL($1::uuid[])`, + [seenItemIds] + )).rowCount ?? 0; + + const ordersTombstoned = seenOrderIds.length === 0 + ? 0 + : (await postgresClient.query( + `UPDATE pax8_orders SET is_deleted = true, deleted_at = NOW() + WHERE is_deleted = false AND id <> ALL($1::uuid[])`, + [seenOrderIds] + )).rowCount ?? 0; + + const tombstoned = itemsTombstoned + ordersTombstoned; + if (tombstoned > 0) { + console.log(`[Pax8Sync] Tombstoned ${ordersTombstoned} order(s), ${itemsTombstoned} order item(s)`); + } + + return { + entity: 'orders', + success: true, + upserted: ordersUpserted + itemsUpserted, + tombstoned, + durationMs: Date.now() - start, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[Pax8Sync] Order sync failed:`, msg); + return { entity: 'orders', success: false, upserted: 0, tombstoned: 0, durationMs: Date.now() - start, error: msg }; + } + } + // ─── Helpers ──────────────────────────────────────────────────────────── private async insertHistoryStarted(triggeredBy: string, startedAt: Date): Promise {