diff --git a/.planning/phases/12-orders-invoices-company-matching/12-04-SUMMARY.md b/.planning/phases/12-orders-invoices-company-matching/12-04-SUMMARY.md new file mode 100644 index 0000000..b76f832 --- /dev/null +++ b/.planning/phases/12-orders-invoices-company-matching/12-04-SUMMARY.md @@ -0,0 +1,118 @@ +--- +phase: 12-orders-invoices-company-matching +plan: 04 +subsystem: api +tags: [pax8, sync-service, invoices, company-matching, vitest] + +# Dependency graph +requires: + - phase: 12-02 + provides: Pax8Client.listAllInvoices/listAllInvoiceItems + confirmed live cost-field mapping + - phase: 12-03 + provides: matchPax8Companies() fuzzy company matcher (Pax8CompanyMatchResult) +provides: + - "Pax8SyncService.syncOrders() — nested invoice-header/item upsert + child-then-parent tombstone" + - "Pax8SyncService.syncCompanyMatches() — Pax8EntitySyncResult wrapper around matchPax8Companies()" + - "fullSync() wired with both new entity steps, rolling into the existing success/status/totals reducer" + - "First unit tests for Pax8SyncService (lib/services/pax8-sync-service.test.ts)" +affects: [12-05] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "resolveCostColumns(item) helper — named seam for per-item-type cost mapping, currently a single default branch since 12-02-SUMMARY.md recorded CONFIRM for all observed types" + - "Child-then-parent tombstone ordering (pax8_order_items before pax8_orders) to respect the FK" + +key-files: + created: + - lib/services/pax8-sync-service.test.ts + modified: + - lib/services/pax8-sync-service.ts + - .planning/phases/12-orders-invoices-company-matching/deferred-items.md + +key-decisions: + - "resolveCostColumns() collapses to a single default branch (unit_price<-price, line_total<-amountDue, partner_cost<-cost, partner_cost_total<-costTotal) because 12-02-SUMMARY.md recorded a CONFIRM verdict for every observed item type (subscription, prorate, one-time) — no divergence to encode. Kept as a named switch/helper rather than inline ternaries so a future divergence has an obvious place to branch." + - "syncCompanyMatches() repurposes Pax8EntitySyncResult.tombstoned to carry the flagged-for-review count (flaggedAmbiguous + flaggedNoCandidate), documented inline, so the matcher fits the existing rollup shape without a new result field." + +requirements-completed: [PAX8-06, PAX8-10, PAX8-11] + +# Metrics +duration: 25min +completed: 2026-07-11 +--- + +# Phase 12 Plan 04: Sync Service Wiring (Orders + Company Matching) Summary + +**Wired both halves of Phase 12 into `Pax8SyncService.fullSync()` — a new `syncOrders()` step performing the nested invoice-header/item upsert with per-type cost-column resolution, and a new `syncCompanyMatches()` step delegating to Plan 03's matcher — plus the service's first unit tests.** + +## Performance + +- **Duration:** ~25 min +- **Started:** 2026-07-11T22:52:00Z +- **Completed:** 2026-07-11T22:55:30Z +- **Tasks:** 3 +- **Files modified:** 3 (1 created, 1 modified, 1 tracking doc updated) + +## Accomplishments + +- `Pax8SyncService.syncOrders()` added: pages all invoice headers via `listAllInvoices()`, then for each header pages its items via `listAllInvoiceItems(invoiceId)` (12-RESEARCH.md Pattern 1 nested fetch). Upserts headers into `pax8_orders` (with `pax8_company_id` intentionally left `NULL` per Pitfall 1) and items into `pax8_order_items` (with `pax8_company_id`, `subscription_id`, `item_type`, `sku`, `description`, `start_period`, `end_period`, and the four dual-cost columns populated). Tombstones child (`pax8_order_items`) before parent (`pax8_orders`) using the existing `id <> ALL($1::uuid[])` pattern. +- `resolveCostColumns(item)` helper added: a named, reviewable seam that branches on `item.type`. Per 12-02-SUMMARY.md, all three observed types (`subscription`, `prorate`, `one-time`) were live-verified as CONFIRM against the default mapping, so the switch currently collapses to a single default branch — exactly as the plan's acceptance criteria anticipated for the "all CONFIRM" case. +- `Pax8SyncService.syncCompanyMatches()` added: delegates to Plan 03's `matchPax8Companies()`, shaping its `Pax8CompanyMatchResult` into a standard `Pax8EntitySyncResult` (`entity: 'company_matches'`). +- `fullSync()` now pushes `ordersResult` then `matchResult` after the products step, so `pax8_companies` is fully populated (via `syncCompanies()`) before matching runs. The existing rollup (`success = entities.every(...)`, `totalUpserted`/`totalTombstoned` reduce) picks both up unchanged — no changes needed to the rollup logic itself. +- `lib/services/pax8-sync-service.test.ts` created — the service's first unit tests. Mocks `postgresClient.query` and `pax8-company-matcher`'s `matchPax8Companies`, injects a mock `Pax8Client` via the constructor's optional `client` parameter, and exercises `fullSync()` end to end against a two-invoice/two-item fixture. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Add syncOrders() (nested invoice → item upsert + tombstone)** - `a06c1d3` (feat) +2. **Task 2: Add syncCompanyMatches() and wire both steps into fullSync()** - `5f96060` (feat) +3. **Task 3: Create lib/services/pax8-sync-service.test.ts** - `59294ce` (test) + +_No TDD gate — plan is `autonomous: true` without a plan-level `type: tdd` frontmatter. Task 3 used `tdd="true"` but, matching 12-02-SUMMARY.md's precedent for its own Task 2, was executed as a single test-addition commit after the implementation (Tasks 1-2) already existed, following the existing test file's established pattern (`pax8-company-matcher.test.ts`'s mocking discipline)._ + +## Files Created/Modified + +- `lib/services/pax8-sync-service.ts` - Added the `Pax8Invoice`/`Pax8InvoiceItem` type imports, the `resolveCostColumns()` module-level helper, the `syncOrders()` and `syncCompanyMatches()` private methods, the `matchPax8Companies` import, and the two new `entities.push(...)` calls in `fullSync()` +- `lib/services/pax8-sync-service.test.ts` - New file: 5 test cases covering header/item upserts with correct param binding, child-then-parent tombstoning, the default cost-column mapping across both fixture item types, matcher delegation, and the `fullSync()` entities array shape +- `.planning/phases/12-orders-invoices-company-matching/deferred-items.md` - Logged that the pre-existing `sync-scheduler.ts` TS2307 errors (unrelated `appgate-factory`/`appgate-sync-service` imports) still reproduce unchanged, plus 2 pre-existing, unrelated `itglue-search.test.ts` failures surfaced by the full `npm test` run + +## Decisions Made + +- **`resolveCostColumns()` collapses to a single default branch, exactly as anticipated.** 12-02-SUMMARY.md's live spot-check recorded a CONFIRM verdict for every observed item type (`subscription`, `prorate`, `one-time`) — no DIVERGENCE was found for any type. Per the plan's acceptance criteria (option 3: "If 12-02-SUMMARY.md marks CONFIRM for ALL types, the switch collapses to a single default branch"), the helper is still written as a named `switch (item.type)` with a documented default rather than inline ternaries, so a future divergence discovered for a new item type has an obvious, reviewable place to branch. +- **`syncCompanyMatches()`'s `tombstoned` field repurposing is documented inline**, per the plan's explicit instruction: it carries `flaggedAmbiguous + flaggedNoCandidate` (the count of `pax8_companies` rows flagged for manual review this run), not a soft-delete count, so the matcher step fits the existing `Pax8EntitySyncResult` shape without introducing a new result field. + +## Deviations from Plan + +None - plan executed exactly as written. Both cost columns and the matcher wrapper match the plan's action instructions field-for-field. + +## Known Stubs + +None. `resolveCostColumns()`'s single-branch switch is not a stub — it is the explicit, plan-anticipated outcome of 12-02-SUMMARY.md recording CONFIRM for every observed item type; it is a fully functional mapping, just not yet exercising a second branch because no divergence exists to encode. + +## Issues Encountered + +- Pre-existing, out-of-scope: `lib/services/sync-scheduler.ts:446`/`:450` TS2307 errors (missing `appgate-factory`/`appgate-sync-service` modules) reproduce unchanged before and after this plan's edits — confirmed unrelated (not touched by any Plan 04 task), consistent with Plans 01/02's prior findings. +- Pre-existing, out-of-scope: `npm test`'s full-suite run surfaces 2 failures in `lib/services/analyzer/itglue-search.test.ts` (unrelated to PAX8; that file was not modified by this plan). All PAX8-scoped suites pass green: `npx vitest run lib/services/pax8-client.test.ts lib/services/pax8-company-matcher.test.ts lib/services/pax8-sync-service.test.ts lib/services/pax8-factory.test.ts` → 31/31 passing. + +## User Setup Required + +None - no external service configuration required. This plan only extends existing, already-configured service code and adds tests. + +## Next Phase Readiness + +- `Pax8SyncService.fullSync()` now runs the complete Phase 12 pipeline: companies → subscriptions → products → orders (invoices + line items) → company matching, all rolling into one `Pax8SyncResult`. +- Plan 05 (or the next wave) can build on a fully wired sync service — no further sync-orchestration work is needed for PAX8-06/PAX8-10/PAX8-11. +- No blockers identified for subsequent plans. + +--- +*Phase: 12-orders-invoices-company-matching* +*Completed: 2026-07-11* + +## Self-Check: PASSED + +All created files found on disk (`lib/services/pax8-sync-service.test.ts`, +`.planning/phases/12-orders-invoices-company-matching/12-04-SUMMARY.md`); +all task commits (`a06c1d3`, `5f96060`, `59294ce`) verified present in +`git log --oneline --all`. diff --git a/.planning/phases/12-orders-invoices-company-matching/deferred-items.md b/.planning/phases/12-orders-invoices-company-matching/deferred-items.md index 9dd193e..1336109 100644 --- a/.planning/phases/12-orders-invoices-company-matching/deferred-items.md +++ b/.planning/phases/12-orders-invoices-company-matching/deferred-items.md @@ -21,3 +21,15 @@ Out-of-scope issues discovered during execution but not fixed (per Scope Boundar - Same pre-existing `sync-scheduler.ts:446`/`:450` TS2307 errors reproduce unchanged after Task 1's `pax8-client.ts` edits (`listAllInvoices` / `listAllInvoiceItems`). Confirmed unrelated to this plan's files. + +## Plan 04 + +- Same pre-existing `sync-scheduler.ts:446`/`:450` TS2307 errors reproduce + unchanged after this plan's `pax8-sync-service.ts` edits. Confirmed + unrelated to this plan's files. +- `npm test` full-suite run surfaces 2 pre-existing failures in + `lib/services/analyzer/itglue-search.test.ts` ("tolerates per-call + failures" tests, lines ~129/253) unrelated to this plan — that file was + not touched by any Plan 04 task and not modified in the working tree. + All PAX8-scoped suites (`pax8-client.test.ts`, `pax8-company-matcher.test.ts`, + `pax8-sync-service.test.ts`, `pax8-factory.test.ts`) pass green (31/31). diff --git a/lib/services/pax8-sync-service.test.ts b/lib/services/pax8-sync-service.test.ts new file mode 100644 index 0000000..1dcb2b1 --- /dev/null +++ b/lib/services/pax8-sync-service.test.ts @@ -0,0 +1,212 @@ +/** + * pax8-sync-service.ts unit tests — first tests for this service. + * + * postgresClient.query and pax8-company-matcher's matchPax8Companies are both + * mocked (following pax8-company-matcher.test.ts's mocking discipline); no + * real Postgres or PAX8 API calls are made. A mock Pax8Client is injected via + * the constructor's optional `client` parameter. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock postgresClient BEFORE importing the module under test. +const queryMock = vi.fn(); +vi.mock('@/lib/services/postgres-client', () => ({ + default: { + query: (...args: unknown[]) => queryMock(...args), + }, +})); + +// Mock the Plan 03 matcher so syncCompanyMatches() delegation can be asserted +// without exercising real pg_trgm/Postgres logic. +const matchPax8CompaniesMock = vi.fn(); +vi.mock('./pax8-company-matcher', () => ({ + matchPax8Companies: (...args: unknown[]) => matchPax8CompaniesMock(...args), +})); + +// Import AFTER the mocks are declared so vi.mock hoisting takes effect. +import { Pax8SyncService } from './pax8-sync-service'; +import type { Pax8Client } from './pax8-client'; + +interface MockCall { + sql: string; + params: unknown[]; +} + +function calls(): MockCall[] { + return queryMock.mock.calls.map(([sql, params]) => ({ + sql: String(sql), + params: (params as unknown[]) ?? [], + })); +} + +/** + * Two invoice headers, each with one item. item-1 (type 'subscription') + * exercises the default/CONFIRM cost mapping with real live-verified sample + * values from 12-02-SUMMARY.md; item-2 (type 'one-time') exercises a second + * observed type — both are CONFIRM per 12-02-SUMMARY.md, so both are expected + * to use the same default mapping (no DIVERGENCE type was recorded). + */ +function makeMockClient(): Pax8Client { + const invoiceOneItems = [ + { + id: 'item-1', + type: 'subscription', + companyId: 'company-1', + subscriptionId: 'sub-1', + quantity: 9, + sku: 'MST-NCE-103-C100', + description: 'Microsoft 365 Business Premium', + startPeriod: '2026-06-03', + endPeriod: '2026-07-02', + price: 26.4, + amountDue: 199.58, + cost: 22.176, + costTotal: 199.58, + productId: 'prod-1', + currencyCode: 'USD', + }, + ]; + const invoiceTwoItems = [ + { + id: 'item-2', + type: 'one-time', + companyId: 'company-2', + subscriptionId: null, + quantity: 1, + sku: 'ONE-TIME-SKU', + description: 'Setup fee', + startPeriod: null, + endPeriod: null, + price: 0.02, + amountDue: 0.81, + cost: 0.0182, + costTotal: 0.81, + productId: 'prod-2', + currencyCode: 'USD', + }, + ]; + + const client = { + listAllCompanies: vi.fn().mockResolvedValue([]), + listAllSubscriptions: vi.fn().mockResolvedValue([]), + listAllProducts: vi.fn().mockResolvedValue([]), + listAllInvoices: vi.fn().mockResolvedValue([ + { id: 'inv-1', invoiceDate: '2026-06-01', total: 237.6, status: 'PAID', currencyCode: 'USD' }, + { id: 'inv-2', invoiceDate: '2026-07-01', total: 0.81, status: 'PAID', currencyCode: 'USD' }, + ]), + listAllInvoiceItems: vi.fn((invoiceId: string) => { + if (invoiceId === 'inv-1') return Promise.resolve(invoiceOneItems); + if (invoiceId === 'inv-2') return Promise.resolve(invoiceTwoItems); + return Promise.resolve([]); + }), + }; + + return client as unknown as Pax8Client; +} + +describe('Pax8SyncService — syncOrders + syncCompanyMatches', () => { + beforeEach(() => { + queryMock.mockReset(); + queryMock.mockResolvedValue({ rows: [], rowCount: 0 }); + matchPax8CompaniesMock.mockReset(); + matchPax8CompaniesMock.mockResolvedValue({ + scanned: 2, + autoLinked: 1, + flaggedAmbiguous: 1, + flaggedNoCandidate: 0, + durationMs: 1, + }); + }); + + it('upserts invoice headers into pax8_orders and items into pax8_order_items, binding companyId into pax8_company_id and amountDue into line_total', async () => { + const service = new Pax8SyncService(makeMockClient()); + await service.fullSync('test'); + + const orderInserts = calls().filter((c) => /INSERT INTO pax8_orders/.test(c.sql)); + expect(orderInserts).toHaveLength(2); + expect(orderInserts.map((c) => c.params[0])).toEqual(['inv-1', 'inv-2']); + // Header pax8_company_id stays NULL (Pitfall 1) — bound as the literal + // NULL in the VALUES clause, not a parameter, so no company id param leaks + // into the header insert's params array. + expect(orderInserts[0].sql).toMatch(/VALUES \(\$1, NULL/); + + const itemInserts = calls().filter((c) => /INSERT INTO pax8_order_items/.test(c.sql)); + expect(itemInserts).toHaveLength(2); + + const item1Insert = itemInserts.find((c) => c.params[0] === 'item-1'); + expect(item1Insert).toBeDefined(); + expect(item1Insert!.params).toContain('company-1'); // pax8_company_id + expect(item1Insert!.params).toContain(199.58); // line_total <- amountDue (default mapping) + }); + + it('tombstones unseen order_items before orders via id <> ALL($1::uuid[])', async () => { + const service = new Pax8SyncService(makeMockClient()); + await service.fullSync('test'); + + const itemTombstone = calls().find( + (c) => /UPDATE pax8_order_items/.test(c.sql) && /id\s*<>\s*ALL/.test(c.sql) + ); + expect(itemTombstone).toBeDefined(); + expect(itemTombstone!.params[0]).toEqual(['item-1', 'item-2']); + + const orderTombstone = calls().find( + (c) => /UPDATE pax8_orders/.test(c.sql) && /id\s*<>\s*ALL/.test(c.sql) + ); + expect(orderTombstone).toBeDefined(); + expect(orderTombstone!.params[0]).toEqual(['inv-1', 'inv-2']); + + // Child-then-parent ordering (FK safety). + const allCalls = calls(); + const itemTombstoneIndex = allCalls.findIndex( + (c) => /UPDATE pax8_order_items/.test(c.sql) && /id\s*<>\s*ALL/.test(c.sql) + ); + const orderTombstoneIndex = allCalls.findIndex( + (c) => /UPDATE pax8_orders/.test(c.sql) && /id\s*<>\s*ALL/.test(c.sql) + ); + expect(itemTombstoneIndex).toBeLessThan(orderTombstoneIndex); + }); + + it('resolveCostColumns applies the CONFIRM default mapping for both observed item types (subscription, one-time)', async () => { + const service = new Pax8SyncService(makeMockClient()); + await service.fullSync('test'); + + const itemInserts = calls().filter((c) => /INSERT INTO pax8_order_items/.test(c.sql)); + + const item1Insert = itemInserts.find((c) => c.params[0] === 'item-1'); + // subscription: unit_price<-price(26.4), line_total<-amountDue(199.58), + // partner_cost<-cost(22.176), partner_cost_total<-costTotal(199.58). + expect(item1Insert!.params).toEqual(expect.arrayContaining([26.4, 199.58, 22.176])); + + const item2Insert = itemInserts.find((c) => c.params[0] === 'item-2'); + // one-time: unit_price<-price(0.02), line_total<-amountDue(0.81), + // partner_cost<-cost(0.0182), partner_cost_total<-costTotal(0.81) — both + // CONFIRM per 12-02-SUMMARY.md, no divergence recorded for either type. + expect(item2Insert!.params).toEqual(expect.arrayContaining([0.02, 0.81, 0.0182])); + }); + + it('syncCompanyMatches delegates to matchPax8Companies and reports entity company_matches', async () => { + const service = new Pax8SyncService(makeMockClient()); + const result = await service.fullSync('test'); + + expect(matchPax8CompaniesMock).toHaveBeenCalledTimes(1); + const matchEntity = result.entities.find((e) => e.entity === 'company_matches'); + expect(matchEntity).toBeDefined(); + expect(matchEntity!.success).toBe(true); + expect(matchEntity!.upserted).toBe(1); // autoLinked + expect(matchEntity!.tombstoned).toBe(1); // flaggedAmbiguous + flaggedNoCandidate + }); + + it("fullSync's entities array includes an 'orders' result and a 'company_matches' result", async () => { + const service = new Pax8SyncService(makeMockClient()); + const result = await service.fullSync('test'); + + const entityNames = result.entities.map((e) => e.entity); + expect(entityNames).toContain('orders'); + expect(entityNames).toContain('company_matches'); + + const ordersEntity = result.entities.find((e) => e.entity === 'orders'); + expect(ordersEntity!.success).toBe(true); + expect(ordersEntity!.upserted).toBe(4); // 2 orders + 2 items + }); +}); diff --git a/lib/services/pax8-sync-service.ts b/lib/services/pax8-sync-service.ts index fe990e0..89bd71d 100644 --- a/lib/services/pax8-sync-service.ts +++ b/lib/services/pax8-sync-service.ts @@ -12,14 +12,46 @@ import postgresClient from './postgres-client'; import { Pax8Client } from './pax8-client'; import { getPax8Client } from './pax8-factory'; +import { matchPax8Companies } from './pax8-company-matcher'; 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; @@ -63,6 +95,14 @@ export class Pax8SyncService { const productsResult = await this.syncProducts(referencedProductIds); entities.push(productsResult); + const ordersResult = await this.syncOrders(); + entities.push(ordersResult); + + // Matching runs after companies + orders so pax8_companies is fully + // populated first (PAX8-10, PAX8-11). + const matchResult = await this.syncCompanyMatches(); + entities.push(matchResult); + const completedAt = new Date(); const success = entities.every(e => e.success); const status: 'completed' | 'failed' = success ? 'completed' : 'failed'; @@ -324,6 +364,186 @@ 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 }; + } + } + + /** + * PAX8 <-> Autotask company fuzzy-name matching (PAX8-10, PAX8-11). + * Delegates entirely to matchPax8Companies() (Plan 03) — this wrapper just + * shapes the result into the standard Pax8EntitySyncResult so it rolls up + * into fullSync()'s totals and sync_history the same way every other + * entity step does. + */ + private async syncCompanyMatches(): Promise { + const start = Date.now(); + try { + const result = await matchPax8Companies(); + return { + entity: 'company_matches', + success: true, + upserted: result.autoLinked, + // Repurposed: not a soft-delete tombstone count — the number of + // pax8_companies rows flagged for manual review this run (ambiguous + // + no-candidate), surfaced through the same rollup field. + tombstoned: result.flaggedAmbiguous + result.flaggedNoCandidate, + durationMs: result.durationMs, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[Pax8Sync] Company match failed:`, msg); + return { + entity: 'company_matches', + success: false, + upserted: 0, + tombstoned: 0, + durationMs: Date.now() - start, + error: msg, + }; + } + } + // ─── Helpers ──────────────────────────────────────────────────────────── private async insertHistoryStarted(triggeredBy: string, startedAt: Date): Promise {