feat(12-04): add syncOrders nested invoice->item upsert + tombstone

- Adds Pax8SyncService.syncOrders(): pages all invoice headers, then
  per-header pages its items (12-RESEARCH.md Pattern 1 nested fetch)
- resolveCostColumns() branches on item.type per 12-02-SUMMARY.md's
  live spot-check verdicts (all types CONFIRM -> single default branch,
  kept as a named seam for future divergence)
- pax8_orders.pax8_company_id stays NULL (Pitfall 1); per-company data
  lives on pax8_order_items.pax8_company_id
- Tombstones child (pax8_order_items) before parent (pax8_orders) to
  respect the FK, using the existing id <> ALL($1::uuid[]) pattern
This commit is contained in:
lorentz 2026-07-10 22:52:03 -04:00
parent a8bb55e394
commit a06c1d314b

View file

@ -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<Pax8EntitySyncResult> {
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<void> {