chore: merge executor worktree (worktree-agent-a878e4ba8fa21ac68)
This commit is contained in:
commit
f8841ebd14
4 changed files with 562 additions and 0 deletions
|
|
@ -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`.
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
212
lib/services/pax8-sync-service.test.ts
Normal file
212
lib/services/pax8-sync-service.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue