chore: merge executor worktree (worktree-agent-a878e4ba8fa21ac68)

This commit is contained in:
lorentz 2026-07-10 22:55:49 -04:00
commit f8841ebd14
4 changed files with 562 additions and 0 deletions

View file

@ -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`.

View file

@ -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).

View file

@ -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
});
});

View file

@ -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<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 };
}
}
/**
* 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<Pax8EntitySyncResult> {
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<void> {