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:
parent
a8bb55e394
commit
a06c1d314b
1 changed files with 176 additions and 0 deletions
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue