diff --git a/apps/api/migrations/2026-07-13-a-pax8-ordering.sql b/apps/api/migrations/2026-07-13-a-pax8-ordering.sql new file mode 100644 index 0000000000..a281858114 --- /dev/null +++ b/apps/api/migrations/2026-07-13-a-pax8-ordering.sql @@ -0,0 +1,141 @@ +-- Pax8 ordering: staged intent ledger. +-- Pax8 has no idempotency key and no order status field, so THIS TABLE — not +-- Pax8 — is the record of whether money was spent. A line is claimed +-- (submit_state='in_flight') in a committed txn before the HTTP call. +-- Partner-axis (RLS shape 3), matching the five existing pax8_* tables: +-- org_id is a linkage column, never the tenancy axis. + +CREATE TABLE IF NOT EXISTS pax8_orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + integration_id UUID NOT NULL, + partner_id UUID NOT NULL REFERENCES partners(id), + org_id UUID NOT NULL, + pax8_company_id VARCHAR(64), + status VARCHAR(20) NOT NULL DEFAULT 'draft', + source VARCHAR(10) NOT NULL DEFAULT 'direct', + source_quote_id UUID REFERENCES quotes(id) ON DELETE SET NULL, + dedupe_key VARCHAR(120) NOT NULL, + pax8_order_id VARCHAR(64), + error TEXT, + created_by UUID REFERENCES users(id), + submitted_by UUID REFERENCES users(id), + submitted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT pax8_orders_status_chk CHECK (status IN ( + 'draft','awaiting_details','ready','submitting','completed','partially_failed','failed','cancelled')), + CONSTRAINT pax8_orders_source_chk CHECK (source IN ('direct','quote')), + CONSTRAINT pax8_orders_integration_partner_fkey + FOREIGN KEY (integration_id, partner_id) + REFERENCES pax8_integrations(id, partner_id) ON DELETE CASCADE, + CONSTRAINT pax8_orders_org_partner_fkey + FOREIGN KEY (org_id, partner_id) + REFERENCES organizations(id, partner_id) ON DELETE CASCADE +); + +-- The idempotency guard. A concurrent submit of the same intent loses this race. +CREATE UNIQUE INDEX IF NOT EXISTS pax8_orders_dedupe_key_uq + ON pax8_orders(partner_id, dedupe_key); +CREATE INDEX IF NOT EXISTS pax8_orders_partner_idx ON pax8_orders(partner_id); +CREATE INDEX IF NOT EXISTS pax8_orders_org_idx ON pax8_orders(org_id); +CREATE INDEX IF NOT EXISTS pax8_orders_status_idx ON pax8_orders(partner_id, status); +CREATE INDEX IF NOT EXISTS pax8_orders_quote_idx ON pax8_orders(source_quote_id); +-- Target for the order_lines composite FK. Including org_id prevents an order +-- line from pointing at another customer under the same MSP partner. +CREATE UNIQUE INDEX IF NOT EXISTS pax8_orders_id_partner_org_idx + ON pax8_orders(id, partner_id, org_id); + +CREATE TABLE IF NOT EXISTS pax8_order_lines ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + order_id UUID NOT NULL, + partner_id UUID NOT NULL REFERENCES partners(id), + org_id UUID NOT NULL, + action VARCHAR(20) NOT NULL, + submit_state VARCHAR(20) NOT NULL DEFAULT 'pending', + pax8_product_id VARCHAR(64), + catalog_item_id UUID, + billing_term VARCHAR(20), + commitment_term_id VARCHAR(64), + quantity NUMERIC(12,2), + provisioning_details JSONB NOT NULL DEFAULT '[]'::jsonb, + target_subscription_id VARCHAR(64), + cancel_date DATE, + result_subscription_id VARCHAR(64), + contract_line_id UUID, + source_quote_line_id UUID, + error TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT pax8_order_lines_action_chk CHECK (action IN ( + 'new_subscription','change_quantity','cancel')), + CONSTRAINT pax8_order_lines_state_chk CHECK (submit_state IN ( + 'pending','in_flight','succeeded','failed','needs_reconcile')), + CONSTRAINT pax8_order_lines_billing_term_chk CHECK ( + billing_term IS NULL OR billing_term IN ( + 'Monthly','Annual','2-Year','3-Year','One-Time','Trial','Activation')), + -- Each action carries a different payload; enforce the shape rather than + -- trusting the service layer. A cancel with a quantity is a bug, not data. + CONSTRAINT pax8_order_lines_action_payload_chk CHECK ( + (action = 'new_subscription' + AND pax8_product_id IS NOT NULL AND billing_term IS NOT NULL + AND quantity IS NOT NULL AND quantity > 0 AND target_subscription_id IS NULL) + OR (action = 'change_quantity' + AND target_subscription_id IS NOT NULL AND quantity IS NOT NULL AND quantity >= 0) + OR (action = 'cancel' + AND target_subscription_id IS NOT NULL AND quantity IS NULL) + ), + CONSTRAINT pax8_order_lines_order_partner_org_fkey + FOREIGN KEY (order_id, partner_id, org_id) + REFERENCES pax8_orders(id, partner_id, org_id) ON DELETE CASCADE, + CONSTRAINT pax8_order_lines_org_partner_fkey + FOREIGN KEY (org_id, partner_id) + REFERENCES organizations(id, partner_id) ON DELETE CASCADE, + CONSTRAINT pax8_order_lines_catalog_item_partner_fkey + FOREIGN KEY (catalog_item_id, partner_id) + REFERENCES catalog_items(id, partner_id) ON DELETE SET NULL (catalog_item_id), + CONSTRAINT pax8_order_lines_contract_line_org_fkey + FOREIGN KEY (contract_line_id, org_id) + REFERENCES contract_lines(id, org_id) ON DELETE SET NULL (contract_line_id) +); + +CREATE INDEX IF NOT EXISTS pax8_order_lines_order_idx ON pax8_order_lines(order_id); +CREATE INDEX IF NOT EXISTS pax8_order_lines_partner_idx ON pax8_order_lines(partner_id); +CREATE INDEX IF NOT EXISTS pax8_order_lines_org_idx ON pax8_order_lines(org_id); +CREATE INDEX IF NOT EXISTS pax8_order_lines_contract_line_idx ON pax8_order_lines(contract_line_id); +-- Finds lines stranded mid-flight (crash between claim and result). +CREATE INDEX IF NOT EXISTS pax8_order_lines_inflight_idx + ON pax8_order_lines(submit_state) WHERE submit_state IN ('in_flight','needs_reconcile'); + +ALTER TABLE pax8_orders ENABLE ROW LEVEL SECURITY; +ALTER TABLE pax8_orders FORCE ROW LEVEL SECURITY; +ALTER TABLE pax8_order_lines ENABLE ROW LEVEL SECURITY; +ALTER TABLE pax8_order_lines FORCE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS breeze_partner_isolation_select ON pax8_orders; +DROP POLICY IF EXISTS breeze_partner_isolation_insert ON pax8_orders; +DROP POLICY IF EXISTS breeze_partner_isolation_update ON pax8_orders; +DROP POLICY IF EXISTS breeze_partner_isolation_delete ON pax8_orders; +CREATE POLICY breeze_partner_isolation_select ON pax8_orders + FOR SELECT USING (public.breeze_has_partner_access(partner_id)); +CREATE POLICY breeze_partner_isolation_insert ON pax8_orders + FOR INSERT WITH CHECK (public.breeze_has_partner_access(partner_id)); +CREATE POLICY breeze_partner_isolation_update ON pax8_orders + FOR UPDATE USING (public.breeze_has_partner_access(partner_id)) + WITH CHECK (public.breeze_has_partner_access(partner_id)); +CREATE POLICY breeze_partner_isolation_delete ON pax8_orders + FOR DELETE USING (public.breeze_has_partner_access(partner_id)); + +DROP POLICY IF EXISTS breeze_partner_isolation_select ON pax8_order_lines; +DROP POLICY IF EXISTS breeze_partner_isolation_insert ON pax8_order_lines; +DROP POLICY IF EXISTS breeze_partner_isolation_update ON pax8_order_lines; +DROP POLICY IF EXISTS breeze_partner_isolation_delete ON pax8_order_lines; +CREATE POLICY breeze_partner_isolation_select ON pax8_order_lines + FOR SELECT USING (public.breeze_has_partner_access(partner_id)); +CREATE POLICY breeze_partner_isolation_insert ON pax8_order_lines + FOR INSERT WITH CHECK (public.breeze_has_partner_access(partner_id)); +CREATE POLICY breeze_partner_isolation_update ON pax8_order_lines + FOR UPDATE USING (public.breeze_has_partner_access(partner_id)) + WITH CHECK (public.breeze_has_partner_access(partner_id)); +CREATE POLICY breeze_partner_isolation_delete ON pax8_order_lines + FOR DELETE USING (public.breeze_has_partner_access(partner_id)); diff --git a/apps/api/migrations/2026-07-14-pax8-direct-draft-uniqueness.sql b/apps/api/migrations/2026-07-14-pax8-direct-draft-uniqueness.sql new file mode 100644 index 0000000000..145eb87f69 --- /dev/null +++ b/apps/api/migrations/2026-07-14-pax8-direct-draft-uniqueness.sql @@ -0,0 +1,41 @@ +-- Keep at most one mutable direct Pax8 order per partner/customer. Quote +-- staging intentionally remains outside this index so multiple accepted quotes +-- may wait for fulfillment details at the same time. +DO $$ +DECLARE + cleaned_count INTEGER; +BEGIN + WITH ranked AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY partner_id, org_id + ORDER BY created_at, id + ) AS ordinal + FROM pax8_orders + WHERE source = 'direct' + AND status IN ('draft', 'awaiting_details') + ), cleaned AS ( + UPDATE pax8_orders AS duplicate + SET + status = 'cancelled', + error = CONCAT_WS( + E'\n', + NULLIF(duplicate.error, ''), + 'Cancelled duplicate mutable direct draft during 2026-07-14 uniqueness migration.' + ), + updated_at = NOW() + FROM ranked + WHERE duplicate.id = ranked.id + AND ranked.ordinal > 1 + RETURNING duplicate.id + ) + SELECT COUNT(*) INTO cleaned_count FROM cleaned; + + RAISE WARNING 'cancelled % duplicate mutable direct Pax8 draft order(s)', cleaned_count; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS pax8_orders_one_mutable_direct_per_org_uq + ON pax8_orders(partner_id, org_id) + WHERE source = 'direct' + AND status IN ('draft', 'awaiting_details'); diff --git a/apps/api/migrations/2026-07-14-pax8-snapshot-quantity-evidence.sql b/apps/api/migrations/2026-07-14-pax8-snapshot-quantity-evidence.sql new file mode 100644 index 0000000000..028488f5a1 --- /dev/null +++ b/apps/api/migrations/2026-07-14-pax8-snapshot-quantity-evidence.sql @@ -0,0 +1,5 @@ +-- Pax8 omits Subscription.quantity in some responses. The client normalizes +-- that absence to 0.00 for wire compatibility; retain the evidence bit so a +-- synthesized zero can never be mistaken for real billing drift. +ALTER TABLE pax8_subscription_snapshots + ADD COLUMN IF NOT EXISTS quantity_known boolean NOT NULL DEFAULT false; diff --git a/apps/api/migrations/2026-07-16-pax8-order-line-authorized-baseline.sql b/apps/api/migrations/2026-07-16-pax8-order-line-authorized-baseline.sql new file mode 100644 index 0000000000..48328272a5 --- /dev/null +++ b/apps/api/migrations/2026-07-16-pax8-order-line-authorized-baseline.sql @@ -0,0 +1,22 @@ +-- Preserve the Breeze manual quantity against which a direct Pax8 quantity +-- change was authorized. Submit must match this value under lock before any +-- vendor write so a later billing edit cannot invert increase/decrease policy. + +ALTER TABLE pax8_order_lines + ADD COLUMN IF NOT EXISTS authorized_baseline_quantity NUMERIC(12,2); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'pax8_order_lines_authorized_baseline_chk' + AND conrelid = 'pax8_order_lines'::regclass + ) THEN + ALTER TABLE pax8_order_lines + ADD CONSTRAINT pax8_order_lines_authorized_baseline_chk CHECK ( + authorized_baseline_quantity IS NULL + OR (action = 'change_quantity' AND authorized_baseline_quantity >= 0) + ); + END IF; +END $$; diff --git a/apps/api/src/__tests__/integration/pax8-rls.integration.test.ts b/apps/api/src/__tests__/integration/pax8-rls.integration.test.ts index 667e256bad..d265875bb3 100644 --- a/apps/api/src/__tests__/integration/pax8-rls.integration.test.ts +++ b/apps/api/src/__tests__/integration/pax8-rls.integration.test.ts @@ -1,6 +1,7 @@ /** * Real-driver cross-tenant forge tests for the Pax8 billing-sync tables, plus a - * functional test of the contract-line quantity-apply gate against real SQL. + * functional tests of observation bookkeeping and read-only drift detection + * against real SQL. * * Runs under vitest.integration.config.ts — code-under-test connects as the * unprivileged `breeze_app` role (rolbypassrls=f), so RLS is actually enforced. @@ -44,7 +45,8 @@ import { contracts, contractLines, } from '../../db/schema'; -import { applyEnabledPax8ContractLineLinks, linkPax8SubscriptionToContractLine } from '../../services/pax8SyncService'; +import { linkPax8SubscriptionToContractLine, recordPax8SubscriptionObservations } from '../../services/pax8SyncService'; +import { detectPax8Drift } from '../../services/pax8Drift'; import { createPartner, createOrganization } from './db-utils'; const runDb = it.runIf(!!process.env.DATABASE_URL); @@ -209,16 +211,19 @@ describe('pax8 partner-axis RLS (breeze_app)', () => { }); }); -describe('applyEnabledPax8ContractLineLinks gate (breeze_app, real SQL)', () => { - runDb('applies only to enabled, manual, same-org, mapped links', async () => { +describe('recordPax8SubscriptionObservations gate (breeze_app, real SQL)', () => { + runDb('records only enabled, manual, same-org, mapped links without changing billing quantity', async () => { const { partnerA, orgA, integrationA, snapshotA } = await seed(); const result = await withSystemDbAccessContext(async () => { + await db.update(pax8SubscriptionSnapshots) + .set({ quantityKnown: true }) + .where(eq(pax8SubscriptionSnapshots.id, snapshotA.id)); const [contract] = await db.insert(contracts).values({ partnerId: partnerA.id, orgId: orgA.id, name: 'C', intervalMonths: 1, startDate: '2026-01-01', }).returning({ id: contracts.id }); - // (1) manual line linked to a mapped snapshot → should be applied (7.00) + // (1) manual line linked to a mapped snapshot → observed, never applied const [manualLine] = await db.insert(contractLines).values({ contractId: contract!.id, orgId: orgA.id, lineType: 'manual', description: 'manual', unitPrice: '0.00', manualQuantity: '0.00', }).returning({ id: contractLines.id }); @@ -227,7 +232,23 @@ describe('applyEnabledPax8ContractLineLinks gate (breeze_app, real SQL)', () => subscriptionSnapshotId: snapshotA.id, contractLineId: manualLine!.id, syncEnabled: true, }); - // (2) NON-manual line → must be skipped even when linked + enabled + // (2) a synthetic zero with no Pax8 evidence must preserve the last + // genuine observation instead of replacing it with 0.00. + const [unknownLine] = await db.insert(contractLines).values({ + contractId: contract!.id, orgId: orgA.id, lineType: 'manual', description: 'unknown', unitPrice: '0.00', manualQuantity: '6.00', + }).returning({ id: contractLines.id }); + const [unknownSnapshot] = await db.insert(pax8SubscriptionSnapshots).values({ + integrationId: integrationA.id, partnerId: partnerA.id, pax8CompanyId: 'pax8-co-a', + pax8SubscriptionId: 'pax8-sub-unknown', orgId: orgA.id, quantity: '0.00', quantityKnown: false, + }).returning({ id: pax8SubscriptionSnapshots.id }); + const previousObservedAt = new Date('2026-01-02T03:04:05.000Z'); + const [unknownLink] = await db.insert(pax8ContractLineLinks).values({ + integrationId: integrationA.id, partnerId: partnerA.id, orgId: orgA.id, + subscriptionSnapshotId: unknownSnapshot!.id, contractLineId: unknownLine!.id, syncEnabled: true, + lastObservedQuantity: '6.00', lastObservedAt: previousObservedAt, + }).returning({ id: pax8ContractLineLinks.id }); + + // (3) NON-manual line → must be skipped even when linked + enabled const [flatLine] = await db.insert(contractLines).values({ contractId: contract!.id, orgId: orgA.id, lineType: 'flat', description: 'flat', unitPrice: '5.00', manualQuantity: null, }).returning({ id: contractLines.id }); @@ -240,7 +261,7 @@ describe('applyEnabledPax8ContractLineLinks gate (breeze_app, real SQL)', () => subscriptionSnapshotId: flatSnapshot!.id, contractLineId: flatLine!.id, syncEnabled: true, }); - // (3) manual line linked to an UNMAPPED snapshot (org_id null) → skipped + // (4) manual line linked to an UNMAPPED snapshot (org_id null) → skipped const [unmappedLine] = await db.insert(contractLines).values({ contractId: contract!.id, orgId: orgA.id, lineType: 'manual', description: 'unmapped', unitPrice: '0.00', manualQuantity: '3.00', }).returning({ id: contractLines.id }); @@ -253,23 +274,33 @@ describe('applyEnabledPax8ContractLineLinks gate (breeze_app, real SQL)', () => subscriptionSnapshotId: unmappedSnapshot!.id, contractLineId: unmappedLine!.id, syncEnabled: true, }); - const applyResult = await applyEnabledPax8ContractLineLinks(integrationA.id); + const observationResult = await recordPax8SubscriptionObservations(integrationA.id); const rows = await db.select({ id: contractLines.id, qty: contractLines.manualQuantity, type: contractLines.lineType }) .from(contractLines).where(eq(contractLines.contractId, contract!.id)); + const [preservedUnknownLink] = await db.select({ + quantity: pax8ContractLineLinks.lastObservedQuantity, + at: pax8ContractLineLinks.lastObservedAt, + }).from(pax8ContractLineLinks).where(eq(pax8ContractLineLinks.id, unknownLink!.id)); const byId = new Map(rows.map((r) => [r.id, r])); return { - applyResult, + observationResult, manualQty: byId.get(manualLine!.id)?.qty, flatQty: byId.get(flatLine!.id)?.qty, unmappedQty: byId.get(unmappedLine!.id)?.qty, + preservedUnknownLink, + previousObservedAt, }; }); - expect(result.applyResult).toEqual({ applied: 1, skipped: 2 }); - expect(result.manualQty).toBe('7.00'); // applied from snapshotA + expect(result.observationResult).toEqual({ observed: 1, skipped: 3 }); + expect(result.manualQty).toBe('0.00'); // billing ledger remains authoritative expect(result.flatQty).toBeNull(); // non-manual untouched expect(result.unmappedQty).toBe('3.00'); // unmapped-snapshot link skipped + expect(result.preservedUnknownLink).toEqual({ + quantity: '6.00', + at: result.previousObservedAt, + }); }); runDb('linking a second subscription to an already-linked line throws a clear error', async () => { @@ -305,3 +336,92 @@ describe('applyEnabledPax8ContractLineLinks gate (breeze_app, real SQL)', () => }); }); }); + +describe('detectPax8Drift (breeze_app, real SQL)', () => { + runDb('returns only enabled, known, partner-owned quantity disagreements', async () => { + const { partnerA, partnerB, orgA, orgB, integrationA, snapshotA } = await seed(); + + const lineId = await withSystemDbAccessContext(async () => { + const [contract] = await db.insert(contracts).values({ + partnerId: partnerA.id, + orgId: orgA.id, + name: 'Drift contract', + intervalMonths: 1, + startDate: '2026-01-01', + }).returning({ id: contracts.id }); + const [line] = await db.insert(contractLines).values({ + contractId: contract!.id, + orgId: orgA.id, + lineType: 'manual', + description: 'Seats', + unitPrice: '1.00', + manualQuantity: '5.00', + }).returning({ id: contractLines.id }); + await db.insert(pax8ContractLineLinks).values({ + integrationId: integrationA.id, + partnerId: partnerA.id, + orgId: orgA.id, + subscriptionSnapshotId: snapshotA.id, + contractLineId: line!.id, + syncEnabled: true, + }); + return line!.id; + }); + + const input = { partnerId: partnerA.id, integrationId: integrationA.id }; + const partnerAOrgCtx = { ...partnerCtx(partnerA.id), accessibleOrgIds: [orgA.id] }; + const partnerBOrgCtx = { ...partnerCtx(partnerB.id), accessibleOrgIds: [orgB.id] }; + // The pre-column/default row is legacy evidence: its synthetic-looking + // quantity is unknown until a fresh sync explicitly marks it known. + await expect(withDbAccessContext(partnerAOrgCtx, () => detectPax8Drift(input))) + .resolves.toEqual([]); + await withSystemDbAccessContext(() => db.update(pax8SubscriptionSnapshots) + .set({ quantityKnown: true }) + .where(eq(pax8SubscriptionSnapshots.id, snapshotA.id))); + await expect(withDbAccessContext(partnerAOrgCtx, () => detectPax8Drift(input))) + .resolves.toEqual([expect.objectContaining({ + contractLineId: lineId, + orgId: orgA.id, + pax8SubscriptionId: 'pax8-sub-a', + breezeQuantity: '5.00', + pax8Quantity: '7.00', + })]); + + await expect(withDbAccessContext(partnerBOrgCtx, () => detectPax8Drift(input))) + .resolves.toEqual([]); + await expect(withSystemDbAccessContext(() => detectPax8Drift({ + partnerId: partnerB.id, + integrationId: integrationA.id, + }))).resolves.toEqual([]); + + await withSystemDbAccessContext(() => db.update(pax8SubscriptionSnapshots) + .set({ orgId: null }) + .where(eq(pax8SubscriptionSnapshots.id, snapshotA.id))); + await expect(withDbAccessContext(partnerAOrgCtx, () => detectPax8Drift(input))) + .resolves.toEqual([]); + await withSystemDbAccessContext(() => db.update(pax8SubscriptionSnapshots) + .set({ orgId: orgA.id }) + .where(eq(pax8SubscriptionSnapshots.id, snapshotA.id))); + + await withSystemDbAccessContext(() => db.update(pax8SubscriptionSnapshots) + .set({ quantity: '0.00', quantityKnown: false }) + .where(eq(pax8SubscriptionSnapshots.id, snapshotA.id))); + await expect(withDbAccessContext(partnerAOrgCtx, () => detectPax8Drift(input))) + .resolves.toEqual([]); + + await withSystemDbAccessContext(() => db.update(pax8SubscriptionSnapshots) + .set({ quantity: '5.00', quantityKnown: true }) + .where(eq(pax8SubscriptionSnapshots.id, snapshotA.id))); + await expect(withDbAccessContext(partnerAOrgCtx, () => detectPax8Drift(input))) + .resolves.toEqual([]); + + await withSystemDbAccessContext(() => db.update(pax8SubscriptionSnapshots) + .set({ quantity: '7.00' }) + .where(eq(pax8SubscriptionSnapshots.id, snapshotA.id))); + await withSystemDbAccessContext(() => db.update(pax8ContractLineLinks) + .set({ syncEnabled: false }) + .where(eq(pax8ContractLineLinks.contractLineId, lineId))); + await expect(withDbAccessContext(partnerAOrgCtx, () => detectPax8Drift(input))) + .resolves.toEqual([]); + }); +}); diff --git a/apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts b/apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts new file mode 100644 index 0000000000..ca0ad5c2a3 --- /dev/null +++ b/apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts @@ -0,0 +1,685 @@ +import './setup'; +import { randomUUID } from 'node:crypto'; +import { describe, expect, it, vi } from 'vitest'; +import { eq, sql } from 'drizzle-orm'; +import { db, withSystemDbAccessContext } from '../../db'; +import { + contractLines, + contracts, + catalogItems, + pax8CompanyMappings, + pax8ContractLineLinks, + pax8Integrations, + pax8OrderLines, + pax8Orders, + pax8ProductMappings, + pax8SubscriptionSnapshots, +} from '../../db/schema'; +import { Pax8ApiError } from '../../services/pax8Client'; +import { createPax8OrderSubmitService } from '../../services/pax8OrderSubmit'; +import { pax8OrderSubmitRepository } from '../../services/pax8OrderSubmitRepository'; +import { removeOrderLine, updateOrderLine } from '../../services/pax8OrderService'; +import { createOrganization, createPartner, createUser } from './db-utils'; +import { getTestDb } from './setup'; + +const runDb = it.runIf(!!process.env.DATABASE_URL); +const READY_COMPANY_METADATA = { + contacts: [{ types: [ + { type: 'Admin', primary: true }, + { type: 'Billing', primary: true }, + { type: 'Technical', primary: true }, + ] }], +}; + +async function seedOrder(options: { + action?: 'new_subscription' | 'cancel'; + source?: 'direct' | 'quote'; + companyReady?: boolean; +} = {}) { + return withSystemDbAccessContext(async () => { + const partner = await createPartner(); + const org = await createOrganization({ partnerId: partner.id }); + const user = await createUser({ partnerId: partner.id }); + const [integration] = await db.insert(pax8Integrations).values({ + partnerId: partner.id, + name: 'Pax8 submit test', + clientIdEncrypted: 'enc:test-client', + clientSecretEncrypted: 'enc:test-secret', + tokenUrl: 'https://api.pax8.com/v1/token', + }).returning(); + if (!integration) throw new Error('failed to seed integration'); + const [catalogItem] = await db.insert(catalogItems).values({ + partnerId: partner.id, + itemType: 'software', + name: 'Pax8 submit product', + billingType: 'recurring', + billingFrequency: 'monthly', + unitPrice: '10.00', + taxable: false, + }).returning(); + if (!catalogItem) throw new Error('failed to seed catalog item'); + await db.insert(pax8ProductMappings).values({ + integrationId: integration.id, + partnerId: partner.id, + pax8ProductId: 'product-1', + productName: 'Pax8 submit product', + catalogItemId: catalogItem.id, + }); + await db.insert(pax8CompanyMappings).values({ + integrationId: integration.id, + partnerId: partner.id, + pax8CompanyId: 'company-1', + pax8CompanyName: 'Acme', + orgId: org.id, + status: 'Active', + metadata: options.companyReady === false ? {} : READY_COMPANY_METADATA, + }); + const [contract] = await db.insert(contracts).values({ + partnerId: partner.id, + orgId: org.id, + name: 'Pax8 contract', + intervalMonths: 1, + startDate: '2026-07-14', + }).returning(); + if (!contract) throw new Error('failed to seed contract'); + const [contractLine] = await db.insert(contractLines).values({ + contractId: contract.id, + orgId: org.id, + lineType: 'manual', + description: 'Pax8 seats', + unitPrice: '10.00', + manualQuantity: options.action === 'cancel' ? '7.00' : null, + }).returning(); + if (!contractLine) throw new Error('failed to seed contract line'); + const [order] = await db.insert(pax8Orders).values({ + integrationId: integration.id, + partnerId: partner.id, + orgId: org.id, + pax8CompanyId: null, + status: 'ready', + source: options.source ?? 'quote', + dedupeKey: `submit-test:${randomUUID()}`, + createdBy: user.id, + }).returning(); + if (!order) throw new Error('failed to seed order'); + const action = options.action ?? 'new_subscription'; + const [line] = await db.insert(pax8OrderLines).values({ + orderId: order.id, + partnerId: partner.id, + orgId: org.id, + action, + pax8ProductId: action === 'new_subscription' ? 'product-1' : null, + catalogItemId: action === 'new_subscription' ? catalogItem.id : null, + billingTerm: action === 'new_subscription' ? 'Monthly' : null, + quantity: action === 'new_subscription' ? '7.00' : null, + targetSubscriptionId: action === 'cancel' ? 'subscription-cancel' : null, + contractLineId: contractLine.id, + }).returning(); + if (!line) throw new Error('failed to seed order line'); + if (action === 'cancel') { + const [snapshot] = await db.insert(pax8SubscriptionSnapshots).values({ + integrationId: integration.id, + partnerId: partner.id, + pax8CompanyId: 'company-1', + pax8SubscriptionId: 'subscription-cancel', + orgId: org.id, + productId: 'product-1', + quantity: '99.00', + quantityKnown: false, + }).returning(); + if (!snapshot) throw new Error('failed to seed subscription snapshot'); + await db.insert(pax8ContractLineLinks).values({ + integrationId: integration.id, + partnerId: partner.id, + orgId: org.id, + subscriptionSnapshotId: snapshot.id, + contractLineId: contractLine.id, + }); + } + return { partner, org, user, order, line, contractLine, integration, catalogItem }; + }); +} + +function serviceWithClient(client: Record) { + return serviceHarness(client).service; +} + +function serviceHarness(client: Record) { + const createClient = vi.fn().mockResolvedValue(client); + return { + createClient, + service: createPax8OrderSubmitService({ + repository: { + ...pax8OrderSubmitRepository, + createClient, + }, + runOutsideDbContext: (fn) => fn(), + }), + }; +} + +function successfulClient() { + return { + createOrder: vi.fn() + .mockResolvedValueOnce({ pax8OrderId: null, lineItems: [] }) + .mockResolvedValueOnce({ + pax8OrderId: 'pax-order-1', + lineItems: [{ lineItemNumber: 1, productId: 'product-1', subscriptionId: 'subscription-1' }], + }), + updateSubscriptionQuantity: vi.fn(), + cancelSubscription: vi.fn(), + listOrders: vi.fn(), + listSubscriptions: vi.fn(), + }; +} + +async function seedChangeOrder(options: { + baseline: string | null; + current: string; + source?: 'direct' | 'quote'; +}) { + const fixture = await seedOrder({ action: 'cancel', source: options.source }); + await withSystemDbAccessContext(async () => { + await db.update(contractLines) + .set({ manualQuantity: options.current }) + .where(eq(contractLines.id, fixture.contractLine.id)); + await db.update(pax8OrderLines).set({ + action: 'change_quantity', + quantity: '15.00', + cancelDate: null, + authorizedBaselineQuantity: options.baseline, + }).where(eq(pax8OrderLines.id, fixture.line.id)); + }); + return fixture; +} + +describe('Pax8 submit pipeline (real Postgres)', () => { + runDb('rejects unready direct and quote-staged orders before client creation or writes', async () => { + for (const source of ['direct', 'quote'] as const) { + const fixture = await seedOrder({ source, companyReady: false }); + const client = successfulClient(); + const { service, createClient } = serviceHarness(client); + + await expect(service.submitOrder({ + partnerId: fixture.partner.id, + orderId: fixture.order.id, + actorUserId: fixture.user.id, + })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('ready') }); + + expect(createClient).not.toHaveBeenCalled(); + expect(client.createOrder).not.toHaveBeenCalled(); + expect(client.updateSubscriptionQuantity).not.toHaveBeenCalled(); + expect(client.cancelSubscription).not.toHaveBeenCalled(); + const [state] = await withSystemDbAccessContext(() => db.select({ + status: pax8Orders.status, + }).from(pax8Orders).where(eq(pax8Orders.id, fixture.order.id))); + const [line] = await withSystemDbAccessContext(() => db.select({ + submitState: pax8OrderLines.submitState, + }).from(pax8OrderLines).where(eq(pax8OrderLines.id, fixture.line.id))); + expect(state?.status).toBe('ready'); + expect(line?.submitState).toBe('pending'); + } + }); + + runDb('rejects a staged future cancellation before any vendor or billing write', async () => { + const fixture = await seedOrder({ action: 'cancel' }); + await withSystemDbAccessContext(() => db.update(pax8OrderLines) + .set({ cancelDate: '2999-01-01' }) + .where(eq(pax8OrderLines.id, fixture.line.id))); + const client = successfulClient(); + const { service, createClient } = serviceHarness(client); + + await expect(service.submitOrder({ + partnerId: fixture.partner.id, + orderId: fixture.order.id, + actorUserId: fixture.user.id, + })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('future') }); + + expect(createClient).not.toHaveBeenCalled(); + expect(client.cancelSubscription).not.toHaveBeenCalled(); + const state = await withSystemDbAccessContext(async () => { + const [order] = await db.select().from(pax8Orders).where(eq(pax8Orders.id, fixture.order.id)); + const [line] = await db.select().from(pax8OrderLines).where(eq(pax8OrderLines.id, fixture.line.id)); + const [billing] = await db.select().from(contractLines).where(eq(contractLines.id, fixture.contractLine.id)); + return { order, line, billing }; + }); + expect(state.order?.status).toBe('ready'); + expect(state.line?.submitState).toBe('pending'); + expect(state.billing?.manualQuantity).toBe('7.00'); + }); + + runDb('rejects a same-org unrelated staged contract line before vendor execution', async () => { + const fixture = await seedOrder({ action: 'cancel' }); + const [contract] = await withSystemDbAccessContext(() => db.select() + .from(contracts).where(eq(contracts.orgId, fixture.org.id))); + const [unrelated] = await withSystemDbAccessContext(() => db.insert(contractLines).values({ + contractId: contract!.id, + orgId: fixture.org.id, + lineType: 'manual', + description: 'Unrelated same-org line', + unitPrice: '1.00', + manualQuantity: '33.00', + }).returning()); + await withSystemDbAccessContext(() => db.update(pax8OrderLines) + .set({ contractLineId: unrelated!.id }) + .where(eq(pax8OrderLines.id, fixture.line.id))); + const client = successfulClient(); + + await expect(serviceWithClient(client).submitOrder({ + partnerId: fixture.partner.id, + orderId: fixture.order.id, + actorUserId: fixture.user.id, + })).rejects.toMatchObject({ status: 409, message: expect.stringContaining('no longer matches') }); + + expect(client.cancelSubscription).not.toHaveBeenCalled(); + const [unrelatedAfter] = await withSystemDbAccessContext(() => db.select() + .from(contractLines).where(eq(contractLines.id, unrelated!.id))); + expect(unrelatedAfter?.manualQuantity).toBe('33.00'); + }); + + runDb('rechecks an active direct product mapping at submit time', async () => { + const fixture = await seedOrder({ source: 'direct' }); + await withSystemDbAccessContext(() => db.update(catalogItems) + .set({ isActive: false }) + .where(eq(catalogItems.id, fixture.catalogItem.id))); + const client = successfulClient(); + + await expect(serviceWithClient(client).submitOrder({ + partnerId: fixture.partner.id, + orderId: fixture.order.id, + actorUserId: fixture.user.id, + })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('active') }); + + expect(client.createOrder).not.toHaveBeenCalled(); + }); + + runDb('fails closed when the manual quantity changed after direction authorization', async () => { + const fixture = await seedChangeOrder({ baseline: '10.00', current: '20.00', source: 'direct' }); + const client = successfulClient(); + const { service, createClient } = serviceHarness(client); + + await expect(service.submitOrder({ + partnerId: fixture.partner.id, + orderId: fixture.order.id, + actorUserId: fixture.user.id, + })).rejects.toMatchObject({ status: 409, message: expect.stringContaining('changed') }); + + expect(createClient).not.toHaveBeenCalled(); + expect(client.updateSubscriptionQuantity).not.toHaveBeenCalled(); + const state = await withSystemDbAccessContext(async () => { + const [orderRow] = await db.select().from(pax8Orders).where(eq(pax8Orders.id, fixture.order.id)); + const [lineRow] = await db.select().from(pax8OrderLines).where(eq(pax8OrderLines.id, fixture.line.id)); + const [billing] = await db.select().from(contractLines).where(eq(contractLines.id, fixture.contractLine.id)); + return { orderRow, lineRow, billing }; + }); + expect(state.orderRow?.status).toBe('draft'); + expect(state.lineRow?.submitState).toBe('pending'); + expect(state.billing?.manualQuantity).toBe('20.00'); + await expect(removeOrderLine({ + partnerId: fixture.partner.id, + orderId: fixture.order.id, + lineId: fixture.line.id, + })).resolves.toEqual({ removed: true }); + await withSystemDbAccessContext(() => db.insert(pax8OrderLines).values({ + orderId: fixture.order.id, + partnerId: fixture.partner.id, + orgId: fixture.org.id, + action: 'change_quantity', + targetSubscriptionId: 'subscription-cancel', + quantity: '25.00', + authorizedBaselineQuantity: '20.00', + contractLineId: fixture.contractLine.id, + })); + const restagedClient = successfulClient(); + await serviceWithClient(restagedClient).submitOrder({ + partnerId: fixture.partner.id, + orderId: fixture.order.id, + actorUserId: fixture.user.id, + }); + expect(restagedClient.updateSubscriptionQuantity) + .toHaveBeenCalledWith('subscription-cancel', 25); + const [restagedBilling] = await withSystemDbAccessContext(() => db.select() + .from(contractLines).where(eq(contractLines.id, fixture.contractLine.id))); + expect(restagedBilling?.manualQuantity).toBe('25.00'); + }); + + runDb('fails closed for a legacy quantity change without an authorization baseline', async () => { + const fixture = await seedChangeOrder({ baseline: null, current: '10.00', source: 'direct' }); + const client = successfulClient(); + + await expect(serviceWithClient(client).submitOrder({ + partnerId: fixture.partner.id, + orderId: fixture.order.id, + actorUserId: fixture.user.id, + })).rejects.toMatchObject({ status: 409, message: expect.stringContaining('baseline') }); + expect(client.updateSubscriptionQuantity).not.toHaveBeenCalled(); + const [orderAfter] = await withSystemDbAccessContext(() => db.select() + .from(pax8Orders).where(eq(pax8Orders.id, fixture.order.id))); + expect(orderAfter?.status).toBe('draft'); + await expect(removeOrderLine({ + partnerId: fixture.partner.id, + orderId: fixture.order.id, + lineId: fixture.line.id, + })).resolves.toEqual({ removed: true }); + }); + + runDb('submits an unchanged authorized baseline and records the new billing quantity', async () => { + const fixture = await seedChangeOrder({ baseline: '10.00', current: '10.00' }); + const client = successfulClient(); + + await serviceWithClient(client).submitOrder({ + partnerId: fixture.partner.id, + orderId: fixture.order.id, + actorUserId: fixture.user.id, + }); + + expect(client.updateSubscriptionQuantity).toHaveBeenCalledWith('subscription-cancel', 15); + const [billing] = await withSystemDbAccessContext(() => db.select() + .from(contractLines).where(eq(contractLines.id, fixture.contractLine.id))); + expect(billing?.manualQuantity).toBe('15.00'); + }); + + runDb('uses post-lock PATCH values when PATCH wins and rejects PATCH when claim wins', async () => { + const patchWins = await seedOrder(); + await withSystemDbAccessContext(() => db.update(pax8Orders) + .set({ status: 'awaiting_details' }) + .where(eq(pax8Orders.id, patchWins.order.id))); + let locked!: () => void; + const lockedPromise = new Promise((resolve) => { locked = resolve; }); + let release!: () => void; + const releasePromise = new Promise((resolve) => { release = resolve; }); + const patchTransaction = getTestDb().transaction(async (tx) => { + await tx.execute(sql`SELECT id FROM pax8_orders WHERE id = ${patchWins.order.id} FOR UPDATE`); + locked(); + await releasePromise; + await tx.update(pax8OrderLines).set({ + commitmentTermId: 'patched-commitment', + provisioningDetails: [{ key: 'domain', values: ['patched.example'] }], + }).where(eq(pax8OrderLines.id, patchWins.line.id)); + }); + await lockedPromise; + const patchWinsClient = successfulClient(); + const submitPending = serviceWithClient(patchWinsClient).submitOrder({ + partnerId: patchWins.partner.id, + orderId: patchWins.order.id, + actorUserId: patchWins.user.id, + }); + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(patchWinsClient.createOrder).not.toHaveBeenCalled(); + release(); + await patchTransaction; + await submitPending; + expect(patchWinsClient.createOrder).toHaveBeenNthCalledWith(2, expect.objectContaining({ + lineItems: [expect.objectContaining({ + commitmentTermId: 'patched-commitment', + provisioningDetails: [{ key: 'domain', values: ['patched.example'] }], + })], + })); + + const claimWins = await seedOrder(); + await withSystemDbAccessContext(() => db.update(pax8Orders) + .set({ status: 'awaiting_details' }) + .where(eq(pax8Orders.id, claimWins.order.id))); + const claimWinsClient = successfulClient(); + await serviceWithClient(claimWinsClient).submitOrder({ + partnerId: claimWins.partner.id, + orderId: claimWins.order.id, + actorUserId: claimWins.user.id, + }); + await expect(updateOrderLine({ + partnerId: claimWins.partner.id, + orderId: claimWins.order.id, + lineId: claimWins.line.id, + provisioningDetails: [{ key: 'domain', values: ['too-late.example'] }], + })).rejects.toMatchObject({ status: 409 }); + }); + + runDb('atomically claims one submit, persists billing success, and keeps rejected billing untouched', async () => { + const fixture = await seedOrder(); + const client = successfulClient(); + const service = serviceWithClient(client); + const input = { partnerId: fixture.partner.id, orderId: fixture.order.id, actorUserId: fixture.user.id }; + + const results = await Promise.allSettled([service.submitOrder(input), service.submitOrder(input)]); + const failureMessages = results.flatMap((result) => result.status === 'rejected' + ? [result.reason instanceof Error ? result.reason.message : String(result.reason)] + : []); + + expect(results.filter((result) => result.status === 'fulfilled'), failureMessages.join(' | ')).toHaveLength(1); + expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1); + expect(client.createOrder).toHaveBeenCalledTimes(2); // one winner: isMock + one real POST + + const state = await withSystemDbAccessContext(async () => { + const [line] = await db.select().from(pax8OrderLines).where(eq(pax8OrderLines.id, fixture.line.id)); + const [contractLine] = await db.select().from(contractLines).where(eq(contractLines.id, fixture.contractLine.id)); + return { line, contractLine }; + }); + expect(state.line?.submitState).toBe('succeeded'); + expect(state.line?.resultSubscriptionId).toBe('subscription-1'); + expect(state.contractLine?.manualQuantity).toBe('7.00'); + + const rejectedFixture = await seedOrder(); + const raw = '{"details":[{"message":"msDomain is required"}]}'; + const rejectedClient = successfulClient(); + rejectedClient.createOrder.mockReset() + .mockRejectedValueOnce(new Pax8ApiError('Pax8 API returned 422', 422, raw)); + const rejectedService = serviceWithClient(rejectedClient); + const result = await rejectedService.submitOrder({ + partnerId: rejectedFixture.partner.id, + orderId: rejectedFixture.order.id, + actorUserId: rejectedFixture.user.id, + }); + + const [contractLine] = await withSystemDbAccessContext(() => + db.select().from(contractLines).where(eq(contractLines.id, rejectedFixture.contractLine.id))); + expect(result.status).toBe('failed'); + expect(result.lines).toHaveLength(1); + expect(result.lines.every((line) => line.submitState === 'failed' && line.error === raw)).toBe(true); + expect(contractLine?.manualQuantity).toBeNull(); + expect(rejectedClient.createOrder).toHaveBeenCalledTimes(1); + + const cancelFixture = await seedOrder({ action: 'cancel' }); + const cancelClient = successfulClient(); + const cancelService = serviceWithClient(cancelClient); + await cancelService.submitOrder({ + partnerId: cancelFixture.partner.id, + orderId: cancelFixture.order.id, + actorUserId: cancelFixture.user.id, + }); + const [cancelContractLine] = await withSystemDbAccessContext(() => + db.select().from(contractLines).where(eq(contractLines.id, cancelFixture.contractLine.id))); + expect(cancelClient.createOrder).not.toHaveBeenCalled(); + expect(cancelClient.cancelSubscription).toHaveBeenCalledWith('subscription-cancel', null); + expect(cancelContractLine?.manualQuantity).toBe('0.00'); + + const timeoutFixture = await seedOrder(); + const timeoutClient = successfulClient(); + timeoutClient.createOrder.mockReset() + .mockResolvedValueOnce({ pax8OrderId: null, lineItems: [] }) + .mockRejectedValueOnce(Object.assign(new Error('timeout'), { name: 'AbortError' })); + const timeoutService = serviceWithClient(timeoutClient); + const timeoutResult = await timeoutService.submitOrder({ + partnerId: timeoutFixture.partner.id, + orderId: timeoutFixture.order.id, + actorUserId: timeoutFixture.user.id, + }); + expect(timeoutResult.lines[0]?.submitState).toBe('needs_reconcile'); + await withSystemDbAccessContext(() => db.update(pax8CompanyMappings) + .set({ pax8CompanyId: 'company-2', pax8CompanyName: 'Acme remapped' }) + .where(eq(pax8CompanyMappings.orgId, timeoutFixture.org.id))); + const reconcileClient = { + ...successfulClient(), + listOrders: vi.fn().mockResolvedValue([]), + listSubscriptions: vi.fn().mockResolvedValue([]), + }; + const reconcileService = serviceWithClient(reconcileClient); + await reconcileService.reconcileOrder({ + partnerId: timeoutFixture.partner.id, + orderId: timeoutFixture.order.id, + }); + const [timeoutOrder] = await withSystemDbAccessContext(() => db.select() + .from(pax8Orders).where(eq(pax8Orders.id, timeoutFixture.order.id))); + expect(reconcileClient.listOrders).toHaveBeenCalledWith({ companyId: 'company-1' }); + expect(reconcileClient.listSubscriptions).toHaveBeenCalledWith({ companyId: 'company-1' }); + expect(timeoutOrder?.pax8CompanyId).toBe('company-1'); + + const parentGuardFixture = await seedOrder(); + const capturedAt = new Date('2026-07-20T01:00:00Z'); + await withSystemDbAccessContext(async () => { + await db.update(pax8Orders).set({ + status: 'submitting', + pax8CompanyId: 'company-1', + pax8OrderId: 'captured-parent', + submittedAt: capturedAt, + }).where(eq(pax8Orders.id, parentGuardFixture.order.id)); + await db.update(pax8OrderLines).set({ submitState: 'needs_reconcile' }) + .where(eq(pax8OrderLines.id, parentGuardFixture.line.id)); + }); + const parentGuardBundle = await pax8OrderSubmitRepository.loadReconcileOrder({ + partnerId: parentGuardFixture.partner.id, + orderId: parentGuardFixture.order.id, + }); + await expect(pax8OrderSubmitRepository.persistReconcileResults( + parentGuardBundle, + [{ + lineId: parentGuardFixture.line.id, + submitState: 'succeeded', + error: null, + resultSubscriptionId: 'conflicting-subscription', + }], + 'conflicting-parent', + )).rejects.toMatchObject({ status: 409 }); + const parentGuardState = await withSystemDbAccessContext(async () => { + const [order] = await db.select().from(pax8Orders) + .where(eq(pax8Orders.id, parentGuardFixture.order.id)); + const [line] = await db.select().from(pax8OrderLines) + .where(eq(pax8OrderLines.id, parentGuardFixture.line.id)); + const [contractLine] = await db.select().from(contractLines) + .where(eq(contractLines.id, parentGuardFixture.contractLine.id)); + return { order, line, contractLine }; + }); + expect(parentGuardState.order?.pax8OrderId).toBe('captured-parent'); + expect(parentGuardState.line?.submitState).toBe('needs_reconcile'); + expect(parentGuardState.line?.resultSubscriptionId).toBeNull(); + expect(parentGuardState.contractLine?.manualQuantity).toBeNull(); + await expect(pax8OrderSubmitRepository.persistReconcileResults( + parentGuardBundle, + [{ + lineId: parentGuardFixture.line.id, + submitState: 'succeeded', + error: null, + resultSubscriptionId: 'captured-subscription', + }], + 'captured-parent', + )).resolves.toEqual({ resolved: 1, stillUnknown: 0 }); + + const nullParentFixture = await seedOrder(); + await withSystemDbAccessContext(async () => { + await db.update(pax8Orders).set({ + status: 'submitting', + pax8CompanyId: 'company-1', + submittedAt: capturedAt, + }).where(eq(pax8Orders.id, nullParentFixture.order.id)); + await db.update(pax8OrderLines).set({ submitState: 'needs_reconcile' }) + .where(eq(pax8OrderLines.id, nullParentFixture.line.id)); + }); + const nullParentBundle = await pax8OrderSubmitRepository.loadReconcileOrder({ + partnerId: nullParentFixture.partner.id, + orderId: nullParentFixture.order.id, + }); + await expect(pax8OrderSubmitRepository.persistReconcileResults( + nullParentBundle, + [{ + lineId: nullParentFixture.line.id, + submitState: 'succeeded', + error: null, + resultSubscriptionId: 'matched-subscription', + }], + 'matched-parent', + )).resolves.toEqual({ resolved: 1, stillUnknown: 0 }); + const [nullParentOrder] = await withSystemDbAccessContext(() => db.select() + .from(pax8Orders).where(eq(pax8Orders.id, nullParentFixture.order.id))); + expect(nullParentOrder?.pax8OrderId).toBe('matched-parent'); + + const rollbackFixture = await seedOrder(); + const rollbackClient = successfulClient(); + rollbackClient.createOrder.mockReset() + .mockResolvedValueOnce({ pax8OrderId: null, lineItems: [] }) + .mockImplementationOnce(async () => { + await withSystemDbAccessContext(() => db.delete(contractLines) + .where(eq(contractLines.id, rollbackFixture.contractLine.id))); + return { + pax8OrderId: 'pax-order-rollback', + lineItems: [{ lineItemNumber: 1, productId: 'product-1', subscriptionId: 'subscription-rollback' }], + }; + }); + const rollbackService = serviceWithClient(rollbackClient); + await expect(rollbackService.submitOrder({ + partnerId: rollbackFixture.partner.id, + orderId: rollbackFixture.order.id, + actorUserId: rollbackFixture.user.id, + })).rejects.toMatchObject({ status: 409 }); + const rollbackState = await withSystemDbAccessContext(async () => { + const [order] = await db.select().from(pax8Orders).where(eq(pax8Orders.id, rollbackFixture.order.id)); + const [line] = await db.select().from(pax8OrderLines).where(eq(pax8OrderLines.id, rollbackFixture.line.id)); + return { order, line }; + }); + expect(rollbackState.order?.status).toBe('submitting'); + expect(rollbackState.line?.submitState).toBe('in_flight'); + + const duplicateFixture = await seedOrder({ action: 'cancel' }); + await withSystemDbAccessContext(() => db.insert(pax8OrderLines).values({ + orderId: duplicateFixture.order.id, + partnerId: duplicateFixture.partner.id, + orgId: duplicateFixture.org.id, + action: 'change_quantity', + targetSubscriptionId: 'subscription-cancel', + quantity: '2.00', + authorizedBaselineQuantity: '7.00', + contractLineId: duplicateFixture.contractLine.id, + })); + const duplicateClient = successfulClient(); + await expect(serviceWithClient(duplicateClient).submitOrder({ + partnerId: duplicateFixture.partner.id, + orderId: duplicateFixture.order.id, + actorUserId: duplicateFixture.user.id, + })).rejects.toMatchObject({ status: 422 }); + expect(duplicateClient.createOrder).not.toHaveBeenCalled(); + expect(duplicateClient.cancelSubscription).not.toHaveBeenCalled(); + + const wrongPartner = await withSystemDbAccessContext(() => createPartner()); + const isolatedClient = successfulClient(); + await expect(serviceWithClient(isolatedClient).submitOrder({ + partnerId: wrongPartner.id, + orderId: duplicateFixture.order.id, + actorUserId: duplicateFixture.user.id, + })).rejects.toMatchObject({ status: 404 }); + expect(isolatedClient.createOrder).not.toHaveBeenCalled(); + + const staleFixture = await seedOrder(); + await getTestDb().execute(sql`DROP TRIGGER IF EXISTS task4_delay_pax8_order_update ON pax8_orders`); + await getTestDb().execute(sql` + CREATE OR REPLACE FUNCTION task4_delay_pax8_order_update() RETURNS trigger + LANGUAGE plpgsql AS $$ BEGIN PERFORM pg_sleep(0.25); RETURN NEW; END $$ + `); + await getTestDb().execute(sql` + CREATE TRIGGER task4_delay_pax8_order_update + BEFORE UPDATE ON pax8_orders + FOR EACH ROW EXECUTE FUNCTION task4_delay_pax8_order_update() + `); + try { + const staleClient = successfulClient(); + const staleService = serviceWithClient(staleClient); + const attempts = await Promise.allSettled([ + staleService.preflightOrder({ partnerId: staleFixture.partner.id, orderId: staleFixture.order.id }), + staleService.preflightOrder({ partnerId: staleFixture.partner.id, orderId: staleFixture.order.id }), + ]); + expect(attempts.filter((attempt) => attempt.status === 'fulfilled')).toHaveLength(1); + expect(attempts.filter((attempt) => attempt.status === 'rejected')).toHaveLength(1); + expect(staleClient.createOrder).toHaveBeenCalledTimes(1); + } finally { + await getTestDb().execute(sql`DROP TRIGGER IF EXISTS task4_delay_pax8_order_update ON pax8_orders`); + await getTestDb().execute(sql`DROP FUNCTION IF EXISTS task4_delay_pax8_order_update()`); + } + }); +}); diff --git a/apps/api/src/__tests__/integration/pax8OrdersPartnerRls.integration.test.ts b/apps/api/src/__tests__/integration/pax8OrdersPartnerRls.integration.test.ts new file mode 100644 index 0000000000..bf31c157cf --- /dev/null +++ b/apps/api/src/__tests__/integration/pax8OrdersPartnerRls.integration.test.ts @@ -0,0 +1,374 @@ +/** + * Real-driver partner-axis RLS and integrity tests for the Pax8 order ledger. + * + * Runs as the unprivileged `breeze_app` role through withDbAccessContext. Each + * test seeds fresh fixtures because integration setup truncates tenant rows in + * beforeEach; memoizing them would make the isolation assertions vacuous. + */ +import './setup'; +import { describe, expect, it } from 'vitest'; +import { and, eq, inArray } from 'drizzle-orm'; +import { + db, + withDbAccessContext, + withSystemDbAccessContext, + type DbAccessContext, +} from '../../db'; +import { + pax8Integrations, + pax8CompanyMappings, + pax8Orders, + pax8OrderLines, + catalogItems, + contracts, + contractLines, +} from '../../db/schema'; +import { getOrCreateDraftOrder, listPax8Orders } from '../../services/pax8OrderService'; +import { createOrganization, createPartner, createUser } from './db-utils'; + +const runDb = it.runIf(!!process.env.DATABASE_URL); +const READY_COMPANY_METADATA = { + contacts: [{ types: [ + { type: 'Admin', primary: true }, + { type: 'Billing', primary: true }, + { type: 'Technical', primary: true }, + ] }], +}; + +function partnerContext(partnerId: string): DbAccessContext { + return { + scope: 'partner', + orgId: null, + accessibleOrgIds: null, + accessiblePartnerIds: [partnerId], + userId: null, + }; +} + +function withPartnerContext(partnerId: string, fn: () => Promise): Promise { + return withDbAccessContext(partnerContext(partnerId), fn); +} + +async function seedIntegration(partnerId: string) { + const [integration] = await db + .insert(pax8Integrations) + .values({ + partnerId, + name: 'Pax8', + clientIdEncrypted: 'enc:client-id', + clientSecretEncrypted: 'enc:client-secret', + tokenUrl: 'https://api.pax8.com/v1/token', + }) + .returning(); + if (!integration) throw new Error('failed to seed Pax8 integration'); + return integration; +} + +// Re-seeds fresh on every call. Intentionally not memoized (see file header). +async function seed() { + return withSystemDbAccessContext(async () => { + const partnerA = await createPartner(); + const orgA = await createOrganization({ partnerId: partnerA.id }); + const partnerB = await createPartner(); + const orgB = await createOrganization({ partnerId: partnerB.id }); + const integrationA = await seedIntegration(partnerA.id); + const integrationB = await seedIntegration(partnerB.id); + + await db.insert(pax8CompanyMappings).values({ + integrationId: integrationA.id, + partnerId: partnerA.id, + pax8CompanyId: 'pax8-co-a', + pax8CompanyName: 'Customer A', + orgId: orgA.id, + status: 'Active', + metadata: READY_COMPANY_METADATA, + }); + + const [orderA] = await db + .insert(pax8Orders) + .values({ + integrationId: integrationA.id, + partnerId: partnerA.id, + orgId: orgA.id, + pax8CompanyId: null, + status: 'awaiting_details', + source: 'quote', + dedupeKey: 'existing-order-a', + }) + .returning(); + if (!orderA) throw new Error('failed to seed partner A order'); + + const [orderB] = await db + .insert(pax8Orders) + .values({ + integrationId: integrationB.id, + partnerId: partnerB.id, + orgId: orgB.id, + pax8CompanyId: 'pax8-co-b', + dedupeKey: 'existing-order-b', + }) + .returning(); + if (!orderB) throw new Error('failed to seed partner B order'); + + return { + partnerA, + orgA, + partnerB, + orgB, + integrationA, + integrationB, + orderA, + orderB, + }; + }); +} + +describe('Pax8 ordering partner-axis RLS and integrity (breeze_app)', () => { + runDb('rejects a cross-partner forged order insert with 42501', async () => { + const { partnerA, partnerB, orgB, integrationB } = await seed(); + + await expect( + withPartnerContext(partnerA.id, () => + db.insert(pax8Orders).values({ + integrationId: integrationB.id, + partnerId: partnerB.id, + orgId: orgB.id, + pax8CompanyId: 'forged-co', + dedupeKey: 'forge-test-1', + }) + ) + ).rejects.toMatchObject({ cause: { code: '42501' } }); + }); + + runDb("hides another partner's orders from SELECT", async () => { + const { partnerA, orderB } = await seed(); + + const existsUnderSystem = await withSystemDbAccessContext(() => + db.select().from(pax8Orders).where(eq(pax8Orders.id, orderB.id)) + ); + expect(existsUnderSystem).toHaveLength(1); + + const rows = await withPartnerContext(partnerA.id, () => + db.select().from(pax8Orders).where(eq(pax8Orders.id, orderB.id)) + ); + expect(rows).toHaveLength(0); + }); + + runDb('partner-wide actionable listing honors the member org allowlist', async () => { + const { partnerA, orgA, integrationA, orderA } = await seed(); + const otherOrgA = await withSystemDbAccessContext(() => + createOrganization({ partnerId: partnerA.id }) + ); + await withSystemDbAccessContext(() => db.insert(pax8Orders).values({ + integrationId: integrationA.id, + partnerId: partnerA.id, + orgId: otherOrgA.id, + status: 'ready', + source: 'direct', + dedupeKey: 'same-partner-hidden-order', + })); + + const rows = await withPartnerContext(partnerA.id, () => listPax8Orders({ + partnerId: partnerA.id, + accessibleOrgIds: [orgA.id], + })); + + expect(rows.map((row) => row.id)).toEqual([orderA.id]); + expect(rows.every((row) => row.orgId === orgA.id)).toBe(true); + }); + + runDb('rejects a second order with the same (partner_id, dedupe_key)', async () => { + const { partnerA, orgA, integrationA, orderA } = await seed(); + + await expect( + withPartnerContext(partnerA.id, () => + db.insert(pax8Orders).values({ + integrationId: integrationA.id, + partnerId: partnerA.id, + orgId: orgA.id, + pax8CompanyId: null, + dedupeKey: orderA.dedupeKey, + }) + ) + ).rejects.toMatchObject({ cause: { code: '23505' } }); + }); + + runDb('concurrent direct draft creation returns one DB-enforced winner without reusing the quote order', async () => { + const { partnerA, orgA, orderA } = await seed(); + const actor = await createUser({ partnerId: partnerA.id }); + const input = { partnerId: partnerA.id, orgId: orgA.id, actorUserId: actor.id }; + + const [first, second] = await Promise.all([ + withPartnerContext(partnerA.id, () => getOrCreateDraftOrder(input)), + withPartnerContext(partnerA.id, () => getOrCreateDraftOrder(input)), + ]); + + expect(first.id).toBe(second.id); + expect(first.id).not.toBe(orderA.id); + const mutableDirect = await withSystemDbAccessContext(() => db + .select({ id: pax8Orders.id }) + .from(pax8Orders) + .where(and( + eq(pax8Orders.partnerId, partnerA.id), + eq(pax8Orders.orgId, orgA.id), + eq(pax8Orders.source, 'direct'), + inArray(pax8Orders.status, ['draft', 'awaiting_details']), + ))); + expect(mutableDirect).toEqual([{ id: first.id }]); + }); + + runDb('rejects a cancel line carrying a quantity (action payload CHECK)', async () => { + const { partnerA, orgA, orderA } = await seed(); + + await expect( + withPartnerContext(partnerA.id, () => + db.insert(pax8OrderLines).values({ + orderId: orderA.id, + partnerId: partnerA.id, + orgId: orgA.id, + action: 'cancel', + targetSubscriptionId: 'sub-1', + quantity: '5.00', + }) + ) + ).rejects.toMatchObject({ cause: { code: '23514' } }); + }); + + runDb('rejects a new-subscription line with an invalid billing term', async () => { + const { partnerA, orgA, orderA } = await seed(); + + await expect( + withPartnerContext(partnerA.id, () => + db.insert(pax8OrderLines).values({ + orderId: orderA.id, + partnerId: partnerA.id, + orgId: orgA.id, + action: 'new_subscription', + pax8ProductId: 'product-1', + billingTerm: 'monthly', + quantity: '1.00', + }) + ) + ).rejects.toMatchObject({ cause: { code: '23514' } }); + }); + + runDb('rejects an order line whose org differs from its parent order', async () => { + const { partnerA, orderA } = await seed(); + const otherOrgA = await withSystemDbAccessContext(() => + createOrganization({ partnerId: partnerA.id }) + ); + + await expect( + withPartnerContext(partnerA.id, () => + db.insert(pax8OrderLines).values({ + orderId: orderA.id, + partnerId: partnerA.id, + orgId: otherOrgA.id, + action: 'cancel', + targetSubscriptionId: 'subscription-1', + }) + ) + ).rejects.toMatchObject({ cause: { code: '23503' } }); + }); + + runDb('deleting a catalog item clears only catalog_item_id on its order line', async () => { + const { partnerA, orgA, orderA } = await seed(); + + const result = await withSystemDbAccessContext(async () => { + const [catalogItem] = await db + .insert(catalogItems) + .values({ + partnerId: partnerA.id, + itemType: 'service', + name: 'Pax8-backed service', + unitPrice: '10.00', + }) + .returning({ id: catalogItems.id }); + if (!catalogItem) throw new Error('failed to seed catalog item'); + + const [orderLine] = await db + .insert(pax8OrderLines) + .values({ + orderId: orderA.id, + partnerId: partnerA.id, + orgId: orgA.id, + action: 'new_subscription', + pax8ProductId: 'product-1', + catalogItemId: catalogItem.id, + billingTerm: 'Monthly', + quantity: '1.00', + }) + .returning({ id: pax8OrderLines.id }); + if (!orderLine) throw new Error('failed to seed catalog-backed order line'); + + await db.delete(catalogItems).where(eq(catalogItems.id, catalogItem.id)); + + const [remaining] = await db + .select({ + catalogItemId: pax8OrderLines.catalogItemId, + partnerId: pax8OrderLines.partnerId, + }) + .from(pax8OrderLines) + .where(eq(pax8OrderLines.id, orderLine.id)); + return remaining; + }); + + expect(result).toEqual({ catalogItemId: null, partnerId: partnerA.id }); + }); + + runDb('deleting a contract line clears only contract_line_id on its order line', async () => { + const { partnerA, orgA, orderA } = await seed(); + + const result = await withSystemDbAccessContext(async () => { + const [contract] = await db + .insert(contracts) + .values({ + partnerId: partnerA.id, + orgId: orgA.id, + name: 'Pax8 contract', + intervalMonths: 1, + startDate: '2026-07-14', + }) + .returning({ id: contracts.id }); + if (!contract) throw new Error('failed to seed contract'); + + const [contractLine] = await db + .insert(contractLines) + .values({ + contractId: contract.id, + orgId: orgA.id, + lineType: 'manual', + description: 'Pax8 subscription', + unitPrice: '10.00', + }) + .returning({ id: contractLines.id }); + if (!contractLine) throw new Error('failed to seed contract line'); + + const [orderLine] = await db + .insert(pax8OrderLines) + .values({ + orderId: orderA.id, + partnerId: partnerA.id, + orgId: orgA.id, + action: 'cancel', + targetSubscriptionId: 'subscription-1', + contractLineId: contractLine.id, + }) + .returning({ id: pax8OrderLines.id }); + if (!orderLine) throw new Error('failed to seed contract-backed order line'); + + await db.delete(contractLines).where(eq(contractLines.id, contractLine.id)); + + const [remaining] = await db + .select({ + contractLineId: pax8OrderLines.contractLineId, + orgId: pax8OrderLines.orgId, + }) + .from(pax8OrderLines) + .where(eq(pax8OrderLines.id, orderLine.id)); + return remaining; + }); + + expect(result).toEqual({ contractLineId: null, orgId: orgA.id }); + }); +}); diff --git a/apps/api/src/__tests__/integration/quoteAcceptPax8Order.integration.test.ts b/apps/api/src/__tests__/integration/quoteAcceptPax8Order.integration.test.ts new file mode 100644 index 0000000000..65fefca513 --- /dev/null +++ b/apps/api/src/__tests__/integration/quoteAcceptPax8Order.integration.test.ts @@ -0,0 +1,274 @@ +import './setup'; +import { describe, expect, it } from 'vitest'; +import { eq, inArray } from 'drizzle-orm'; +import { db, withDbAccessContext, withSystemDbAccessContext, type DbAccessContext } from '../../db'; +import { + catalogItems, + contractLines, + contracts, + pax8CompanyMappings, + pax8Integrations, + pax8OrderLines, + pax8Orders, + pax8ProductMappings, + quotes, +} from '../../db/schema'; +import { acceptQuote } from '../../services/quoteAcceptService'; +import { pax8CompanyOrderReadiness } from '../../services/pax8CompanyReadiness'; +import { PAX8_COMPANY_MAPPING_REQUIRED_ERROR } from '../../services/quoteToPax8Order'; +import { addCatalogLine, createQuote, getQuote } from '../../services/quoteService'; +import { sendQuote } from '../../services/quoteLifecycle'; +import type { QuoteActor } from '../../services/quoteTypes'; +import { createOrganization, createPartner, createUser } from './db-utils'; + +const runDb = it.runIf(!!process.env.DATABASE_URL); + +function context(orgId: string, partnerId: string): DbAccessContext { + return { + scope: 'organization', + orgId, + accessibleOrgIds: [orgId], + accessiblePartnerIds: [partnerId], + currentPartnerId: partnerId, + userId: null, + }; +} + +function actor(orgId: string, partnerId: string): QuoteActor { + return { userId: null, partnerId, accessibleOrgIds: [orgId] }; +} + +async function seedPax8Quote(options: { + recurrence?: 'monthly' | 'annual' | 'one_time'; + mappedCompany?: boolean; + backed?: boolean; + duplicateLines?: number; +} = {}) { + return withSystemDbAccessContext(async () => { + const partner = await createPartner(); + const org = await createOrganization({ partnerId: partner.id }); + const user = await createUser({ partnerId: partner.id }); + const recurrence = options.recurrence ?? 'monthly'; + const [catalogItem] = await db.insert(catalogItems).values({ + partnerId: partner.id, + itemType: 'software', + name: 'Pax8-backed license', + billingType: recurrence === 'one_time' ? 'one_time' : 'recurring', + billingFrequency: recurrence === 'annual' ? 'annual' : (recurrence === 'monthly' ? 'monthly' : null), + unitPrice: '19.95', + taxable: false, + }).returning(); + if (!catalogItem) throw new Error('catalog seed failed'); + + const [integration] = await db.insert(pax8Integrations).values({ + partnerId: partner.id, + name: 'Pax8 quote staging test', + clientIdEncrypted: 'enc:test-client', + clientSecretEncrypted: 'enc:test-secret', + tokenUrl: 'https://api.pax8.com/v1/token', + isActive: true, + }).returning(); + if (!integration) throw new Error('integration seed failed'); + + if (options.backed !== false) { + await db.insert(pax8ProductMappings).values({ + integrationId: integration.id, + partnerId: partner.id, + pax8ProductId: 'product-1', + productName: 'Pax8-backed license', + catalogItemId: catalogItem.id, + }); + } + if (options.mappedCompany) { + await db.insert(pax8CompanyMappings).values({ + integrationId: integration.id, + partnerId: partner.id, + pax8CompanyId: 'company-1', + pax8CompanyName: 'Customer company', + orgId: org.id, + }); + } + + const ctx = context(org.id, partner.id); + const quoteActor = actor(org.id, partner.id); + const quote = await createQuote({ orgId: org.id, currencyCode: 'USD' }, quoteActor); + const quoteLines = []; + for (let index = 0; index < (options.duplicateLines ?? 1); index++) { + quoteLines.push(await addCatalogLine(quote.id, catalogItem.id, index + 2, undefined, quoteActor)); + } + await sendQuote(quote.id, quoteActor); + return { partner, org, user, integration, catalogItem, quote, quoteLines, ctx }; + }); +} + +describe('quote acceptance stages Pax8 fulfillment (real Postgres)', () => { + runDb('stages with missing company readiness and wires only Phase 4 contract lines', async () => { + const fixture = await seedPax8Quote({ duplicateLines: 2 }); + + const result = await withDbAccessContext(fixture.ctx, () => acceptQuote({ + quoteId: fixture.quote.id, + signerName: 'A Customer', + actorUserId: fixture.user.id, + })); + + expect(result.pax8OrderId).toMatch(/^[0-9a-f-]{36}$/); + const state = await withSystemDbAccessContext(async () => { + const [order] = await db.select().from(pax8Orders) + .where(eq(pax8Orders.sourceQuoteId, fixture.quote.id)); + const lines = order + ? await db.select().from(pax8OrderLines).where(eq(pax8OrderLines.orderId, order.id)).orderBy(pax8OrderLines.sortOrder) + : []; + const createdContractLines = await db.select({ + id: contractLines.id, + catalogItemId: contractLines.catalogItemId, + unitPrice: contractLines.unitPrice, + }) + .from(contractLines) + .where(inArray(contractLines.contractId, result.contractIds)); + const companyMappings = await db.select({ id: pax8CompanyMappings.id }) + .from(pax8CompanyMappings) + .where(eq(pax8CompanyMappings.integrationId, fixture.integration.id)); + return { order, lines, createdContractLines, companyMappings }; + }); + + expect(state.order).toMatchObject({ + id: result.pax8OrderId, + integrationId: fixture.integration.id, + partnerId: fixture.partner.id, + orgId: fixture.org.id, + pax8CompanyId: null, + status: 'awaiting_details', + source: 'quote', + sourceQuoteId: fixture.quote.id, + createdBy: fixture.user.id, + error: PAX8_COMPANY_MAPPING_REQUIRED_ERROR, + }); + expect(state.lines).toHaveLength(2); + expect(state.companyMappings).toEqual([]); + expect(state.lines.map((line) => line.quantity)).toEqual(['2.00', '3.00']); + expect(state.lines.map((line) => line.billingTerm)).toEqual(['Monthly', 'Monthly']); + expect(state.lines.every((line) => line.action === 'new_subscription')).toBe(true); + expect(state.lines.every((line) => Array.isArray(line.provisioningDetails) && line.provisioningDetails.length === 0)).toBe(true); + expect(new Set(state.lines.map((line) => line.contractLineId)).size).toBe(2); + expect(state.lines.every((line) => state.createdContractLines.some((created) => created.id === line.contractLineId))).toBe(true); + expect(state.createdContractLines.every((line) => line.catalogItemId === null)).toBe(true); + expect(state.createdContractLines.every((line) => line.unitPrice === '19.95')).toBe(true); + }); + + runDb('returns the staged order summary on quote reload without leaking it cross-tenant', async () => { + const fixture = await seedPax8Quote({ duplicateLines: 2, mappedCompany: true }); + const accepted = await withDbAccessContext(fixture.ctx, () => acceptQuote({ + quoteId: fixture.quote.id, + signerName: 'A Customer', + })); + + const reloaded = await withDbAccessContext(fixture.ctx, () => + getQuote(fixture.quote.id, actor(fixture.org.id, fixture.partner.id))); + expect(reloaded).toMatchObject({ + pax8OrderId: accepted.pax8OrderId, + pax8OrderLineCount: 2, + }); + + const foreign = await withSystemDbAccessContext(async () => { + const partner = await createPartner(); + const org = await createOrganization({ partnerId: partner.id }); + return { partner, org }; + }); + await expect(withDbAccessContext(context(foreign.org.id, foreign.partner.id), () => + getQuote(fixture.quote.id, actor(foreign.org.id, foreign.partner.id)))) + .rejects.toMatchObject({ status: 404, code: 'QUOTE_NOT_FOUND' }); + }); + + runDb('captures a mapped company and leaves one-time fulfillment detached from contracts', async () => { + const fixture = await seedPax8Quote({ recurrence: 'one_time', mappedCompany: true }); + + const result = await withDbAccessContext(fixture.ctx, () => acceptQuote({ + quoteId: fixture.quote.id, + signerName: 'A Customer', + })); + + expect(result.contractIds).toEqual([]); + const [order] = await withSystemDbAccessContext(() => db.select().from(pax8Orders) + .where(eq(pax8Orders.id, result.pax8OrderId!))); + const [line] = await withSystemDbAccessContext(() => db.select().from(pax8OrderLines) + .where(eq(pax8OrderLines.orderId, result.pax8OrderId!))); + const [mapping] = await withSystemDbAccessContext(() => db.select({ + status: pax8CompanyMappings.status, + metadata: pax8CompanyMappings.metadata, + }).from(pax8CompanyMappings).where(eq(pax8CompanyMappings.orgId, fixture.org.id))); + expect(pax8CompanyOrderReadiness(mapping?.status, mapping?.metadata).orderReady).toBe(false); + expect(order?.pax8CompanyId).toBe('company-1'); + expect(order?.error).toBeNull(); + expect(line).toMatchObject({ + sourceQuoteLineId: fixture.quoteLines[0]!.id, + billingTerm: 'One-Time', + quantity: '2.00', + contractLineId: null, + }); + }); + + runDb('stages nothing when only a foreign partner has a product mapping', async () => { + const fixture = await seedPax8Quote({ backed: false }); + await withSystemDbAccessContext(async () => { + const foreignPartner = await createPartner(); + const [foreignCatalog] = await db.insert(catalogItems).values({ + partnerId: foreignPartner.id, + itemType: 'software', + name: 'Foreign product', + billingType: 'recurring', + billingFrequency: 'monthly', + unitPrice: '5.00', + }).returning(); + const [foreignIntegration] = await db.insert(pax8Integrations).values({ + partnerId: foreignPartner.id, + name: 'Foreign Pax8', + clientIdEncrypted: 'enc:foreign', + clientSecretEncrypted: 'enc:foreign', + tokenUrl: 'https://api.pax8.com/v1/token', + }).returning(); + await db.insert(pax8ProductMappings).values({ + integrationId: foreignIntegration!.id, + partnerId: foreignPartner.id, + pax8ProductId: 'foreign-product', + catalogItemId: foreignCatalog!.id, + }); + }); + + const result = await withDbAccessContext(fixture.ctx, () => acceptQuote({ + quoteId: fixture.quote.id, + signerName: 'A Customer', + })); + + expect(result.pax8OrderId).toBeNull(); + const orders = await withSystemDbAccessContext(() => db.select().from(pax8Orders) + .where(eq(pax8Orders.sourceQuoteId, fixture.quote.id))); + expect(orders).toEqual([]); + }); + + runDb('rolls contracts and staged order back together after a later failure', async () => { + const fixture = await seedPax8Quote({ mappedCompany: true }); + let contractIds: string[] = []; + let stagedOrderId: string | null = null; + + await expect(withDbAccessContext(fixture.ctx, async () => { + const result = await acceptQuote({ quoteId: fixture.quote.id, signerName: 'A Customer' }); + contractIds = result.contractIds; + stagedOrderId = result.pax8OrderId; + const [insideOrder] = await db.select().from(pax8Orders) + .where(eq(pax8Orders.id, result.pax8OrderId!)); + expect(insideOrder?.sourceQuoteId).toBe(fixture.quote.id); + throw new Error('forced failure after Phase 5'); + })).rejects.toThrow('forced failure after Phase 5'); + + expect(stagedOrderId).not.toBeNull(); + expect(contractIds).toHaveLength(1); + const after = await withSystemDbAccessContext(async () => ({ + orders: await db.select().from(pax8Orders).where(eq(pax8Orders.sourceQuoteId, fixture.quote.id)), + contracts: await db.select().from(contracts).where(inArray(contracts.id, contractIds)), + quote: (await db.select().from(quotes).where(eq(quotes.id, fixture.quote.id)))[0], + })); + expect(after.orders).toEqual([]); + expect(after.contracts).toEqual([]); + expect(after.quote?.status).toBe('sent'); + }); +}); diff --git a/apps/api/src/__tests__/integration/rls-coverage.integration.test.ts b/apps/api/src/__tests__/integration/rls-coverage.integration.test.ts index ade9c1ed89..0c459cdbf6 100644 --- a/apps/api/src/__tests__/integration/rls-coverage.integration.test.ts +++ b/apps/api/src/__tests__/integration/rls-coverage.integration.test.ts @@ -116,6 +116,11 @@ const ORG_AXIS_POLICY_EXCLUDED_TABLES: ReadonlySet = new Set([ 'pax8_company_mappings', 'pax8_subscription_snapshots', 'pax8_contract_line_links', + // pax8_orders / pax8_order_lines (2026-07-13, ordering): same shape — org_id + // is the customer the order is FOR, not the tenancy axis. Ordering is an + // MSP-side act; an org-scoped token must never see one. + 'pax8_orders', + 'pax8_order_lines', // customer_email_domains (Phase 5): partner-axis (Shape 3) carrying a // denormalized org_id (the routing target). RLS axis is partner_id; the // org_id is for routing + cascade only. Functional cross-partner/cross-org @@ -165,6 +170,8 @@ const PARTNER_TENANT_TABLES: ReadonlyMap = new Map { + it('treats legacy rows as unknown in both Drizzle and the migration', () => { + const column = getTableConfig(pax8SubscriptionSnapshots).columns + .find((candidate) => candidate.name === 'quantity_known'); + expect(column?.default).toBe(false); + + const migration = readFileSync(fileURLToPath(new URL( + '../../../migrations/2026-07-14-pax8-snapshot-quantity-evidence.sql', + import.meta.url, + )), 'utf8'); + expect(migration).toMatch(/quantity_known boolean NOT NULL DEFAULT false/i); + }); +}); + +describe('Pax8 quantity authorization baseline', () => { + it('is represented in Drizzle and added by an idempotent fix-forward migration', () => { + const column = getTableConfig(pax8OrderLines).columns + .find((candidate) => candidate.name === 'authorized_baseline_quantity'); + expect(column).toMatchObject({ notNull: false }); + + const migration = readFileSync(fileURLToPath(new URL( + '../../../migrations/2026-07-16-pax8-order-line-authorized-baseline.sql', + import.meta.url, + )), 'utf8'); + expect(migration).toMatch(/ADD COLUMN IF NOT EXISTS authorized_baseline_quantity NUMERIC\(12,2\)/i); + expect(migration).toMatch(/action = 'change_quantity'/i); + }); +}); diff --git a/apps/api/src/db/schema/pax8.ts b/apps/api/src/db/schema/pax8.ts index b568b8177c..a9388e9226 100644 --- a/apps/api/src/db/schema/pax8.ts +++ b/apps/api/src/db/schema/pax8.ts @@ -91,6 +91,7 @@ export const pax8SubscriptionSnapshots = pgTable('pax8_subscription_snapshots', status: varchar('status', { length: 40 }), billingTerm: varchar('billing_term', { length: 40 }), quantity: numeric('quantity', { precision: 12, scale: 2 }).notNull().default('0'), + quantityKnown: boolean('quantity_known').notNull().default(false), unitPrice: numeric('unit_price', { precision: 12, scale: 2 }), unitCost: numeric('unit_cost', { precision: 12, scale: 2 }), currencyCode: char('currency_code', { length: 3 }), @@ -157,8 +158,10 @@ export const pax8ContractLineLinks = pgTable('pax8_contract_line_links', { subscriptionSnapshotId: uuid('subscription_snapshot_id').notNull().references(() => pax8SubscriptionSnapshots.id, { onDelete: 'cascade' }), contractLineId: uuid('contract_line_id').notNull().references(() => contractLines.id, { onDelete: 'cascade' }), syncEnabled: boolean('sync_enabled').notNull().default(false), - lastAppliedQuantity: numeric('last_applied_quantity', { precision: 12, scale: 2 }), - lastAppliedAt: timestamp('last_applied_at', { withTimezone: true }), + // Physical names are retained for migration compatibility. These values are + // observations only; they are never applied to contract billing quantity. + lastObservedQuantity: numeric('last_applied_quantity', { precision: 12, scale: 2 }), + lastObservedAt: timestamp('last_applied_at', { withTimezone: true }), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), }, (table) => ({ diff --git a/apps/api/src/db/schema/pax8Orders.ts b/apps/api/src/db/schema/pax8Orders.ts new file mode 100644 index 0000000000..12515a31c2 --- /dev/null +++ b/apps/api/src/db/schema/pax8Orders.ts @@ -0,0 +1,117 @@ +import { + pgTable, + uuid, + varchar, + text, + timestamp, + jsonb, + numeric, + date, + integer, + index, + uniqueIndex, + foreignKey, +} from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; +import { partners, organizations } from './orgs'; +import { users } from './users'; +import { catalogItems } from './catalog'; +import { contractLines } from './contracts'; +import { quotes } from './quotes'; +import { pax8Integrations } from './pax8'; + +export const pax8Orders = pgTable('pax8_orders', { + id: uuid('id').primaryKey().defaultRandom(), + integrationId: uuid('integration_id').notNull(), + partnerId: uuid('partner_id').notNull().references(() => partners.id), + orgId: uuid('org_id').notNull(), + // Quote acceptance may stage an awaiting_details order before the customer + // has a Pax8 company mapping. Preflight resolves this before submission. + pax8CompanyId: varchar('pax8_company_id', { length: 64 }), + status: varchar('status', { length: 20 }).notNull().default('draft'), + source: varchar('source', { length: 10 }).notNull().default('direct'), + sourceQuoteId: uuid('source_quote_id').references(() => quotes.id, { onDelete: 'set null' }), + dedupeKey: varchar('dedupe_key', { length: 120 }).notNull(), + pax8OrderId: varchar('pax8_order_id', { length: 64 }), + error: text('error'), + createdBy: uuid('created_by').references(() => users.id), + submittedBy: uuid('submitted_by').references(() => users.id), + submittedAt: timestamp('submitted_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}, (table) => ({ + dedupeKeyIdx: uniqueIndex('pax8_orders_dedupe_key_uq').on(table.partnerId, table.dedupeKey), + oneMutableDirectPerOrgIdx: uniqueIndex('pax8_orders_one_mutable_direct_per_org_uq') + .on(table.partnerId, table.orgId) + .where(sql`${table.source} = 'direct' AND ${table.status} IN ('draft', 'awaiting_details')`), + idPartnerOrgIdx: uniqueIndex('pax8_orders_id_partner_org_idx') + .on(table.id, table.partnerId, table.orgId), + partnerIdx: index('pax8_orders_partner_idx').on(table.partnerId), + orgIdx: index('pax8_orders_org_idx').on(table.orgId), + statusIdx: index('pax8_orders_status_idx').on(table.partnerId, table.status), + quoteIdx: index('pax8_orders_quote_idx').on(table.sourceQuoteId), + integrationPartnerFk: foreignKey({ + columns: [table.integrationId, table.partnerId], + foreignColumns: [pax8Integrations.id, pax8Integrations.partnerId], + name: 'pax8_orders_integration_partner_fkey', + }).onDelete('cascade'), + orgPartnerFk: foreignKey({ + columns: [table.orgId, table.partnerId], + foreignColumns: [organizations.id, organizations.partnerId], + name: 'pax8_orders_org_partner_fkey', + }).onDelete('cascade'), +})); + +export const pax8OrderLines = pgTable('pax8_order_lines', { + id: uuid('id').primaryKey().defaultRandom(), + orderId: uuid('order_id').notNull(), + partnerId: uuid('partner_id').notNull().references(() => partners.id), + orgId: uuid('org_id').notNull(), + action: varchar('action', { length: 20 }).notNull(), + submitState: varchar('submit_state', { length: 20 }).notNull().default('pending'), + pax8ProductId: varchar('pax8_product_id', { length: 64 }), + catalogItemId: uuid('catalog_item_id'), + billingTerm: varchar('billing_term', { length: 20 }), + commitmentTermId: varchar('commitment_term_id', { length: 64 }), + quantity: numeric('quantity', { precision: 12, scale: 2 }), + authorizedBaselineQuantity: numeric('authorized_baseline_quantity', { precision: 12, scale: 2 }), + provisioningDetails: jsonb('provisioning_details').notNull().default([]), + targetSubscriptionId: varchar('target_subscription_id', { length: 64 }), + cancelDate: date('cancel_date'), + resultSubscriptionId: varchar('result_subscription_id', { length: 64 }), + contractLineId: uuid('contract_line_id'), + sourceQuoteLineId: uuid('source_quote_line_id'), + error: text('error'), + sortOrder: integer('sort_order').notNull().default(0), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}, (table) => ({ + orderIdx: index('pax8_order_lines_order_idx').on(table.orderId), + partnerIdx: index('pax8_order_lines_partner_idx').on(table.partnerId), + orgIdx: index('pax8_order_lines_org_idx').on(table.orgId), + contractLineIdx: index('pax8_order_lines_contract_line_idx').on(table.contractLineId), + orderPartnerOrgFk: foreignKey({ + columns: [table.orderId, table.partnerId, table.orgId], + foreignColumns: [pax8Orders.id, pax8Orders.partnerId, pax8Orders.orgId], + name: 'pax8_order_lines_order_partner_org_fkey', + }).onDelete('cascade'), + orgPartnerFk: foreignKey({ + columns: [table.orgId, table.partnerId], + foreignColumns: [organizations.id, organizations.partnerId], + name: 'pax8_order_lines_org_partner_fkey', + }).onDelete('cascade'), + // Drizzle cannot express PostgreSQL's column-list SET NULL action. The SQL + // migration narrows this to catalog_item_id so partner_id remains intact. + catalogItemPartnerFk: foreignKey({ + columns: [table.catalogItemId, table.partnerId], + foreignColumns: [catalogItems.id, catalogItems.partnerId], + name: 'pax8_order_lines_catalog_item_partner_fkey', + }).onDelete('set null'), + // Likewise, the SQL migration narrows SET NULL to contract_line_id so the + // required org_id tenancy-linkage column is never cleared. + contractLineOrgFk: foreignKey({ + columns: [table.contractLineId, table.orgId], + foreignColumns: [contractLines.id, contractLines.orgId], + name: 'pax8_order_lines_contract_line_org_fkey', + }).onDelete('set null'), +})); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index c374f4509d..41f5951caa 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -138,6 +138,7 @@ import { sentinelOneRoutes } from './routes/sentinelOne'; import { softwareInventoryRoutes } from './routes/softwareInventory'; import { huntressRoutes } from './routes/huntress'; import { pax8Routes } from './routes/pax8'; +import { pax8OrderRoutes } from './routes/pax8Orders'; import { unifiRoutes } from './routes/unifi'; import { accountingRoutes } from './routes/accounting'; import { sensitiveDataRoutes } from './routes/sensitiveData'; @@ -921,6 +922,7 @@ api.route('/dns-security', dnsSecurityRoutes); api.route('/s1', sentinelOneRoutes); api.route('/huntress', huntressRoutes); api.route('/pax8', pax8Routes); +api.route('/pax8', pax8OrderRoutes); api.route('/unifi', unifiRoutes); api.route('/accounting', accountingRoutes); api.route('/software-inventory', softwareInventoryRoutes); diff --git a/apps/api/src/middleware/selfManagedDbContextRoutes.test.ts b/apps/api/src/middleware/selfManagedDbContextRoutes.test.ts index 93e28c44ad..a9334d9e8c 100644 --- a/apps/api/src/middleware/selfManagedDbContextRoutes.test.ts +++ b/apps/api/src/middleware/selfManagedDbContextRoutes.test.ts @@ -36,6 +36,22 @@ describe('isSelfManagedDbContextRoute', () => { ['patch', '/api/v1/sso/providers/abc-123'], // method is case-insensitive ['POST', '/api/v1/sso/providers/abc-123/test'], ['POST', '/api/v1/sso/providers/abc-123/test/'], + // Pax8 line authoring may fetch commitment dependencies from Pax8. + ['POST', '/api/v1/pax8/orders/ord-1/lines'], + ['POST', '/api/v1/pax8/orders/ord-1/lines/'], + ['post', '/api/v1/pax8/orders/ord-1/lines'], // method is case-insensitive + // Pax8 submit/reconcile phases make outbound calls between short DB txns. + ['POST', '/api/v1/pax8/orders/ord-1/preflight'], + ['POST', '/api/v1/pax8/orders/ord-1/preflight/'], + ['POST', '/api/v1/pax8/orders/ord-1/submit'], + ['POST', '/api/v1/pax8/orders/ord-1/submit/'], + ['POST', '/api/v1/pax8/orders/ord-1/reconcile'], + ['POST', '/api/v1/pax8/orders/ord-1/reconcile/'], + // Product form metadata proxies Pax8 HTTP after a short credential read. + ['GET', '/api/v1/pax8/products/prod-1/provision-details'], + ['GET', '/api/v1/pax8/products/prod-1/provision-details/'], + ['GET', '/api/v1/pax8/products/prod-1/dependencies'], + ['GET', '/api/v1/pax8/products/prod-1/dependencies/'], ]; const NO_MATCH: ReadonlyArray<[string, string, string]> = [ @@ -78,6 +94,17 @@ describe('isSelfManagedDbContextRoute', () => { ['POST', '/api/v1/sso/providers/abc-123/test/extra', 'extra segment must not match'], ['POST', '/api/v1/sso/domains', 'domain routes are DB-only'], ['POST', '/api/v1/sso/link/start/abc-123', 'link start is DB-only'], + ['GET', '/api/v1/pax8/orders/ord-1/lines', 'Pax8 line authoring is POST-only'], + ['POST', '/api/v1/pax8/orders//lines', 'Pax8 order id must not be empty'], + ['POST', '/api/v1/pax8/orders/ord-1/lines/extra', 'extra segment must not match'], + ['GET', '/api/v1/pax8/orders/ord-1/preflight', 'Pax8 preflight is POST-only'], + ['GET', '/api/v1/pax8/orders/ord-1/submit', 'Pax8 submit is POST-only'], + ['GET', '/api/v1/pax8/orders/ord-1/reconcile', 'Pax8 reconcile is POST-only'], + ['POST', '/api/v1/pax8/orders//submit', 'Pax8 order id must not be empty'], + ['POST', '/api/v1/pax8/orders/ord-1/submit/extra', 'extra segment must not match'], + ['POST', '/api/v1/pax8/products/prod-1/dependencies', 'Pax8 product metadata routes are GET-only'], + ['GET', '/api/v1/pax8/products//dependencies', 'Pax8 product id must not be empty'], + ['GET', '/api/v1/pax8/products/prod-1/dependencies/extra', 'extra segment must not match'], ]; it.each(MATCH)('opts out: %s %s', (method, path) => { diff --git a/apps/api/src/middleware/selfManagedDbContextRoutes.ts b/apps/api/src/middleware/selfManagedDbContextRoutes.ts index 2fcfcab2bb..cab01e7512 100644 --- a/apps/api/src/middleware/selfManagedDbContextRoutes.ts +++ b/apps/api/src/middleware/selfManagedDbContextRoutes.ts @@ -65,6 +65,17 @@ const SELF_MANAGED_DB_CONTEXT_ROUTES: readonly SelfManagedRoute[] = [ { method: 'POST', pattern: /^\/api\/v1\/sso\/providers\/?$/ }, { method: 'PATCH', pattern: /^\/api\/v1\/sso\/providers\/[^/]+\/?$/ }, { method: 'POST', pattern: /^\/api\/v1\/sso\/providers\/[^/]+\/test\/?$/ }, + // Pax8 order line authoring may fetch product commitment dependencies. The + // service re-enters short partner-scoped DB contexts around each DB phase, + // with the Pax8 HTTP request running after those contexts close. + { method: 'POST', pattern: /^\/api\/v1\/pax8\/orders\/[^/]+\/lines\/?$/ }, + // Pax8 preflight, submit, and human reconciliation likewise split DB claims + // and result persistence around Pax8 HTTP. Pax8 writes are never retried. + { method: 'POST', pattern: /^\/api\/v1\/pax8\/orders\/[^/]+\/(?:preflight|submit|reconcile)\/?$/ }, + // Dynamic Pax8 order forms proxy product metadata. The route reads and + // decrypts the active integration in one short partner context, then closes + // it before making the outbound request. + { method: 'GET', pattern: /^\/api\/v1\/pax8\/products\/[^/]+\/(?:provision-details|dependencies)\/?$/ }, ]; /** diff --git a/apps/api/src/routes/pax8.test.ts b/apps/api/src/routes/pax8.test.ts index acef4523dc..4e197db130 100644 --- a/apps/api/src/routes/pax8.test.ts +++ b/apps/api/src/routes/pax8.test.ts @@ -42,6 +42,7 @@ vi.mock('../db/schema', () => ({ id: 'contract_lines.id', orgId: 'contract_lines.org_id', lineType: 'contract_lines.line_type', + manualQuantity: 'contract_lines.manual_quantity', }, pax8Integrations: { id: 'pax8_integrations.id', @@ -68,6 +69,7 @@ vi.mock('../db/schema', () => ({ ignored: 'pax8_company_mappings.ignored', lastSeenAt: 'pax8_company_mappings.last_seen_at', updatedAt: 'pax8_company_mappings.updated_at', + metadata: 'pax8_company_mappings.metadata', integrationId: 'pax8_company_mappings.integration_id', partnerId: 'pax8_company_mappings.partner_id', }, @@ -84,10 +86,12 @@ vi.mock('../db/schema', () => ({ status: 'pax8_subscription_snapshots.status', billingTerm: 'pax8_subscription_snapshots.billing_term', quantity: 'pax8_subscription_snapshots.quantity', + quantityKnown: 'pax8_subscription_snapshots.quantity_known', unitPrice: 'pax8_subscription_snapshots.unit_price', unitCost: 'pax8_subscription_snapshots.unit_cost', currencyCode: 'pax8_subscription_snapshots.currency_code', lastSeenAt: 'pax8_subscription_snapshots.last_seen_at', + raw: 'pax8_subscription_snapshots.raw', }, pax8ContractLineLinks: { id: 'pax8_contract_line_links.id', @@ -96,8 +100,8 @@ vi.mock('../db/schema', () => ({ subscriptionSnapshotId: 'pax8_contract_line_links.subscription_snapshot_id', contractLineId: 'pax8_contract_line_links.contract_line_id', syncEnabled: 'pax8_contract_line_links.sync_enabled', - lastAppliedQuantity: 'pax8_contract_line_links.last_applied_quantity', - lastAppliedAt: 'pax8_contract_line_links.last_applied_at', + lastObservedQuantity: 'pax8_contract_line_links.last_applied_quantity', + lastObservedAt: 'pax8_contract_line_links.last_applied_at', }, })); @@ -188,13 +192,15 @@ function mockSubscriptionSelectOnce(integrationRows: unknown[], snapshotRows: un })), } as any); - // Second call: the snapshot join query (from/leftJoin/leftJoin/where/orderBy/limit) + // Second call: the snapshot join query (three left joins, then filtering). const leftJoinMock = vi.fn().mockReturnThis(); vi.mocked(db.select).mockReturnValueOnce({ from: vi.fn(() => ({ leftJoin: leftJoinMock.mockImplementation(() => ({ leftJoin: vi.fn(() => ({ - where: whereSpy, + leftJoin: vi.fn(() => ({ + where: whereSpy, + })), })), })), })), @@ -203,6 +209,16 @@ function mockSubscriptionSelectOnce(integrationRows: unknown[], snapshotRows: un return { whereSpy, getConditions: () => whereConditions }; } +function mockCompanySelectOnce(integrationRows: unknown[], companyRows: unknown[]) { + mockSelectOnce(integrationRows); + const chain: Record = {}; + chain.from = vi.fn(() => chain); + chain.leftJoin = vi.fn(() => chain); + chain.where = vi.fn(() => chain); + chain.orderBy = vi.fn(async () => companyRows); + vi.mocked(db.select).mockReturnValueOnce(chain as any); +} + describe('pax8 routes', () => { let app: Hono; @@ -233,6 +249,30 @@ describe('pax8 routes', () => { expect(res.status).toBe(403); }); + it('GET /companies returns bounded ordering readiness without contact PII or metadata', async () => { + const integration = { id: '44444444-4444-4444-4444-444444444444', partnerId: authState.partnerId }; + mockCompanySelectOnce([integration], [{ + pax8CompanyId: 'company-1', pax8CompanyName: 'Acme', status: 'Active', mappedOrgId: ORG_A, + mappedOrgName: 'Acme', ignored: false, lastSeenAt: null, updatedAt: null, + metadata: { + contacts: [{ email: 'private@example.com', types: [ + { type: 'Admin', primary: true }, { type: 'Billing', primary: true }, { type: 'Technical', primary: true }, + ] }], + }, + }]); + + const res = await app.request('/pax8/companies'); + + expect(res.status).toBe(200); + const body = await res.json() as any; + expect(body.data[0]).toMatchObject({ + statusActive: true, primaryAdminReady: true, primaryBillingReady: true, + primaryTechnicalReady: true, orderReady: true, + }); + expect(body.data[0]).not.toHaveProperty('metadata'); + expect(JSON.stringify(body)).not.toContain('private@example.com'); + }); + it('requires credentials when creating a Pax8 integration', async () => { mockSelectOnce([]); @@ -470,6 +510,29 @@ describe('pax8 routes', () => { expect(orgConditionSpy).toHaveReturnedWith(`pax8_subscription_snapshots.org_id IN (${ORG_A})`); const body = await res.json(); expect(body).toHaveProperty('integrationId', integration.id); + expect(vi.mocked(db.select).mock.calls[1]?.[0]).toMatchObject({ + breezeQuantity: 'contract_lines.manual_quantity', + quantity: 'pax8_subscription_snapshots.quantity', + quantityKnown: 'pax8_subscription_snapshots.quantity_known', + }); + }); + + it('GET /subscriptions projects active commitment evidence without exposing the raw snapshot', async () => { + const integration = { id: '44444444-4444-4444-4444-444444444444', partnerId: authState.partnerId }; + mockSubscriptionSelectOnce([integration], [{ + id: 'snap-1', orgId: ORG_A, + raw: { commitment: { id: 'active-commitment', secret: 'do-not-return' } }, + }]); + + const res = await app.request(`/pax8/subscriptions?orgId=${ORG_A}`); + + expect(res.status).toBe(200); + const body = await res.json() as any; + expect(body.data[0]).toMatchObject({ + activeCommitmentId: 'active-commitment', activeCommitmentAmbiguous: false, + }); + expect(body.data[0]).not.toHaveProperty('raw'); + expect(JSON.stringify(body)).not.toContain('do-not-return'); }); it('GET /subscriptions with inaccessible orgId returns 403', async () => { diff --git a/apps/api/src/routes/pax8.ts b/apps/api/src/routes/pax8.ts index f456948b74..70d2ddf619 100644 --- a/apps/api/src/routes/pax8.ts +++ b/apps/api/src/routes/pax8.ts @@ -19,6 +19,8 @@ import { DEFAULT_PAX8_API_BASE_URL, DEFAULT_PAX8_TOKEN_URL } from '../services/p import { createPax8ClientForIntegration, linkPax8SubscriptionToContractLine, mapPax8Company, unlinkPax8Subscription } from '../services/pax8SyncService'; import { enqueuePax8Sync } from '../jobs/pax8SyncWorker'; import { captureException } from '../services/sentry'; +import { pax8CompanyOrderReadiness } from '../services/pax8CompanyReadiness'; +import { snapshotActiveCommitmentEvidence } from '../services/pax8OrderService'; export const pax8Routes = new Hono(); @@ -301,12 +303,19 @@ pax8Routes.get('/companies', partnerScopes, readPerm, zValidator('query', compan ignored: pax8CompanyMappings.ignored, lastSeenAt: pax8CompanyMappings.lastSeenAt, updatedAt: pax8CompanyMappings.updatedAt, + metadata: pax8CompanyMappings.metadata, }) .from(pax8CompanyMappings) .leftJoin(organizations, eq(pax8CompanyMappings.orgId, organizations.id)) .where(eq(pax8CompanyMappings.integrationId, integration.id)) .orderBy(pax8CompanyMappings.pax8CompanyName, pax8CompanyMappings.pax8CompanyId); - return c.json({ data: rows, integrationId: integration.id }); + return c.json({ + data: rows.map(({ metadata, ...row }) => ({ + ...row, + ...pax8CompanyOrderReadiness(row.status, metadata), + })), + integrationId: integration.id, + }); }); pax8Routes.post('/companies/map', partnerScopes, writePerm, requireMfa(), zValidator('json', companyMapSchema), async (c) => { @@ -380,14 +389,17 @@ pax8Routes.get('/subscriptions', partnerScopes, readPerm, zValidator('query', su status: pax8SubscriptionSnapshots.status, billingTerm: pax8SubscriptionSnapshots.billingTerm, quantity: pax8SubscriptionSnapshots.quantity, + quantityKnown: pax8SubscriptionSnapshots.quantityKnown, unitPrice: pax8SubscriptionSnapshots.unitPrice, unitCost: pax8SubscriptionSnapshots.unitCost, currencyCode: pax8SubscriptionSnapshots.currencyCode, lastSeenAt: pax8SubscriptionSnapshots.lastSeenAt, contractLineId: pax8ContractLineLinks.contractLineId, + breezeQuantity: contractLines.manualQuantity, syncEnabled: pax8ContractLineLinks.syncEnabled, - lastAppliedQuantity: pax8ContractLineLinks.lastAppliedQuantity, - lastAppliedAt: pax8ContractLineLinks.lastAppliedAt, + lastObservedQuantity: pax8ContractLineLinks.lastObservedQuantity, + lastObservedAt: pax8ContractLineLinks.lastObservedAt, + raw: pax8SubscriptionSnapshots.raw, }) .from(pax8SubscriptionSnapshots) .leftJoin(pax8CompanyMappings, and( @@ -395,11 +407,22 @@ pax8Routes.get('/subscriptions', partnerScopes, readPerm, zValidator('query', su eq(pax8SubscriptionSnapshots.pax8CompanyId, pax8CompanyMappings.pax8CompanyId), )) .leftJoin(pax8ContractLineLinks, eq(pax8SubscriptionSnapshots.id, pax8ContractLineLinks.subscriptionSnapshotId)) + .leftJoin(contractLines, and( + eq(pax8ContractLineLinks.contractLineId, contractLines.id), + eq(pax8ContractLineLinks.orgId, contractLines.orgId), + eq(pax8SubscriptionSnapshots.orgId, contractLines.orgId), + )) .where(and(...conditions)) .orderBy(desc(pax8SubscriptionSnapshots.lastSeenAt)) .limit(query.limit); - return c.json({ data: rows, integrationId: integration.id }); + return c.json({ + data: rows.map(({ raw, ...row }) => ({ + ...row, + ...snapshotActiveCommitmentEvidence(raw), + })), + integrationId: integration.id, + }); }); pax8Routes.post('/subscriptions/link', partnerScopes, writePerm, requireMfa(), zValidator('json', linkSchema), async (c) => { diff --git a/apps/api/src/routes/pax8Orders.test.ts b/apps/api/src/routes/pax8Orders.test.ts new file mode 100644 index 0000000000..96ad20d853 --- /dev/null +++ b/apps/api/src/routes/pax8Orders.test.ts @@ -0,0 +1,737 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Hono } from 'hono'; + +const PARTNER_A = '11111111-1111-4111-8111-111111111111'; +const PARTNER_B = '99999999-9999-4999-8999-999999999999'; +const ORG_A = '22222222-2222-4222-8222-222222222222'; +const ORG_B = '88888888-8888-4888-8888-888888888888'; +const USER_ID = '33333333-3333-4333-8333-333333333333'; +const ORDER_ID = '44444444-4444-4444-8444-444444444444'; +const LINE_ID = '55555555-5555-4555-8555-555555555555'; +const INTEGRATION_ID = '66666666-6666-4666-8666-666666666666'; + +const state = vi.hoisted(() => ({ + unauthenticated: false, + permissionDenied: false, + mfaDenied: false, + scope: 'partner' as 'partner' | 'organization' | 'system', + partnerId: '11111111-1111-4111-8111-111111111111' as string | null, + accessibleOrgIds: ['22222222-2222-4222-8222-222222222222'] as string[] | null, +})); + +const mocks = vi.hoisted(() => ({ + listPax8Orders: vi.fn(), + getOrderWithLines: vi.fn(), + getOrCreateDraftOrder: vi.fn(), + addOrderLine: vi.fn(), + updateOrderLine: vi.fn(), + removeOrderLine: vi.fn(), + listPax8Products: vi.fn(), + preflightOrder: vi.fn(), + submitOrder: vi.fn(), + reconcileOrder: vi.fn(), + detectPax8Drift: vi.fn(), + writeRouteAudit: vi.fn(), + createPax8ClientForIntegration: vi.fn(), + getProvisionDetails: vi.fn(), + getProductDependencies: vi.fn(), + runOutsideDbContext: vi.fn((fn: () => unknown) => fn()), + withDbAccessContext: vi.fn((_context: unknown, fn: () => unknown) => fn()), + dbSelect: vi.fn(), +})); + +vi.mock('../middleware/auth', () => ({ + authMiddleware: vi.fn((c: any, next: any) => { + if (state.unauthenticated) return c.json({ error: 'Unauthorized' }, 401); + c.set('auth', { + scope: state.scope, + partnerId: state.partnerId, + orgId: state.scope === 'organization' ? ORG_A : null, + accessibleOrgIds: state.accessibleOrgIds, + canAccessOrg: (orgId: string) => state.accessibleOrgIds === null || state.accessibleOrgIds.includes(orgId), + user: { id: USER_ID, email: 'admin@example.com', name: 'Admin', isPlatformAdmin: false }, + }); + return next(); + }), + requireScope: vi.fn(() => (c: any, next: any) => { + if (!['partner', 'system'].includes(c.get('auth').scope)) { + return c.json({ error: 'Insufficient permissions' }, 403); + } + return next(); + }), + requirePermission: vi.fn(() => (c: any, next: any) => { + if (state.permissionDenied) return c.json({ error: 'Forbidden' }, 403); + return next(); + }), + requireMfa: vi.fn(() => (c: any, next: any) => { + if (state.mfaDenied) return c.json({ error: 'MFA required' }, 403); + return next(); + }), +})); + +vi.mock('../services/permissions', () => ({ + PERMISSIONS: { BILLING_MANAGE: { resource: 'billing', action: 'manage' } }, +})); + +vi.mock('../services/auditEvents', () => ({ writeRouteAudit: mocks.writeRouteAudit })); + +vi.mock('../services/pax8OrderService', async () => { + class Pax8OrderError extends Error { + constructor(message: string, public readonly status: 400 | 403 | 404 | 409 | 422) { + super(message); + this.name = 'Pax8OrderError'; + } + } + return { + Pax8OrderError, + listPax8Orders: mocks.listPax8Orders, + getOrderWithLines: mocks.getOrderWithLines, + getOrCreateDraftOrder: mocks.getOrCreateDraftOrder, + addOrderLine: mocks.addOrderLine, + updateOrderLine: mocks.updateOrderLine, + removeOrderLine: mocks.removeOrderLine, + listPax8Products: mocks.listPax8Products, + }; +}); + +vi.mock('../services/pax8OrderSubmit', () => ({ + preflightOrder: mocks.preflightOrder, + submitOrder: mocks.submitOrder, + reconcileOrder: mocks.reconcileOrder, +})); + +vi.mock('../services/pax8Drift', () => ({ detectPax8Drift: mocks.detectPax8Drift })); + +vi.mock('../services/pax8SyncService', () => ({ + createPax8ClientForIntegration: mocks.createPax8ClientForIntegration, +})); + +vi.mock('../db', () => ({ + db: { select: mocks.dbSelect }, + runOutsideDbContext: mocks.runOutsideDbContext, + withDbAccessContext: mocks.withDbAccessContext, +})); + +vi.mock('../db/schema', () => ({ + pax8Integrations: { + id: 'pax8_integrations.id', + partnerId: 'pax8_integrations.partner_id', + isActive: 'pax8_integrations.is_active', + }, +})); + +vi.mock('../services/pax8Client', async () => { + class Pax8ApiError extends Error { + constructor(message: string, public readonly status?: number, public readonly body?: string) { + super(message); + this.name = 'Pax8ApiError'; + } + } + return { Pax8ApiError }; +}); + +import { Pax8ApiError } from '../services/pax8Client'; +import { Pax8OrderError } from '../services/pax8OrderService'; +import { pax8OrderRoutes } from './pax8Orders'; + +const baseOrder = { + id: ORDER_ID, + partnerId: PARTNER_A, + orgId: ORG_A, + integrationId: INTEGRATION_ID, + status: 'draft', +}; + +function mockIntegrationClient(): void { + const chain: Record = {}; + chain.from = vi.fn(() => chain); + chain.where = vi.fn(() => chain); + chain.limit = vi.fn(async () => [{ id: INTEGRATION_ID, partnerId: PARTNER_A }]); + mocks.dbSelect.mockReturnValueOnce(chain); + mocks.createPax8ClientForIntegration.mockResolvedValueOnce({ + integration: { id: INTEGRATION_ID, partnerId: PARTNER_A }, + client: { + getProvisionDetails: mocks.getProvisionDetails, + getProductDependencies: mocks.getProductDependencies, + }, + }); +} + +function request(path: string, init?: RequestInit) { + const app = new Hono(); + app.route('/pax8', pax8OrderRoutes); + return app.request(`/pax8${path}`, init); +} + +beforeEach(() => { + vi.clearAllMocks(); + state.unauthenticated = false; + state.permissionDenied = false; + state.mfaDenied = false; + state.scope = 'partner'; + state.partnerId = PARTNER_A; + state.accessibleOrgIds = [ORG_A]; + mocks.listPax8Orders.mockResolvedValue([baseOrder]); + mocks.getOrderWithLines.mockResolvedValue({ order: baseOrder, lines: [] }); + mocks.getOrCreateDraftOrder.mockResolvedValue(baseOrder); + mocks.addOrderLine.mockResolvedValue({ id: LINE_ID, orderId: ORDER_ID, orgId: ORG_A }); + mocks.updateOrderLine.mockResolvedValue({ id: LINE_ID, orderId: ORDER_ID, orgId: ORG_A, action: 'new_subscription' }); + mocks.removeOrderLine.mockResolvedValue({ removed: true }); + mocks.listPax8Products.mockResolvedValue([{ pax8ProductId: 'prod-1', catalogItemId: 'cat-1', catalogName: 'M365' }]); + mocks.preflightOrder.mockResolvedValue({ ok: true }); + mocks.submitOrder.mockResolvedValue({ orderId: ORDER_ID, status: 'completed', lines: [] }); + mocks.reconcileOrder.mockResolvedValue({ resolved: 1, stillUnknown: 0 }); + mocks.detectPax8Drift.mockResolvedValue([]); +}); + +describe('Pax8 order route security and tenancy', () => { + it('rejects unauthenticated callers', async () => { + state.unauthenticated = true; + expect((await request('/orders')).status).toBe(401); + }); + + it('requires billing:manage', async () => { + state.permissionDenied = true; + expect((await request('/orders')).status).toBe(403); + expect(mocks.listPax8Orders).not.toHaveBeenCalled(); + expect(mocks.writeRouteAudit).not.toHaveBeenCalled(); + }); + + it('rejects organization scope with the ordering-specific message', async () => { + state.scope = 'organization'; + const res = await request('/orders'); + expect(res.status).toBe(403); + await expect(res.json()).resolves.toEqual({ error: 'Pax8 ordering is managed at partner scope' }); + }); + + it('rejects a partner token requesting another partner', async () => { + const res = await request(`/orders?partnerId=${PARTNER_B}`); + expect(res.status).toBe(403); + expect(mocks.listPax8Orders).not.toHaveBeenCalled(); + }); + + it('enforces authentication, billing permission, and partner scope on drift reads', async () => { + state.unauthenticated = true; + expect((await request(`/drift?integrationId=${INTEGRATION_ID}`)).status).toBe(401); + + state.unauthenticated = false; + state.permissionDenied = true; + expect((await request(`/drift?integrationId=${INTEGRATION_ID}`)).status).toBe(403); + + state.permissionDenied = false; + state.scope = 'organization'; + expect((await request(`/drift?integrationId=${INTEGRATION_ID}`)).status).toBe(403); + + state.scope = 'partner'; + expect((await request(`/drift?integrationId=${INTEGRATION_ID}&partnerId=${PARTNER_B}`)).status).toBe(403); + expect(mocks.detectPax8Drift).not.toHaveBeenCalled(); + }); + + it('requires system drift callers to choose a valid partner', async () => { + state.scope = 'system'; + state.partnerId = null; + state.accessibleOrgIds = null; + expect((await request(`/drift?integrationId=${INTEGRATION_ID}`)).status).toBe(400); + expect((await request(`/drift?integrationId=${INTEGRATION_ID}&partnerId=not-a-uuid`)).status).toBe(400); + expect(mocks.detectPax8Drift).not.toHaveBeenCalled(); + }); + + it('requires a valid requested partner for system scope', async () => { + state.scope = 'system'; + state.partnerId = null; + expect((await request('/orders')).status).toBe(400); + expect((await request('/orders?partnerId=not-a-uuid')).status).toBe(400); + }); + + it('passes only the system-requested partner to the service', async () => { + state.scope = 'system'; + state.partnerId = null; + state.accessibleOrgIds = null; + const res = await request(`/orders?partnerId=${PARTNER_B}`); + expect(res.status).toBe(200); + expect(mocks.listPax8Orders).toHaveBeenCalledWith({ partnerId: PARTNER_B }); + }); + + it('rejects a list query for an inaccessible org', async () => { + const res = await request(`/orders?orgId=${ORG_B}`); + expect(res.status).toBe(403); + expect(mocks.listPax8Orders).not.toHaveBeenCalled(); + }); + + it('limits a partner-wide list to the member accessible-org allowlist', async () => { + const res = await request('/orders'); + expect(res.status).toBe(200); + expect(mocks.listPax8Orders).toHaveBeenCalledWith({ + partnerId: PARTNER_A, + accessibleOrgIds: [ORG_A], + }); + }); + + it('rejects detail and actions for an inaccessible same-partner org', async () => { + mocks.getOrderWithLines.mockResolvedValue({ order: { ...baseOrder, orgId: ORG_B }, lines: [] }); + const detail = await request(`/orders/${ORDER_ID}`); + expect(detail.status).toBe(403); + + const submit = await request(`/orders/${ORDER_ID}/submit`, { method: 'POST' }); + expect(submit.status).toBe(403); + expect(mocks.submitOrder).not.toHaveBeenCalled(); + expect(mocks.writeRouteAudit).not.toHaveBeenCalled(); + }); + + it('requires MFA on every POST and DELETE action', async () => { + state.mfaDenied = true; + const mutations: Array<[string, RequestInit]> = [ + ['/orders', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ orgId: ORG_A }) }], + [`/orders/${ORDER_ID}/lines`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'cancel', targetSubscriptionId: 'sub-1' }) }], + [`/orders/${ORDER_ID}/lines/${LINE_ID}`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provisioningDetails: [] }) }], + [`/orders/${ORDER_ID}/lines/${LINE_ID}`, { method: 'DELETE' }], + [`/orders/${ORDER_ID}/preflight`, { method: 'POST' }], + [`/orders/${ORDER_ID}/submit`, { method: 'POST' }], + [`/orders/${ORDER_ID}/reconcile`, { method: 'POST' }], + ]; + for (const [path, init] of mutations) { + expect((await request(path, init)).status, path).toBe(403); + } + expect(mocks.getOrCreateDraftOrder).not.toHaveBeenCalled(); + expect(mocks.submitOrder).not.toHaveBeenCalled(); + expect(mocks.writeRouteAudit).not.toHaveBeenCalled(); + }); +}); + +describe('Pax8 order route handlers', () => { + it('returns drift for the resolved partner after validating integration ownership', async () => { + const chain: Record = {}; + chain.from = vi.fn(() => chain); + chain.where = vi.fn(() => chain); + chain.limit = vi.fn(async () => [{ id: INTEGRATION_ID }]); + mocks.dbSelect.mockReturnValueOnce(chain); + mocks.detectPax8Drift.mockResolvedValueOnce([{ contractLineId: LINE_ID }]); + const res = await request(`/drift?integrationId=${INTEGRATION_ID}`); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ data: [{ contractLineId: LINE_ID }] }); + expect(mocks.detectPax8Drift).toHaveBeenCalledWith({ + partnerId: PARTNER_A, + integrationId: INTEGRATION_ID, + }); + expect(mocks.runOutsideDbContext).not.toHaveBeenCalled(); + }); + + it('does not require MFA for a read-only drift request', async () => { + state.mfaDenied = true; + const chain: Record = {}; + chain.from = vi.fn(() => chain); + chain.where = vi.fn(() => chain); + chain.limit = vi.fn(async () => [{ id: INTEGRATION_ID }]); + mocks.dbSelect.mockReturnValueOnce(chain); + + expect((await request(`/drift?integrationId=${INTEGRATION_ID}`)).status).toBe(200); + expect(mocks.detectPax8Drift).toHaveBeenCalledTimes(1); + }); + + it('rejects missing, invalid, and foreign-partner drift integrations without reading drift', async () => { + expect((await request('/drift')).status).toBe(400); + expect((await request('/drift?integrationId=not-a-uuid')).status).toBe(400); + + const chain: Record = {}; + chain.from = vi.fn(() => chain); + chain.where = vi.fn(() => chain); + chain.limit = vi.fn(async () => []); + mocks.dbSelect.mockReturnValueOnce(chain); + expect((await request(`/drift?integrationId=${INTEGRATION_ID}`)).status).toBe(404); + expect(mocks.detectPax8Drift).not.toHaveBeenCalled(); + }); + + it('lists by org and returns order detail', async () => { + expect((await request(`/orders?orgId=${ORG_A}`)).status).toBe(200); + expect(mocks.listPax8Orders).toHaveBeenCalledWith({ partnerId: PARTNER_A, orgId: ORG_A }); + + const detail = await request(`/orders/${ORDER_ID}`); + expect(detail.status).toBe(200); + await expect(detail.json()).resolves.toEqual({ data: { order: baseOrder, lines: [] } }); + }); + + it('creates a draft using authenticated tenancy and audits safe identifiers', async () => { + const res = await request('/orders', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ orgId: ORG_A }), + }); + expect(res.status).toBe(201); + expect(mocks.getOrCreateDraftOrder).toHaveBeenCalledWith({ + partnerId: PARTNER_A, orgId: ORG_A, actorUserId: USER_ID, + }); + expect(mocks.writeRouteAudit).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + orgId: ORG_A, action: 'pax8.order.create', resourceId: ORDER_ID, + details: expect.objectContaining({ partnerId: PARTNER_A }), + })); + }); + + it('audits a create service failure only after org authorization', async () => { + mocks.getOrCreateDraftOrder.mockRejectedValueOnce(new Pax8OrderError('No mapping.', 409)); + const res = await request('/orders', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ orgId: ORG_A }), + }); + expect(res.status).toBe(409); + expect(mocks.writeRouteAudit).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + orgId: ORG_A, action: 'pax8.order.create', result: 'failure', + details: { partnerId: PARTNER_A, status: 409, errorClass: 'Pax8OrderError' }, + })); + }); + + it('adds and removes lines, returning 404 when no line was removed', async () => { + const add = await request(`/orders/${ORDER_ID}/lines`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'new_subscription', pax8ProductId: 'prod-1', billingTerm: 'Monthly', quantity: '3' }), + }); + expect(add.status).toBe(201); + expect(mocks.addOrderLine).toHaveBeenCalledWith(expect.objectContaining({ + partnerId: PARTNER_A, orderId: ORDER_ID, action: 'new_subscription', + })); + + mocks.removeOrderLine.mockResolvedValueOnce({ removed: false }); + const missing = await request(`/orders/${ORDER_ID}/lines/${LINE_ID}`, { method: 'DELETE' }); + expect(missing.status).toBe(404); + expect(mocks.writeRouteAudit).toHaveBeenCalledTimes(2); + expect(mocks.writeRouteAudit).toHaveBeenLastCalledWith(expect.anything(), expect.objectContaining({ + action: 'pax8.order.line.delete', result: 'failure', + details: { partnerId: PARTNER_A, lineId: LINE_ID, status: 404, errorClass: 'NotFound' }, + })); + }); + + it('rejects caller-controlled contract linkage at the public add-line boundary', async () => { + const res = await request(`/orders/${ORDER_ID}/lines`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + action: 'cancel', targetSubscriptionId: 'sub-1', contractLineId: LINE_ID, + }), + }); + + expect(res.status).toBe(400); + expect(mocks.addOrderLine).not.toHaveBeenCalled(); + expect(mocks.writeRouteAudit).not.toHaveBeenCalled(); + }); + + it('updates only mutable staged-line provisioning fields and audits the change', async () => { + const res = await request(`/orders/${ORDER_ID}/lines/${LINE_ID}`, { + method: 'PATCH', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + commitmentTermId: 'commit-1', + provisioningDetails: [{ key: 'domain', values: ['acme.example'] }], + }), + }); + expect(res.status).toBe(200); + expect(mocks.updateOrderLine).toHaveBeenCalledWith({ + partnerId: PARTNER_A, orderId: ORDER_ID, lineId: LINE_ID, + commitmentTermId: 'commit-1', + provisioningDetails: [{ key: 'domain', values: ['acme.example'] }], + }); + expect(mocks.writeRouteAudit).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: 'pax8.order.line.update', resourceId: ORDER_ID, + details: { partnerId: PARTNER_A, lineId: LINE_ID }, + })); + }); + + it('validates staged-line PATCH and preserves cross-org authorization', async () => { + const invalid = await request(`/orders/${ORDER_ID}/lines/${LINE_ID}`, { + method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'cancel' }), + }); + expect(invalid.status).toBe(400); + expect(mocks.updateOrderLine).not.toHaveBeenCalled(); + + mocks.getOrderWithLines.mockResolvedValueOnce({ order: { ...baseOrder, orgId: ORG_B }, lines: [] }); + const foreign = await request(`/orders/${ORDER_ID}/lines/${LINE_ID}`, { + method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provisioningDetails: [] }), + }); + expect(foreign.status).toBe(403); + expect(mocks.updateOrderLine).not.toHaveBeenCalled(); + }); + + it('maps Pax8OrderError status and message exactly', async () => { + mocks.submitOrder.mockRejectedValueOnce(new Pax8OrderError('Order is already submitting.', 409)); + const res = await request(`/orders/${ORDER_ID}/submit`, { method: 'POST' }); + expect(res.status).toBe(409); + await expect(res.json()).resolves.toEqual({ error: 'Order is already submitting.' }); + expect(mocks.writeRouteAudit).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: 'pax8.order.submit', result: 'failure', + details: { partnerId: PARTNER_A, status: 409, errorClass: 'Pax8OrderError' }, + })); + }); + + it('uses the same bounded failure audit for every authorized order operation', async () => { + const cases: Array<{ + service: typeof mocks.addOrderLine; + path: string; + init: RequestInit; + action: string; + safeDetails?: Record; + }> = [ + { + service: mocks.addOrderLine, + path: `/orders/${ORDER_ID}/lines`, + init: { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'cancel', targetSubscriptionId: 'sub-1' }), + }, + action: 'pax8.order.line.create', + safeDetails: { lineAction: 'cancel' }, + }, + { + service: mocks.removeOrderLine, + path: `/orders/${ORDER_ID}/lines/${LINE_ID}`, + init: { method: 'DELETE' }, + action: 'pax8.order.line.delete', + safeDetails: { lineId: LINE_ID }, + }, + { + service: mocks.updateOrderLine, + path: `/orders/${ORDER_ID}/lines/${LINE_ID}`, + init: { + method: 'PATCH', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ provisioningDetails: [] }), + }, + action: 'pax8.order.line.update', + safeDetails: { lineId: LINE_ID }, + }, + { + service: mocks.preflightOrder, + path: `/orders/${ORDER_ID}/preflight`, + init: { method: 'POST' }, + action: 'pax8.order.preflight', + }, + { + service: mocks.submitOrder, + path: `/orders/${ORDER_ID}/submit`, + init: { method: 'POST' }, + action: 'pax8.order.submit', + }, + { + service: mocks.reconcileOrder, + path: `/orders/${ORDER_ID}/reconcile`, + init: { method: 'POST' }, + action: 'pax8.order.reconcile', + }, + ]; + + for (const scenario of cases) { + mocks.writeRouteAudit.mockClear(); + scenario.service.mockRejectedValueOnce(new Pax8OrderError('Rejected.', 422)); + const res = await request(scenario.path, scenario.init); + expect(res.status, scenario.action).toBe(422); + expect(mocks.writeRouteAudit, scenario.action).toHaveBeenCalledTimes(1); + expect(mocks.writeRouteAudit).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: scenario.action, + result: 'failure', + details: { + partnerId: PARTNER_A, + ...scenario.safeDetails, + status: 422, + errorClass: 'Pax8OrderError', + }, + })); + } + }); + + it('returns raw preflight validation bodies with 422 and audits the failed outcome', async () => { + const body = '{"details":[{"field":"tenant","message":"required"}]}'; + mocks.preflightOrder.mockResolvedValueOnce({ ok: false, errorBody: body }); + const res = await request(`/orders/${ORDER_ID}/preflight`, { method: 'POST' }); + expect(res.status).toBe(422); + expect(await res.text()).toBe(body); + expect(mocks.writeRouteAudit).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: 'pax8.order.preflight', result: 'failure', + })); + }); + + it('submits and reconciles with the authenticated actor and audits after success', async () => { + expect((await request(`/orders/${ORDER_ID}/submit`, { method: 'POST' })).status).toBe(200); + expect(mocks.submitOrder).toHaveBeenCalledWith({ partnerId: PARTNER_A, orderId: ORDER_ID, actorUserId: USER_ID }); + expect((await request(`/orders/${ORDER_ID}/reconcile`, { method: 'POST' })).status).toBe(200); + expect(mocks.reconcileOrder).toHaveBeenCalledWith({ partnerId: PARTNER_A, orderId: ORDER_ID }); + expect(mocks.writeRouteAudit).toHaveBeenCalledTimes(2); + expect(mocks.writeRouteAudit).toHaveBeenNthCalledWith(1, expect.anything(), expect.objectContaining({ + action: 'pax8.order.submit', result: 'success', + details: { + partnerId: PARTNER_A, orderStatus: 'completed', + succeededCount: 0, failedCount: 0, needsReconcileCount: 0, + }, + })); + expect(mocks.writeRouteAudit).toHaveBeenNthCalledWith(2, expect.anything(), expect.objectContaining({ + action: 'pax8.order.reconcile', result: 'success', + })); + }); + + it('audits a terminally failed submit outcome as failure', async () => { + mocks.submitOrder.mockResolvedValueOnce({ orderId: ORDER_ID, status: 'failed', lines: [] }); + const res = await request(`/orders/${ORDER_ID}/submit`, { method: 'POST' }); + expect(res.status).toBe(200); + expect(mocks.writeRouteAudit).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: 'pax8.order.submit', result: 'failure', + details: { + partnerId: PARTNER_A, orderStatus: 'failed', + succeededCount: 0, failedCount: 0, needsReconcileCount: 0, + status: 200, errorClass: 'OrderResultNotCompleted', + }, + })); + }); + + it('audits non-completed submit state counts without line payloads', async () => { + mocks.submitOrder.mockResolvedValueOnce({ + orderId: ORDER_ID, + status: 'partially_failed', + lines: [ + { lineId: 'line-success', submitState: 'succeeded', error: null }, + { lineId: 'line-failed', submitState: 'failed', error: 'sensitive vendor detail' }, + { lineId: 'line-unknown', submitState: 'needs_reconcile', error: 'raw Pax8 body' }, + ], + }); + const res = await request(`/orders/${ORDER_ID}/submit`, { method: 'POST' }); + expect(res.status).toBe(200); + const audit = mocks.writeRouteAudit.mock.calls[0]?.[1]; + expect(audit).toMatchObject({ + action: 'pax8.order.submit', result: 'failure', + details: { + partnerId: PARTNER_A, + orderStatus: 'partially_failed', + succeededCount: 1, + failedCount: 1, + needsReconcileCount: 1, + status: 200, + errorClass: 'OrderResultNotCompleted', + }, + }); + expect(JSON.stringify(audit)).not.toContain('line-success'); + expect(JSON.stringify(audit)).not.toContain('sensitive vendor detail'); + expect(JSON.stringify(audit)).not.toContain('raw Pax8 body'); + }); + + it('audits incomplete reconciliation totals without changing its response', async () => { + mocks.reconcileOrder.mockResolvedValueOnce({ resolved: 2, stillUnknown: 1 }); + const res = await request(`/orders/${ORDER_ID}/reconcile`, { method: 'POST' }); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ resolved: 2, stillUnknown: 1 }); + expect(mocks.writeRouteAudit).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: 'pax8.order.reconcile', result: 'failure', + details: { + partnerId: PARTNER_A, + resolved: 2, + stillUnknown: 1, + status: 200, + errorClass: 'ReconciliationIncomplete', + }, + })); + }); + + it('validates UUID params and line bodies before service calls', async () => { + expect((await request('/orders/not-a-uuid')).status).toBe(400); + const badLine = await request(`/orders/${ORDER_ID}/lines`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'unknown' }), + }); + expect(badLine.status).toBe(400); + const clientOwnedPosition = await request(`/orders/${ORDER_ID}/lines`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + action: 'new_subscription', pax8ProductId: 'prod-1', billingTerm: 'Monthly', + quantity: '1.00', sortOrder: 99, + }), + }); + expect(clientOwnedPosition.status).toBe(400); + expect(mocks.addOrderLine).not.toHaveBeenCalled(); + expect(mocks.writeRouteAudit).not.toHaveBeenCalled(); + }); + + it('preserves a raw Pax8ApiError response but excludes its body from failure audit metadata', async () => { + const raw = '{"details":[{"secretProvisioningValue":"do-not-audit"}]}'; + mocks.submitOrder.mockRejectedValueOnce(new Pax8ApiError('vendor failure', 422, raw)); + const res = await request(`/orders/${ORDER_ID}/submit`, { method: 'POST' }); + expect(res.status).toBe(502); + expect(await res.text()).toBe(raw); + expect(mocks.writeRouteAudit).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: 'pax8.order.submit', result: 'failure', + details: { partnerId: PARTNER_A, status: 502, errorClass: 'Pax8ApiError' }, + })); + expect(JSON.stringify(mocks.writeRouteAudit.mock.calls)).not.toContain('do-not-audit'); + }); + + it('audits an unexpected authorized mutation failure without its message or stack', async () => { + mocks.reconcileOrder.mockRejectedValueOnce(new Error('postgresql://user:secret@internal/orders')); + const res = await request(`/orders/${ORDER_ID}/reconcile`, { method: 'POST' }); + expect(res.status).toBe(500); + const audit = mocks.writeRouteAudit.mock.calls[0]?.[1]; + expect(audit).toMatchObject({ + action: 'pax8.order.reconcile', result: 'failure', + details: { partnerId: PARTNER_A, status: 500, errorClass: 'UnexpectedError' }, + }); + expect(JSON.stringify(audit)).not.toContain('secret'); + }); + + it('returns a generic 500 without leaking unexpected service errors', async () => { + mocks.listPax8Orders.mockRejectedValueOnce(new Error('postgresql://user:secret@internal/orders')); + const res = await request('/orders'); + expect(res.status).toBe(500); + expect(await res.text()).not.toContain('secret'); + }); +}); + +describe('Pax8 product proxy routes', () => { + it('requires billing permission and the exact authenticated partner for product mappings', async () => { + state.permissionDenied = true; + expect((await request('/products')).status).toBe(403); + state.permissionDenied = false; + expect((await request(`/products?partnerId=${PARTNER_B}`)).status).toBe(403); + expect(mocks.listPax8Products).not.toHaveBeenCalled(); + }); + + it('lists bounded local product mappings without MFA or Pax8 HTTP', async () => { + state.mfaDenied = true; + const res = await request('/products'); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + data: [{ pax8ProductId: 'prod-1', catalogItemId: 'cat-1', catalogName: 'M365' }], + }); + expect(mocks.listPax8Products).toHaveBeenCalledWith({ partnerId: PARTNER_A }); + expect(mocks.createPax8ClientForIntegration).not.toHaveBeenCalled(); + }); + it('proxies provision details and dependencies through the partner active integration', async () => { + mocks.getProvisionDetails.mockResolvedValueOnce([{ key: 'tenant', valueType: 'Input' }]); + mockIntegrationClient(); + const details = await request('/products/prod-1/provision-details'); + expect(details.status).toBe(200); + expect(mocks.getProvisionDetails).toHaveBeenCalledWith('prod-1'); + + mocks.getProductDependencies.mockResolvedValueOnce({ commitments: [{ id: 'c1', allowForQuantityIncrease: true }] }); + mockIntegrationClient(); + const dependencies = await request('/products/prod-1/dependencies'); + expect(dependencies.status).toBe(200); + expect(mocks.getProductDependencies).toHaveBeenCalledWith('prod-1'); + expect(mocks.runOutsideDbContext).toHaveBeenCalled(); + }); + + it('returns 404 without HTTP when the partner has no active integration', async () => { + const chain: Record = {}; + chain.from = vi.fn(() => chain); + chain.where = vi.fn(() => chain); + chain.limit = vi.fn(async () => []); + mocks.dbSelect.mockReturnValueOnce(chain); + const res = await request('/products/prod-1/dependencies'); + expect(res.status).toBe(404); + expect(mocks.createPax8ClientForIntegration).not.toHaveBeenCalled(); + }); + + it('returns a Pax8ApiError raw body as 502', async () => { + mockIntegrationClient(); + mocks.getProductDependencies.mockRejectedValueOnce(new Pax8ApiError('vendor error', 500, 'upstream unavailable')); + const res = await request('/products/prod-1/dependencies'); + expect(res.status).toBe(502); + expect(await res.text()).toBe('upstream unavailable'); + }); + + it('refuses a foreign integration returned during client resolution', async () => { + mockIntegrationClient(); + mocks.createPax8ClientForIntegration.mockReset(); + mocks.createPax8ClientForIntegration.mockResolvedValueOnce({ + integration: { id: INTEGRATION_ID, partnerId: PARTNER_B }, + client: { getProductDependencies: mocks.getProductDependencies }, + }); + const res = await request('/products/prod-1/dependencies'); + expect(res.status).toBe(403); + expect(mocks.getProductDependencies).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/routes/pax8Orders.ts b/apps/api/src/routes/pax8Orders.ts new file mode 100644 index 0000000000..28c889c6a1 --- /dev/null +++ b/apps/api/src/routes/pax8Orders.ts @@ -0,0 +1,663 @@ +import { PAX8_BILLING_TERMS, PAX8_ORDER_ACTIONS } from '@breeze/shared'; +import { and, eq } from 'drizzle-orm'; +import { Hono, type Context } from 'hono'; +import { z } from 'zod'; +import { db, runOutsideDbContext, withDbAccessContext, type DbAccessContext } from '../db'; +import { pax8Integrations } from '../db/schema'; +import { zValidator } from '../lib/validation'; +import { + authMiddleware, + requireMfa, + requirePermission, + requireScope, + type AuthContext, +} from '../middleware/auth'; +import { writeRouteAudit } from '../services/auditEvents'; +import { Pax8ApiError, type Pax8Client } from '../services/pax8Client'; +import { + addOrderLine, + getOrderWithLines, + getOrCreateDraftOrder, + listPax8Products, + listPax8Orders, + Pax8OrderError, + removeOrderLine, + updateOrderLine, +} from '../services/pax8OrderService'; +import { preflightOrder, reconcileOrder, submitOrder } from '../services/pax8OrderSubmit'; +import { createPax8ClientForIntegration } from '../services/pax8SyncService'; +import { detectPax8Drift } from '../services/pax8Drift'; +import { PERMISSIONS } from '../services/permissions'; +import { captureException } from '../services/sentry'; + +export const pax8OrderRoutes = new Hono(); + +type RouteAuth = Pick< + AuthContext, + 'scope' | 'partnerId' | 'orgId' | 'canAccessOrg' | 'accessibleOrgIds' | 'user' +>; + +function resolvePartnerId(auth: RouteAuth, requested?: string): { partnerId: string } | { error: string; status: 400 | 403 } { + if (auth.scope === 'partner') { + if (!auth.partnerId) return { error: 'Partner context required', status: 403 }; + if (requested && requested !== auth.partnerId) return { error: 'Access to this partner denied', status: 403 }; + return { partnerId: auth.partnerId }; + } + if (auth.scope === 'organization') { + return { error: 'Pax8 ordering is managed at partner scope', status: 403 }; + } + if (!requested) return { error: 'partnerId is required for system scope', status: 400 }; + return { partnerId: requested }; +} + +const billingManage = requirePermission( + PERMISSIONS.BILLING_MANAGE.resource, + PERMISSIONS.BILLING_MANAGE.action, +); +const partnerScopes = requireScope('partner', 'system'); + +const partnerQuerySchema = z.object({ + partnerId: z.string().guid().optional(), +}); + +const orderListQuerySchema = partnerQuerySchema.extend({ + orgId: z.string().guid().optional(), +}); + +const driftQuerySchema = partnerQuerySchema.extend({ + integrationId: z.string().guid(), +}); + +const orderIdSchema = z.object({ id: z.string().guid() }); +const orderLineIdSchema = orderIdSchema.extend({ lineId: z.string().guid() }); +const productIdSchema = z.object({ productId: z.string().trim().min(1).max(64) }); + +const createOrderSchema = z.object({ + orgId: z.string().guid(), +}); + +const provisioningDetailSchema = z.object({ + key: z.string().trim().min(1).max(200), + values: z.array(z.string().max(5000)).max(100), +}); + +const addLineSchema = z.object({ + action: z.enum(PAX8_ORDER_ACTIONS), + pax8ProductId: z.string().trim().min(1).max(64).optional(), + catalogItemId: z.string().guid().optional(), + billingTerm: z.enum(PAX8_BILLING_TERMS).optional(), + commitmentTermId: z.string().trim().min(1).max(64).optional(), + quantity: z.string().trim().min(1).max(40).optional(), + provisioningDetails: z.array(provisioningDetailSchema).max(200).optional(), + targetSubscriptionId: z.string().trim().min(1).max(64).optional(), + cancelDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), +}).strict(); + +const updateLineSchema = z.object({ + commitmentTermId: z.string().trim().min(1).max(64).nullable().optional(), + provisioningDetails: z.array(provisioningDetailSchema).max(200).optional(), +}).strict().refine( + (value) => value.commitmentTermId !== undefined || value.provisioningDetails !== undefined, + { message: 'At least one editable line field is required.' }, +); + +function partnerDbContext(auth: RouteAuth, partnerId: string): DbAccessContext { + const system = auth.scope === 'system'; + return { + scope: system ? 'system' : 'partner', + orgId: null, + accessibleOrgIds: system ? null : auth.accessibleOrgIds, + accessiblePartnerIds: system ? null : [partnerId], + userId: auth.user.id, + currentPartnerId: partnerId, + }; +} + +async function loadAuthorizedOrder(auth: RouteAuth, partnerId: string, orderId: string) { + const bundle = await withDbAccessContext( + partnerDbContext(auth, partnerId), + () => getOrderWithLines({ partnerId, orderId }), + ); + if (!auth.canAccessOrg(bundle.order.orgId)) { + throw new Pax8OrderError('Access to this organization denied.', 403); + } + return bundle; +} + +async function resolveProductClient(auth: RouteAuth, partnerId: string): Promise { + return runOutsideDbContext(() => withDbAccessContext(partnerDbContext(auth, partnerId), async () => { + const [integration] = await db + .select({ id: pax8Integrations.id, partnerId: pax8Integrations.partnerId }) + .from(pax8Integrations) + .where(and( + eq(pax8Integrations.partnerId, partnerId), + eq(pax8Integrations.isActive, true), + )) + .limit(1); + if (!integration) throw new Pax8OrderError('Pax8 integration not found.', 404); + + const created = await createPax8ClientForIntegration(integration.id); + if (created.integration.partnerId !== partnerId) { + throw new Pax8OrderError('The Pax8 integration belongs to a different partner.', 403); + } + return created.client; + })); +} + +function isJsonBody(body: string): boolean { + try { + JSON.parse(body); + return true; + } catch { + return false; + } +} + +function rawBody(c: Context, body: string, status: 422 | 502): Response { + return c.body(body, status, { + 'content-type': isJsonBody(body) ? 'application/json' : 'text/plain; charset=UTF-8', + }); +} + +function routeError(c: Context, error: unknown): Response { + if (error instanceof Pax8OrderError) return c.json({ error: error.message }, error.status); + if (error instanceof Pax8ApiError) return rawBody(c, error.body || error.message, 502); + captureException(error instanceof Error ? error : new Error(String(error))); + return c.json({ error: 'Internal server error' }, 500); +} + +function auditOrderAction( + c: Context, + input: { + action: string; + partnerId: string; + orgId: string; + orderId?: string; + result?: 'success' | 'failure'; + details?: Record; + }, +): void { + writeRouteAudit(c, { + orgId: input.orgId, + action: input.action, + resourceType: 'pax8_order', + resourceId: input.orderId, + result: input.result, + details: { partnerId: input.partnerId, ...input.details }, + }); +} + +type FailureClass = + | 'Pax8OrderError' + | 'Pax8ApiError' + | 'UnexpectedError' + | 'NotFound' + | 'Pax8Validation' + | 'OrderResultNotCompleted' + | 'ReconciliationIncomplete'; + +function classifyFailure(error: unknown): { status: number; errorClass: FailureClass } { + if (error instanceof Pax8OrderError) return { status: error.status, errorClass: 'Pax8OrderError' }; + if (error instanceof Pax8ApiError) return { status: 502, errorClass: 'Pax8ApiError' }; + return { status: 500, errorClass: 'UnexpectedError' }; +} + +function auditOrderFailure( + c: Context, + input: Omit[1], 'result'>, + failure: { status: number; errorClass: FailureClass }, +): void { + auditOrderAction(c, { + ...input, + result: 'failure', + details: { ...input.details, ...failure }, + }); +} + +async function runAuditedMutation( + c: Context, + audit: Omit[1], 'result'>, + operation: () => Promise, +): Promise<{ value: T } | { response: Response }> { + try { + return { value: await operation() }; + } catch (error) { + // The route audit is deliberately fire-and-forget, matching every existing + // writeRouteAudit call. It receives only a bounded classification; response + // mapping still receives the original error so raw Pax8 bodies reach the + // caller but can never enter audit metadata. + auditOrderFailure(c, audit, classifyFailure(error)); + return { response: routeError(c, error) }; + } +} + +function submitAuditDetails(result: Awaited>) { + let succeededCount = 0; + let failedCount = 0; + let needsReconcileCount = 0; + for (const line of result.lines) { + if (line.submitState === 'succeeded') succeededCount += 1; + else if (line.submitState === 'failed') failedCount += 1; + else if (line.submitState === 'needs_reconcile') needsReconcileCount += 1; + } + return { + orderStatus: result.status, + succeededCount, + failedCount, + needsReconcileCount, + }; +} + +pax8OrderRoutes.use('*', authMiddleware); + +// `requireScope()` intentionally follows this check. Its generic 403 would +// otherwise hide the ordering-specific refusal promised to org-scoped callers. +pax8OrderRoutes.use('*', async (c, next) => { + const auth = c.get('auth'); + if (auth.scope === 'organization') { + return c.json({ error: 'Pax8 ordering is managed at partner scope' }, 403); + } + await next(); +}); + +pax8OrderRoutes.get( + '/drift', + partnerScopes, + billingManage, + zValidator('query', driftQuerySchema), + async (c) => { + const auth = c.get('auth'); + const query = c.req.valid('query'); + const partner = resolvePartnerId(auth, query.partnerId); + if ('error' in partner) return c.json({ error: partner.error }, partner.status); + try { + const data = await withDbAccessContext(partnerDbContext(auth, partner.partnerId), async () => { + const [integration] = await db + .select({ id: pax8Integrations.id }) + .from(pax8Integrations) + .where(and( + eq(pax8Integrations.id, query.integrationId), + eq(pax8Integrations.partnerId, partner.partnerId), + )) + .limit(1); + if (!integration) throw new Pax8OrderError('Pax8 integration not found.', 404); + return detectPax8Drift({ + partnerId: partner.partnerId, + integrationId: integration.id, + }); + }); + return c.json({ data }); + } catch (error) { + return routeError(c, error); + } + }, +); + +pax8OrderRoutes.get( + '/orders', + partnerScopes, + billingManage, + zValidator('query', orderListQuerySchema), + async (c) => { + const auth = c.get('auth'); + const query = c.req.valid('query'); + const partner = resolvePartnerId(auth, query.partnerId); + if ('error' in partner) return c.json({ error: partner.error }, partner.status); + if (query.orgId && !auth.canAccessOrg(query.orgId)) { + return c.json({ error: 'Access to this organization denied.' }, 403); + } + try { + const data = await listPax8Orders({ + partnerId: partner.partnerId, + ...(query.orgId ? { orgId: query.orgId } : {}), + ...(!query.orgId && auth.scope === 'partner' + ? { accessibleOrgIds: auth.accessibleOrgIds } + : {}), + }); + return c.json({ data }); + } catch (error) { + return routeError(c, error); + } + }, +); + +pax8OrderRoutes.get( + '/orders/:id', + partnerScopes, + billingManage, + zValidator('query', partnerQuerySchema), + zValidator('param', orderIdSchema), + async (c) => { + const auth = c.get('auth'); + const query = c.req.valid('query'); + const { id } = c.req.valid('param'); + const partner = resolvePartnerId(auth, query.partnerId); + if ('error' in partner) return c.json({ error: partner.error }, partner.status); + try { + return c.json({ data: await loadAuthorizedOrder(auth, partner.partnerId, id) }); + } catch (error) { + return routeError(c, error); + } + }, +); + +pax8OrderRoutes.post( + '/orders', + partnerScopes, + billingManage, + requireMfa(), + zValidator('query', partnerQuerySchema), + zValidator('json', createOrderSchema), + async (c) => { + const auth = c.get('auth'); + const query = c.req.valid('query'); + const body = c.req.valid('json'); + const partner = resolvePartnerId(auth, query.partnerId); + if ('error' in partner) return c.json({ error: partner.error }, partner.status); + if (!auth.canAccessOrg(body.orgId)) return c.json({ error: 'Access to this organization denied.' }, 403); + const mutation = await runAuditedMutation(c, { + action: 'pax8.order.create', partnerId: partner.partnerId, orgId: body.orgId, + }, () => getOrCreateDraftOrder({ + partnerId: partner.partnerId, + orgId: body.orgId, + actorUserId: auth.user.id, + })); + if ('response' in mutation) return mutation.response; + const order = mutation.value; + auditOrderAction(c, { + action: 'pax8.order.create', partnerId: partner.partnerId, + orgId: order.orgId, orderId: order.id, + }); + return c.json({ data: order }, 201); + }, +); + +pax8OrderRoutes.patch( + '/orders/:id/lines/:lineId', + partnerScopes, + billingManage, + requireMfa(), + zValidator('query', partnerQuerySchema), + zValidator('param', orderLineIdSchema), + zValidator('json', updateLineSchema), + async (c) => { + const auth = c.get('auth'); + const query = c.req.valid('query'); + const { id, lineId } = c.req.valid('param'); + const body = c.req.valid('json'); + const partner = resolvePartnerId(auth, query.partnerId); + if ('error' in partner) return c.json({ error: partner.error }, partner.status); + let bundle: Awaited>; + try { + bundle = await loadAuthorizedOrder(auth, partner.partnerId, id); + } catch (error) { + return routeError(c, error); + } + const audit = { + action: 'pax8.order.line.update', partnerId: partner.partnerId, + orgId: bundle.order.orgId, orderId: id, details: { lineId }, + }; + const mutation = await runAuditedMutation(c, audit, () => updateOrderLine({ + partnerId: partner.partnerId, + orderId: id, + lineId, + ...body, + })); + if ('response' in mutation) return mutation.response; + auditOrderAction(c, { ...audit, result: 'success' }); + return c.json({ data: mutation.value }); + }, +); + +pax8OrderRoutes.post( + '/orders/:id/lines', + partnerScopes, + billingManage, + requireMfa(), + zValidator('query', partnerQuerySchema), + zValidator('param', orderIdSchema), + zValidator('json', addLineSchema), + async (c) => { + const auth = c.get('auth'); + const query = c.req.valid('query'); + const { id } = c.req.valid('param'); + const body = c.req.valid('json'); + const partner = resolvePartnerId(auth, query.partnerId); + if ('error' in partner) return c.json({ error: partner.error }, partner.status); + let bundle: Awaited>; + try { + bundle = await loadAuthorizedOrder(auth, partner.partnerId, id); + } catch (error) { + return routeError(c, error); + } + const mutation = await runAuditedMutation(c, { + action: 'pax8.order.line.create', partnerId: partner.partnerId, + orgId: bundle.order.orgId, orderId: id, details: { lineAction: body.action }, + }, () => addOrderLine({ partnerId: partner.partnerId, orderId: id, ...body })); + if ('response' in mutation) return mutation.response; + const line = mutation.value; + auditOrderAction(c, { + action: 'pax8.order.line.create', partnerId: partner.partnerId, + orgId: bundle.order.orgId, orderId: id, + details: { lineId: line.id, lineAction: line.action }, + }); + return c.json({ data: line }, 201); + }, +); + +pax8OrderRoutes.delete( + '/orders/:id/lines/:lineId', + partnerScopes, + billingManage, + requireMfa(), + zValidator('query', partnerQuerySchema), + zValidator('param', orderLineIdSchema), + async (c) => { + const auth = c.get('auth'); + const query = c.req.valid('query'); + const { id, lineId } = c.req.valid('param'); + const partner = resolvePartnerId(auth, query.partnerId); + if ('error' in partner) return c.json({ error: partner.error }, partner.status); + let bundle: Awaited>; + try { + bundle = await loadAuthorizedOrder(auth, partner.partnerId, id); + } catch (error) { + return routeError(c, error); + } + const audit = { + action: 'pax8.order.line.delete', partnerId: partner.partnerId, + orgId: bundle.order.orgId, orderId: id, details: { lineId }, + }; + const mutation = await runAuditedMutation(c, audit, () => + removeOrderLine({ partnerId: partner.partnerId, orderId: id, lineId })); + if ('response' in mutation) return mutation.response; + if (!mutation.value.removed) { + auditOrderFailure(c, audit, { status: 404, errorClass: 'NotFound' }); + return c.json({ error: 'Pax8 order line not found.' }, 404); + } + auditOrderAction(c, { ...audit, result: 'success' }); + return c.json({ removed: true }); + }, +); + +pax8OrderRoutes.post( + '/orders/:id/preflight', + partnerScopes, + billingManage, + requireMfa(), + zValidator('query', partnerQuerySchema), + zValidator('param', orderIdSchema), + async (c) => { + const auth = c.get('auth'); + const query = c.req.valid('query'); + const { id } = c.req.valid('param'); + const partner = resolvePartnerId(auth, query.partnerId); + if ('error' in partner) return c.json({ error: partner.error }, partner.status); + let bundle: Awaited>; + try { + bundle = await loadAuthorizedOrder(auth, partner.partnerId, id); + } catch (error) { + return routeError(c, error); + } + const audit = { + action: 'pax8.order.preflight', partnerId: partner.partnerId, + orgId: bundle.order.orgId, orderId: id, + }; + const mutation = await runAuditedMutation(c, audit, () => + preflightOrder({ partnerId: partner.partnerId, orderId: id })); + if ('response' in mutation) return mutation.response; + const result = mutation.value; + if (!result.ok) { + auditOrderFailure(c, audit, { status: 422, errorClass: 'Pax8Validation' }); + return rawBody(c, result.errorBody, 422); + } + auditOrderAction(c, { ...audit, result: 'success' }); + return c.json(result); + }, +); + +pax8OrderRoutes.post( + '/orders/:id/submit', + partnerScopes, + billingManage, + requireMfa(), + zValidator('query', partnerQuerySchema), + zValidator('param', orderIdSchema), + async (c) => { + const auth = c.get('auth'); + const query = c.req.valid('query'); + const { id } = c.req.valid('param'); + const partner = resolvePartnerId(auth, query.partnerId); + if ('error' in partner) return c.json({ error: partner.error }, partner.status); + let bundle: Awaited>; + try { + bundle = await loadAuthorizedOrder(auth, partner.partnerId, id); + } catch (error) { + return routeError(c, error); + } + const audit = { + action: 'pax8.order.submit', partnerId: partner.partnerId, + orgId: bundle.order.orgId, orderId: id, + }; + const mutation = await runAuditedMutation(c, audit, () => + submitOrder({ partnerId: partner.partnerId, orderId: id, actorUserId: auth.user.id })); + if ('response' in mutation) return mutation.response; + const result = mutation.value; + const details = submitAuditDetails(result); + if (result.status === 'completed') { + auditOrderAction(c, { + ...audit, + result: 'success', + details, + }); + } else { + auditOrderFailure(c, { ...audit, details }, { + status: 200, errorClass: 'OrderResultNotCompleted', + }); + } + return c.json(result); + }, +); + +pax8OrderRoutes.post( + '/orders/:id/reconcile', + partnerScopes, + billingManage, + requireMfa(), + zValidator('query', partnerQuerySchema), + zValidator('param', orderIdSchema), + async (c) => { + const auth = c.get('auth'); + const query = c.req.valid('query'); + const { id } = c.req.valid('param'); + const partner = resolvePartnerId(auth, query.partnerId); + if ('error' in partner) return c.json({ error: partner.error }, partner.status); + let bundle: Awaited>; + try { + bundle = await loadAuthorizedOrder(auth, partner.partnerId, id); + } catch (error) { + return routeError(c, error); + } + const audit = { + action: 'pax8.order.reconcile', partnerId: partner.partnerId, + orgId: bundle.order.orgId, orderId: id, + }; + const mutation = await runAuditedMutation(c, audit, () => + reconcileOrder({ partnerId: partner.partnerId, orderId: id })); + if ('response' in mutation) return mutation.response; + const result = mutation.value; + if (result.stillUnknown === 0) { + auditOrderAction(c, { + ...audit, result: 'success', + details: { resolved: result.resolved, stillUnknown: result.stillUnknown }, + }); + } else { + auditOrderFailure(c, { + ...audit, details: { resolved: result.resolved, stillUnknown: result.stillUnknown }, + }, { status: 200, errorClass: 'ReconciliationIncomplete' }); + } + return c.json(result); + }, +); + +pax8OrderRoutes.get( + '/products', + partnerScopes, + billingManage, + zValidator('query', partnerQuerySchema), + async (c) => { + const auth = c.get('auth'); + const query = c.req.valid('query'); + const partner = resolvePartnerId(auth, query.partnerId); + if ('error' in partner) return c.json({ error: partner.error }, partner.status); + try { + return c.json({ data: await listPax8Products({ partnerId: partner.partnerId }) }); + } catch (error) { + return routeError(c, error); + } + }, +); + +pax8OrderRoutes.get( + '/products/:productId/provision-details', + partnerScopes, + billingManage, + zValidator('query', partnerQuerySchema), + zValidator('param', productIdSchema), + async (c) => { + const auth = c.get('auth'); + const query = c.req.valid('query'); + const { productId } = c.req.valid('param'); + const partner = resolvePartnerId(auth, query.partnerId); + if ('error' in partner) return c.json({ error: partner.error }, partner.status); + try { + const client = await resolveProductClient(auth, partner.partnerId); + const data = await runOutsideDbContext(() => client.getProvisionDetails(productId)); + return c.json({ data }); + } catch (error) { + return routeError(c, error); + } + }, +); + +pax8OrderRoutes.get( + '/products/:productId/dependencies', + partnerScopes, + billingManage, + zValidator('query', partnerQuerySchema), + zValidator('param', productIdSchema), + async (c) => { + const auth = c.get('auth'); + const query = c.req.valid('query'); + const { productId } = c.req.valid('param'); + const partner = resolvePartnerId(auth, query.partnerId); + if ('error' in partner) return c.json({ error: partner.error }, partner.status); + try { + const client = await resolveProductClient(auth, partner.partnerId); + const data = await runOutsideDbContext(() => client.getProductDependencies(productId)); + return c.json({ data }); + } catch (error) { + return routeError(c, error); + } + }, +); diff --git a/apps/api/src/routes/portal/quotes.ts b/apps/api/src/routes/portal/quotes.ts index 5a68a8d278..a0b7d1400e 100644 --- a/apps/api/src/routes/portal/quotes.ts +++ b/apps/api/src/routes/portal/quotes.ts @@ -137,7 +137,7 @@ quoteRoutes.post('/quotes/:id/accept', zValidator('param', idParam), zValidator( // render, matching invoiceService.issueInvoice. Fire-and-forget; never fails the // accept the customer already completed. await emitAcceptInvoiceIssued(res, auth.user.id); - return c.json({ data: { invoiceId: res.invoiceId, status: res.quote.status } }); + return c.json({ data: { invoiceId: res.invoiceId, status: res.quote.status, pax8OrderId: res.pax8OrderId } }); } catch (err) { if (err instanceof QuoteServiceError) return c.json({ error: err.message, code: err.code }, err.status); throw err; } }); diff --git a/apps/api/src/routes/quotes/quotes.test.ts b/apps/api/src/routes/quotes/quotes.test.ts index f51e2aafce..cec8f45718 100644 --- a/apps/api/src/routes/quotes/quotes.test.ts +++ b/apps/api/src/routes/quotes/quotes.test.ts @@ -149,14 +149,34 @@ describe('quote crud + lines routes', () => { }); it('GET /:id fetches one quote', async () => { - (svc.getQuote as any).mockResolvedValue({ quote: { id: QUOTE_ID }, blocks: [], lines: [] }); + (svc.getQuote as any).mockResolvedValue({ + quote: { id: QUOTE_ID }, + blocks: [], + lines: [], + pax8OrderId: '55555555-5555-5555-5555-555555555555', + pax8OrderLineCount: 2, + }); const res = await app().request(`/${QUOTE_ID}`, { method: 'GET' }); expect(res.status).toBe(200); const body = await res.json(); expect(body.data.quote.id).toBe(QUOTE_ID); + expect(body.data).toMatchObject({ + pax8OrderId: '55555555-5555-5555-5555-555555555555', + pax8OrderLineCount: 2, + }); expect(svc.getQuote).toHaveBeenCalledWith(QUOTE_ID, expect.anything()); }); + it('GET /:id denies callers without quotes:read before loading the staged-order summary', async () => { + const { HTTPException } = await import('hono/http-exception'); + gate.permGate = async () => { throw new HTTPException(403, { message: 'Permission denied' }); }; + + const res = await app().request(`/${QUOTE_ID}`, { method: 'GET' }); + + expect(res.status).toBe(403); + expect(svc.getQuote).not.toHaveBeenCalled(); + }); + it('POST /:id/lines adds a manual line', async () => { (svc.addManualLine as any).mockResolvedValue({ id: 'line1' }); const res = await app().request(`/${QUOTE_ID}/lines`, { diff --git a/apps/api/src/routes/quotesPublic.ts b/apps/api/src/routes/quotesPublic.ts index 7b721f3003..c1339d309b 100644 --- a/apps/api/src/routes/quotesPublic.ts +++ b/apps/api/src/routes/quotesPublic.ts @@ -117,7 +117,7 @@ quotesPublicRoutes.post('/:token/accept', zValidator('param', tokenParam), zVali captureException(err instanceof Error ? err : new Error(String(err))); } } - return c.json({ data: { status: res.quote.status, invoiceNumber: null, payUrl, payDeferred } }); + return c.json({ data: { status: res.quote.status, invoiceNumber: null, payUrl, payDeferred, pax8OrderId: res.pax8OrderId } }); } catch (err) { if (err instanceof QuoteServiceError) return c.json({ error: err.message, code: err.code }, err.status); throw err; } }); diff --git a/apps/api/src/services/contractService.ts b/apps/api/src/services/contractService.ts index cc5e685df3..1da1ce010d 100644 --- a/apps/api/src/services/contractService.ts +++ b/apps/api/src/services/contractService.ts @@ -433,9 +433,16 @@ export async function generateDueInvoice(contractId: string, asOf: Date = new Da // a bare request handler — a contextless/org-only call hits the partner-axis writes' // RLS WITH CHECK and fails (now a typed CONTRACT_CREATE_FAILED, previously a 0-row // silent write). Always lands status='draft'; the MSP activates later. -export async function createContractWithLines( +export interface CreatedContractWithLines { + contract: typeof contracts.$inferSelect; + lines: Array<{ id: string; sourceQuoteLineId: string | null; sortOrder: number }>; +} + +/** Detailed Phase-4 variant that returns an in-memory quote-line correlation. + * `sourceQuoteLineId` is intentionally never persisted on contract_lines. */ +export async function createContractWithLinesDetailed( spec: NewContractSpec, -): Promise { +): Promise { const [contract] = await db .insert(contracts) .values({ @@ -462,6 +469,7 @@ export async function createContractWithLines( ); } + const createdLines: CreatedContractWithLines['lines'] = []; for (let i = 0; i < spec.lines.length; i++) { const l = spec.lines[i]!; const [insertedLine] = await db.insert(contractLines).values({ @@ -483,7 +491,18 @@ export async function createContractWithLines( 500, 'CONTRACT_LINE_CREATE_FAILED', ); } + createdLines.push({ + id: insertedLine.id, + sourceQuoteLineId: l.sourceQuoteLineId ?? null, + sortOrder: l.sortOrder ?? i, + }); } - return contract; + return { contract, lines: createdLines }; +} + +export async function createContractWithLines( + spec: NewContractSpec, +): Promise { + return (await createContractWithLinesDetailed(spec)).contract; } diff --git a/apps/api/src/services/pax8Client.test.ts b/apps/api/src/services/pax8Client.test.ts index 4a5e2e19e6..db24005ac5 100644 --- a/apps/api/src/services/pax8Client.test.ts +++ b/apps/api/src/services/pax8Client.test.ts @@ -91,6 +91,7 @@ describe('Pax8Client', () => { vendorName: 'Microsoft', vendorSkuId: 'sku-1', quantity: '12.00', + quantityKnown: true, unitPrice: '22.00', unitCost: '18.50', currencyCode: 'USD', @@ -256,3 +257,187 @@ describe('Pax8Client.getProductPricing', () => { expect(rows[1]).toMatchObject({ commitmentTerm: 'Monthly', partnerBuyRate: '20.00', suggestedRetailPrice: '25.00', currencyCode: 'USD' }); }); }); + +function clientWith(fetchImpl: ReturnType) { + return new Pax8Client({ + credentials: { clientId: 'id', clientSecret: 'secret', accessToken: 'tok', accessTokenExpiresAt: new Date(Date.now() + 3_600_000) }, + fetch: fetchImpl as never, + }); +} + +describe('Pax8Client.updateSubscriptionQuantity', () => { + it('sends ONLY quantity — never price, partnerCost, or currencyCode', async () => { + const doFetch = vi.fn().mockResolvedValue(jsonResponse({ id: 'sub-1', quantity: 11 })); + await clientWith(doFetch).updateSubscriptionQuantity('sub-1', 11); + + const [url, init] = doFetch.mock.calls[0]!; + expect(url).toBe('https://api.pax8.com/v1/subscriptions/sub-1'); + expect(init.method).toBe('PUT'); + // The whole point: PUT is a partial update and price IS writable. A body + // with any extra key can silently overwrite the customer's rate. + expect(JSON.parse(init.body)).toEqual({ quantity: 11 }); + }); +}); + +describe('Pax8Client.cancelSubscription', () => { + it('DELETEs with no body and no cancelDate when none given', async () => { + const doFetch = vi.fn().mockResolvedValue({ ok: true, status: 204, text: async () => '' } as Response); + await clientWith(doFetch).cancelSubscription('sub-9'); + + const [url, init] = doFetch.mock.calls[0]!; + expect(url).toBe('https://api.pax8.com/v1/subscriptions/sub-9'); + expect(init.method).toBe('DELETE'); + expect(init.body).toBeUndefined(); + }); + + it('passes cancelDate as a query param', async () => { + const doFetch = vi.fn().mockResolvedValue({ ok: true, status: 204, text: async () => '' } as Response); + await clientWith(doFetch).cancelSubscription('sub-9', '2026-09-01'); + expect(doFetch.mock.calls[0]![0]).toBe('https://api.pax8.com/v1/subscriptions/sub-9?cancelDate=2026-09-01'); + }); +}); + +describe('Pax8Client.createOrder', () => { + it('posts companyId + lineItems and sets isMock when asked', async () => { + const doFetch = vi.fn().mockResolvedValue(jsonResponse({ id: 'ord-1', lineItems: [{ id: 'li-1', subscriptionId: 'sub-1' }] })); + const res = await clientWith(doFetch).createOrder({ + companyId: 'co-1', + lineItems: [{ + lineItemNumber: 1, + productId: 'prod-1', + quantity: 5, + billingTerm: 'Monthly', + provisioningDetails: [{ key: 'msDomain', values: ['acme'] }], + }], + }, { isMock: true }); + + const [url, init] = doFetch.mock.calls[0]!; + expect(url).toBe('https://api.pax8.com/v1/orders?isMock=true'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body)).toEqual({ + companyId: 'co-1', + lineItems: [{ + lineItemNumber: 1, productId: 'prod-1', quantity: 5, billingTerm: 'Monthly', + provisioningDetails: [{ key: 'msDomain', values: ['acme'] }], + }], + }); + expect(res.pax8OrderId).toBe('ord-1'); + expect(res.lineItems[0]!.subscriptionId).toBe('sub-1'); + }); + + it('surfaces Pax8 422 details verbatim on Pax8ApiError.body', async () => { + const body = { status: 422, message: 'Invalid order', details: [{ message: 'msDomain is required' }] }; + const doFetch = vi.fn().mockResolvedValue({ ok: false, status: 422, text: async () => JSON.stringify(body) } as Response); + const err = await clientWith(doFetch).createOrder({ companyId: 'co-1', lineItems: [] }).catch((error) => error); + + expect(err).toBeInstanceOf(Pax8ApiError); + expect(err).toMatchObject({ name: 'Pax8ApiError', status: 422 }); + expect(err.body).toBe(JSON.stringify(body)); + }); +}); + +describe('Pax8Client reconciliation reads', () => { + it('lists only the requested company subscriptions', async () => { + const doFetch = vi.fn().mockResolvedValue(jsonResponse({ content: [], last: true })); + await clientWith(doFetch).listSubscriptions({ companyId: 'company/a' }); + + expect(doFetch.mock.calls[0]![0]).toBe( + 'https://api.pax8.com/v1/subscriptions?companyId=company%2Fa&page=0&size=200', + ); + expect(doFetch.mock.calls[0]![1].method).toBe('GET'); + }); + + it('lists and normalizes company orders without issuing a write', async () => { + const doFetch = vi.fn().mockResolvedValue(jsonResponse({ + content: [{ + id: 'order-1', + companyId: 'company-1', + createdDate: '2026-07-14', + lineItems: [{ + lineItemNumber: 3, + productId: 'product-1', + quantity: 7, + subscriptionId: 'subscription-1', + }], + }], + last: true, + })); + + const orders = await clientWith(doFetch).listOrders({ companyId: 'company-1' }); + + expect(doFetch.mock.calls[0]![0]).toBe( + 'https://api.pax8.com/v1/orders?companyId=company-1&page=0&size=200', + ); + expect(doFetch.mock.calls[0]![1].method).toBe('GET'); + expect(orders).toEqual([{ + pax8OrderId: 'order-1', + pax8CompanyId: 'company-1', + createdDate: '2026-07-14', + lineItems: [{ + lineItemNumber: 3, + productId: 'product-1', + quantity: '7.00', + quantityKnown: true, + subscriptionId: 'subscription-1', + }], + raw: expect.any(Object), + }]); + }); + + it('marks omitted quantities unknown instead of presenting synthesized zero as evidence', async () => { + const doFetch = vi.fn(async (url: string) => { + if (url.includes('/subscriptions')) { + return jsonResponse({ content: [{ id: 'sub-1', companyId: 'company-1', productId: 'product-1' }], last: true }); + } + return jsonResponse({ content: [{ + id: 'order-1', companyId: 'company-1', createdDate: '2026-07-20', + lineItems: [{ lineItemNumber: 1, productId: 'product-1', subscriptionId: 'sub-1' }], + }], last: true }); + }); + const client = clientWith(doFetch); + + const subscriptions = await client.listSubscriptions({ companyId: 'company-1' }); + const orders = await client.listOrders({ companyId: 'company-1' }); + + expect(subscriptions[0]).toMatchObject({ quantity: '0.00', quantityKnown: false }); + expect(orders[0]!.lineItems[0]).toMatchObject({ quantity: '0.00', quantityKnown: false }); + }); +}); + +describe('Pax8Client.getProvisionDetails', () => { + it('returns the discoverable field descriptors', async () => { + const doFetch = vi.fn().mockResolvedValue(jsonResponse({ content: [ + { key: 'msCustExists', label: 'Existing Microsoft account?', valueType: 'Single-Value', possibleValues: ['No', 'Yes'] }, + { key: 'msDomain', label: 'Domain prefix', valueType: 'Input', possibleValues: null }, + ] })); + const details = await clientWith(doFetch).getProvisionDetails('prod-1'); + expect(doFetch.mock.calls[0]![0]).toBe('https://api.pax8.com/v1/products/prod-1/provision-details'); + expect(details).toHaveLength(2); + expect(details[1]).toMatchObject({ key: 'msDomain', valueType: 'Input', possibleValues: null }); + }); +}); + +describe('Pax8Client.getProductDependencies', () => { + it('returns normalized commitment dependencies', async () => { + const doFetch = vi.fn().mockResolvedValue(jsonResponse({ commitmentDependencies: [{ + id: 'commit-1', + term: 'Annual', + allowForQuantityIncrease: true, + allowForQuantityDecrease: false, + allowForEarlyCancellation: false, + cancellationFeeApplied: true, + }] })); + + const dependencies = await clientWith(doFetch).getProductDependencies('prod-1'); + + expect(doFetch.mock.calls[0]![0]).toBe('https://api.pax8.com/v1/products/prod-1/dependencies'); + expect(dependencies.commitments).toEqual([{ + id: 'commit-1', + term: 'Annual', + allowForQuantityIncrease: true, + allowForQuantityDecrease: false, + allowForEarlyCancellation: false, + cancellationFeeApplied: true, + }]); + }); +}); diff --git a/apps/api/src/services/pax8Client.ts b/apps/api/src/services/pax8Client.ts index 1d6e9f7679..9912f501b3 100644 --- a/apps/api/src/services/pax8Client.ts +++ b/apps/api/src/services/pax8Client.ts @@ -25,6 +25,7 @@ export interface Pax8SubscriptionRecord { status: string | null; billingTerm: string | null; quantity: string; + quantityKnown: boolean; unitPrice: string | null; unitCost: string | null; currencyCode: string | null; @@ -53,6 +54,67 @@ export interface Pax8ProductPriceRecord { raw: JsonRecord; } +export interface Pax8ProvisioningDetailInput { + key: string; + values: string[]; +} + +export interface Pax8OrderLineInput { + lineItemNumber: number; + productId: string; + quantity: number; + billingTerm: string; + commitmentTermId?: string; + provisioningDetails?: Pax8ProvisioningDetailInput[]; +} + +export interface Pax8CreateOrderInput { + companyId: string; + lineItems: Pax8OrderLineInput[]; + orderedBy?: 'Pax8 Partner' | 'Customer' | 'Pax8'; + orderedByUserEmail?: string; +} + +export interface Pax8OrderResult { + pax8OrderId: string | null; + lineItems: Array<{ lineItemNumber: number | null; productId: string | null; subscriptionId: string | null }>; +} + +export interface Pax8OrderRecord { + pax8OrderId: string; + pax8CompanyId: string; + createdDate: string | null; + lineItems: Array<{ + lineItemNumber: number | null; + productId: string | null; + quantity: string; + quantityKnown: boolean; + subscriptionId: string | null; + }>; + raw: JsonRecord; +} + +export interface Pax8ProvisionDetail { + key: string; + label: string | null; + description: string | null; + valueType: 'Input' | 'Single-Value' | 'Multi-Value' | null; + possibleValues: string[] | null; +} + +export interface Pax8Commitment { + id: string; + term: string | null; + allowForQuantityIncrease: boolean; + allowForQuantityDecrease: boolean; + allowForEarlyCancellation: boolean; + cancellationFeeApplied: boolean; +} + +export interface Pax8ProductDependencies { + commitments: Pax8Commitment[]; +} + export interface Pax8ClientCredentials { clientId: string; clientSecret: string; @@ -125,9 +187,12 @@ function normalizeMoney(value: unknown): string | null { return numberValue.toFixed(2); } -function normalizeQuantity(value: unknown): string { +function normalizeQuantityEvidence(value: unknown): { quantity: string; quantityKnown: boolean } { const normalized = normalizeMoney(value); - return normalized ?? '0.00'; + return { + quantity: normalized ?? '0.00', + quantityKnown: normalized !== null, + }; } function normalizeIsoDate(value: unknown): string | null { @@ -185,6 +250,7 @@ function normalizeSubscription(value: unknown): Pax8SubscriptionRecord | null { const pricing = nestedRecord(record, 'pricing') ?? nestedRecord(record, 'price'); const cost = nestedRecord(record, 'cost'); + const quantity = normalizeQuantityEvidence(record.quantity ?? record.seats ?? record.licenses ?? record.licenseCount); return { pax8SubscriptionId: id, pax8CompanyId: companyId, @@ -194,7 +260,7 @@ function normalizeSubscription(value: unknown): Pax8SubscriptionRecord | null { vendorSkuId: firstString(record, ['vendorSkuId', 'vendor_sku_id', 'sku', 'skuId']) ?? (product ? firstString(product, ['vendorSkuId', 'sku', 'skuId']) : null), status: firstString(record, ['status', 'state']), billingTerm: firstString(record, ['billingTerm', 'billing_term', 'term']), - quantity: normalizeQuantity(record.quantity ?? record.seats ?? record.licenses ?? record.licenseCount), + ...quantity, unitPrice: pricing ? normalizeMoney(pricing.unitPrice ?? pricing.price ?? pricing.amount) : normalizeMoney(record.unitPrice), unitCost: cost ? normalizeMoney(cost.unitCost ?? cost.cost ?? cost.amount) : normalizeMoney(record.unitCost), currencyCode: firstString(record, ['currencyCode', 'currency']) ?? (pricing ? firstString(pricing, ['currencyCode', 'currency']) : null), @@ -206,6 +272,33 @@ function normalizeSubscription(value: unknown): Pax8SubscriptionRecord | null { }; } +function normalizeOrder(value: unknown): Pax8OrderRecord | null { + const record = asRecord(value); + if (!record) return null; + const id = firstString(record, ['id', 'orderId', 'order_id']); + const companyId = + firstString(record, ['companyId', 'company_id', 'customerId', 'customer_id']) ?? + firstNestedString(record, [['company', ['id', 'companyId']], ['customer', ['id', 'companyId']]]); + if (!id || !companyId) return null; + const lineItems = extractArray(record.lineItems).map((value) => { + const line = asRecord(value); + if (!line) return null; + return { + lineItemNumber: firstNumber(line, ['lineItemNumber', 'line_item_number']), + productId: firstString(line, ['productId', 'product_id']), + ...normalizeQuantityEvidence(line.quantity), + subscriptionId: firstString(line, ['subscriptionId', 'subscription_id']), + }; + }).filter((line): line is Pax8OrderRecord['lineItems'][number] => line !== null); + return { + pax8OrderId: id, + pax8CompanyId: companyId, + createdDate: normalizeIsoDate(record.createdDate ?? record.created_date), + lineItems, + raw: record, + }; +} + function normalizeProduct(value: unknown): Pax8ProductRecord | null { const record = asRecord(value); if (!record) return null; @@ -265,11 +358,17 @@ export class Pax8Client { return rows.map(normalizeCompany).filter((row): row is Pax8CompanyRecord => row !== null); } - async listSubscriptions(opts: { limit?: number } = {}): Promise { - const rows = await this.fetchPaged('/subscriptions', opts.limit); + async listSubscriptions(opts: { limit?: number; companyId?: string } = {}): Promise { + const rows = await this.fetchPaged('/subscriptions', opts.limit, { companyId: opts.companyId }); return rows.map(normalizeSubscription).filter((row): row is Pax8SubscriptionRecord => row !== null); } + /** Read-only company order listing used only by human-triggered reconcile. */ + async listOrders(opts: { limit?: number; companyId: string }): Promise { + const rows = await this.fetchPaged('/orders', opts.limit, { companyId: opts.companyId }); + return rows.map(normalizeOrder).filter((row): row is Pax8OrderRecord => row !== null); + } + async listProducts(opts: { limit?: number; vendorName?: string } = {}): Promise { const query: Record = {}; if (opts.vendorName) query.vendorName = opts.vendorName; @@ -282,6 +381,98 @@ export class Pax8Client { return extractArray(payload).map(normalizeProductPrice).filter((row): row is Pax8ProductPriceRecord => row !== null); } + /** + * POST /v1/orders. Pax8 has NO idempotency key — calling this twice creates + * two real, billable orders, and Order.createdDate is a DATE (not a timestamp) + * so you cannot tell them apart afterward. Callers MUST claim their intent row + * in a committed transaction before invoking this, and MUST NOT retry on + * timeout. See pax8OrderSubmit.ts. + * + * `isMock: true` validates without touching Pax8's database. It is the ONLY + * machine-checkable oracle for whether provisioningDetails are complete, + * because their provision-details endpoint does not expose requiredness. + */ + async createOrder(input: Pax8CreateOrderInput, opts: { isMock?: boolean } = {}): Promise { + const payload = await this.requestJson( + '/orders', + opts.isMock ? { isMock: true } : {}, + { method: 'POST', body: input }, + ); + const record = asRecord(payload); + const lineItems = extractArray(record?.lineItems).map((raw) => { + const li = asRecord(raw); + return { + lineItemNumber: li ? firstNumber(li, ['lineItemNumber']) : null, + productId: li ? firstString(li, ['productId', 'product_id']) : null, + subscriptionId: li ? firstString(li, ['subscriptionId', 'subscription_id']) : null, + }; + }); + return { pax8OrderId: record ? firstString(record, ['id', 'orderId']) : null, lineItems }; + } + + /** + * PUT /v1/subscriptions/{id}. Despite the verb this is a PARTIAL update, and + * `price`, `partnerCost`, `currencyCode`, `startDate`, and `endDate` are all + * writable. We send `quantity` and nothing else — a read-modify-write would + * re-send pricing and can overwrite the customer's rate. Do not "helpfully" + * add fields to this body. + */ + async updateSubscriptionQuantity(subscriptionId: string, quantity: number): Promise { + await this.requestJson( + `/subscriptions/${encodeURIComponent(subscriptionId)}`, + {}, + { method: 'PUT', body: { quantity } }, + ); + } + + /** DELETE /v1/subscriptions/{id}. No body. Cancel is terminal — Pax8 exposes no reactivate. */ + async cancelSubscription(subscriptionId: string, cancelDate?: string | null): Promise { + await this.requestJson( + `/subscriptions/${encodeURIComponent(subscriptionId)}`, + cancelDate ? { cancelDate } : {}, + { method: 'DELETE' }, + ); + } + + async getProvisionDetails(productId: string): Promise { + const payload = await this.requestJson(`/products/${encodeURIComponent(productId)}/provision-details`); + return extractArray(payload).map((raw): Pax8ProvisionDetail | null => { + const r = asRecord(raw); + const key = r ? firstString(r, ['key']) : null; + if (!r || !key) return null; + const valueType = firstString(r, ['valueType']); + const possible = Array.isArray(r.possibleValues) + ? r.possibleValues.filter((v): v is string => typeof v === 'string') + : null; + return { + key, + label: firstString(r, ['label']), + description: firstString(r, ['description']), + valueType: (valueType as Pax8ProvisionDetail['valueType']) ?? null, + possibleValues: possible, + }; + }).filter((d): d is Pax8ProvisionDetail => d !== null); + } + + async getProductDependencies(productId: string): Promise { + const payload = await this.requestJson(`/products/${encodeURIComponent(productId)}/dependencies`); + const root = asRecord(payload); + const commitments = extractArray(root?.commitmentDependencies).map((raw): Pax8Commitment | null => { + const r = asRecord(raw); + const id = r ? firstString(r, ['id']) : null; + if (!r || !id) return null; + return { + id, + term: firstString(r, ['term']), + allowForQuantityIncrease: r.allowForQuantityIncrease === true, + allowForQuantityDecrease: r.allowForQuantityDecrease === true, + allowForEarlyCancellation: r.allowForEarlyCancellation === true, + cancellationFeeApplied: r.cancellationFeeApplied === true, + }; + }).filter((c): c is Pax8Commitment => c !== null); + return { commitments }; + } + private async fetchPaged(path: string, limit?: number, extraQuery: Record = {}): Promise { const all: unknown[] = []; let page = 0; @@ -297,24 +488,38 @@ export class Pax8Client { return all; } - private async requestJson(path: string, query: Record = {}): Promise { + private async requestJson( + path: string, + query: Record = {}, + init: { method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; body?: unknown } = {}, + ): Promise { const token = await this.getAccessToken(); const url = new URL(`${this.apiBaseUrl}${path.startsWith('/') ? path : `/${path}`}`); for (const [key, value] of Object.entries(query)) { if (value !== undefined) url.searchParams.set(key, String(value)); } + const method = init.method ?? 'GET'; + const headers: Record = { + authorization: `Bearer ${token}`, + accept: 'application/json', + }; + if (init.body !== undefined) headers['content-type'] = 'application/json'; + const res = await this.doFetch(url.toString(), { - headers: { - authorization: `Bearer ${token}`, - accept: 'application/json', - }, + method, + headers, + body: init.body === undefined ? undefined : JSON.stringify(init.body), timeoutMs: DEFAULT_TIMEOUT_MS, } as RequestInit & { timeoutMs?: number }); if (!res.ok) { const body = await res.text().catch(() => ''); - throw new Pax8ApiError(`Pax8 API returned ${res.status}`, res.status, body.slice(0, 500)); + // Pax8 puts per-line-item validation failures in `details[]`. Keep the raw + // body — the UI shows it verbatim rather than guessing at what's wrong, + // because requiredness is NOT discoverable from their spec. + throw new Pax8ApiError(`Pax8 API returned ${res.status}`, res.status, body.slice(0, 4000)); } + if (res.status === 204) return null; return res.json(); } diff --git a/apps/api/src/services/pax8CompanyReadiness.ts b/apps/api/src/services/pax8CompanyReadiness.ts new file mode 100644 index 0000000000..813f5c6e4d --- /dev/null +++ b/apps/api/src/services/pax8CompanyReadiness.ts @@ -0,0 +1,54 @@ +type JsonRecord = Record; + +export interface Pax8CompanyOrderReadiness { + statusActive: boolean; + primaryAdminReady: boolean; + primaryBillingReady: boolean; + primaryTechnicalReady: boolean; + orderReady: boolean; +} + +function asRecord(value: unknown): JsonRecord | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as JsonRecord + : null; +} + +/** + * Projects only the ordering capability required by Pax8. Contact PII and the + * raw company payload never leave the server. Missing contact evidence is + * deliberately unknown/not-ready even when the company says Active. + */ +export function pax8CompanyOrderReadiness( + status: string | null | undefined, + metadata: unknown, +): Pax8CompanyOrderReadiness { + const statusActive = status?.trim().toLowerCase() === 'active'; + const record = asRecord(metadata); + const contacts = Array.isArray(record?.contacts) ? record.contacts : []; + const primaryTypes = new Set(); + + for (const candidate of contacts) { + const contact = asRecord(candidate); + if (!contact || !Array.isArray(contact.types)) continue; + for (const value of contact.types) { + const type = asRecord(value); + if (type?.primary !== true || typeof type.type !== 'string') continue; + primaryTypes.add(type.type.trim().toLowerCase()); + } + } + + const primaryAdminReady = primaryTypes.has('admin'); + const primaryBillingReady = primaryTypes.has('billing'); + const primaryTechnicalReady = primaryTypes.has('technical'); + return { + statusActive, + primaryAdminReady, + primaryBillingReady, + primaryTechnicalReady, + orderReady: statusActive + && primaryAdminReady + && primaryBillingReady + && primaryTechnicalReady, + }; +} diff --git a/apps/api/src/services/pax8Drift.test.ts b/apps/api/src/services/pax8Drift.test.ts new file mode 100644 index 0000000000..8fd63db092 --- /dev/null +++ b/apps/api/src/services/pax8Drift.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../db', () => ({ db: { select: vi.fn() } })); + +vi.mock('../db/schema', () => ({ + contractLines: { + id: 'contract_lines.id', + orgId: 'contract_lines.org_id', + lineType: 'contract_lines.line_type', + manualQuantity: 'contract_lines.manual_quantity', + }, + pax8ContractLineLinks: { + subscriptionSnapshotId: 'pax8_contract_line_links.subscription_snapshot_id', + contractLineId: 'pax8_contract_line_links.contract_line_id', + integrationId: 'pax8_contract_line_links.integration_id', + partnerId: 'pax8_contract_line_links.partner_id', + orgId: 'pax8_contract_line_links.org_id', + syncEnabled: 'pax8_contract_line_links.sync_enabled', + }, + pax8Integrations: { + id: 'pax8_integrations.id', + partnerId: 'pax8_integrations.partner_id', + }, + pax8SubscriptionSnapshots: { + id: 'pax8_subscription_snapshots.id', + integrationId: 'pax8_subscription_snapshots.integration_id', + partnerId: 'pax8_subscription_snapshots.partner_id', + orgId: 'pax8_subscription_snapshots.org_id', + pax8SubscriptionId: 'pax8_subscription_snapshots.pax8_subscription_id', + productName: 'pax8_subscription_snapshots.product_name', + quantity: 'pax8_subscription_snapshots.quantity', + quantityKnown: 'pax8_subscription_snapshots.quantity_known', + }, +})); + +import { db } from '../db'; +import { detectPax8Drift } from './pax8Drift'; + +function mockRows(rows: unknown[]) { + const chain: Record = {}; + chain.innerJoin = vi.fn(() => chain); + chain.where = vi.fn(() => chain); + chain.orderBy = vi.fn(() => chain); + chain.limit = vi.fn(async () => rows); + vi.mocked(db.select).mockReturnValueOnce({ from: vi.fn(() => chain) } as any); + return chain; +} + +describe('detectPax8Drift', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns exact Breeze and known Pax8 quantities in deterministic order', async () => { + const rows = [{ + contractLineId: 'cl-1', + orgId: 'org-1', + pax8SubscriptionId: 'sub-1', + productName: 'Product', + breezeQuantity: '5.00', + pax8Quantity: '8.00', + }]; + const chain = mockRows(rows); + + await expect(detectPax8Drift({ partnerId: 'partner-1', integrationId: 'int-1' })).resolves.toEqual(rows); + expect(chain.orderBy).toHaveBeenCalled(); + expect(chain.limit).toHaveBeenCalledWith(1000); + }); + + it('returns no rows when quantities agree or quantity evidence is missing', async () => { + mockRows([]); + await expect(detectPax8Drift({ partnerId: 'partner-1', integrationId: 'int-1' })).resolves.toEqual([]); + }); +}); diff --git a/apps/api/src/services/pax8Drift.ts b/apps/api/src/services/pax8Drift.ts new file mode 100644 index 0000000000..39d0815402 --- /dev/null +++ b/apps/api/src/services/pax8Drift.ts @@ -0,0 +1,68 @@ +import { and, asc, eq, isNotNull, sql } from 'drizzle-orm'; +import { db } from '../db'; +import { + contractLines, + pax8ContractLineLinks, + pax8Integrations, + pax8SubscriptionSnapshots, +} from '../db/schema'; + +export interface Pax8DriftRow { + contractLineId: string; + orgId: string; + pax8SubscriptionId: string; + productName: string | null; + breezeQuantity: string | null; + pax8Quantity: string; +} + +export interface DetectPax8DriftInput { + partnerId: string; + integrationId: string; +} + +const MAX_DRIFT_ROWS = 1000; + +/** + * Reads known Pax8 quantity observations that disagree with Breeze's billing + * ledger. This deliberately performs no Pax8 HTTP calls and no writes. + */ +export async function detectPax8Drift(input: DetectPax8DriftInput): Promise { + return db + .select({ + contractLineId: contractLines.id, + orgId: pax8ContractLineLinks.orgId, + pax8SubscriptionId: pax8SubscriptionSnapshots.pax8SubscriptionId, + productName: pax8SubscriptionSnapshots.productName, + breezeQuantity: contractLines.manualQuantity, + pax8Quantity: pax8SubscriptionSnapshots.quantity, + }) + .from(pax8ContractLineLinks) + .innerJoin(pax8Integrations, and( + eq(pax8Integrations.id, pax8ContractLineLinks.integrationId), + eq(pax8Integrations.partnerId, pax8ContractLineLinks.partnerId), + )) + .innerJoin(pax8SubscriptionSnapshots, and( + eq(pax8SubscriptionSnapshots.id, pax8ContractLineLinks.subscriptionSnapshotId), + eq(pax8SubscriptionSnapshots.integrationId, pax8ContractLineLinks.integrationId), + eq(pax8SubscriptionSnapshots.partnerId, pax8ContractLineLinks.partnerId), + eq(pax8SubscriptionSnapshots.orgId, pax8ContractLineLinks.orgId), + )) + .innerJoin(contractLines, and( + eq(contractLines.id, pax8ContractLineLinks.contractLineId), + eq(contractLines.orgId, pax8ContractLineLinks.orgId), + )) + .where(and( + eq(pax8Integrations.id, input.integrationId), + eq(pax8Integrations.partnerId, input.partnerId), + eq(pax8ContractLineLinks.integrationId, input.integrationId), + eq(pax8ContractLineLinks.partnerId, input.partnerId), + eq(pax8ContractLineLinks.syncEnabled, true), + eq(pax8SubscriptionSnapshots.quantityKnown, true), + isNotNull(pax8SubscriptionSnapshots.orgId), + eq(contractLines.lineType, 'manual'), + sql`${contractLines.manualQuantity} IS DISTINCT FROM ${pax8SubscriptionSnapshots.quantity}`, + )) + .orderBy(asc(contractLines.id), asc(pax8SubscriptionSnapshots.pax8SubscriptionId)) + .limit(MAX_DRIFT_ROWS); +} diff --git a/apps/api/src/services/pax8OrderService.test.ts b/apps/api/src/services/pax8OrderService.test.ts new file mode 100644 index 0000000000..19cdd19d9d --- /dev/null +++ b/apps/api/src/services/pax8OrderService.test.ts @@ -0,0 +1,1158 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + db: { + select: vi.fn(), + insert: vi.fn(), + delete: vi.fn(), + update: vi.fn(), + transaction: vi.fn(), + }, + runOutsideDbContext: vi.fn((fn: () => unknown) => fn()), + withDbAccessContext: vi.fn(), + createPax8ClientForIntegration: vi.fn(), + getProductDependencies: vi.fn(), + contextDepth: 0, + contextExits: 0, +})); + +vi.mock('../db', () => ({ + db: mocks.db, + runOutsideDbContext: mocks.runOutsideDbContext, + withDbAccessContext: mocks.withDbAccessContext, +})); + +vi.mock('../db/schema', () => new Proxy({ + pax8Orders: { + id: 'pax8_orders.id', + integrationId: 'pax8_orders.integration_id', + partnerId: 'pax8_orders.partner_id', + orgId: 'pax8_orders.org_id', + status: 'pax8_orders.status', + source: 'pax8_orders.source', + updatedAt: 'pax8_orders.updated_at', + }, + pax8OrderLines: { + id: 'pax8_order_lines.id', + orderId: 'pax8_order_lines.order_id', + partnerId: 'pax8_order_lines.partner_id', + orgId: 'pax8_order_lines.org_id', + action: 'pax8_order_lines.action', + sortOrder: 'pax8_order_lines.sort_order', + authorizedBaselineQuantity: 'pax8_order_lines.authorized_baseline_quantity', + }, + pax8CompanyMappings: { + partnerId: 'pax8_company_mappings.partner_id', + orgId: 'pax8_company_mappings.org_id', + ignored: 'pax8_company_mappings.ignored', + }, + pax8SubscriptionSnapshots: { + id: 'pax8_subscription_snapshots.id', + integrationId: 'pax8_subscription_snapshots.integration_id', + partnerId: 'pax8_subscription_snapshots.partner_id', + orgId: 'pax8_subscription_snapshots.org_id', + pax8SubscriptionId: 'pax8_subscription_snapshots.pax8_subscription_id', + productId: 'pax8_subscription_snapshots.product_id', + }, + pax8ContractLineLinks: { + integrationId: 'pax8_contract_line_links.integration_id', + partnerId: 'pax8_contract_line_links.partner_id', + orgId: 'pax8_contract_line_links.org_id', + subscriptionSnapshotId: 'pax8_contract_line_links.subscription_snapshot_id', + contractLineId: 'pax8_contract_line_links.contract_line_id', + }, + contractLines: { + id: 'contract_lines.id', + orgId: 'contract_lines.org_id', + lineType: 'contract_lines.line_type', + manualQuantity: 'contract_lines.manual_quantity', + }, + pax8Integrations: { + id: 'pax8_integrations.id', + partnerId: 'pax8_integrations.partner_id', + isActive: 'pax8_integrations.is_active', + }, + pax8ProductMappings: { + integrationId: 'pax8_product_mappings.integration_id', + partnerId: 'pax8_product_mappings.partner_id', + pax8ProductId: 'pax8_product_mappings.pax8_product_id', + catalogItemId: 'pax8_product_mappings.catalog_item_id', + productName: 'pax8_product_mappings.product_name', + vendorSkuId: 'pax8_product_mappings.vendor_sku_id', + metadata: 'pax8_product_mappings.metadata', + }, + catalogItems: { + id: 'catalog_items.id', + partnerId: 'catalog_items.partner_id', + name: 'catalog_items.name', + sku: 'catalog_items.sku', + description: 'catalog_items.description', + billingFrequency: 'catalog_items.billing_frequency', + commitmentTermMonths: 'catalog_items.commitment_term_months', + isActive: 'catalog_items.is_active', + }, +}, { + get(target, prop) { + if (prop in target) return target[prop as keyof typeof target]; + return {}; + }, + // Vitest checks named exports with `in` before resolving them. + has() { + return true; + }, +})); + +vi.mock('./pax8SyncService', () => ({ + createPax8ClientForIntegration: mocks.createPax8ClientForIntegration, +})); + +import { + addOrderLine, + buildDedupeKey, + getOrderWithLines, + getOrCreateDraftOrder, + listPax8Products, + listPax8Orders, + removeOrderLine, + updateOrderLine, + validateDirectOrderLinesForSubmit, +} from './pax8OrderService'; + +const baseOrder = { + id: 'ord-1', + integrationId: 'i1', + partnerId: 'p1', + orgId: 'o1', + pax8CompanyId: 'co-1', + status: 'draft', +}; + +const baseSnapshot = { + id: 'snap-1', + integrationId: 'i1', + partnerId: 'p1', + orgId: 'o1', + pax8SubscriptionId: 'sub-1', + productId: 'prod-1', + quantity: '10.00', +}; + +function queryChain(rows: unknown[]) { + const chain: Record = {}; + chain.from = vi.fn(() => chain); + chain.where = vi.fn(() => chain); + chain.innerJoin = vi.fn(() => chain); + chain.for = vi.fn(() => chain); + chain.orderBy = vi.fn(() => chain); + chain.limit = vi.fn(async () => rows); + chain.then = (resolve: (value: unknown[]) => unknown, reject: (error: unknown) => unknown) => + Promise.resolve(rows).then(resolve, reject); + return chain; +} + +describe('listPax8Orders', () => { + it('lists a bounded org history with stable ordering and partner/org predicates', async () => { + const chain = selectRowsOnce([{ ...baseOrder }]); + + await expect(listPax8Orders({ partnerId: 'p1', orgId: 'o1' })).resolves.toHaveLength(1); + + expect(chain.where).toHaveBeenCalledWith(expect.objectContaining({ + queryChunks: expect.any(Array), + })); + expect(chain.orderBy).toHaveBeenCalledTimes(1); + expect(containsValue(vi.mocked(chain.orderBy as any).mock.calls[0], 'pax8_orders.updated_at')).toBe(true); + expect(containsValue(vi.mocked(chain.orderBy as any).mock.calls[0], 'pax8_orders.id')).toBe(true); + expect(chain.limit).toHaveBeenCalledWith(100); + expect(containsValue(vi.mocked(chain.where as any).mock.calls[0]?.[0], 'p1')).toBe(true); + expect(containsValue(vi.mocked(chain.where as any).mock.calls[0]?.[0], 'o1')).toBe(true); + }); + + it('limits the partner-wide view to nonterminal actionable orders', async () => { + const chain = selectRowsOnce([{ ...baseOrder }]); + + await listPax8Orders({ partnerId: 'p1', accessibleOrgIds: ['o1'] }); + + const where = vi.mocked(chain.where as any).mock.calls[0]?.[0]; + expect(containsValue(where, 'completed')).toBe(true); + expect(containsValue(where, 'cancelled')).toBe(true); + expect(containsValue(where, 'o1')).toBe(true); + }); + + it('fails closed for a partner member with no accessible organizations', async () => { + const chain = selectRowsOnce([]); + + await listPax8Orders({ partnerId: 'p1', accessibleOrgIds: [] }); + + expect(containsValue(vi.mocked(chain.where as any).mock.calls[0]?.[0], false)).toBe(true); + }); +}); + +function containsValue(value: unknown, expected: unknown, seen = new WeakSet()): boolean { + if (value === expected) return true; + if (!value || typeof value !== 'object') return false; + if (seen.has(value)) return false; + seen.add(value); + return Object.values(value).some((nested) => containsValue(nested, expected, seen)); +} + +function queryChainByPredicate(rowsWithoutDirectFilter: unknown[], rowsWithDirectFilter: unknown[]) { + let rows = rowsWithoutDirectFilter; + const chain: Record = {}; + chain.from = vi.fn(() => chain); + chain.where = vi.fn((condition: unknown) => { + rows = containsValue(condition, 'direct') ? rowsWithDirectFilter : rowsWithoutDirectFilter; + return chain; + }); + chain.limit = vi.fn(async () => rows); + chain.then = (resolve: (value: unknown[]) => unknown, reject: (error: unknown) => unknown) => + Promise.resolve(rows).then(resolve, reject); + return chain; +} + +function selectRowsOnce(rows: unknown[]) { + const chain = queryChain(rows); + mocks.db.select.mockReturnValueOnce(chain); + return chain; +} + +function mockCompanyMappingLookup(mapping: Record | null) { + return selectRowsOnce(mapping ? [{ + orgId: 'o1', + status: 'Active', + metadata: { + contacts: [{ types: [ + { type: 'Admin', primary: true }, + { type: 'Billing', primary: true }, + { type: 'Technical', primary: true }, + ] }], + }, + ...mapping, + }] : []); +} + +function mockOrder(overrides: Record = {}) { + return selectRowsOnce([{ ...baseOrder, ...overrides }]); +} + +function mockSubscriptionSnapshot(overrides: Record = {}) { + selectRowsOnce([{ ...baseSnapshot, ...overrides }]); + selectRowsOnce([{ contractLineId: 'contract-line-1', manualQuantity: '10.00' }]); +} + +function mockDependencies(dependencies: Record) { + mocks.getProductDependencies.mockResolvedValueOnce(dependencies); + mocks.createPax8ClientForIntegration.mockResolvedValueOnce({ + integration: { id: 'i1', partnerId: 'p1' }, + client: { getProductDependencies: mocks.getProductDependencies }, + }); +} + +function insertReturningOnce(rows: unknown[]) { + const returning = vi.fn(async () => rows); + const values = vi.fn(() => ({ returning })); + mocks.db.insert.mockReturnValueOnce({ values }); + return { values, returning }; +} + +function insertRejectingOnce(error: unknown) { + const returning = vi.fn(async () => { throw error; }); + const values = vi.fn(() => ({ returning })); + mocks.db.insert.mockReturnValueOnce({ values }); +} + +function deleteReturningOnce(rows: unknown[]) { + const returning = vi.fn(async () => rows); + const where = vi.fn(() => ({ returning })); + mocks.db.delete.mockReturnValueOnce({ where }); + return { where, returning }; +} + +beforeEach(() => { + vi.useRealTimers(); + mocks.db.select.mockReset(); + mocks.db.insert.mockReset(); + mocks.db.delete.mockReset(); + mocks.db.update.mockReset(); + mocks.db.transaction.mockReset(); + mocks.runOutsideDbContext.mockReset(); + mocks.withDbAccessContext.mockReset(); + mocks.createPax8ClientForIntegration.mockReset(); + mocks.getProductDependencies.mockReset(); + mocks.contextDepth = 0; + mocks.contextExits = 0; + mocks.runOutsideDbContext.mockImplementation((fn: () => unknown) => fn()); + mocks.withDbAccessContext.mockImplementation(async (_context: unknown, fn: () => unknown) => { + mocks.contextDepth += 1; + try { + return await fn(); + } finally { + mocks.contextDepth -= 1; + mocks.contextExits += 1; + } + }); + mocks.db.transaction.mockImplementation((fn: (tx: { insert: typeof mocks.db.insert }) => unknown) => + fn({ insert: mocks.db.insert, select: mocks.db.select } as never)); +}); + +describe('listPax8Products', () => { + it('lists a bounded stable set from the active partner integration and active catalog', async () => { + const rows = [{ pax8ProductId: 'prod-1', catalogItemId: 'cat-1', catalogName: 'M365' }]; + const chain = queryChain(rows); + chain.innerJoin = vi.fn(() => chain); + mocks.db.select.mockReturnValueOnce(chain); + + await expect(listPax8Products({ partnerId: 'p1' })).resolves.toEqual(rows); + + expect(chain.innerJoin).toHaveBeenCalledTimes(2); + expect(chain.orderBy).toHaveBeenCalled(); + expect(chain.limit).toHaveBeenCalledWith(200); + const where = vi.mocked(chain.where as any).mock.calls[0]?.[0]; + expect(containsValue(where, 'p1')).toBe(true); + expect(containsValue(where, true)).toBe(true); + }); +}); + +describe('getOrCreateDraftOrder', () => { + it('throws 409 when the org has no Pax8 company mapping', async () => { + mockCompanyMappingLookup(null); + + await expect(getOrCreateDraftOrder({ partnerId: 'p1', orgId: 'o1', actorUserId: 'u1' })) + .rejects.toMatchObject({ + status: 409, + message: expect.stringContaining('not mapped to a Pax8 company'), + }); + }); + + it.each([ + ['contact evidence is absent', { metadata: null }], + ['the company is inactive', { status: 'Inactive' }], + ['the primary admin contact is missing', { metadata: { contacts: [{ types: [ + { type: 'Billing', primary: true }, { type: 'Technical', primary: true }, + ] }] } }], + ['the primary billing contact is missing', { metadata: { contacts: [{ types: [ + { type: 'Admin', primary: true }, { type: 'Technical', primary: true }, + ] }] } }], + ['the primary technical contact is missing', { metadata: { contacts: [{ types: [ + { type: 'Admin', primary: true }, { type: 'Billing', primary: true }, + ] }] } }], + ])('fails closed before creating a draft when %s', async (_name, mapping) => { + mockCompanyMappingLookup({ pax8CompanyId: 'co-1', integrationId: 'i1', ...mapping }); + selectRowsOnce([]); + insertReturningOnce([{ ...baseOrder, id: 'should-not-create' }]); + + await expect(getOrCreateDraftOrder({ partnerId: 'p1', orgId: 'o1', actorUserId: 'u1' })) + .rejects.toMatchObject({ status: 422, message: expect.stringContaining('ready') }); + expect(mocks.db.insert).not.toHaveBeenCalled(); + }); + + it('reuses the existing open draft rather than creating a second one', async () => { + mockCompanyMappingLookup({ pax8CompanyId: 'co-1', integrationId: 'i1' }); + selectRowsOnce([{ ...baseOrder, id: 'ord-existing' }]); + + const order = await getOrCreateDraftOrder({ partnerId: 'p1', orgId: 'o1', actorUserId: 'u1' }); + + expect(order.id).toBe('ord-existing'); + expect(mocks.db.insert).not.toHaveBeenCalled(); + }); + + it('does not reuse an awaiting-details quote order as the direct draft', async () => { + mockCompanyMappingLookup({ pax8CompanyId: 'co-1', integrationId: 'i1' }); + mocks.db.select.mockReturnValueOnce(queryChainByPredicate( + [{ ...baseOrder, id: 'quote-order', source: 'quote', status: 'awaiting_details' }], + [], + )); + mockCompanyMappingLookup({ pax8CompanyId: 'co-1', integrationId: 'i1' }); + insertReturningOnce([{ ...baseOrder, id: 'direct-order', source: 'direct' }]); + + const order = await getOrCreateDraftOrder({ partnerId: 'p1', orgId: 'o1', actorUserId: 'u1' }); + + expect(order.id).toBe('direct-order'); + }); + + it('returns the winning direct draft when its insert loses the unique-index race', async () => { + mockCompanyMappingLookup({ pax8CompanyId: 'co-1', integrationId: 'i1' }); + selectRowsOnce([]); + mockCompanyMappingLookup({ pax8CompanyId: 'co-1', integrationId: 'i1' }); + insertRejectingOnce({ cause: { code: '23505', constraint_name: 'pax8_orders_one_mutable_direct_per_org_uq' } }); + selectRowsOnce([{ ...baseOrder, id: 'winning-order', source: 'direct' }]); + + const order = await getOrCreateDraftOrder({ partnerId: 'p1', orgId: 'o1', actorUserId: 'u1' }); + + expect(order.id).toBe('winning-order'); + }); + + it('creates a direct draft with a stable per-order dedupe key', async () => { + mockCompanyMappingLookup({ pax8CompanyId: 'co-1', integrationId: 'i1' }); + selectRowsOnce([]); + mockCompanyMappingLookup({ pax8CompanyId: 'co-1', integrationId: 'i1' }); + const insert = insertReturningOnce([{ ...baseOrder, id: 'created-order' }]); + + await getOrCreateDraftOrder({ partnerId: 'p1', orgId: 'o1', actorUserId: 'u1' }); + + expect(insert.values).toHaveBeenCalledWith(expect.objectContaining({ + integrationId: 'i1', + partnerId: 'p1', + orgId: 'o1', + pax8CompanyId: 'co-1', + status: 'draft', + source: 'direct', + createdBy: 'u1', + dedupeKey: expect.stringMatching(/^order:[0-9a-f-]{36}$/), + })); + }); + + it('rechecks company readiness under a share lock in the draft insert transaction', async () => { + mockCompanyMappingLookup({ pax8CompanyId: 'co-1', integrationId: 'i1' }); + selectRowsOnce([]); + const finalMapping = mockCompanyMappingLookup({ + pax8CompanyId: 'co-1', integrationId: 'i1', status: 'Inactive', + }); + insertReturningOnce([{ ...baseOrder, id: 'should-not-create' }]); + + await expect(getOrCreateDraftOrder({ partnerId: 'p1', orgId: 'o1', actorUserId: 'u1' })) + .rejects.toMatchObject({ status: 422, message: expect.stringContaining('ready') }); + + expect(finalMapping.for).toHaveBeenCalledWith('share'); + expect(mocks.db.insert).not.toHaveBeenCalled(); + }); +}); + +describe('addOrderLine', () => { + it('rejects public line additions to quote-staged orders even while mutable', async () => { + mockOrder({ source: 'quote', status: 'awaiting_details' }); + + await expect(addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'new_subscription', + pax8ProductId: 'prod-1', catalogItemId: 'catalog-1', billingTerm: 'Monthly', quantity: '1.00', + })).rejects.toMatchObject({ status: 409, message: expect.stringContaining('quote') }); + + expect(mocks.db.insert).not.toHaveBeenCalled(); + }); + + it('rejects a future cancellation date at authoring time', async () => { + vi.setSystemTime(new Date('2026-07-14T23:59:59Z')); + mockOrder(); + + await expect(addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'cancel', + targetSubscriptionId: 'sub-1', cancelDate: '2026-07-15', + })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('future') }); + + expect(mocks.createPax8ClientForIntegration).not.toHaveBeenCalled(); + }); + + it('rejects a normalized-but-invalid UTC cancellation date', async () => { + mockOrder(); + + await expect(addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'cancel', + targetSubscriptionId: 'sub-1', cancelDate: '2026-02-30', + })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('valid UTC') }); + + expect(mocks.createPax8ClientForIntegration).not.toHaveBeenCalled(); + }); + + it('rejects caller-supplied contract linkage for a direct new subscription', async () => { + mockOrder(); + + await expect(addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'new_subscription', + pax8ProductId: 'prod-1', catalogItemId: 'catalog-1', billingTerm: 'Monthly', quantity: '1.00', + contractLineId: 'untrusted-line', + })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('contract line') }); + }); + it('fails closed before staging a direct line when company contact evidence is absent', async () => { + mocks.db.select.mockImplementation((selection?: Record) => { + let rows: unknown[] = []; + const chain: Record = {}; + chain.from = vi.fn((table: unknown) => { + rows = Object.prototype.hasOwnProperty.call(selection ?? {}, 'maxSortOrder') + ? [{ maxSortOrder: null }] + : containsValue(table, 'pax8_company_mappings.ignored') + ? [{ orgId: 'o1', pax8CompanyId: 'co-1', integrationId: 'i1', status: 'Active', metadata: null }] + : [{ ...baseOrder, source: 'direct' }]; + return chain; + }); + chain.where = vi.fn(() => chain); + chain.for = vi.fn(() => chain); + chain.limit = vi.fn(async () => rows); + chain.then = (resolve: (value: unknown[]) => unknown, reject: (error: unknown) => unknown) => + Promise.resolve(rows).then(resolve, reject); + return chain; + }); + insertReturningOnce([{ id: 'should-not-stage' }]); + + await expect(addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'new_subscription', + pax8ProductId: 'prod-1', billingTerm: 'Monthly', quantity: '1.00', + })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('ready') }); + expect(mocks.db.insert).not.toHaveBeenCalled(); + }); + + it('rechecks direct-order readiness under a share lock in the final line transaction', async () => { + mockOrder({ source: 'direct' }); + mockCompanyMappingLookup({ pax8CompanyId: 'co-1', integrationId: 'i1' }); + selectRowsOnce([{ pax8ProductId: 'prod-1', catalogItemId: 'catalog-1' }]); + mockOrder({ source: 'direct' }); + const finalMapping = mockCompanyMappingLookup({ + pax8CompanyId: 'co-1', integrationId: 'i1', metadata: null, + }); + insertReturningOnce([{ id: 'should-not-stage' }]); + + await expect(addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'new_subscription', + pax8ProductId: 'prod-1', catalogItemId: 'catalog-1', billingTerm: 'Monthly', quantity: '1.00', + })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('ready') }); + + expect(finalMapping.for).toHaveBeenCalledWith('share'); + expect(mocks.db.insert).not.toHaveBeenCalled(); + }); + + it('rejects a change_quantity whose commitment forbids a decrease', async () => { + mockOrder(); + mockSubscriptionSnapshot({ quantity: '0.00', quantityKnown: false }); + mockDependencies({ + commitments: [{ + id: 'c1', + allowForQuantityDecrease: false, + allowForQuantityIncrease: true, + allowForEarlyCancellation: false, + }], + }); + + await expect(addOrderLine({ + partnerId: 'p1', + orderId: 'ord-1', + action: 'change_quantity', + targetSubscriptionId: 'sub-1', + quantity: '5.00', + })).rejects.toMatchObject({ + status: 422, + message: expect.stringContaining('decrease'), + }); + + expect(mocks.runOutsideDbContext).toHaveBeenCalled(); + }); + + it('uses the active commitment for a decrease instead of an unrelated permissive commitment', async () => { + mockOrder(); + mockSubscriptionSnapshot({ raw: { commitmentTermId: 'blocked' } }); + mockDependencies({ + commitments: [ + { id: 'allowed', allowForQuantityDecrease: true, allowForQuantityIncrease: true, allowForEarlyCancellation: true }, + { id: 'blocked', allowForQuantityDecrease: false, allowForQuantityIncrease: true, allowForEarlyCancellation: true }, + ], + }); + insertReturningOnce([{ id: 'wrongly-authorized-line' }]); + + await expect(addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'change_quantity', + targetSubscriptionId: 'sub-1', quantity: '5.00', + })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('decrease') }); + }); + + it('rejects a change_quantity whose commitment forbids an increase', async () => { + mockOrder(); + mockSubscriptionSnapshot({ quantity: '100.00' }); + mockDependencies({ + commitments: [{ + id: 'c1', + allowForQuantityDecrease: true, + allowForQuantityIncrease: false, + allowForEarlyCancellation: false, + }], + }); + + await expect(addOrderLine({ + partnerId: 'p1', + orderId: 'ord-1', + action: 'change_quantity', + targetSubscriptionId: 'sub-1', + quantity: '11.00', + })).rejects.toMatchObject({ + status: 422, + message: expect.stringContaining('increase'), + }); + }); + + it('uses a nested active commitment for an increase instead of an unrelated permissive commitment', async () => { + mockOrder(); + mockSubscriptionSnapshot({ raw: { commitment: { id: 'blocked' } } }); + mockDependencies({ + commitments: [ + { id: 'allowed', allowForQuantityDecrease: true, allowForQuantityIncrease: true, allowForEarlyCancellation: true }, + { id: 'blocked', allowForQuantityDecrease: true, allowForQuantityIncrease: false, allowForEarlyCancellation: true }, + ], + }); + insertReturningOnce([{ id: 'wrongly-authorized-line' }]); + + await expect(addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'change_quantity', + targetSubscriptionId: 'sub-1', quantity: '11.00', + })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('increase') }); + }); + + it('fails closed when multiple commitments exist but the active one is unknown', async () => { + mockOrder(); + mockSubscriptionSnapshot({ raw: {} }); + mockDependencies({ + commitments: [ + { id: 'allowed', allowForQuantityDecrease: true, allowForQuantityIncrease: true, allowForEarlyCancellation: true }, + { id: 'blocked', allowForQuantityDecrease: false, allowForQuantityIncrease: false, allowForEarlyCancellation: false }, + ], + }); + insertReturningOnce([{ id: 'wrongly-authorized-line' }]); + + await expect(addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'change_quantity', + targetSubscriptionId: 'sub-1', quantity: '5.00', + })).rejects.toMatchObject({ + status: 422, + message: expect.stringContaining('active commitment'), + }); + }); + + it('fails closed when the snapshot contains conflicting active commitment ids', async () => { + mockOrder(); + mockSubscriptionSnapshot({ + raw: { + commitmentTermId: 'allowed', + commitment: { id: 'blocked' }, + }, + }); + mockDependencies({ + commitments: [ + { id: 'allowed', allowForQuantityDecrease: true, allowForQuantityIncrease: true, allowForEarlyCancellation: true }, + { id: 'blocked', allowForQuantityDecrease: false, allowForQuantityIncrease: false, allowForEarlyCancellation: false }, + ], + }); + insertReturningOnce([{ id: 'wrongly-authorized-line' }]); + + await expect(addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'change_quantity', + targetSubscriptionId: 'sub-1', quantity: '5.00', + })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('ambiguous') }); + + expect(mocks.db.insert).not.toHaveBeenCalled(); + }); + + it('fails closed when duplicate dependency entries match the active commitment id', async () => { + mockOrder(); + mockSubscriptionSnapshot({ raw: { commitmentTermId: 'c1' } }); + mockDependencies({ + commitments: [ + { id: 'c1', allowForQuantityDecrease: true, allowForQuantityIncrease: true, allowForEarlyCancellation: true }, + { id: 'c1', allowForQuantityDecrease: false, allowForQuantityIncrease: false, allowForEarlyCancellation: false }, + ], + }); + insertReturningOnce([{ id: 'wrongly-authorized-line' }]); + + await expect(addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'change_quantity', + targetSubscriptionId: 'sub-1', quantity: '5.00', + })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('ambiguous') }); + + expect(mocks.db.insert).not.toHaveBeenCalled(); + }); + + it('rejects a line targeting a subscription in a different org', async () => { + mockOrder(); + mockSubscriptionSnapshot({ orgId: 'OTHER-ORG' }); + + await expect(addOrderLine({ + partnerId: 'p1', + orderId: 'ord-1', + action: 'cancel', + targetSubscriptionId: 'sub-1', + })).rejects.toMatchObject({ status: 403 }); + + expect(mocks.createPax8ClientForIntegration).not.toHaveBeenCalled(); + }); + + it('refuses to modify an order that is not draft/awaiting_details', async () => { + mockOrder({ status: 'submitting' }); + + await expect(addOrderLine({ + partnerId: 'p1', + orderId: 'ord-1', + action: 'cancel', + targetSubscriptionId: 'sub-1', + })).rejects.toMatchObject({ status: 409 }); + }); + + it('returns 404 when the partner-scoped order does not exist', async () => { + selectRowsOnce([]); + + await expect(addOrderLine({ + partnerId: 'p1', + orderId: 'ord-1', + action: 'cancel', + targetSubscriptionId: 'sub-1', + })).rejects.toMatchObject({ status: 404 }); + }); + + it('returns 404 when the target subscription does not exist', async () => { + mockOrder(); + selectRowsOnce([]); + + await expect(addOrderLine({ + partnerId: 'p1', + orderId: 'ord-1', + action: 'cancel', + targetSubscriptionId: 'sub-1', + })).rejects.toMatchObject({ status: 404 }); + }); + + it('fails closed when the exact linked manual contract line has no Breeze quantity', async () => { + mockOrder(); + selectRowsOnce([{ ...baseSnapshot, quantity: '0.00', quantityKnown: false }]); + selectRowsOnce([{ contractLineId: 'contract-line-1', manualQuantity: null }]); + + await expect(addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'change_quantity', + targetSubscriptionId: 'sub-1', quantity: '5.00', + })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('manual contract quantity') }); + + expect(mocks.createPax8ClientForIntegration).not.toHaveBeenCalled(); + }); + + it('fails closed at submit validation for a legacy change line without an authorization baseline', async () => { + selectRowsOnce([{ contractLineId: 'contract-line-1', manualQuantity: '10.00' }]); + + await expect(validateDirectOrderLinesForSubmit( + { ...baseOrder, source: 'direct' } as never, + [{ + id: 'line-1', action: 'change_quantity', targetSubscriptionId: 'sub-1', + contractLineId: 'contract-line-1', authorizedBaselineQuantity: null, + } as never], + )).rejects.toMatchObject({ status: 409, message: expect.stringContaining('baseline') }); + }); + + it('fails closed at submit when the linked Breeze quantity changed since authorization', async () => { + selectRowsOnce([{ contractLineId: 'contract-line-1', manualQuantity: '20.00' }]); + + await expect(validateDirectOrderLinesForSubmit( + { ...baseOrder, source: 'direct' } as never, + [{ + id: 'line-1', action: 'change_quantity', targetSubscriptionId: 'sub-1', + contractLineId: 'contract-line-1', authorizedBaselineQuantity: '10.00', + } as never], + )).rejects.toMatchObject({ status: 409, message: expect.stringContaining('changed') }); + }); + + it('rejects a same-org but unrelated caller contract line and never stages it', async () => { + mockOrder(); + mockSubscriptionSnapshot(); + + await expect(addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'cancel', targetSubscriptionId: 'sub-1', + contractLineId: 'same-org-unrelated-line', + })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('does not match') }); + + expect(mocks.createPax8ClientForIntegration).not.toHaveBeenCalled(); + expect(mocks.db.insert).not.toHaveBeenCalled(); + }); + + it('rejects an unmapped or mismatched direct product/catalog tuple', async () => { + mockOrder(); + selectRowsOnce([]); + + await expect(addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'new_subscription', + pax8ProductId: 'prod-1', catalogItemId: 'unrelated-catalog', billingTerm: 'Monthly', quantity: '1.00', + })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('mapped') }); + + expect(mocks.db.insert).not.toHaveBeenCalled(); + }); + + it('rejects when the exact subscription link changes before the final insert', async () => { + mockOrder(); + mockSubscriptionSnapshot(); + mockDependencies({ commitments: [{ + id: 'c1', allowForQuantityDecrease: true, allowForQuantityIncrease: true, allowForEarlyCancellation: true, + }] }); + mockOrder(); + selectRowsOnce([{ contractLineId: 'replacement-line', manualQuantity: '10.00' }]); + insertReturningOnce([{ id: 'wrong-line' }]); + + await expect(addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'change_quantity', + targetSubscriptionId: 'sub-1', quantity: '11.00', + })).rejects.toMatchObject({ status: 409, message: expect.stringContaining('linkage changed') }); + + expect(mocks.db.insert).not.toHaveBeenCalled(); + }); + + it('rejects an early cancellation forbidden by the commitment', async () => { + mockOrder(); + mockSubscriptionSnapshot(); + mockDependencies({ + commitments: [{ + id: 'c1', + allowForQuantityDecrease: true, + allowForQuantityIncrease: true, + allowForEarlyCancellation: false, + }], + }); + + await expect(addOrderLine({ + partnerId: 'p1', + orderId: 'ord-1', + action: 'cancel', + targetSubscriptionId: 'sub-1', + })).rejects.toMatchObject({ + status: 422, + message: expect.stringContaining('cancellation'), + }); + }); + + it('uses a vendor-cased nested commitment id for cancellation authorization', async () => { + mockOrder(); + mockSubscriptionSnapshot({ raw: { commitmentTerm: { commitmentTermID: 'blocked' } } }); + mockDependencies({ + commitments: [ + { id: 'allowed', allowForQuantityDecrease: true, allowForQuantityIncrease: true, allowForEarlyCancellation: true }, + { id: 'blocked', allowForQuantityDecrease: true, allowForQuantityIncrease: true, allowForEarlyCancellation: false }, + ], + }); + insertReturningOnce([{ id: 'wrongly-authorized-line' }]); + + await expect(addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'cancel', targetSubscriptionId: 'sub-1', + })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('cancellation') }); + }); + + it('closes every partner DB context before awaiting the dependency HTTP call', async () => { + mockOrder({ source: 'direct' }); + mockCompanyMappingLookup({ pax8CompanyId: 'co-1', integrationId: 'i1' }); + mockSubscriptionSnapshot({ raw: { commitmentId: 'c1' } }); + let resolveDependencies!: (value: { commitments: Array> }) => void; + let clientLookupDepth = -1; + mocks.createPax8ClientForIntegration.mockImplementationOnce(async () => { + clientLookupDepth = mocks.contextDepth; + return { integration: { id: 'i1', partnerId: 'p1' }, client: { getProductDependencies: mocks.getProductDependencies } }; + }); + let httpDepth = -1; + let contextExitsAtHttp = -1; + mocks.getProductDependencies.mockImplementationOnce(() => { + httpDepth = mocks.contextDepth; + contextExitsAtHttp = mocks.contextExits; + return new Promise((resolve) => { resolveDependencies = resolve; }); + }); + mockOrder({ source: 'direct' }); + const finalMapping = mockCompanyMappingLookup({ pax8CompanyId: 'co-1', integrationId: 'i1' }); + selectRowsOnce([{ contractLineId: 'contract-line-1', manualQuantity: '10.00' }]); + selectRowsOnce([{ maxSortOrder: null }]); + const insert = insertReturningOnce([{ id: 'line-1', orderId: 'ord-1', partnerId: 'p1', orgId: 'o1', action: 'change_quantity' }]); + + const pending = addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'change_quantity', + targetSubscriptionId: 'sub-1', quantity: '5.00', + }); + await vi.waitFor(() => expect(mocks.getProductDependencies).toHaveBeenCalledTimes(1)); + + const insertStartedBeforeHttpResolved = mocks.db.insert.mock.calls.length > 0; + resolveDependencies({ + commitments: [{ id: 'c1', allowForQuantityDecrease: true, allowForQuantityIncrease: true, allowForEarlyCancellation: true }], + }); + await expect(pending).resolves.toMatchObject({ id: 'line-1' }); + + expect(clientLookupDepth).toBe(1); + expect(httpDepth).toBe(0); + expect(contextExitsAtHttp).toBe(5); + expect(insertStartedBeforeHttpResolved).toBe(false); + expect(mocks.contextDepth).toBe(0); + expect(mocks.contextExits).toBe(6); + expect(insert.values).toHaveBeenCalledWith(expect.objectContaining({ + contractLineId: 'contract-line-1', + authorizedBaselineQuantity: '10.00', + })); + expect(finalMapping.for).toHaveBeenCalledWith('share'); + for (const [context] of mocks.withDbAccessContext.mock.calls) { + expect(context).toMatchObject({ + scope: 'partner', + accessiblePartnerIds: ['p1'], + currentPartnerId: 'p1', + }); + } + }); + + it('rejects a new subscription without a product', async () => { + mockOrder(); + + await expect(addOrderLine({ + partnerId: 'p1', + orderId: 'ord-1', + action: 'new_subscription', + billingTerm: 'Monthly', + quantity: '1.00', + })).rejects.toMatchObject({ + status: 422, + message: expect.stringContaining('product'), + }); + }); + + it('rejects a billing term that does not exactly match the shared vocabulary', async () => { + mockOrder(); + + await expect(addOrderLine({ + partnerId: 'p1', + orderId: 'ord-1', + action: 'new_subscription', + pax8ProductId: 'prod-1', + billingTerm: 'monthly' as never, + quantity: '1.00', + })).rejects.toMatchObject({ + status: 422, + message: expect.stringContaining('billing term'), + }); + }); + + it('rejects a new subscription with a non-positive quantity', async () => { + mockOrder(); + + await expect(addOrderLine({ + partnerId: 'p1', + orderId: 'ord-1', + action: 'new_subscription', + pax8ProductId: 'prod-1', + billingTerm: 'Monthly', + quantity: '0', + })).rejects.toMatchObject({ + status: 422, + message: expect.stringContaining('greater than zero'), + }); + }); + + it('rejects a change_quantity without a target subscription', async () => { + mockOrder(); + + await expect(addOrderLine({ + partnerId: 'p1', + orderId: 'ord-1', + action: 'change_quantity', + quantity: '5.00', + })).rejects.toMatchObject({ + status: 422, + message: expect.stringContaining('target subscription'), + }); + }); + + it('rejects a cancel action that includes a quantity', async () => { + mockOrder(); + + await expect(addOrderLine({ + partnerId: 'p1', + orderId: 'ord-1', + action: 'cancel', + targetSubscriptionId: 'sub-1', + quantity: '1.00', + })).rejects.toMatchObject({ + status: 422, + message: expect.stringContaining('must not include a quantity'), + }); + }); + + it('inserts a valid new subscription line with order tenancy fields', async () => { + mockOrder({ status: 'awaiting_details' }); + selectRowsOnce([{ pax8ProductId: 'prod-1', catalogItemId: 'catalog-1' }]); + mockOrder({ status: 'awaiting_details' }); + selectRowsOnce([{ pax8ProductId: 'prod-1', catalogItemId: 'catalog-1' }]); + selectRowsOnce([{ maxSortOrder: null }]); + const insert = insertReturningOnce([{ + id: 'line-1', + orderId: 'ord-1', + partnerId: 'p1', + orgId: 'o1', + action: 'new_subscription', + }]); + + const line = await addOrderLine({ + partnerId: 'p1', + orderId: 'ord-1', + action: 'new_subscription', + pax8ProductId: 'prod-1', + catalogItemId: 'catalog-1', + billingTerm: 'Annual', + quantity: '2.00', + provisioningDetails: [{ key: 'domain', values: ['example.com'] }], + }); + + expect(line.id).toBe('line-1'); + expect(insert.values).toHaveBeenCalledWith(expect.objectContaining({ + orderId: 'ord-1', + partnerId: 'p1', + orgId: 'o1', + action: 'new_subscription', + submitState: 'pending', + sortOrder: 0, + })); + }); + + it('allocates distinct deterministic positions for consecutive direct lines', async () => { + mockOrder(); + selectRowsOnce([{ pax8ProductId: 'prod-1', catalogItemId: 'catalog-1' }]); + mockOrder(); + selectRowsOnce([{ pax8ProductId: 'prod-1', catalogItemId: 'catalog-1' }]); + selectRowsOnce([{ maxSortOrder: null }]); + mockOrder(); + selectRowsOnce([{ pax8ProductId: 'prod-2', catalogItemId: 'catalog-2' }]); + mockOrder(); + selectRowsOnce([{ pax8ProductId: 'prod-2', catalogItemId: 'catalog-2' }]); + selectRowsOnce([{ maxSortOrder: 0 }]); + const firstInsert = insertReturningOnce([{ id: 'line-1' }]); + const secondInsert = insertReturningOnce([{ id: 'line-2' }]); + + await addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'new_subscription', + pax8ProductId: 'prod-1', catalogItemId: 'catalog-1', billingTerm: 'Monthly', quantity: '1.00', + }); + await addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'new_subscription', + pax8ProductId: 'prod-2', catalogItemId: 'catalog-2', billingTerm: 'Monthly', quantity: '2.00', + }); + + expect(firstInsert.values).toHaveBeenCalledWith(expect.objectContaining({ sortOrder: 0 })); + expect(secondInsert.values).toHaveBeenCalledWith(expect.objectContaining({ sortOrder: 1 })); + }); + + it('rejects when the order becomes immutable before the final insert', async () => { + mockOrder({ status: 'draft' }); + selectRowsOnce([{ pax8ProductId: 'prod-1', catalogItemId: 'catalog-1' }]); + const finalOrderQuery = mockOrder({ status: 'submitting' }); + insertReturningOnce([{ id: 'wrongly-inserted-line' }]); + + await expect(addOrderLine({ + partnerId: 'p1', + orderId: 'ord-1', + action: 'new_subscription', + pax8ProductId: 'prod-1', + catalogItemId: 'catalog-1', + billingTerm: 'Monthly', + quantity: '1.00', + })).rejects.toMatchObject({ status: 409 }); + + expect(mocks.db.insert).not.toHaveBeenCalled(); + expect(finalOrderQuery.for).toHaveBeenCalledWith('update'); + }); +}); + +describe('removeOrderLine', () => { + it('rejects removal from a quote-staged order even while mutable', async () => { + mockOrder({ source: 'quote', status: 'awaiting_details' }); + + await expect(removeOrderLine({ partnerId: 'p1', orderId: 'ord-1', lineId: 'line-1' })) + .rejects.toMatchObject({ status: 409, message: expect.stringContaining('quote') }); + expect(mocks.db.delete).not.toHaveBeenCalled(); + }); + it('refuses to remove a line from an immutable order', async () => { + mockOrder({ status: 'ready' }); + + await expect(removeOrderLine({ partnerId: 'p1', orderId: 'ord-1', lineId: 'line-1' })) + .rejects.toMatchObject({ status: 409 }); + }); + + it('rejects without deleting when the order becomes immutable before deletion', async () => { + const transitioningOrder = { ...baseOrder, status: 'draft' }; + const lockedOrderQuery = selectRowsOnce([transitioningOrder]); + mocks.withDbAccessContext.mockImplementationOnce(async (_context: unknown, fn: () => unknown) => { + transitioningOrder.status = 'submitting'; + return fn(); + }); + deleteReturningOnce([{ id: 'wrongly-deleted-line' }]); + + await expect(removeOrderLine({ partnerId: 'p1', orderId: 'ord-1', lineId: 'line-1' })) + .rejects.toMatchObject({ status: 409 }); + + expect(mocks.db.delete).not.toHaveBeenCalled(); + expect(lockedOrderQuery.for).toHaveBeenCalledWith('update'); + }); + + it('deletes only the partner and order-scoped line', async () => { + mockOrder(); + deleteReturningOnce([{ id: 'line-1' }]); + + await expect(removeOrderLine({ partnerId: 'p1', orderId: 'ord-1', lineId: 'line-1' })) + .resolves.toEqual({ removed: true }); + }); +}); + +describe('updateOrderLine', () => { + function updateReturningOnce(rows: unknown[]) { + const returning = vi.fn(async () => rows); + const where = vi.fn(() => ({ returning })); + const set = vi.fn(() => ({ where })); + mocks.db.update.mockReturnValueOnce({ set }); + return { set, where, returning }; + } + + it('locks the mutable parent and updates only editable new-subscription details', async () => { + const orderQuery = mockOrder({ status: 'awaiting_details' }); + const lineQuery = selectRowsOnce([{ + id: 'line-1', orderId: 'ord-1', partnerId: 'p1', orgId: 'o1', + action: 'new_subscription', quantity: '2.00', billingTerm: 'Annual', + }]); + const update = updateReturningOnce([{ id: 'line-1', commitmentTermId: 'commit-2' }]); + + await expect(updateOrderLine({ + partnerId: 'p1', orderId: 'ord-1', lineId: 'line-1', + commitmentTermId: 'commit-2', + provisioningDetails: [{ key: 'domain', values: ['acme.example'] }], + })).resolves.toMatchObject({ id: 'line-1' }); + + expect(orderQuery.for).toHaveBeenCalledWith('update'); + expect(lineQuery.for).toHaveBeenCalledWith('update'); + expect(update.set).toHaveBeenCalledWith({ + commitmentTermId: 'commit-2', + provisioningDetails: [{ key: 'domain', values: ['acme.example'] }], + }); + }); + + it('rejects an immutable-order race before writing', async () => { + mockOrder({ status: 'submitting' }); + + await expect(updateOrderLine({ + partnerId: 'p1', orderId: 'ord-1', lineId: 'line-1', provisioningDetails: [], + })).rejects.toMatchObject({ status: 409 }); + expect(mocks.db.update).not.toHaveBeenCalled(); + }); + + it('rejects editing a non-new-subscription line', async () => { + mockOrder({ status: 'draft' }); + selectRowsOnce([{ + id: 'line-1', orderId: 'ord-1', partnerId: 'p1', orgId: 'o1', action: 'cancel', + }]); + + await expect(updateOrderLine({ + partnerId: 'p1', orderId: 'ord-1', lineId: 'line-1', provisioningDetails: [], + })).rejects.toMatchObject({ status: 422 }); + expect(mocks.db.update).not.toHaveBeenCalled(); + }); +}); + +describe('getOrderWithLines', () => { + it('returns a partner-scoped order and its partner-scoped lines', async () => { + mockOrder(); + selectRowsOnce([{ id: 'line-1', orderId: 'ord-1', partnerId: 'p1' }]); + + await expect(getOrderWithLines({ partnerId: 'p1', orderId: 'ord-1' })) + .resolves.toMatchObject({ + order: { id: 'ord-1' }, + lines: [{ id: 'line-1' }], + }); + }); + + it('returns 404 when the partner-scoped order does not exist', async () => { + selectRowsOnce([]); + + await expect(getOrderWithLines({ partnerId: 'p1', orderId: 'ord-1' })) + .rejects.toMatchObject({ status: 404 }); + }); +}); + +describe('buildDedupeKey', () => { + it('is stable for the same order', () => { + expect(buildDedupeKey('ord-1')).toBe(buildDedupeKey('ord-1')); + expect(buildDedupeKey('ord-1')).toBe('order:ord-1'); + }); +}); diff --git a/apps/api/src/services/pax8OrderService.ts b/apps/api/src/services/pax8OrderService.ts new file mode 100644 index 0000000000..21aca451b6 --- /dev/null +++ b/apps/api/src/services/pax8OrderService.ts @@ -0,0 +1,942 @@ +import { randomUUID } from 'node:crypto'; +import { PAX8_BILLING_TERMS, type Pax8BillingTerm, type Pax8OrderAction } from '@breeze/shared'; +import { and, asc, desc, eq, inArray, max, notInArray, sql, type SQL } from 'drizzle-orm'; +import { + db, + runOutsideDbContext, + withDbAccessContext, + type DbAccessContext, +} from '../db'; +import { + pax8CompanyMappings, + pax8Integrations, + pax8OrderLines, + pax8Orders, + pax8ProductMappings, + pax8ContractLineLinks, + pax8SubscriptionSnapshots, + catalogItems, + contractLines, +} from '../db/schema'; +import { createPax8ClientForIntegration } from './pax8SyncService'; +import { pax8CompanyOrderReadiness } from './pax8CompanyReadiness'; +import type { Pax8Commitment } from './pax8Client'; + +export class Pax8OrderError extends Error { + constructor( + message: string, + public readonly status: 400 | 403 | 404 | 409 | 422, + ) { + super(message); + this.name = 'Pax8OrderError'; + } +} + +export class Pax8OrderRestageRequiredError extends Pax8OrderError { + constructor(message: string) { + super(message, 409); + this.name = 'Pax8OrderRestageRequiredError'; + } +} + +export type Pax8OrderRow = typeof pax8Orders.$inferSelect; +export type Pax8OrderLineRow = typeof pax8OrderLines.$inferSelect; + +export interface AddOrderLineInput { + partnerId: string; + orderId: string; + action: Pax8OrderAction; + pax8ProductId?: string; + catalogItemId?: string; + billingTerm?: Pax8BillingTerm; + commitmentTermId?: string; + quantity?: string; + provisioningDetails?: Array<{ key: string; values: string[] }>; + targetSubscriptionId?: string; + cancelDate?: string; + contractLineId?: string; + sourceQuoteLineId?: string; +} + +/** Stable per-order. The unique index on (partner_id, dedupe_key) is what makes + * a concurrent submit lose the race — see pax8OrderSubmit.claimLine. */ +export function buildDedupeKey(orderId: string): string { + return `order:${orderId}`; +} + +const MUTABLE_STATUSES = new Set(['draft', 'awaiting_details']); +const BILLING_TERMS = new Set(PAX8_BILLING_TERMS); +const MUTABLE_DIRECT_ORDER_UNIQUE_INDEX = 'pax8_orders_one_mutable_direct_per_org_uq'; + +function partnerDbContext(partnerId: string, orgId?: string): DbAccessContext { + return { + scope: 'partner', + orgId: orgId ?? null, + accessibleOrgIds: orgId ? [orgId] : null, + accessiblePartnerIds: [partnerId], + userId: null, + currentPartnerId: partnerId, + }; +} + +/** + * Pax8 line authoring is a self-managed route with no ambient request + * transaction. Exit defensively before opening each short partner context so + * an accidental ambient caller still cannot make these phases reuse its tx. + */ +function withPartnerDbContext(partnerId: string, fn: () => Promise, orgId?: string): Promise { + return runOutsideDbContext(() => withDbAccessContext(partnerDbContext(partnerId, orgId), fn)); +} + +function requireMutableOrder(order: Pax8OrderRow): void { + if (!MUTABLE_STATUSES.has(order.status)) { + throw new Pax8OrderError('Only draft or awaiting-details Pax8 orders can be modified.', 409); + } +} + +function requirePubliclyMutableOrder(order: Pax8OrderRow): void { + requireMutableOrder(order); + if (order.source === 'quote') { + throw new Pax8OrderError( + 'quote-staged Pax8 order lines are immutable; only provisioning details may be updated.', + 409, + ); + } +} + +function utcDateString(now = new Date()): string { + return now.toISOString().slice(0, 10); +} + +export function requireImmediateCancelDate(cancelDate: string | null | undefined, now = new Date()): void { + if (!cancelDate) return; + const parsed = new Date(`${cancelDate}T00:00:00.000Z`); + if (!/^\d{4}-\d{2}-\d{2}$/.test(cancelDate) + || Number.isNaN(parsed.getTime()) + || parsed.toISOString().slice(0, 10) !== cancelDate) { + throw new Pax8OrderError('Cancellation date must be a valid UTC calendar date.', 422); + } + if (cancelDate > utcDateString(now)) { + throw new Pax8OrderError('future-dated Pax8 cancellations are not supported.', 422); + } +} + +async function loadOrder(partnerId: string, orderId: string): Promise { + const [order] = await db + .select() + .from(pax8Orders) + .where(and(eq(pax8Orders.partnerId, partnerId), eq(pax8Orders.id, orderId))) + .limit(1); + if (!order) throw new Pax8OrderError('Pax8 order not found.', 404); + return order; +} + +async function findMutableDirectOrder(partnerId: string, orgId: string): Promise { + const [order] = await db + .select() + .from(pax8Orders) + .where(and( + eq(pax8Orders.partnerId, partnerId), + eq(pax8Orders.orgId, orgId), + eq(pax8Orders.source, 'direct'), + inArray(pax8Orders.status, [...MUTABLE_STATUSES]), + )) + .limit(1); + return order; +} + +function isUniqueViolation(error: unknown, constraint: string): boolean { + let candidate: unknown = error; + for (let depth = 0; candidate && depth < 5; depth += 1) { + if (typeof candidate !== 'object') break; + const details = candidate as { + code?: unknown; + constraint_name?: unknown; + message?: unknown; + cause?: unknown; + }; + if (details.code === '23505' + && (details.constraint_name === constraint || typeof details.constraint_name !== 'string')) { + return true; + } + if (typeof details.message === 'string' && details.message.includes(constraint)) return true; + candidate = details.cause; + } + return false; +} + +function numericQuantity(value: string | undefined): number | null { + if (value === undefined || value.trim() === '') return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function sameNumericQuantity(left: string | null | undefined, right: string | null | undefined): boolean { + if (left === null || left === undefined || right === null || right === undefined) return false; + const leftNumber = Number(left); + const rightNumber = Number(right); + return Number.isFinite(leftNumber) && Number.isFinite(rightNumber) && leftNumber === rightNumber; +} + +type JsonRecord = Record; + +const COMMITMENT_ID_KEYS = [ + 'commitmentTermId', + 'commitmentTermID', + 'commitment_term_id', + 'commitmentId', + 'commitmentID', + 'commitment_id', +] as const; + +const COMMITMENT_CONTAINERS = [ + 'commitment', + 'commitmentTerm', + 'commitment_term', + 'commitmentDetails', + 'commitmentDependency', +] as const; + +function asRecord(value: unknown): JsonRecord | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as JsonRecord; +} + +function nonEmptyString(value: unknown): string | null { + if (typeof value === 'string' && value.trim()) return value.trim(); + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + return null; +} + +function collectCommitmentIds( + record: JsonRecord, + ids: Set, + options: { allowGenericId: boolean; allowEnvelopes: boolean }, + seen = new Set(), +): void { + if (seen.has(record)) return; + seen.add(record); + + for (const key of COMMITMENT_ID_KEYS) { + const id = nonEmptyString(record[key]); + if (id) ids.add(id); + } + if (options.allowGenericId) { + const id = nonEmptyString(record.id); + if (id) ids.add(id); + } + + for (const key of COMMITMENT_CONTAINERS) { + const nested = asRecord(record[key]); + if (!nested) continue; + collectCommitmentIds(nested, ids, { allowGenericId: true, allowEnvelopes: false }, seen); + } + + // Some Pax8 payloads wrap the subscription details one level below the + // response item. Restrict recursion to named envelopes so product/company + // IDs can never be mistaken for a commitment ID. + if (options.allowEnvelopes) { + for (const key of ['subscription', 'details'] as const) { + const nested = asRecord(record[key]); + if (!nested) continue; + collectCommitmentIds(nested, ids, { allowGenericId: false, allowEnvelopes: false }, seen); + } + } +} + +function activeCommitmentIds(raw: unknown): string[] { + const record = asRecord(raw); + if (!record) return []; + const ids = new Set(); + collectCommitmentIds(record, ids, { allowGenericId: false, allowEnvelopes: true }); + return [...ids]; +} + +export function snapshotActiveCommitmentEvidence(raw: unknown): { + activeCommitmentId: string | null; + activeCommitmentAmbiguous: boolean; +} { + const ids = activeCommitmentIds(raw); + return { + activeCommitmentId: ids.length === 1 ? ids[0]! : null, + activeCommitmentAmbiguous: ids.length > 1, + }; +} + +function activeCommitment(raw: unknown, commitments: Pax8Commitment[]): Pax8Commitment { + const evidence = snapshotActiveCommitmentEvidence(raw); + if (evidence.activeCommitmentAmbiguous) { + throw new Pax8OrderError( + 'The Pax8 subscription snapshot contains ambiguous active commitment identifiers.', + 422, + ); + } + const activeId = evidence.activeCommitmentId; + if (activeId) { + const matches = commitments.filter((commitment) => commitment.id === activeId); + if (matches.length === 1) return matches[0]!; + if (matches.length > 1) { + throw new Pax8OrderError( + `Pax8 returned ambiguous dependency entries for the active commitment (${activeId}).`, + 422, + ); + } + throw new Pax8OrderError( + `The active Pax8 commitment (${activeId}) was not present in the product dependencies. Refresh Pax8 data before ordering.`, + 422, + ); + } + if (commitments.length === 1) return commitments[0]!; + if (commitments.length === 0) { + throw new Pax8OrderError('Pax8 returned no commitment details for the target subscription.', 422); + } + throw new Pax8OrderError( + 'Unable to determine the active commitment from the Pax8 subscription snapshot.', + 422, + ); +} + +function validateActionPayload(input: AddOrderLineInput): void { + if (input.billingTerm !== undefined && !BILLING_TERMS.has(input.billingTerm)) { + throw new Pax8OrderError( + `Invalid Pax8 billing term. Expected one of: ${PAX8_BILLING_TERMS.join(', ')}.`, + 422, + ); + } + + switch (input.action) { + case 'new_subscription': { + if (!input.pax8ProductId) { + throw new Pax8OrderError('A Pax8 product is required for a new subscription.', 422); + } + if (!input.billingTerm) { + throw new Pax8OrderError('A valid Pax8 billing term is required for a new subscription.', 422); + } + const quantity = numericQuantity(input.quantity); + if (quantity === null || quantity <= 0) { + throw new Pax8OrderError('New subscription quantity must be greater than zero.', 422); + } + if (input.targetSubscriptionId) { + throw new Pax8OrderError('A new subscription must not target an existing subscription.', 422); + } + if (input.contractLineId) { + throw new Pax8OrderError('A direct new subscription cannot supply a contract line.', 422); + } + return; + } + case 'change_quantity': { + if (!input.targetSubscriptionId) { + throw new Pax8OrderError('A target subscription is required to change quantity.', 422); + } + const quantity = numericQuantity(input.quantity); + if (quantity === null || quantity < 0) { + throw new Pax8OrderError('Changed subscription quantity must be zero or greater.', 422); + } + return; + } + case 'cancel': + if (!input.targetSubscriptionId) { + throw new Pax8OrderError('A target subscription is required to cancel.', 422); + } + if (input.quantity !== undefined) { + throw new Pax8OrderError('A cancellation must not include a quantity.', 422); + } + requireImmediateCancelDate(input.cancelDate); + return; + default: + throw new Pax8OrderError('Unsupported Pax8 order action.', 422); + } +} + +type IntegrityReader = Pick; + +interface DirectProductMapping { + pax8ProductId: string; + catalogItemId: string; +} + +async function currentDirectProductMapping( + reader: IntegrityReader, + order: Pax8OrderRow, + input: { pax8ProductId: string; catalogItemId: string }, + lock: boolean, +): Promise { + const query = reader + .select({ + pax8ProductId: pax8ProductMappings.pax8ProductId, + catalogItemId: pax8ProductMappings.catalogItemId, + }) + .from(pax8ProductMappings) + .innerJoin(pax8Integrations, and( + eq(pax8ProductMappings.integrationId, pax8Integrations.id), + eq(pax8ProductMappings.partnerId, pax8Integrations.partnerId), + )) + .innerJoin(catalogItems, and( + eq(pax8ProductMappings.catalogItemId, catalogItems.id), + eq(pax8ProductMappings.partnerId, catalogItems.partnerId), + )) + .where(and( + eq(pax8ProductMappings.integrationId, order.integrationId), + eq(pax8ProductMappings.partnerId, order.partnerId), + eq(pax8ProductMappings.pax8ProductId, input.pax8ProductId), + eq(pax8ProductMappings.catalogItemId, input.catalogItemId), + eq(pax8Integrations.isActive, true), + eq(catalogItems.isActive, true), + )); + const rows = lock ? await query.for('share') : await query; + if (rows.length !== 1 || !rows[0]!.catalogItemId) { + throw new Pax8OrderError( + 'Select an active Pax8 product that is mapped to the supplied active catalog item.', + 422, + ); + } + return rows[0] as DirectProductMapping; +} + +interface LinkedManualContractLine { + contractLineId: string; + manualQuantity: string; +} + +async function currentLinkedManualContractLine( + reader: IntegrityReader, + order: Pax8OrderRow, + targetSubscriptionId: string, + lock: boolean, +): Promise { + const query = reader + .select({ + contractLineId: pax8ContractLineLinks.contractLineId, + manualQuantity: contractLines.manualQuantity, + }) + .from(pax8ContractLineLinks) + .innerJoin(pax8SubscriptionSnapshots, and( + eq(pax8ContractLineLinks.subscriptionSnapshotId, pax8SubscriptionSnapshots.id), + eq(pax8ContractLineLinks.integrationId, pax8SubscriptionSnapshots.integrationId), + eq(pax8ContractLineLinks.partnerId, pax8SubscriptionSnapshots.partnerId), + eq(pax8ContractLineLinks.orgId, pax8SubscriptionSnapshots.orgId), + )) + .innerJoin(contractLines, and( + eq(pax8ContractLineLinks.contractLineId, contractLines.id), + eq(pax8ContractLineLinks.orgId, contractLines.orgId), + )) + .where(and( + eq(pax8ContractLineLinks.integrationId, order.integrationId), + eq(pax8ContractLineLinks.partnerId, order.partnerId), + eq(pax8ContractLineLinks.orgId, order.orgId), + eq(pax8SubscriptionSnapshots.pax8SubscriptionId, targetSubscriptionId), + eq(contractLines.lineType, 'manual' as never), + )); + const rows = lock ? await query.for('share') : await query; + if (rows.length !== 1 || rows[0]!.manualQuantity === null) { + throw new Pax8OrderError( + 'The target Pax8 subscription must have exactly one linked Breeze manual contract quantity.', + 422, + ); + } + return rows[0] as LinkedManualContractLine; +} + +export async function validateDirectOrderLinesForSubmit( + order: Pax8OrderRow, + lines: Pax8OrderLineRow[], +): Promise { + const validated: Pax8OrderLineRow[] = []; + for (const line of lines) { + if (line.action === 'new_subscription') { + if (order.source === 'quote') { + validated.push(line); + continue; + } + if (!line.pax8ProductId || !line.catalogItemId) { + throw new Pax8OrderError('A direct subscription requires a mapped catalog item.', 422); + } + await currentDirectProductMapping(db, order, { + pax8ProductId: line.pax8ProductId, + catalogItemId: line.catalogItemId, + }, true); + if (line.contractLineId) { + throw new Pax8OrderError('A direct new subscription cannot carry a contract line.', 422); + } + validated.push(line); + continue; + } + if (!line.targetSubscriptionId) { + throw new Pax8OrderError('A subscription action has no target subscription.', 422); + } + requireImmediateCancelDate(line.action === 'cancel' ? line.cancelDate : null); + const linked = await currentLinkedManualContractLine(db, order, line.targetSubscriptionId, true); + if (line.contractLineId !== linked.contractLineId) { + throw new Pax8OrderError('The staged contract line no longer matches the target Pax8 subscription.', 409); + } + if (line.action === 'change_quantity') { + if (line.authorizedBaselineQuantity === null) { + throw new Pax8OrderRestageRequiredError( + 'This legacy quantity change has no authorization baseline; remove and stage it again.', + ); + } + if (!sameNumericQuantity(line.authorizedBaselineQuantity, linked.manualQuantity)) { + throw new Pax8OrderRestageRequiredError( + 'The linked Breeze contract quantity changed since this Pax8 action was authorized; remove and stage it again.', + ); + } + } + validated.push({ ...line, contractLineId: linked.contractLineId }); + } + return validated; +} + +function requireCompanyOrderReady(mapping: { + status?: string | null; + metadata?: unknown; +}): void { + if (!pax8CompanyOrderReadiness(mapping.status, mapping.metadata).orderReady) { + throw new Pax8OrderError( + 'The mapped Pax8 company is not ready for ordering. It must be Active with primary Admin, Billing, and Technical contacts.', + 422, + ); + } +} + +type CompanyMappingReader = Pick; + +async function currentCompanyMappings( + reader: CompanyMappingReader, + scope: { integrationId: string; partnerId: string; orgId: string }, + lock: boolean, +) { + const query = reader + .select({ + integrationId: pax8CompanyMappings.integrationId, + pax8CompanyId: pax8CompanyMappings.pax8CompanyId, + status: pax8CompanyMappings.status, + metadata: pax8CompanyMappings.metadata, + }) + .from(pax8CompanyMappings) + .where(and( + eq(pax8CompanyMappings.integrationId, scope.integrationId), + eq(pax8CompanyMappings.partnerId, scope.partnerId), + eq(pax8CompanyMappings.orgId, scope.orgId), + eq(pax8CompanyMappings.ignored, false), + )); + return lock ? query.for('share') : query; +} + +async function requireCurrentCompanyOrderReady( + order: Pax8OrderRow, + options: { reader?: CompanyMappingReader; lock?: boolean } = {}, +): Promise { + const mappings = await currentCompanyMappings(options.reader ?? db, order, options.lock ?? false); + if (mappings.length !== 1) { + throw new Pax8OrderError('Resolve the Pax8 company mapping before staging this order.', 422); + } + requireCompanyOrderReady(mappings[0]!); +} + +export async function getOrCreateDraftOrder(input: { + partnerId: string; + orgId: string; + actorUserId: string; +}): Promise { + const [mapping] = await db + .select() + .from(pax8CompanyMappings) + .where(and( + eq(pax8CompanyMappings.partnerId, input.partnerId), + eq(pax8CompanyMappings.orgId, input.orgId), + eq(pax8CompanyMappings.ignored, false), + )) + .limit(1); + + if (!mapping?.orgId) { + throw new Pax8OrderError( + 'This organization is not mapped to a Pax8 company. Map it before ordering.', + 409, + ); + } + requireCompanyOrderReady(mapping); + + const existing = await findMutableDirectOrder(input.partnerId, input.orgId); + if (existing) return existing; + + const id = randomUUID(); + try { + // A nested transaction gives an ambient request transaction a SAVEPOINT. + // Without it, a handled 23505 would leave the request transaction aborted + // and the winner re-read below would fail with 25P02. + const [created] = await db.transaction(async (tx) => { + const finalMappings = await currentCompanyMappings(tx, { + integrationId: mapping.integrationId, + partnerId: input.partnerId, + orgId: input.orgId, + }, true); + if (finalMappings.length !== 1) { + throw new Pax8OrderError('Resolve the Pax8 company mapping before creating this order.', 422); + } + const finalMapping = finalMappings[0]!; + requireCompanyOrderReady(finalMapping); + return tx.insert(pax8Orders).values({ + id, + integrationId: finalMapping.integrationId, + partnerId: input.partnerId, + orgId: input.orgId, + pax8CompanyId: finalMapping.pax8CompanyId, + status: 'draft', + source: 'direct', + dedupeKey: buildDedupeKey(id), + createdBy: input.actorUserId, + }).returning(); + }); + if (!created) throw new Pax8OrderError('The Pax8 draft order could not be created.', 409); + return created; + } catch (error) { + if (!isUniqueViolation(error, MUTABLE_DIRECT_ORDER_UNIQUE_INDEX)) throw error; + const winner = await findMutableDirectOrder(input.partnerId, input.orgId); + if (winner) return winner; + throw error; + } +} + +export async function addOrderLine(input: AddOrderLineInput): Promise { + const order = await withPartnerDbContext(input.partnerId, () => + loadOrder(input.partnerId, input.orderId)); + requirePubliclyMutableOrder(order); + if (order.source === 'direct') { + await withPartnerDbContext(input.partnerId, () => requireCurrentCompanyOrderReady(order)); + } + validateActionPayload(input); + + let derivedContractLine: LinkedManualContractLine | null = null; + let authoringBaseline: string | null = null; + if (input.action === 'new_subscription') { + if (!input.catalogItemId) { + throw new Pax8OrderError('A mapped catalog item is required for a direct subscription.', 422); + } + await withPartnerDbContext(input.partnerId, () => currentDirectProductMapping(db, order, { + pax8ProductId: input.pax8ProductId!, + catalogItemId: input.catalogItemId!, + }, false)); + } else { + const [snapshot] = await withPartnerDbContext(input.partnerId, () => db + .select() + .from(pax8SubscriptionSnapshots) + .where(and( + eq(pax8SubscriptionSnapshots.integrationId, order.integrationId), + eq(pax8SubscriptionSnapshots.partnerId, input.partnerId), + eq(pax8SubscriptionSnapshots.pax8SubscriptionId, input.targetSubscriptionId!), + )) + .limit(1)); + if (!snapshot) throw new Pax8OrderError('Pax8 subscription not found.', 404); + if (snapshot.orgId !== order.orgId) { + throw new Pax8OrderError('The target subscription belongs to a different organization.', 403); + } + if (!snapshot.productId) { + throw new Pax8OrderError('The target subscription has no Pax8 product identifier.', 422); + } + derivedContractLine = await withPartnerDbContext(input.partnerId, () => + currentLinkedManualContractLine(db, order, input.targetSubscriptionId!, false), order.orgId); + authoringBaseline = derivedContractLine.manualQuantity; + if (input.contractLineId && input.contractLineId !== derivedContractLine.contractLineId) { + throw new Pax8OrderError('The supplied contract line does not match the target Pax8 subscription.', 422); + } + + const { client } = await withPartnerDbContext(input.partnerId, () => + createPax8ClientForIntegration(order.integrationId)); + const dependencies = await runOutsideDbContext(() => + client.getProductDependencies(snapshot.productId!), + ); + + if (input.action === 'change_quantity') { + const currentQuantity = Number(authoringBaseline); + const requestedQuantity = Number(input.quantity); + if (requestedQuantity < currentQuantity) { + if (!activeCommitment(snapshot.raw, dependencies.commitments).allowForQuantityDecrease) { + throw new Pax8OrderError('This product commitment does not allow a quantity decrease.', 422); + } + } + if (requestedQuantity > currentQuantity) { + if (!activeCommitment(snapshot.raw, dependencies.commitments).allowForQuantityIncrease) { + throw new Pax8OrderError('This product commitment does not allow a quantity increase.', 422); + } + } + } else if (!activeCommitment(snapshot.raw, dependencies.commitments).allowForEarlyCancellation) { + throw new Pax8OrderError('This product commitment does not allow early cancellation.', 422); + } + } + + const [created] = await withPartnerDbContext(input.partnerId, async () => { + // The context is a transaction. Lock and re-check immediately before the + // insert so a submit transition cannot race the earlier validation/HTTP. + const [lockedOrder] = await db + .select() + .from(pax8Orders) + .where(and(eq(pax8Orders.partnerId, input.partnerId), eq(pax8Orders.id, input.orderId))) + .for('update') + .limit(1); + if (!lockedOrder) throw new Pax8OrderError('Pax8 order not found.', 404); + requirePubliclyMutableOrder(lockedOrder); + if (lockedOrder.source === 'direct') { + await requireCurrentCompanyOrderReady(lockedOrder, { lock: true }); + } + + let finalContractLineId: string | undefined; + if (input.action === 'new_subscription') { + await currentDirectProductMapping(db, lockedOrder, { + pax8ProductId: input.pax8ProductId!, + catalogItemId: input.catalogItemId!, + }, true); + } else { + const linked = await currentLinkedManualContractLine( + db, + lockedOrder, + input.targetSubscriptionId!, + true, + ); + if (linked.contractLineId !== derivedContractLine?.contractLineId) { + throw new Pax8OrderError( + 'The Pax8 subscription contract linkage changed while validating this action; stage it again.', + 409, + ); + } + finalContractLineId = linked.contractLineId; + if (input.action === 'change_quantity') { + const initialBaseline = Number(authoringBaseline); + const finalBaseline = Number(linked.manualQuantity); + if (!Number.isFinite(initialBaseline) || !Number.isFinite(finalBaseline)) { + throw new Pax8OrderError('The linked Breeze manual contract quantity is invalid.', 422); + } + // The vendor dependency decision was made against this exact Breeze + // baseline. Any concurrent billing edit invalidates that authorization, + // even if it happens to preserve direction today. + if (initialBaseline !== finalBaseline) { + throw new Pax8OrderError( + 'The Breeze contract quantity changed while validating this action; stage it again.', + 409, + ); + } + } + } + + const [position] = await db + .select({ maxSortOrder: max(pax8OrderLines.sortOrder) }) + .from(pax8OrderLines) + .where(and( + eq(pax8OrderLines.partnerId, lockedOrder.partnerId), + eq(pax8OrderLines.orgId, lockedOrder.orgId), + eq(pax8OrderLines.orderId, lockedOrder.id), + )); + const sortOrder = Number(position?.maxSortOrder ?? -1) + 1; + if (!Number.isSafeInteger(sortOrder) || sortOrder > 100_000) { + throw new Pax8OrderError('The Pax8 order has too many lines.', 422); + } + + return db + .insert(pax8OrderLines) + .values({ + orderId: lockedOrder.id, + partnerId: lockedOrder.partnerId, + orgId: lockedOrder.orgId, + action: input.action, + submitState: 'pending', + pax8ProductId: input.pax8ProductId, + catalogItemId: input.catalogItemId, + billingTerm: input.billingTerm, + commitmentTermId: input.commitmentTermId, + quantity: input.quantity, + authorizedBaselineQuantity: input.action === 'change_quantity' ? authoringBaseline : undefined, + provisioningDetails: input.provisioningDetails ?? [], + targetSubscriptionId: input.targetSubscriptionId, + cancelDate: input.cancelDate, + contractLineId: finalContractLineId, + sourceQuoteLineId: input.sourceQuoteLineId, + sortOrder, + }) + .returning(); + }, order.orgId); + if (!created) throw new Pax8OrderError('The Pax8 order line could not be created.', 409); + return created; +} + +export async function removeOrderLine(input: { + partnerId: string; + orderId: string; + lineId: string; +}): Promise<{ removed: boolean }> { + return withPartnerDbContext(input.partnerId, async () => { + const [order] = await db + .select() + .from(pax8Orders) + .where(and(eq(pax8Orders.partnerId, input.partnerId), eq(pax8Orders.id, input.orderId))) + .for('update') + .limit(1); + if (!order) throw new Pax8OrderError('Pax8 order not found.', 404); + requirePubliclyMutableOrder(order); + + const removed = await db + .delete(pax8OrderLines) + .where(and( + eq(pax8OrderLines.partnerId, input.partnerId), + eq(pax8OrderLines.orderId, input.orderId), + eq(pax8OrderLines.id, input.lineId), + )) + .returning({ id: pax8OrderLines.id }); + return { removed: removed.length > 0 }; + }); +} + +export interface UpdateOrderLineInput { + partnerId: string; + orderId: string; + lineId: string; + commitmentTermId?: string | null; + provisioningDetails?: Array<{ key: string; values: string[] }>; +} + +/** + * Completes quote-staged provisioning details without replacing the immutable + * source/contract linkage. Parent and child are locked in one short partner + * transaction so submit cannot transition the order between validation and + * persistence. + */ +export async function updateOrderLine(input: UpdateOrderLineInput): Promise { + if (input.commitmentTermId === undefined && input.provisioningDetails === undefined) { + throw new Pax8OrderError('No editable Pax8 order line fields were provided.', 422); + } + + return withPartnerDbContext(input.partnerId, async () => { + const [order] = await db + .select() + .from(pax8Orders) + .where(and(eq(pax8Orders.partnerId, input.partnerId), eq(pax8Orders.id, input.orderId))) + .for('update') + .limit(1); + if (!order) throw new Pax8OrderError('Pax8 order not found.', 404); + requireMutableOrder(order); + + const [line] = await db + .select() + .from(pax8OrderLines) + .where(and( + eq(pax8OrderLines.partnerId, input.partnerId), + eq(pax8OrderLines.orgId, order.orgId), + eq(pax8OrderLines.orderId, order.id), + eq(pax8OrderLines.id, input.lineId), + )) + .for('update') + .limit(1); + if (!line) throw new Pax8OrderError('Pax8 order line not found.', 404); + if (line.action !== 'new_subscription') { + throw new Pax8OrderError('Only new-subscription provisioning details can be edited.', 422); + } + + const changes: Pick = {}; + if (input.commitmentTermId !== undefined) changes.commitmentTermId = input.commitmentTermId; + if (input.provisioningDetails !== undefined) changes.provisioningDetails = input.provisioningDetails; + + const [updated] = await db + .update(pax8OrderLines) + .set(changes) + .where(and( + eq(pax8OrderLines.partnerId, input.partnerId), + eq(pax8OrderLines.orderId, order.id), + eq(pax8OrderLines.id, line.id), + )) + .returning(); + if (!updated) throw new Pax8OrderError('The Pax8 order line could not be updated.', 409); + return updated; + }); +} + +export async function getOrderWithLines(input: { + partnerId: string; + orderId: string; +}): Promise<{ order: Pax8OrderRow; lines: Pax8OrderLineRow[] }> { + const order = await loadOrder(input.partnerId, input.orderId); + const lines = await db + .select() + .from(pax8OrderLines) + .where(and( + eq(pax8OrderLines.partnerId, input.partnerId), + eq(pax8OrderLines.orderId, input.orderId), + )) + .orderBy(asc(pax8OrderLines.sortOrder), asc(pax8OrderLines.id)); + return { order, lines }; +} + +/** + * Partner-scoped order history for the ordering UI. An org filter returns that + * org's complete history; the partner-wide queue intentionally excludes only + * terminal completed/cancelled rows so failed and partially-failed work stays + * visible for technician action. Both views are bounded and deterministic. + */ +export async function listPax8Orders(input: { + partnerId: string; + orgId?: string; + accessibleOrgIds?: string[] | null; +}): Promise { + const conditions: SQL[] = [eq(pax8Orders.partnerId, input.partnerId)]; + if (input.orgId) { + conditions.push(eq(pax8Orders.orgId, input.orgId)); + } else { + conditions.push(notInArray(pax8Orders.status, ['completed', 'cancelled'])); + if (input.accessibleOrgIds !== undefined && input.accessibleOrgIds !== null) { + conditions.push(input.accessibleOrgIds.length > 0 + ? inArray(pax8Orders.orgId, input.accessibleOrgIds) + : sql`false`); + } + } + return db + .select() + .from(pax8Orders) + .where(and(...conditions)) + .orderBy(desc(pax8Orders.updatedAt), desc(pax8Orders.id)) + .limit(100); +} + +export interface Pax8ProductOption { + pax8ProductId: string; + catalogItemId: string; + catalogName: string; + catalogSku: string | null; + catalogDescription: string | null; + productName: string | null; + vendorSkuId: string | null; + billingFrequency: string | null; + commitmentTermMonths: number | null; +} + +/** Product choices are entirely local metadata: no Pax8 HTTP and no secrets. */ +export async function listPax8Products(input: { partnerId: string }): Promise { + return db + .select({ + pax8ProductId: pax8ProductMappings.pax8ProductId, + catalogItemId: catalogItems.id, + catalogName: catalogItems.name, + catalogSku: catalogItems.sku, + catalogDescription: catalogItems.description, + productName: pax8ProductMappings.productName, + vendorSkuId: pax8ProductMappings.vendorSkuId, + billingFrequency: catalogItems.billingFrequency, + commitmentTermMonths: catalogItems.commitmentTermMonths, + }) + .from(pax8ProductMappings) + .innerJoin(pax8Integrations, and( + eq(pax8ProductMappings.integrationId, pax8Integrations.id), + eq(pax8ProductMappings.partnerId, pax8Integrations.partnerId), + )) + .innerJoin(catalogItems, and( + eq(pax8ProductMappings.catalogItemId, catalogItems.id), + eq(pax8ProductMappings.partnerId, catalogItems.partnerId), + )) + .where(and( + eq(pax8ProductMappings.partnerId, input.partnerId), + eq(pax8Integrations.isActive, true), + eq(catalogItems.isActive, true), + )) + .orderBy( + asc(catalogItems.name), + asc(pax8ProductMappings.pax8ProductId), + asc(catalogItems.id), + ) + .limit(200); +} diff --git a/apps/api/src/services/pax8OrderSubmit.test.ts b/apps/api/src/services/pax8OrderSubmit.test.ts new file mode 100644 index 0000000000..dab9514413 --- /dev/null +++ b/apps/api/src/services/pax8OrderSubmit.test.ts @@ -0,0 +1,582 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { RunOutsideDbContextFn } from '../db'; +import { Pax8ApiError } from './pax8Client'; +import { + createPax8OrderSubmitService, + type Pax8OrderSubmitRepository, + type SubmitBundle, +} from './pax8OrderSubmit'; + +const newLine = { + id: 'line-new', + orderId: 'ord-1', + partnerId: 'p1', + orgId: 'o1', + action: 'new_subscription', + submitState: 'pending', + pax8ProductId: 'prod-1', + billingTerm: 'Monthly', + commitmentTermId: 'commit-1', + quantity: '7.00', + provisioningDetails: [{ key: 'msDomain', values: ['acme'] }], + targetSubscriptionId: null, + cancelDate: null, + contractLineId: 'contract-line-1', + sortOrder: 0, +} as const; + +const changeLine = { + ...newLine, + id: 'line-change', + action: 'change_quantity', + pax8ProductId: null, + billingTerm: null, + commitmentTermId: null, + quantity: '5.00', + provisioningDetails: [], + targetSubscriptionId: 'sub-existing', + contractLineId: null, + sortOrder: 1, +} as const; + +const duplicateNewLine = { + ...newLine, + id: 'line-new-2', + sortOrder: 1, +} as const; + +const cancelLine = { + ...changeLine, + id: 'line-cancel', + action: 'cancel', + quantity: null, + cancelDate: '2026-09-01', + sortOrder: 2, +} as const; + +function bundle(lines: readonly unknown[] = [newLine]): SubmitBundle { + return { + order: { + id: 'ord-1', + integrationId: 'integration-1', + partnerId: 'p1', + orgId: 'o1', + pax8CompanyId: 'company-1', + status: 'submitting', + createdAt: new Date('2026-07-14T01:00:00Z'), + submittedAt: new Date('2026-07-20T01:00:00Z'), + } as SubmitBundle['order'], + lines: lines as SubmitBundle['lines'], + }; +} + +function resultFor( + lines: Array<{ lineId: string; submitState: 'succeeded' | 'failed' | 'needs_reconcile'; error: string | null }>, +) { + const states = lines.map((line) => line.submitState); + const status = states.every((state) => state === 'succeeded') + ? 'completed' + : states.every((state) => state === 'failed') ? 'failed' : 'partially_failed'; + return { orderId: 'ord-1', status, lines } as const; +} + +function setup( + lines: readonly unknown[] = [newLine], + orderOverrides: Partial = {}, +) { + const client = { + createOrder: vi.fn(), + updateSubscriptionQuantity: vi.fn(), + cancelSubscription: vi.fn(), + listOrders: vi.fn(), + listSubscriptions: vi.fn(), + }; + const claimed = bundle(lines); + claimed.order = { ...claimed.order, ...orderOverrides }; + const repository: Pax8OrderSubmitRepository = { + loadResolvedOrder: vi.fn().mockResolvedValue(claimed), + claimOrder: vi.fn().mockResolvedValue(claimed), + createClient: vi.fn().mockResolvedValue(client), + claimLines: vi.fn().mockResolvedValue(undefined), + persistPreflightFailure: vi.fn().mockImplementation(async (_bundle, errorBody) => + resultFor(claimed.lines.map((line) => ({ + lineId: line.id, + submitState: 'failed', + error: errorBody, + })) as never)), + persistSubmitResults: vi.fn().mockImplementation(async (_bundle, outcomes) => + resultFor(outcomes.map((outcome: any) => ({ + lineId: outcome.lineId, + submitState: outcome.submitState, + error: outcome.error, + })))), + loadReconcileOrder: vi.fn().mockResolvedValue(claimed), + resetUnsentOrder: vi.fn().mockResolvedValue({ resolved: 0, stillUnknown: 0 }), + persistReconcileResults: vi.fn().mockResolvedValue({ resolved: 0, stillUnknown: 0 }), + }; + const outside = vi.fn((fn: () => unknown): unknown => fn()) as unknown as RunOutsideDbContextFn; + return { + client, + repository, + outside, + service: createPax8OrderSubmitService({ repository, runOutsideDbContext: outside }), + }; +} + +beforeEach(() => vi.clearAllMocks()); + +describe('preflightOrder', () => { + it('resolves the mapping before the isMock request and returns the raw 422 body', async () => { + const { service, repository, client, outside } = setup(); + const raw = '{"details":[{"message":"msDomain is required"}]}'; + client.createOrder.mockRejectedValueOnce(new Pax8ApiError('Pax8 API returned 422', 422, raw)); + + await expect(service.preflightOrder({ partnerId: 'p1', orderId: 'ord-1' })) + .resolves.toEqual({ ok: false, errorBody: raw }); + + expect(repository.loadResolvedOrder).toHaveBeenCalledBefore(repository.createClient as any); + expect(client.createOrder).toHaveBeenCalledWith(expect.objectContaining({ companyId: 'company-1' }), { isMock: true }); + expect(outside).toHaveBeenCalledTimes(1); + }); + + it('skips Pax8 when there are no new subscriptions', async () => { + const { service, client, repository } = setup([changeLine]); + await expect(service.preflightOrder({ partnerId: 'p1', orderId: 'ord-1' })) + .resolves.toEqual({ ok: true }); + expect(client.createOrder).not.toHaveBeenCalled(); + expect(repository.createClient).not.toHaveBeenCalled(); + }); +}); + +describe('submitOrder', () => { + it('runs isMock before any real write and aborts on raw 422', async () => { + const { service, client, repository } = setup(); + const raw = '{"details":[{"message":"msDomain is required"}]}'; + client.createOrder.mockRejectedValueOnce(new Pax8ApiError('Pax8 API returned 422', 422, raw)); + + const res = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1' }); + + expect(client.createOrder).toHaveBeenCalledTimes(1); + expect(client.createOrder.mock.calls[0]![1]).toEqual({ isMock: true }); + expect(repository.claimLines).not.toHaveBeenCalled(); + expect(repository.persistPreflightFailure).toHaveBeenCalledWith(expect.anything(), raw); + expect(res.status).toBe('failed'); + expect(res.lines[0]!.error).toContain('msDomain is required'); + }); + + it('terminally fails every mixed-action line when isMock rejects before any real write', async () => { + const { service, client } = setup([newLine, changeLine, cancelLine]); + client.createOrder.mockRejectedValueOnce(new Pax8ApiError('Pax8 API returned 422', 422, 'invalid provisioning')); + + const res = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1' }); + + expect(res.status).toBe('failed'); + expect(res.lines.every((line) => line.submitState === 'failed')).toBe(true); + expect(client.updateSubscriptionQuantity).not.toHaveBeenCalled(); + expect(client.cancelSubscription).not.toHaveBeenCalled(); + }); + + it('claims every line in a committed DB phase before the one real write', async () => { + const { service, client, repository } = setup(); + client.createOrder + .mockResolvedValueOnce({ pax8OrderId: null, lineItems: [] }) + .mockResolvedValueOnce({ pax8OrderId: 'pax-order-1', lineItems: [] }); + + await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1' }); + + expect(repository.claimOrder).toHaveBeenCalledBefore(repository.claimLines as any); + expect(client.createOrder).toHaveBeenCalledTimes(2); + expect((repository.claimLines as any).mock.invocationCallOrder[0]) + .toBeLessThan(client.createOrder.mock.invocationCallOrder[1]!); // before real, after isMock + }); + + it('marks a timeout needs_reconcile and never retries the real write', async () => { + const { service, client } = setup(); + client.createOrder + .mockResolvedValueOnce({ pax8OrderId: null, lineItems: [] }) + .mockRejectedValueOnce(Object.assign(new Error('aborted'), { name: 'AbortError' })); + + const res = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1' }); + + expect(res.lines[0]!.submitState).toBe('needs_reconcile'); + expect(client.createOrder).toHaveBeenCalledTimes(2); + }); + + it('does not issue HTTP when the atomic order claim rejects an ambiguous prior state', async () => { + const { service, repository, client } = setup(); + (repository.claimOrder as any).mockRejectedValueOnce(Object.assign(new Error('Reconcile first'), { status: 409 })); + + await expect(service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1' })) + .rejects.toMatchObject({ status: 409 }); + expect(client.createOrder).not.toHaveBeenCalled(); + }); + + it('records partially_failed when POST succeeds but a quantity PUT gets a definite 422', async () => { + const { service, client } = setup([newLine, changeLine]); + client.createOrder + .mockResolvedValueOnce({ pax8OrderId: null, lineItems: [] }) + .mockResolvedValueOnce({ + pax8OrderId: 'pax-order-1', + lineItems: [{ lineItemNumber: 1, productId: 'prod-1', subscriptionId: 'sub-new' }], + }); + client.updateSubscriptionQuantity.mockRejectedValueOnce( + new Pax8ApiError('Pax8 API returned 422', 422, '{"message":"seat decrease not allowed"}'), + ); + + const res = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1' }); + + expect(res.status).toBe('partially_failed'); + expect(res.lines.find((line) => line.lineId === 'line-new')!.submitState).toBe('succeeded'); + expect(res.lines.find((line) => line.lineId === 'line-change')!.submitState).toBe('failed'); + expect(client.updateSubscriptionQuantity).toHaveBeenCalledTimes(1); + }); + + it('fails closed before HTTP when the expected line claim is incomplete', async () => { + const { service, repository, client } = setup(); + client.createOrder.mockResolvedValueOnce({ pax8OrderId: null, lineItems: [] }); + (repository.claimLines as any).mockRejectedValueOnce(Object.assign(new Error('Incomplete claim'), { status: 409 })); + + await expect(service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1' })) + .rejects.toMatchObject({ status: 409 }); + expect(client.createOrder).toHaveBeenCalledTimes(1); // isMock only + }); + + it('globally consumes create response items for duplicate products', async () => { + const { service, client, repository } = setup([newLine, duplicateNewLine]); + client.createOrder + .mockResolvedValueOnce({ pax8OrderId: null, lineItems: [] }) + .mockResolvedValueOnce({ pax8OrderId: 'pax-order-1', lineItems: [ + { lineItemNumber: 1, productId: 'prod-1', subscriptionId: 'sub-1' }, + { lineItemNumber: 2, productId: 'prod-1', subscriptionId: 'sub-2' }, + ] }); + + const res = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1' }); + + expect(res.lines.every((line) => line.submitState === 'succeeded')).toBe(true); + expect(repository.persistSubmitResults).toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining([ + expect.objectContaining({ lineId: 'line-new', resultSubscriptionId: 'sub-1' }), + expect.objectContaining({ lineId: 'line-new-2', resultSubscriptionId: 'sub-2' }), + ]), + 'pax-order-1', + ); + }); + + it.each([ + { + name: 'partial response', + lineItems: [{ lineItemNumber: 1, productId: 'prod-1', subscriptionId: 'sub-1' }], + }, + { + name: 'mismatched line number and product', + lineItems: [ + { lineItemNumber: 1, productId: 'other-product', subscriptionId: 'sub-1' }, + { lineItemNumber: 2, productId: 'prod-1', subscriptionId: 'sub-2' }, + ], + }, + ])('marks every new line unknown after external success with $name', async ({ lineItems }) => { + const { service, client } = setup([newLine, duplicateNewLine]); + client.createOrder + .mockResolvedValueOnce({ pax8OrderId: null, lineItems: [] }) + .mockResolvedValueOnce({ pax8OrderId: 'pax-order-1', lineItems }); + + const res = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1' }); + + expect(res.lines.every((line) => line.submitState === 'needs_reconcile')).toBe(true); + }); + + it('keeps new lines unknown when the successful response omits the parent order id', async () => { + const { service, client } = setup([newLine]); + client.createOrder + .mockResolvedValueOnce({ pax8OrderId: null, lineItems: [] }) + .mockResolvedValueOnce({ + pax8OrderId: null, + lineItems: [{ lineItemNumber: 1, productId: 'prod-1', subscriptionId: 'sub-1' }], + }); + + const res = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1' }); + + expect(res.lines[0]!.submitState).toBe('needs_reconcile'); + }); + + it.each([ + ['PUT timeout', changeLine, 'updateSubscriptionQuantity', Object.assign(new Error('timeout'), { name: 'AbortError' })], + ['DELETE 503', cancelLine, 'cancelSubscription', new Pax8ApiError('Pax8 API returned 503', 503, 'unavailable')], + ] as const)('does not retry a %s', async (_name, line, method, error) => { + const { service, client } = setup([line]); + client[method].mockRejectedValueOnce(error); + + const res = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1' }); + + expect(client[method]).toHaveBeenCalledTimes(1); + expect(res.lines[0]!.submitState).toBe('needs_reconcile'); + }); + + it('rejects duplicate subscription targets before external writes', async () => { + const secondChange = { ...changeLine, id: 'line-change-2', quantity: '6.00' }; + const { service, repository, client } = setup([changeLine, secondChange]); + (repository.claimOrder as any).mockRejectedValueOnce(Object.assign(new Error('duplicate target'), { status: 422 })); + + await expect(service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1' })) + .rejects.toMatchObject({ status: 422 }); + expect(client.updateSubscriptionQuantity).not.toHaveBeenCalled(); + }); +}); + +describe('reconcileOrder', () => { + it('recovers a submitting order whose pending lines prove no real write was claimed', async () => { + const { service, client, repository } = setup([newLine]); + + await expect(service.reconcileOrder({ partnerId: 'p1', orderId: 'ord-1' })) + .resolves.toEqual({ resolved: 0, stillUnknown: 0 }); + expect(repository.resetUnsentOrder).toHaveBeenCalledWith(expect.anything()); + expect(client.listOrders).not.toHaveBeenCalled(); + expect(client.listSubscriptions).not.toHaveBeenCalled(); + }); + + it('reconciles a line left in_flight by a crash instead of leaving it permanently stuck', async () => { + const crashedLine = { ...changeLine, submitState: 'in_flight' }; + const { service, client, repository } = setup([crashedLine]); + client.listOrders.mockResolvedValueOnce([]); + client.listSubscriptions.mockResolvedValueOnce([{ + pax8SubscriptionId: 'sub-existing', pax8CompanyId: 'company-1', + quantity: '5.00', quantityKnown: true, + }]); + (repository.persistReconcileResults as any).mockResolvedValueOnce({ resolved: 1, stillUnknown: 0 }); + + await expect(service.reconcileOrder({ partnerId: 'p1', orderId: 'ord-1' })) + .resolves.toEqual({ resolved: 1, stillUnknown: 0 }); + expect(repository.persistReconcileResults).toHaveBeenCalledWith( + expect.anything(), + [expect.objectContaining({ lineId: 'line-change', submitState: 'succeeded' })], + null, + ); + }); + + it('uses reads only and leaves ambiguous same-day product matches unknown', async () => { + const unknownLine = { ...newLine, submitState: 'needs_reconcile' }; + const { service, client, repository } = setup([unknownLine]); + client.listOrders.mockResolvedValueOnce([ + { pax8OrderId: 'a', pax8CompanyId: 'company-1', createdDate: '2026-07-20', lineItems: [{ lineItemNumber: 1, productId: 'prod-1', quantity: '7.00', quantityKnown: true, subscriptionId: 's1' }] }, + { pax8OrderId: 'b', pax8CompanyId: 'company-1', createdDate: '2026-07-20', lineItems: [{ lineItemNumber: 1, productId: 'prod-1', quantity: '7.00', quantityKnown: true, subscriptionId: 's2' }] }, + ]); + client.listSubscriptions.mockResolvedValueOnce([]); + (repository.persistReconcileResults as any).mockResolvedValueOnce({ resolved: 0, stillUnknown: 1 }); + + await expect(service.reconcileOrder({ partnerId: 'p1', orderId: 'ord-1' })) + .resolves.toEqual({ resolved: 0, stillUnknown: 1 }); + + expect(client.listOrders).toHaveBeenCalledWith({ companyId: 'company-1' }); + expect(client.listSubscriptions).toHaveBeenCalledWith({ companyId: 'company-1' }); + expect(client.createOrder).not.toHaveBeenCalled(); + expect(client.updateSubscriptionQuantity).not.toHaveBeenCalled(); + expect(client.cancelSubscription).not.toHaveBeenCalled(); + expect(repository.persistReconcileResults).toHaveBeenCalledWith( + expect.anything(), + [expect.objectContaining({ lineId: 'line-new', submitState: 'needs_reconcile' })], + null, + ); + }); + + it('pins reconciliation to the parent id captured by the real POST', async () => { + const unknownLine = { ...newLine, submitState: 'needs_reconcile' }; + const { service, client, repository } = setup( + [unknownLine], + { pax8OrderId: 'captured-parent' }, + ); + client.listOrders.mockResolvedValueOnce([ + { + pax8OrderId: 'captured-parent', + pax8CompanyId: 'company-1', + createdDate: '2026-07-20', + lineItems: [], + }, + { + pax8OrderId: 'unrelated-full-match', + pax8CompanyId: 'company-1', + createdDate: '2026-07-20', + lineItems: [{ + lineItemNumber: 1, + productId: 'prod-1', + quantity: '7.00', + quantityKnown: true, + subscriptionId: 'unrelated-subscription', + }], + }, + ]); + client.listSubscriptions.mockResolvedValueOnce([]); + + await service.reconcileOrder({ partnerId: 'p1', orderId: 'ord-1' }); + + expect(repository.persistReconcileResults).toHaveBeenCalledWith( + expect.anything(), + [expect.objectContaining({ + lineId: 'line-new', + submitState: 'needs_reconcile', + resultSubscriptionId: null, + })], + null, + ); + }); + + it('matches the whole new-subscription batch to one order on submittedAt date', async () => { + const lines = [ + { ...newLine, submitState: 'needs_reconcile' }, + { ...duplicateNewLine, submitState: 'needs_reconcile' }, + ]; + const { service, client, repository } = setup(lines); + client.listOrders.mockResolvedValueOnce([{ + pax8OrderId: 'matched-parent', + pax8CompanyId: 'company-1', + createdDate: '2026-07-20', + lineItems: [ + { lineItemNumber: 1, productId: 'prod-1', quantity: '7.00', quantityKnown: true, subscriptionId: 's1' }, + { lineItemNumber: 2, productId: 'prod-1', quantity: '7.00', quantityKnown: true, subscriptionId: 's2' }, + ], + }]); + client.listSubscriptions.mockResolvedValueOnce([]); + (repository.persistReconcileResults as any).mockResolvedValueOnce({ resolved: 2, stillUnknown: 0 }); + + await service.reconcileOrder({ partnerId: 'p1', orderId: 'ord-1' }); + + expect(repository.persistReconcileResults).toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining([ + expect.objectContaining({ lineId: 'line-new', submitState: 'succeeded', resultSubscriptionId: 's1' }), + expect.objectContaining({ lineId: 'line-new-2', submitState: 'succeeded', resultSubscriptionId: 's2' }), + ]), + 'matched-parent', + ); + }); + + it.each([ + ['missing', [null, 's2']], + ['duplicate', ['duplicate-subscription', 'duplicate-subscription']], + ] as const)('leaves the whole new batch unresolved when subscription ids are %s', async (_name, subscriptionIds) => { + const lines = [ + { ...newLine, submitState: 'needs_reconcile' }, + { ...duplicateNewLine, submitState: 'needs_reconcile' }, + ]; + const { service, client, repository } = setup(lines); + client.listOrders.mockResolvedValueOnce([{ + pax8OrderId: 'matched-parent', + pax8CompanyId: 'company-1', + createdDate: '2026-07-20', + lineItems: [ + { lineItemNumber: 1, productId: 'prod-1', quantity: '7.00', quantityKnown: true, subscriptionId: subscriptionIds[0] }, + { lineItemNumber: 2, productId: 'prod-1', quantity: '7.00', quantityKnown: true, subscriptionId: subscriptionIds[1] }, + ], + }]); + client.listSubscriptions.mockResolvedValueOnce([]); + + await service.reconcileOrder({ partnerId: 'p1', orderId: 'ord-1' }); + + expect(repository.persistReconcileResults).toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining([ + expect.objectContaining({ lineId: 'line-new', submitState: 'needs_reconcile', resultSubscriptionId: null }), + expect.objectContaining({ lineId: 'line-new-2', submitState: 'needs_reconcile', resultSubscriptionId: null }), + ]), + null, + ); + }); + + it('never assembles one batch match from line items belonging to different parent orders', async () => { + const lines = [ + { ...newLine, submitState: 'needs_reconcile' }, + { ...duplicateNewLine, submitState: 'needs_reconcile' }, + ]; + const { service, client, repository } = setup(lines); + client.listOrders.mockResolvedValueOnce([ + { pax8OrderId: 'parent-a', pax8CompanyId: 'company-1', createdDate: '2026-07-20', lineItems: [{ lineItemNumber: 1, productId: 'prod-1', quantity: '7.00', quantityKnown: true, subscriptionId: 's1' }] }, + { pax8OrderId: 'parent-b', pax8CompanyId: 'company-1', createdDate: '2026-07-20', lineItems: [{ lineItemNumber: 2, productId: 'prod-1', quantity: '7.00', quantityKnown: true, subscriptionId: 's2' }] }, + ]); + client.listSubscriptions.mockResolvedValueOnce([]); + + await service.reconcileOrder({ partnerId: 'p1', orderId: 'ord-1' }); + + expect(repository.persistReconcileResults).toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining([ + expect.objectContaining({ lineId: 'line-new', submitState: 'needs_reconcile' }), + expect.objectContaining({ lineId: 'line-new-2', submitState: 'needs_reconcile' }), + ]), + null, + ); + }); + + it('uses the captured company snapshot even after the org mapping changes', async () => { + const unknownLine = { ...changeLine, submitState: 'needs_reconcile' }; + const { service, client } = setup([unknownLine]); + client.listOrders.mockResolvedValueOnce([]); + client.listSubscriptions.mockResolvedValueOnce([]); + + await service.reconcileOrder({ partnerId: 'p1', orderId: 'ord-1' }); + + expect(client.listOrders).toHaveBeenCalledWith({ companyId: 'company-1' }); + expect(client.listSubscriptions).toHaveBeenCalledWith({ companyId: 'company-1' }); + }); + + it('recognizes a still-present future-dated cancellation only from matching end-date evidence', async () => { + const unknownLine = { ...cancelLine, submitState: 'needs_reconcile' }; + const { service, client, repository } = setup([unknownLine]); + client.listOrders.mockResolvedValueOnce([]); + client.listSubscriptions.mockResolvedValueOnce([{ + pax8SubscriptionId: 'sub-existing', pax8CompanyId: 'company-1', + quantity: '0.00', quantityKnown: false, + status: 'ACTIVE', endDate: '2026-09-01', + }]); + + await service.reconcileOrder({ partnerId: 'p1', orderId: 'ord-1' }); + + expect(repository.persistReconcileResults).toHaveBeenCalledWith( + expect.anything(), + [expect.objectContaining({ submitState: 'succeeded' })], + null, + ); + }); + + it.each([ + ['missing target cancellation', { ...cancelLine, submitState: 'needs_reconcile' }, []], + ['unknown zero quantity', { ...changeLine, quantity: '0.00', submitState: 'needs_reconcile' }, [{ + pax8SubscriptionId: 'sub-existing', pax8CompanyId: 'company-1', + quantity: '0.00', quantityKnown: false, + }]], + ] as const)('leaves %s unresolved without conclusive evidence', async (_name, line, subscriptions) => { + const { service, client, repository } = setup([line]); + client.listOrders.mockResolvedValueOnce([]); + client.listSubscriptions.mockResolvedValueOnce(subscriptions); + + await service.reconcileOrder({ partnerId: 'p1', orderId: 'ord-1' }); + + expect(repository.persistReconcileResults).toHaveBeenCalledWith( + expect.anything(), + [expect.objectContaining({ submitState: 'needs_reconcile' })], + null, + ); + }); + + it.each([ + ['quantity change', { ...changeLine, submitState: 'needs_reconcile' }, { + pax8SubscriptionId: 'sub-existing', pax8CompanyId: 'company-2', + quantity: '5.00', quantityKnown: true, + }], + ['cancellation', { ...cancelLine, submitState: 'needs_reconcile' }, { + pax8SubscriptionId: 'sub-existing', pax8CompanyId: 'company-2', + quantity: '0.00', quantityKnown: false, status: 'CANCELLED', endDate: '2026-09-01', + }], + ] as const)('rejects wrong-company evidence for %s reconciliation', async (_name, line, subscription) => { + const { service, client, repository } = setup([line]); + client.listOrders.mockResolvedValueOnce([]); + client.listSubscriptions.mockResolvedValueOnce([subscription]); + + await service.reconcileOrder({ partnerId: 'p1', orderId: 'ord-1' }); + + expect(repository.persistReconcileResults).toHaveBeenCalledWith( + expect.anything(), + [expect.objectContaining({ submitState: 'needs_reconcile', resultSubscriptionId: null })], + null, + ); + }); +}); diff --git a/apps/api/src/services/pax8OrderSubmit.ts b/apps/api/src/services/pax8OrderSubmit.ts new file mode 100644 index 0000000000..509b734118 --- /dev/null +++ b/apps/api/src/services/pax8OrderSubmit.ts @@ -0,0 +1,409 @@ +import type { Pax8OrderStatus, Pax8SubmitState } from '@breeze/shared'; +import { runOutsideDbContext } from '../db'; +import type { Pax8OrderLineRow, Pax8OrderRow } from './pax8OrderService'; +import { Pax8OrderError } from './pax8OrderService'; +import { + Pax8ApiError, + type Pax8Client, + type Pax8CreateOrderInput, + type Pax8OrderRecord, + type Pax8OrderResult, + type Pax8SubscriptionRecord, +} from './pax8Client'; +import { pax8OrderSubmitRepository } from './pax8OrderSubmitRepository'; + +export interface SubmitBundle { + order: Pax8OrderRow; + lines: Pax8OrderLineRow[]; +} + +export interface SubmitLineOutcome { + lineId: string; + submitState: 'succeeded' | 'failed' | 'needs_reconcile'; + error: string | null; + resultSubscriptionId: string | null; +} + +export interface SubmitResult { + orderId: string; + status: Pax8OrderStatus; + lines: Array<{ lineId: string; submitState: Pax8SubmitState; error: string | null }>; +} + +export interface Pax8OrderSubmitRepository { + loadResolvedOrder(input: { partnerId: string; orderId: string }): Promise; + claimOrder(input: { partnerId: string; orderId: string; actorUserId: string }): Promise; + createClient(bundle: SubmitBundle): Promise; + claimLines(bundle: SubmitBundle): Promise; + persistPreflightFailure(bundle: SubmitBundle, errorBody: string): Promise; + persistSubmitResults( + bundle: SubmitBundle, + outcomes: SubmitLineOutcome[], + pax8OrderId: string | null, + ): Promise; + loadReconcileOrder(input: { partnerId: string; orderId: string }): Promise; + resetUnsentOrder(bundle: SubmitBundle): Promise<{ resolved: number; stillUnknown: number }>; + persistReconcileResults( + bundle: SubmitBundle, + outcomes: SubmitLineOutcome[], + pax8OrderId: string | null, + ): Promise<{ resolved: number; stillUnknown: number }>; +} + +interface ServiceDeps { + repository: Pax8OrderSubmitRepository; + runOutsideDbContext: typeof runOutsideDbContext; +} + +function numberQuantity(value: string | null): number { + const quantity = Number(value); + if (!Number.isFinite(quantity)) { + throw new Pax8OrderError('A Pax8 order line has an invalid quantity.', 422); + } + return quantity; +} + +function newSubscriptionLines(bundle: SubmitBundle): Pax8OrderLineRow[] { + return bundle.lines.filter((line) => line.action === 'new_subscription'); +} + +function buildCreateOrderInput(bundle: SubmitBundle): Pax8CreateOrderInput | null { + const lines = newSubscriptionLines(bundle); + if (lines.length === 0) return null; + if (!bundle.order.pax8CompanyId) { + throw new Pax8OrderError('Map this organization to a Pax8 company before ordering.', 422); + } + return { + companyId: bundle.order.pax8CompanyId, + lineItems: lines.map((line) => { + if (!line.pax8ProductId || !line.billingTerm || line.quantity === null) { + throw new Pax8OrderError('A new Pax8 subscription line is incomplete.', 422); + } + if (!Array.isArray(line.provisioningDetails)) { + throw new Pax8OrderError('A new Pax8 subscription line has invalid provisioning details.', 422); + } + const provisioningDetails = line.provisioningDetails as Array<{ key: string; values: string[] }>; + return { + lineItemNumber: line.sortOrder + 1, + productId: line.pax8ProductId, + quantity: numberQuantity(line.quantity), + billingTerm: line.billingTerm, + ...(line.commitmentTermId ? { commitmentTermId: line.commitmentTermId } : {}), + ...(provisioningDetails.length > 0 + ? { provisioningDetails } + : {}), + }; + }), + }; +} + +function errorText(error: unknown): string { + if (error instanceof Pax8ApiError) return error.body || error.message; + return error instanceof Error ? error.message : String(error); +} + +function classifyWriteError(error: unknown): Pick { + if ((error instanceof Pax8ApiError && error.status !== undefined + && error.status >= 400 && error.status < 500) + || error instanceof Pax8OrderError) { + return { submitState: 'failed', error: errorText(error) }; + } + return { submitState: 'needs_reconcile', error: errorText(error) }; +} + +function uniqueBijection( + local: TLocal[], + remote: TRemote[], + compatible: (local: TLocal, remote: TRemote) => boolean, +): number[] | null { + if (local.length !== remote.length) return null; + const assignments: number[][] = []; + const used = new Set(); + const current: number[] = []; + const visit = (index: number): void => { + if (assignments.length > 1) return; + if (index === local.length) { + assignments.push([...current]); + return; + } + for (let remoteIndex = 0; remoteIndex < remote.length; remoteIndex += 1) { + if (used.has(remoteIndex) || !compatible(local[index]!, remote[remoteIndex]!)) continue; + used.add(remoteIndex); + current.push(remoteIndex); + visit(index + 1); + current.pop(); + used.delete(remoteIndex); + } + }; + visit(0); + return assignments.length === 1 ? assignments[0]! : null; +} + +function createResponseAssignment(lines: Pax8OrderLineRow[], result: Pax8OrderResult): number[] | null { + return uniqueBijection(lines, result.lineItems, (line, item) => { + if (!item.subscriptionId) return false; + const numberEvidence = item.lineItemNumber !== null; + const productEvidence = item.productId !== null; + if (!numberEvidence && !productEvidence) return false; + if (numberEvidence && item.lineItemNumber !== line.sortOrder + 1) return false; + if (productEvidence && item.productId !== line.pax8ProductId) return false; + return true; + }); +} + +async function preflightBundle( + bundle: SubmitBundle, + client: Pax8Client, + outside: typeof runOutsideDbContext, +): Promise<{ ok: true } | { ok: false; errorBody: string }> { + const createInput = buildCreateOrderInput(bundle); + if (!createInput) return { ok: true }; + try { + await outside(() => client.createOrder(createInput, { isMock: true })); + return { ok: true }; + } catch (error) { + return { ok: false, errorBody: errorText(error) }; + } +} + +async function executeWrites( + bundle: SubmitBundle, + client: Pax8Client, +): Promise<{ outcomes: SubmitLineOutcome[]; pax8OrderId: string | null }> { + const outcomes: SubmitLineOutcome[] = []; + let pax8OrderId: string | null = null; + const newLines = newSubscriptionLines(bundle); + const createInput = buildCreateOrderInput(bundle); + + if (createInput && newLines.length > 0) { + try { + const result = await client.createOrder(createInput); + pax8OrderId = result.pax8OrderId; + const assignment = createResponseAssignment(newLines, result); + if (!assignment || !result.pax8OrderId) { + for (const line of newLines) { + outcomes.push({ + lineId: line.id, + submitState: 'needs_reconcile', + error: 'Pax8 created the order, but its returned line mapping was missing or ambiguous.', + resultSubscriptionId: null, + }); + } + } else { + for (let index = 0; index < newLines.length; index += 1) { + outcomes.push({ + lineId: newLines[index]!.id, + submitState: 'succeeded', + error: null, + resultSubscriptionId: result.lineItems[assignment[index]!]!.subscriptionId, + }); + } + } + } catch (error) { + const classified = classifyWriteError(error); + for (const line of newLines) { + outcomes.push({ + lineId: line.id, + ...classified, + resultSubscriptionId: null, + }); + } + } + } + + for (const line of bundle.lines) { + if (line.action === 'new_subscription') continue; + try { + if (!line.targetSubscriptionId) { + throw new Pax8OrderError('A subscription action has no target subscription.', 422); + } + if (line.action === 'change_quantity') { + if (line.quantity === null) throw new Pax8OrderError('A quantity change has no quantity.', 422); + await client.updateSubscriptionQuantity(line.targetSubscriptionId, numberQuantity(line.quantity)); + } else { + await client.cancelSubscription(line.targetSubscriptionId, line.cancelDate); + } + outcomes.push({ + lineId: line.id, + submitState: 'succeeded', + error: null, + resultSubscriptionId: line.targetSubscriptionId, + }); + } catch (error) { + outcomes.push({ + lineId: line.id, + ...classifyWriteError(error), + resultSubscriptionId: null, + }); + } + } + return { outcomes, pax8OrderId }; +} + +function sameQuantity(left: string | null, right: string): boolean { + return left !== null && Number(left) === Number(right); +} + +function reconcileNewBatch( + bundle: SubmitBundle, + unknownNewLines: Pax8OrderLineRow[], + orders: Pax8OrderRecord[], +): { outcomes: SubmitLineOutcome[]; pax8OrderId: string | null } { + if (unknownNewLines.length === 0) return { outcomes: [], pax8OrderId: null }; + const allNewLines = newSubscriptionLines(bundle); + const submittedDate = bundle.order.submittedAt?.toISOString().slice(0, 10) ?? null; + const candidates: Array<{ + order: Pax8OrderRecord; + subscriptionIds: string[]; + }> = []; + if (submittedDate) { + for (const order of orders) { + if (order.pax8CompanyId !== bundle.order.pax8CompanyId || order.createdDate !== submittedDate) continue; + if (bundle.order.pax8OrderId && order.pax8OrderId !== bundle.order.pax8OrderId) continue; + const assignment = uniqueBijection(allNewLines, order.lineItems, (line, item) => { + if (!item.quantityKnown || !sameQuantity(line.quantity, item.quantity)) return false; + const numberEvidence = item.lineItemNumber !== null; + const productEvidence = item.productId !== null; + if (!numberEvidence && !productEvidence) return false; + if (numberEvidence && item.lineItemNumber !== line.sortOrder + 1) return false; + if (productEvidence && item.productId !== line.pax8ProductId) return false; + return true; + }); + if (!assignment) continue; + const subscriptionIds = assignment.map((index) => order.lineItems[index]!.subscriptionId); + if (subscriptionIds.some((id) => !id) + || new Set(subscriptionIds).size !== allNewLines.length) continue; + candidates.push({ order, subscriptionIds: subscriptionIds as string[] }); + } + } + if (candidates.length === 1) { + const candidate = candidates[0]!; + const localIndex = new Map(allNewLines.map((line, index) => [line.id, index])); + return { + outcomes: unknownNewLines.map((line) => { + const index = localIndex.get(line.id)!; + return { + lineId: line.id, + submitState: 'succeeded', + error: null, + resultSubscriptionId: candidate.subscriptionIds[index]!, + }; + }), + pax8OrderId: candidate.order.pax8OrderId, + }; + } + // Pax8 Order.createdDate is only a date, not a timestamp. Multiple matching + // same-day orders cannot be safely disambiguated, so human reconcile leaves + // the line unknown rather than guessing which billable write landed. + const error = candidates.length === 0 + ? 'No complete, conclusive Pax8 order match was found.' + : 'Multiple matching same-day Pax8 orders were found.'; + return { + outcomes: unknownNewLines.map((line) => ({ + lineId: line.id, + submitState: 'needs_reconcile', + error, + resultSubscriptionId: null, + })), + pax8OrderId: null, + }; +} + +function reconcileSubscriptionLine( + line: Pax8OrderLineRow, + pax8CompanyId: string, + subscriptions: Pax8SubscriptionRecord[], +): SubmitLineOutcome { + const target = subscriptions.filter((row) => + row.pax8SubscriptionId === line.targetSubscriptionId + && row.pax8CompanyId === pax8CompanyId); + if (target.length > 1) { + return { lineId: line.id, submitState: 'needs_reconcile', error: 'Pax8 returned duplicate target subscriptions.', resultSubscriptionId: null }; + } + if (line.action === 'cancel') { + const subscription = target[0]; + const terminal = subscription?.status + ? ['cancelled', 'canceled', 'terminated'].includes(subscription.status.toLowerCase()) + : false; + const scheduled = !!line.cancelDate && subscription?.endDate === line.cancelDate; + return terminal || scheduled + ? { lineId: line.id, submitState: 'succeeded', error: null, resultSubscriptionId: line.targetSubscriptionId } + : { lineId: line.id, submitState: 'needs_reconcile', error: 'Pax8 cancellation evidence is not conclusive.', resultSubscriptionId: null }; + } + if (target.length === 1) { + return target[0]!.quantityKnown && sameQuantity(line.quantity, target[0]!.quantity) + ? { lineId: line.id, submitState: 'succeeded', error: null, resultSubscriptionId: target[0]!.pax8SubscriptionId } + : { lineId: line.id, submitState: 'needs_reconcile', error: 'Pax8 quantity evidence is not conclusive.', resultSubscriptionId: null }; + } + return { lineId: line.id, submitState: 'needs_reconcile', error: 'The target Pax8 subscription was not conclusively found.', resultSubscriptionId: null }; +} + +export function createPax8OrderSubmitService(deps: ServiceDeps) { + return { + async preflightOrder(input: { partnerId: string; orderId: string }) { + const bundle = await deps.repository.loadResolvedOrder(input); + if (newSubscriptionLines(bundle).length === 0) return { ok: true } as const; + const client = await deps.repository.createClient(bundle); + return preflightBundle(bundle, client, deps.runOutsideDbContext); + }, + + async submitOrder(input: { partnerId: string; orderId: string; actorUserId: string }): Promise { + const bundle = await deps.repository.claimOrder(input); + let client: Pax8Client; + try { + client = await deps.repository.createClient(bundle); + } catch (error) { + return deps.repository.persistPreflightFailure(bundle, errorText(error)); + } + const preflight = await preflightBundle(bundle, client, deps.runOutsideDbContext); + if (!preflight.ok) { + return deps.repository.persistPreflightFailure(bundle, preflight.errorBody); + } + await deps.repository.claimLines(bundle); + const execution = await deps.runOutsideDbContext(() => executeWrites(bundle, client)); + return deps.repository.persistSubmitResults(bundle, execution.outcomes, execution.pax8OrderId); + }, + + async reconcileOrder(input: { partnerId: string; orderId: string }): Promise<{ resolved: number; stillUnknown: number }> { + const bundle = await deps.repository.loadReconcileOrder(input); + // A crash can leave a line in_flight before or during the one real Pax8 + // attempt. Both states are ambiguous and require the same read-only human + // reconciliation path; neither may be re-sent blindly. + const unknown = bundle.lines.filter((line) => + line.submitState === 'in_flight' || line.submitState === 'needs_reconcile'); + if (unknown.length === 0) { + if (bundle.order.status === 'submitting' + && bundle.lines.length > 0 + && bundle.lines.every((line) => line.submitState === 'pending')) { + return deps.repository.resetUnsentOrder(bundle); + } + return { resolved: 0, stillUnknown: 0 }; + } + if (!bundle.order.pax8CompanyId) { + throw new Pax8OrderError('Map this organization to a Pax8 company before reconciliation.', 422); + } + const client = await deps.repository.createClient(bundle); + const [orders, subscriptions] = await deps.runOutsideDbContext(() => Promise.all([ + client.listOrders({ companyId: bundle.order.pax8CompanyId! }), + client.listSubscriptions({ companyId: bundle.order.pax8CompanyId! }), + ])); + const unknownNew = unknown.filter((line) => line.action === 'new_subscription'); + const newBatch = reconcileNewBatch(bundle, unknownNew, orders); + const outcomes = [ + ...newBatch.outcomes, + ...unknown.filter((line) => line.action !== 'new_subscription') + .map((line) => reconcileSubscriptionLine(line, bundle.order.pax8CompanyId!, subscriptions)), + ]; + return deps.repository.persistReconcileResults(bundle, outcomes, newBatch.pax8OrderId); + }, + }; +} + +const defaultService = createPax8OrderSubmitService({ + repository: pax8OrderSubmitRepository, + runOutsideDbContext, +}); + +export const preflightOrder = defaultService.preflightOrder; +export const submitOrder = defaultService.submitOrder; +export const reconcileOrder = defaultService.reconcileOrder; diff --git a/apps/api/src/services/pax8OrderSubmitRepository.test.ts b/apps/api/src/services/pax8OrderSubmitRepository.test.ts new file mode 100644 index 0000000000..298a6052c7 --- /dev/null +++ b/apps/api/src/services/pax8OrderSubmitRepository.test.ts @@ -0,0 +1,198 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + db: { select: vi.fn(), update: vi.fn() }, + validateLines: vi.fn(), + events: [] as string[], +})); + +vi.mock('../db', () => ({ + db: mocks.db, + runOutsideDbContext: (fn: () => unknown) => fn(), + withDbAccessContext: (_context: unknown, fn: () => unknown) => fn(), +})); + +vi.mock('./pax8OrderService', () => { + class Pax8OrderError extends Error { + constructor(message: string, public readonly status: number) { + super(message); + } + } + class Pax8OrderRestageRequiredError extends Pax8OrderError { + constructor(message: string) { + super(message, 409); + } + } + return { + Pax8OrderError, + Pax8OrderRestageRequiredError, + requireImmediateCancelDate: vi.fn(), + validateDirectOrderLinesForSubmit: mocks.validateLines, + }; +}); + +vi.mock('./pax8SyncService', () => ({ createPax8ClientForIntegration: vi.fn() })); + +import { pax8OrderSubmitRepository } from './pax8OrderSubmitRepository'; + +const READY_METADATA = { + contacts: [{ types: [ + { type: 'Admin', primary: true }, + { type: 'Billing', primary: true }, + { type: 'Technical', primary: true }, + ] }], +}; + +const order = { + id: 'order-1', integrationId: 'integration-1', partnerId: 'partner-1', orgId: 'org-1', + pax8CompanyId: 'company-1', status: 'ready', source: 'quote', sourceQuoteId: null, + dedupeKey: 'order:order-1', pax8OrderId: null, error: null, createdBy: 'user-1', + submittedBy: null, submittedAt: null, createdAt: new Date(), updatedAt: new Date(), + rowVersion: '7', +} as const; + +function selectChain(rows: unknown[], terminal: 'limit' | 'for' | 'orderBy', event?: string) { + const chain: Record = {}; + chain.from = vi.fn(() => chain); + chain.where = vi.fn(() => chain); + chain.limit = vi.fn(async () => { + if (terminal === 'limit' && event) mocks.events.push(event); + return rows; + }); + chain.for = vi.fn(async () => { + if (terminal === 'for' && event) mocks.events.push(event); + return rows; + }); + chain.orderBy = vi.fn(async () => { + if (terminal === 'orderBy' && event) mocks.events.push(event); + return rows; + }); + return chain; +} + +beforeEach(() => { + mocks.db.select.mockReset(); + mocks.db.update.mockReset(); + mocks.validateLines.mockReset(); + mocks.events.length = 0; +}); + +describe('pax8OrderSubmitRepository.claimOrder', () => { + it('reads and returns the authoritative line only after the parent claim lock', async () => { + const patchedLine = { + id: 'line-1', orderId: order.id, partnerId: order.partnerId, orgId: order.orgId, + action: 'new_subscription', submitState: 'pending', pax8ProductId: 'product-1', + catalogItemId: 'catalog-1', billingTerm: 'Monthly', commitmentTermId: 'commit-new', + quantity: '2.00', authorizedBaselineQuantity: null, + provisioningDetails: [{ key: 'domain', values: ['patched.example'] }], + targetSubscriptionId: null, cancelDate: null, resultSubscriptionId: null, + contractLineId: null, sourceQuoteLineId: 'quote-line-1', error: null, sortOrder: 0, + createdAt: new Date(), updatedAt: new Date(), + }; + mocks.db.select + .mockReturnValueOnce(selectChain([order], 'limit', 'org-discovered')) + .mockReturnValueOnce(selectChain([order], 'limit', 'order-reloaded')) + .mockReturnValueOnce(selectChain([{ + pax8CompanyId: 'company-1', status: 'Active', metadata: READY_METADATA, + }], 'for', 'company-locked')) + .mockReturnValueOnce(selectChain([patchedLine], 'orderBy', 'lines-read')); + const returning = vi.fn(async () => { + mocks.events.push('parent-claimed'); + return [{ ...order, status: 'submitting', submittedAt: new Date() }]; + }); + mocks.db.update.mockReturnValue({ + set: vi.fn(() => ({ where: vi.fn(() => ({ returning })) })), + }); + mocks.validateLines.mockImplementation(async (_order, lines) => { + mocks.events.push('lines-validated'); + return lines; + }); + + const bundle = await pax8OrderSubmitRepository.claimOrder({ + partnerId: order.partnerId, + orderId: order.id, + actorUserId: 'actor-1', + }); + + expect(mocks.events.indexOf('parent-claimed')).toBeLessThan(mocks.events.indexOf('lines-read')); + expect(mocks.events.indexOf('lines-read')).toBeLessThan(mocks.events.indexOf('lines-validated')); + expect(bundle.lines).toEqual([patchedLine]); + }); + + it('commits a safe direct ready-to-draft recovery before surfacing a baseline conflict', async () => { + const directOrder = { ...order, source: 'direct' as const }; + const legacyLine = { + id: 'line-legacy', orderId: order.id, partnerId: order.partnerId, orgId: order.orgId, + action: 'change_quantity', submitState: 'pending', targetSubscriptionId: 'sub-1', + contractLineId: 'contract-line-1', authorizedBaselineQuantity: null, + }; + mocks.db.select + .mockReturnValueOnce(selectChain([directOrder], 'limit')) + .mockReturnValueOnce(selectChain([directOrder], 'limit')) + .mockReturnValueOnce(selectChain([{ + pax8CompanyId: 'company-1', status: 'Active', metadata: READY_METADATA, + }], 'for')) + .mockReturnValueOnce(selectChain([legacyLine], 'orderBy')); + const claimedAt = new Date('2026-07-14T12:00:00.000Z'); + const claimReturning = vi.fn(async () => [{ + ...directOrder, status: 'submitting', submittedAt: claimedAt, + }]); + const resetReturning = vi.fn(async () => { + mocks.events.push('draft-reset-committed'); + return [{ id: order.id }]; + }); + const claimSet = vi.fn(() => ({ where: vi.fn(() => ({ returning: claimReturning })) })); + const resetSet = vi.fn(() => ({ where: vi.fn(() => ({ returning: resetReturning })) })); + mocks.db.update + .mockReturnValueOnce({ set: claimSet }) + .mockReturnValueOnce({ set: resetSet }); + const { Pax8OrderRestageRequiredError } = await import('./pax8OrderService'); + mocks.validateLines.mockRejectedValueOnce(new Pax8OrderRestageRequiredError( + 'This legacy quantity change has no authorization baseline; remove and stage it again.', + )); + + await expect(pax8OrderSubmitRepository.claimOrder({ + partnerId: order.partnerId, + orderId: order.id, + actorUserId: 'actor-1', + })).rejects.toMatchObject({ status: 409, message: expect.stringContaining('stage it again') }); + + expect(resetSet).toHaveBeenCalledWith(expect.objectContaining({ + status: 'draft', submittedBy: null, submittedAt: null, + })); + expect(mocks.events).toContain('draft-reset-committed'); + }); + + it('never demotes a quote order for a restage-required conflict', async () => { + const quoteLine = { + id: 'line-quote', orderId: order.id, partnerId: order.partnerId, orgId: order.orgId, + action: 'change_quantity', submitState: 'pending', targetSubscriptionId: 'sub-1', + contractLineId: 'contract-line-1', authorizedBaselineQuantity: null, + }; + mocks.db.select + .mockReturnValueOnce(selectChain([order], 'limit')) + .mockReturnValueOnce(selectChain([order], 'limit')) + .mockReturnValueOnce(selectChain([{ + pax8CompanyId: 'company-1', status: 'Active', metadata: READY_METADATA, + }], 'for')) + .mockReturnValueOnce(selectChain([quoteLine], 'orderBy')); + const claimReturning = vi.fn(async () => [{ + ...order, status: 'submitting', submittedAt: new Date('2026-07-14T12:00:00.000Z'), + }]); + mocks.db.update.mockReturnValueOnce({ + set: vi.fn(() => ({ where: vi.fn(() => ({ returning: claimReturning })) })), + }); + const { Pax8OrderRestageRequiredError } = await import('./pax8OrderService'); + mocks.validateLines.mockRejectedValueOnce(new Pax8OrderRestageRequiredError( + 'Quote conflict must remain non-actionable.', + )); + + await expect(pax8OrderSubmitRepository.claimOrder({ + partnerId: order.partnerId, + orderId: order.id, + actorUserId: 'actor-1', + })).rejects.toMatchObject({ status: 409 }); + + expect(mocks.db.update).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/api/src/services/pax8OrderSubmitRepository.ts b/apps/api/src/services/pax8OrderSubmitRepository.ts new file mode 100644 index 0000000000..b5469c24ee --- /dev/null +++ b/apps/api/src/services/pax8OrderSubmitRepository.ts @@ -0,0 +1,567 @@ +import type { Pax8OrderStatus, Pax8SubmitState } from '@breeze/shared'; +import { and, asc, eq, getTableColumns, inArray, isNull, or, sql } from 'drizzle-orm'; +import { + db, + runOutsideDbContext, + withDbAccessContext, + type DbAccessContext, +} from '../db'; +import { + contractLines, + pax8CompanyMappings, + pax8OrderLines, + pax8Orders, +} from '../db/schema'; +import { + Pax8OrderError, + Pax8OrderRestageRequiredError, + requireImmediateCancelDate, + validateDirectOrderLinesForSubmit, + type Pax8OrderLineRow, + type Pax8OrderRow, +} from './pax8OrderService'; +import type { + Pax8OrderSubmitRepository, + SubmitBundle, + SubmitLineOutcome, + SubmitResult, +} from './pax8OrderSubmit'; +import { createPax8ClientForIntegration } from './pax8SyncService'; +import { pax8CompanyOrderReadiness } from './pax8CompanyReadiness'; + +const SUBMITTABLE_STATUSES = ['draft', 'awaiting_details', 'ready'] as const; + +function partnerDbContext(partnerId: string): DbAccessContext { + return { + scope: 'partner', + orgId: null, + accessibleOrgIds: null, + accessiblePartnerIds: [partnerId], + userId: null, + currentPartnerId: partnerId, + }; +} + +function orderDbContext(partnerId: string, orgId: string): DbAccessContext { + return { + ...partnerDbContext(partnerId), + orgId, + accessibleOrgIds: [orgId], + }; +} + +function withPartnerDbContext(partnerId: string, fn: () => Promise): Promise { + return runOutsideDbContext(() => withDbAccessContext(partnerDbContext(partnerId), fn)); +} + +function withOrderDbContext(bundle: SubmitBundle, fn: () => Promise): Promise { + return runOutsideDbContext(() => withDbAccessContext( + orderDbContext(bundle.order.partnerId, bundle.order.orgId), + fn, + )); +} + +function withOrderScopeDbContext( + partnerId: string, + orgId: string, + fn: () => Promise, +): Promise { + return runOutsideDbContext(() => withDbAccessContext(orderDbContext(partnerId, orgId), fn)); +} + +type VersionedOrder = Pax8OrderRow & { rowVersion: string }; + +async function findOrder(partnerId: string, orderId: string): Promise { + const [order] = await db + .select({ ...getTableColumns(pax8Orders), rowVersion: sql`xmin::text` }) + .from(pax8Orders) + .where(and(eq(pax8Orders.partnerId, partnerId), eq(pax8Orders.id, orderId))) + .limit(1); + if (!order) throw new Pax8OrderError('Pax8 order not found.', 404); + return order; +} + +async function findOrderLines(order: Pax8OrderRow): Promise { + return db + .select() + .from(pax8OrderLines) + .where(and( + eq(pax8OrderLines.partnerId, order.partnerId), + eq(pax8OrderLines.orgId, order.orgId), + eq(pax8OrderLines.orderId, order.id), + )) + .orderBy(asc(pax8OrderLines.sortOrder), asc(pax8OrderLines.id)); +} + +async function resolveCompany(order: Pax8OrderRow): Promise { + const mappings = await db + .select({ + pax8CompanyId: pax8CompanyMappings.pax8CompanyId, + status: pax8CompanyMappings.status, + metadata: pax8CompanyMappings.metadata, + }) + .from(pax8CompanyMappings) + .where(and( + eq(pax8CompanyMappings.integrationId, order.integrationId), + eq(pax8CompanyMappings.partnerId, order.partnerId), + eq(pax8CompanyMappings.orgId, order.orgId), + eq(pax8CompanyMappings.ignored, false), + )) + .for('share'); + if (mappings.length === 0) { + throw new Pax8OrderError('Map this organization to a Pax8 company before ordering.', 422); + } + if (mappings.length !== 1) { + throw new Pax8OrderError('Multiple Pax8 companies are mapped to this organization; resolve the mapping before ordering.', 422); + } + if (!pax8CompanyOrderReadiness(mappings[0]!.status, mappings[0]!.metadata).orderReady) { + throw new Pax8OrderError( + 'The mapped Pax8 company is not ready for ordering. It must be Active with primary Admin, Billing, and Technical contacts.', + 422, + ); + } + return mappings[0]!.pax8CompanyId; +} + +async function persistResolvedCompany(order: VersionedOrder, pax8CompanyId: string): Promise { + const [updated] = await db + .update(pax8Orders) + .set({ pax8CompanyId, updatedAt: new Date() }) + .where(and( + eq(pax8Orders.id, order.id), + eq(pax8Orders.partnerId, order.partnerId), + eq(pax8Orders.orgId, order.orgId), + eq(pax8Orders.integrationId, order.integrationId), + eq(pax8Orders.status, order.status), + sql`${pax8Orders}.xmin::text = ${order.rowVersion}`, + )) + .returning(); + if (!updated) throw new Pax8OrderError('The Pax8 company mapping changed while loading the order.', 409); + return updated; +} + +function assertSubmittable(order: Pax8OrderRow): void { + if (!(SUBMITTABLE_STATUSES as readonly string[]).includes(order.status)) { + throw new Pax8OrderError('This Pax8 order cannot be submitted in its current state. Reconcile any unknown write first.', 409); + } +} + +function deriveOrderStatus(states: Pax8SubmitState[]): Pax8OrderStatus { + if (states.length > 0 && states.every((state) => state === 'succeeded')) return 'completed'; + if (states.length > 0 && states.every((state) => state === 'failed')) return 'failed'; + return 'partially_failed'; +} + +function resultFromRows(orderId: string, status: Pax8OrderStatus, lines: Pax8OrderLineRow[]): SubmitResult { + return { + orderId, + status, + lines: lines.map((line) => ({ + lineId: line.id, + submitState: line.submitState as Pax8SubmitState, + error: line.error, + })), + }; +} + +async function lockExecutingOrder(bundle: SubmitBundle): Promise { + if (!bundle.order.pax8CompanyId || !bundle.order.submittedAt) { + throw new Pax8OrderError('The Pax8 order is missing its immutable submission snapshot.', 409); + } + const [order] = await db + .select() + .from(pax8Orders) + .where(and( + eq(pax8Orders.id, bundle.order.id), + eq(pax8Orders.partnerId, bundle.order.partnerId), + eq(pax8Orders.orgId, bundle.order.orgId), + eq(pax8Orders.integrationId, bundle.order.integrationId), + eq(pax8Orders.status, 'submitting'), + eq(pax8Orders.pax8CompanyId, bundle.order.pax8CompanyId), + eq(pax8Orders.submittedAt, bundle.order.submittedAt), + )) + .for('update') + .limit(1); + if (!order) throw new Pax8OrderError('The Pax8 order execution state changed.', 409); + return order; +} + +async function updateManualContractQuantity( + line: Pax8OrderLineRow, +): Promise { + if (!line.contractLineId) return; + const [contractLine] = await db + .select({ id: contractLines.id, lineType: contractLines.lineType }) + .from(contractLines) + .where(and( + eq(contractLines.id, line.contractLineId), + eq(contractLines.orgId, line.orgId), + )) + .limit(1); + if (!contractLine) { + throw new Pax8OrderError('The linked contract line is unavailable; the Pax8 result requires reconciliation.', 409); + } + if (contractLine.lineType !== 'manual') return; + const quantity = line.action === 'cancel' ? '0' : line.quantity; + if (quantity === null) return; + const updated = await db + .update(contractLines) + .set({ manualQuantity: quantity }) + .where(and( + eq(contractLines.id, line.contractLineId), + eq(contractLines.orgId, line.orgId), + eq(contractLines.lineType, 'manual' as never), + )) + .returning({ id: contractLines.id }); + if (updated.length !== 1) { + throw new Pax8OrderError('The linked manual contract line changed before billing quantity was recorded.', 409); + } +} + +async function persistLineOutcomes( + bundle: SubmitBundle, + outcomes: SubmitLineOutcome[], + expectedState: 'in_flight' | readonly ['in_flight', 'needs_reconcile'], +): Promise { + const lineById = new Map(bundle.lines.map((line) => [line.id, line])); + if (new Set(outcomes.map((outcome) => outcome.lineId)).size !== outcomes.length) { + throw new Pax8OrderError('Duplicate Pax8 line outcomes were produced.', 409); + } + for (const outcome of outcomes) { + const line = lineById.get(outcome.lineId); + if (!line) throw new Pax8OrderError('A Pax8 line result does not belong to this order.', 409); + const stateCondition = typeof expectedState === 'string' + ? eq(pax8OrderLines.submitState, expectedState) + : inArray(pax8OrderLines.submitState, [...expectedState]); + const updated = await db + .update(pax8OrderLines) + .set({ + submitState: outcome.submitState, + resultSubscriptionId: outcome.resultSubscriptionId, + error: outcome.error, + updatedAt: new Date(), + }) + .where(and( + eq(pax8OrderLines.id, line.id), + eq(pax8OrderLines.orderId, bundle.order.id), + eq(pax8OrderLines.partnerId, bundle.order.partnerId), + eq(pax8OrderLines.orgId, bundle.order.orgId), + stateCondition, + )) + .returning({ id: pax8OrderLines.id }); + if (updated.length !== 1) { + throw new Pax8OrderError('A Pax8 line execution state changed before its result could be recorded.', 409); + } + if (outcome.submitState === 'succeeded') await updateManualContractQuantity(line); + } +} + +async function reloadLines(bundle: SubmitBundle): Promise { + return db + .select() + .from(pax8OrderLines) + .where(and( + eq(pax8OrderLines.orderId, bundle.order.id), + eq(pax8OrderLines.partnerId, bundle.order.partnerId), + eq(pax8OrderLines.orgId, bundle.order.orgId), + )); +} + +export const pax8OrderSubmitRepository: Pax8OrderSubmitRepository = { + loadResolvedOrder(input) { + return withPartnerDbContext(input.partnerId, async () => { + const existing = await findOrder(input.partnerId, input.orderId); + assertSubmittable(existing); + const companyId = await resolveCompany(existing); + const order = await persistResolvedCompany(existing, companyId); + return { order, lines: await findOrderLines(order) }; + }); + }, + + async claimOrder(input) { + // The parent is partner-axis, so discover its org in one bounded read. + // Re-open the final CAS transaction with that single org authorized so + // forced RLS permits the exact contract-line integrity join. + const discovered = await withPartnerDbContext(input.partnerId, () => + findOrder(input.partnerId, input.orderId)); + const outcome = await withOrderScopeDbContext(input.partnerId, discovered.orgId, async () => { + const existing = await findOrder(input.partnerId, input.orderId); + if (existing.orgId !== discovered.orgId) { + throw new Pax8OrderError('The Pax8 order organization changed while claiming it.', 409); + } + assertSubmittable(existing); + const companyId = await resolveCompany(existing); + const now = new Date(); + const [claimed] = await db + .update(pax8Orders) + .set({ + pax8CompanyId: companyId, + status: 'submitting', + submittedBy: input.actorUserId, + submittedAt: now, + error: null, + updatedAt: now, + }) + .where(and( + eq(pax8Orders.id, input.orderId), + eq(pax8Orders.partnerId, input.partnerId), + eq(pax8Orders.orgId, existing.orgId), + eq(pax8Orders.integrationId, existing.integrationId), + eq(pax8Orders.status, existing.status), + sql`${pax8Orders}.xmin::text = ${existing.rowVersion}`, + sql`NOT EXISTS ( + SELECT 1 FROM pax8_order_lines pol + WHERE pol.order_id = ${pax8Orders.id} + AND pol.partner_id = ${pax8Orders.partnerId} + AND pol.org_id = ${pax8Orders.orgId} + AND pol.submit_state <> 'pending' + )`, + )) + .returning(); + if (!claimed) { + throw new Pax8OrderError('Another submit won the order claim, or an earlier write requires reconciliation.', 409); + } + // The parent transition owns the row lock that every authoring mutation + // must acquire. Read only after that lock: if PATCH committed first we + // see its values; if this claim won, PATCH wakes to a non-mutable parent. + let lines = await findOrderLines(claimed); + if (lines.length === 0) throw new Pax8OrderError('Add at least one line before submitting the Pax8 order.', 422); + if (lines.some((line) => line.submitState !== 'pending')) { + throw new Pax8OrderError('An earlier Pax8 line write requires reconciliation.', 409); + } + for (const line of lines) { + if (line.action === 'cancel') requireImmediateCancelDate(line.cancelDate); + } + try { + lines = await validateDirectOrderLinesForSubmit(claimed, lines); + } catch (error) { + if (!(error instanceof Pax8OrderRestageRequiredError) || claimed.source !== 'direct') throw error; + const reset = await db + .update(pax8Orders) + .set({ + status: 'draft', + submittedBy: null, + submittedAt: null, + error: error.message, + updatedAt: new Date(), + }) + .where(and( + eq(pax8Orders.id, claimed.id), + eq(pax8Orders.partnerId, claimed.partnerId), + eq(pax8Orders.orgId, claimed.orgId), + eq(pax8Orders.integrationId, claimed.integrationId), + eq(pax8Orders.source, 'direct'), + eq(pax8Orders.status, 'submitting'), + eq(pax8Orders.submittedAt, claimed.submittedAt!), + sql`NOT EXISTS ( + SELECT 1 FROM pax8_order_lines pol + WHERE pol.order_id = ${pax8Orders.id} + AND pol.partner_id = ${pax8Orders.partnerId} + AND pol.org_id = ${pax8Orders.orgId} + AND pol.submit_state <> 'pending' + )`, + )) + .returning({ id: pax8Orders.id }); + if (reset.length !== 1) { + throw new Pax8OrderError( + 'The Pax8 order could not be safely reopened for restaging; reconcile it before retrying.', + 409, + ); + } + return { restageError: error } as const; + } + const targets = lines + .filter((line) => line.action !== 'new_subscription') + .map((line) => line.targetSubscriptionId); + if (targets.some((target, index) => target !== null && targets.indexOf(target) !== index)) { + throw new Pax8OrderError('Only one change or cancellation may target a Pax8 subscription per order.', 422); + } + return { bundle: { order: claimed, lines } } as const; + }); + if ('restageError' in outcome) throw outcome.restageError; + return outcome.bundle; + }, + + createClient(bundle) { + return withPartnerDbContext(bundle.order.partnerId, async () => { + const created = await createPax8ClientForIntegration(bundle.order.integrationId); + if (created.integration.partnerId !== bundle.order.partnerId) { + throw new Pax8OrderError('The Pax8 integration belongs to a different partner.', 403); + } + return created.client; + }); + }, + + claimLines(bundle) { + return withPartnerDbContext(bundle.order.partnerId, async () => { + await lockExecutingOrder(bundle); + const claimed = await db + .update(pax8OrderLines) + .set({ submitState: 'in_flight', error: null, updatedAt: new Date() }) + .where(and( + eq(pax8OrderLines.orderId, bundle.order.id), + eq(pax8OrderLines.partnerId, bundle.order.partnerId), + eq(pax8OrderLines.orgId, bundle.order.orgId), + eq(pax8OrderLines.submitState, 'pending'), + )) + .returning({ id: pax8OrderLines.id }); + const expectedIds = new Set(bundle.lines.map((line) => line.id)); + if (claimed.length !== expectedIds.size || claimed.some((row) => !expectedIds.has(row.id))) { + // Throwing rolls back the whole UPDATE. No Pax8 write is attempted after + // an incomplete committed claim. + throw new Pax8OrderError('The Pax8 order line claim was incomplete; no external write was attempted.', 409); + } + }); + }, + + persistPreflightFailure(bundle, errorBody) { + return withOrderDbContext(bundle, async () => { + await lockExecutingOrder(bundle); + const lineIds = bundle.lines.map((line) => line.id); + const updatedLines = await db + .update(pax8OrderLines) + .set({ submitState: 'failed', error: errorBody, updatedAt: new Date() }) + .where(and( + eq(pax8OrderLines.orderId, bundle.order.id), + eq(pax8OrderLines.partnerId, bundle.order.partnerId), + eq(pax8OrderLines.orgId, bundle.order.orgId), + eq(pax8OrderLines.submitState, 'pending'), + inArray(pax8OrderLines.id, lineIds), + )) + .returning({ id: pax8OrderLines.id }); + if (updatedLines.length !== lineIds.length) { + throw new Pax8OrderError('The Pax8 preflight failure could not terminally classify every line.', 409); + } + await db + .update(pax8Orders) + .set({ status: 'failed', error: errorBody, updatedAt: new Date() }) + .where(and( + eq(pax8Orders.id, bundle.order.id), + eq(pax8Orders.partnerId, bundle.order.partnerId), + eq(pax8Orders.orgId, bundle.order.orgId), + eq(pax8Orders.status, 'submitting'), + )); + return resultFromRows(bundle.order.id, 'failed', await reloadLines(bundle)); + }); + }, + + persistSubmitResults(bundle, outcomes, pax8OrderId) { + return withOrderDbContext(bundle, async () => { + await lockExecutingOrder(bundle); + if (outcomes.length !== bundle.lines.length) { + throw new Pax8OrderError('Pax8 did not produce one result for every claimed order line.', 409); + } + await persistLineOutcomes(bundle, outcomes, 'in_flight'); + const status = deriveOrderStatus(outcomes.map((outcome) => outcome.submitState)); + const errors = outcomes.flatMap((outcome) => outcome.error ? [outcome.error] : []); + const updated = await db + .update(pax8Orders) + .set({ + status, + pax8OrderId, + error: errors.length > 0 ? errors.join('\n').slice(0, 4000) : null, + updatedAt: new Date(), + }) + .where(and( + eq(pax8Orders.id, bundle.order.id), + eq(pax8Orders.partnerId, bundle.order.partnerId), + eq(pax8Orders.orgId, bundle.order.orgId), + eq(pax8Orders.status, 'submitting'), + )) + .returning({ id: pax8Orders.id }); + if (updated.length !== 1) throw new Pax8OrderError('The Pax8 order result could not be recorded.', 409); + return resultFromRows(bundle.order.id, status, await reloadLines(bundle)); + }); + }, + + loadReconcileOrder(input) { + return withPartnerDbContext(input.partnerId, async () => { + const order = await findOrder(input.partnerId, input.orderId); + return { order, lines: await findOrderLines(order) }; + }); + }, + + resetUnsentOrder(bundle) { + return withOrderDbContext(bundle, async () => { + await lockExecutingOrder(bundle); + const currentLines = await reloadLines(bundle); + if (currentLines.length !== bundle.lines.length + || currentLines.length === 0 + || currentLines.some((line) => line.submitState !== 'pending')) { + throw new Pax8OrderError('The Pax8 order is no longer provably unsent; reconcile its unknown writes.', 409); + } + const updated = await db + .update(pax8Orders) + .set({ + status: 'ready', + submittedBy: null, + submittedAt: null, + error: null, + updatedAt: new Date(), + }) + .where(and( + eq(pax8Orders.id, bundle.order.id), + eq(pax8Orders.partnerId, bundle.order.partnerId), + eq(pax8Orders.orgId, bundle.order.orgId), + eq(pax8Orders.integrationId, bundle.order.integrationId), + eq(pax8Orders.status, 'submitting'), + )) + .returning({ id: pax8Orders.id }); + if (updated.length !== 1) throw new Pax8OrderError('The unsent Pax8 order could not be recovered.', 409); + return { resolved: 0, stillUnknown: 0 }; + }); + }, + + persistReconcileResults(bundle, outcomes, pax8OrderId) { + return withOrderDbContext(bundle, async () => { + const [locked] = await db + .select() + .from(pax8Orders) + .where(and( + eq(pax8Orders.id, bundle.order.id), + eq(pax8Orders.partnerId, bundle.order.partnerId), + eq(pax8Orders.orgId, bundle.order.orgId), + eq(pax8Orders.integrationId, bundle.order.integrationId), + eq(pax8Orders.pax8CompanyId, bundle.order.pax8CompanyId!), + eq(pax8Orders.submittedAt, bundle.order.submittedAt!), + )) + .for('update') + .limit(1); + if (!locked) throw new Pax8OrderError('Pax8 order not found.', 404); + if (pax8OrderId && locked.pax8OrderId && locked.pax8OrderId !== pax8OrderId) { + throw new Pax8OrderError('The reconciled Pax8 parent conflicts with the captured order id.', 409); + } + await persistLineOutcomes(bundle, outcomes, ['in_flight', 'needs_reconcile']); + const lines = await reloadLines(bundle); + const status = deriveOrderStatus(lines.map((line) => line.submitState as Pax8SubmitState)); + const errors = lines.flatMap((line) => line.error ? [line.error] : []); + const updated = await db + .update(pax8Orders) + .set({ + status, + ...(pax8OrderId ? { pax8OrderId } : {}), + error: errors.length > 0 ? errors.join('\n').slice(0, 4000) : null, + updatedAt: new Date(), + }) + .where(and( + eq(pax8Orders.id, bundle.order.id), + eq(pax8Orders.partnerId, bundle.order.partnerId), + eq(pax8Orders.orgId, bundle.order.orgId), + eq(pax8Orders.pax8CompanyId, bundle.order.pax8CompanyId!), + eq(pax8Orders.submittedAt, bundle.order.submittedAt!), + pax8OrderId ? or( + isNull(pax8Orders.pax8OrderId), + eq(pax8Orders.pax8OrderId, pax8OrderId), + ) : undefined, + )) + .returning({ id: pax8Orders.id }); + if (updated.length !== 1) { + throw new Pax8OrderError('The reconciled Pax8 parent conflicts with the captured order id.', 409); + } + return { + resolved: outcomes.filter((outcome) => outcome.submitState !== 'needs_reconcile').length, + stillUnknown: outcomes.filter((outcome) => outcome.submitState === 'needs_reconcile').length, + }; + }); + }, +}; diff --git a/apps/api/src/services/pax8SyncService.dbcontext.test.ts b/apps/api/src/services/pax8SyncService.dbcontext.test.ts index 13bd745610..75558dc5f1 100644 --- a/apps/api/src/services/pax8SyncService.dbcontext.test.ts +++ b/apps/api/src/services/pax8SyncService.dbcontext.test.ts @@ -14,6 +14,9 @@ const fetchDepths: number[] = []; const dbCallDepths: number[] = []; // `.set(payload)` calls (update writes) with the context depth at call time. const updatePayloads: Array<{ depth: number; payload: Record }> = []; +const insertPayloads: unknown[] = []; +let subscriptionRecords: Array> = []; +let observationRows: Array> = []; function chain(result: unknown) { const c: Record = {}; @@ -21,7 +24,10 @@ function chain(result: unknown) { 'from', 'where', 'limit', 'values', 'returning', 'onConflictDoUpdate', 'onConflictDoNothing', 'innerJoin', 'leftJoin', ]) { - c[m] = vi.fn(() => c); + c[m] = vi.fn((payload?: unknown) => { + if (m === 'values') insertPayloads.push(payload); + return c; + }); } c.set = vi.fn((payload: Record) => { updatePayloads.push({ depth: contextDepth, payload }); @@ -49,9 +55,11 @@ vi.mock('../db', () => ({ // `.select()` (no projection) = the integration read in // createPax8ClientForIntegration; `.select({...})` = the mapped-company / // contract-line reads, which return empty for this empty-sync test. - select: vi.fn((projection?: unknown) => { + select: vi.fn((projection?: Record) => { dbCallDepths.push(contextDepth); - return chain(projection === undefined ? [INTEGRATION_ROW] : []); + if (projection === undefined) return chain([INTEGRATION_ROW]); + if ('linkId' in projection) return chain(observationRows); + return chain([]); }), update: vi.fn(() => { dbCallDepths.push(contextDepth); @@ -91,7 +99,7 @@ const listCompanies = vi.fn(async () => { }); const listSubscriptions = vi.fn(async () => { fetchDepths.push(contextDepth); - return []; + return subscriptionRecords; }); vi.mock('./pax8Client', () => ({ @@ -118,6 +126,9 @@ describe('pax8SyncService — DB context boundaries (#1697)', () => { fetchDepths.length = 0; dbCallDepths.length = 0; updatePayloads.length = 0; + insertPayloads.length = 0; + subscriptionRecords = []; + observationRows = []; }); it('fetches from Pax8 with no DB context held, but reads/writes inside one', async () => { @@ -129,7 +140,7 @@ describe('pax8SyncService — DB context boundaries (#1697)', () => { // Core regression guard: the external calls ran with NO open transaction. expect(fetchDepths).toEqual([0, 0]); - // Reads/writes (running status, integration read, contract-line apply, + // Reads/writes (running status, integration read, link observation, // success status) all ran inside a context. expect(dbCallDepths.length).toBeGreaterThan(0); for (const depth of dbCallDepths) { @@ -137,6 +148,60 @@ describe('pax8SyncService — DB context boundaries (#1697)', () => { } expect(result.integrationId).toBe('pax8-int-1'); + expect(result).toMatchObject({ observedContractLines: 0 }); + }); + + it('persists whether Pax8 supplied trustworthy subscription quantity evidence', async () => { + subscriptionRecords = [{ + pax8SubscriptionId: 'sub-missing-quantity', + pax8CompanyId: 'company-1', + productId: null, + productName: 'Unknown quantity product', + vendorName: null, + vendorSkuId: null, + status: 'ACTIVE', + billingTerm: null, + quantity: '0.00', + quantityKnown: false, + unitPrice: null, + unitCost: null, + currencyCode: null, + startDate: null, + endDate: null, + billingStart: null, + commitmentTermEndDate: null, + raw: {}, + }]; + + await syncPax8Integration('pax8-int-1'); + + expect(insertPayloads).toContainEqual([ + expect.objectContaining({ + pax8SubscriptionId: 'sub-missing-quantity', + quantity: '0.00', + quantityKnown: false, + }), + ]); + }); + + it('preserves the last genuine observation when current quantity evidence is unknown', async () => { + observationRows = [{ + linkId: 'link-unknown', + contractLineId: 'line-1', + linkOrgId: 'org-1', + quantity: '0.00', + quantityKnown: false, + lineType: 'manual', + lineOrgId: 'org-1', + subscriptionOrgId: 'org-1', + }]; + + const result = await syncPax8Integration('pax8-int-1'); + + expect(result).toMatchObject({ observedContractLines: 0, skippedContractLines: 1 }); + expect(updatePayloads).not.toContainEqual(expect.objectContaining({ + payload: expect.objectContaining({ lastObservedQuantity: '0.00' }), + })); }); it('on fetch failure, records the failed status on a fresh context and re-throws the ORIGINAL error', async () => { diff --git a/apps/api/src/services/pax8SyncService.test.ts b/apps/api/src/services/pax8SyncService.test.ts index 4b54375686..087b856651 100644 --- a/apps/api/src/services/pax8SyncService.test.ts +++ b/apps/api/src/services/pax8SyncService.test.ts @@ -36,8 +36,8 @@ vi.mock('../db/schema', () => ({ subscriptionSnapshotId: 'pax8_contract_line_links.subscription_snapshot_id', contractLineId: 'pax8_contract_line_links.contract_line_id', syncEnabled: 'pax8_contract_line_links.sync_enabled', - lastAppliedQuantity: 'pax8_contract_line_links.last_applied_quantity', - lastAppliedAt: 'pax8_contract_line_links.last_applied_at', + lastObservedQuantity: 'pax8_contract_line_links.last_applied_quantity', + lastObservedAt: 'pax8_contract_line_links.last_applied_at', updatedAt: 'pax8_contract_line_links.updated_at', }, pax8Integrations: { @@ -55,6 +55,7 @@ vi.mock('../db/schema', () => ({ pax8CompanyId: 'pax8_subscription_snapshots.pax8_company_id', orgId: 'pax8_subscription_snapshots.org_id', quantity: 'pax8_subscription_snapshots.quantity', + quantityKnown: 'pax8_subscription_snapshots.quantity_known', }, })); @@ -64,7 +65,7 @@ vi.mock('./secretCrypto', () => ({ })); import { db } from '../db'; -import { applyEnabledPax8ContractLineLinks, linkPax8SubscriptionToContractLine, mapPax8Company, unlinkPax8Subscription } from './pax8SyncService'; +import { linkPax8SubscriptionToContractLine, mapPax8Company, recordPax8SubscriptionObservations, unlinkPax8Subscription } from './pax8SyncService'; function selectRowsOnce(rows: unknown[]) { vi.mocked(db.select).mockReturnValueOnce({ @@ -194,7 +195,7 @@ describe('pax8SyncService', () => { }); }); - it('applies quantities only for valid manual same-org links', async () => { + it('records valid subscription observations without writing contract line quantities', async () => { const joinChain = { innerJoin: vi.fn(() => joinChain), where: vi.fn(async () => [ @@ -203,6 +204,7 @@ describe('pax8SyncService', () => { contractLineId: 'line-1', linkOrgId: 'org-1', quantity: '12.00', + quantityKnown: true, lineType: 'manual', lineOrgId: 'org-1', subscriptionOrgId: 'org-1', @@ -212,6 +214,17 @@ describe('pax8SyncService', () => { contractLineId: 'line-2', linkOrgId: 'org-2', quantity: '5.00', + quantityKnown: true, + lineType: 'manual', + lineOrgId: 'org-1', + subscriptionOrgId: 'org-1', + }, + { + linkId: 'link-3', + contractLineId: 'line-3', + linkOrgId: 'org-1', + quantity: '0.00', + quantityKnown: false, lineType: 'manual', lineOrgId: 'org-1', subscriptionOrgId: 'org-1', @@ -221,11 +234,11 @@ describe('pax8SyncService', () => { vi.mocked(db.select).mockReturnValueOnce({ from: vi.fn(() => joinChain), } as any); - const lineUpdate = updateNoReturnOnce(); const linkUpdate = updateNoReturnOnce(); - await expect(applyEnabledPax8ContractLineLinks('integration-1')).resolves.toEqual({ applied: 1, skipped: 1 }); - expect(lineUpdate.set).toHaveBeenCalledWith({ manualQuantity: '12.00' }); - expect(linkUpdate.set).toHaveBeenCalledWith(expect.objectContaining({ lastAppliedQuantity: '12.00' })); + await expect(recordPax8SubscriptionObservations('integration-1')).resolves.toEqual({ observed: 1, skipped: 2 }); + expect(db.update).not.toHaveBeenCalledWith(expect.objectContaining({ id: 'contract_lines.id' })); + expect(db.update).toHaveBeenCalledTimes(1); + expect(linkUpdate.set).toHaveBeenCalledWith(expect.objectContaining({ lastObservedQuantity: '12.00' })); }); }); diff --git a/apps/api/src/services/pax8SyncService.ts b/apps/api/src/services/pax8SyncService.ts index 012ab7a02e..0cef738c5e 100644 --- a/apps/api/src/services/pax8SyncService.ts +++ b/apps/api/src/services/pax8SyncService.ts @@ -153,6 +153,7 @@ async function upsertSubscriptions(params: { status: truncate(sub.status, 40), billingTerm: truncate(sub.billingTerm, 40), quantity: sub.quantity, + quantityKnown: sub.quantityKnown, unitPrice: sub.unitPrice, unitCost: sub.unitCost, currencyCode: sub.currencyCode?.slice(0, 3).toUpperCase() ?? null, @@ -179,6 +180,7 @@ async function upsertSubscriptions(params: { status: sql`excluded.status`, billingTerm: sql`excluded.billing_term`, quantity: sql`excluded.quantity`, + quantityKnown: sql`excluded.quantity_known`, unitPrice: sql`excluded.unit_price`, unitCost: sql`excluded.unit_cost`, currencyCode: sql`excluded.currency_code`, @@ -229,13 +231,28 @@ async function upsertProductMappings(integrationId: string, partnerId: string, s return values.length; } -export async function applyEnabledPax8ContractLineLinks(integrationId: string): Promise<{ applied: number; skipped: number }> { +/** + * Records what Pax8 currently REPORTS for each linked subscription. It does NOT + * write contract_lines.manual_quantity — that was the old behavior and it was a + * billing bug: Pax8's API Subscription.quantity is stale and does not match the + * seat counts Pax8 actually invoices the partner for, so every sync_enabled link + * was feeding a wrong number into the contract billing sweep and out onto the + * customer's invoice. + * + * Breeze's order ledger (pax8_orders / pax8_order_lines) is the source of truth + * for billable quantity: we know what the customer has because every add, change, + * and cancel went through us. Pax8 is now only a DRIFT DETECTOR — see + * detectPax8Drift(), which surfaces the disagreement (someone changed seats in + * the Pax8 portal, bypassing Breeze) instead of silently overwriting the bill. + */ +export async function recordPax8SubscriptionObservations(integrationId: string): Promise<{ observed: number; skipped: number }> { const rows = await db .select({ linkId: pax8ContractLineLinks.id, contractLineId: pax8ContractLineLinks.contractLineId, linkOrgId: pax8ContractLineLinks.orgId, quantity: pax8SubscriptionSnapshots.quantity, + quantityKnown: pax8SubscriptionSnapshots.quantityKnown, lineType: contractLines.lineType, lineOrgId: contractLines.orgId, subscriptionOrgId: pax8SubscriptionSnapshots.orgId, @@ -245,23 +262,20 @@ export async function applyEnabledPax8ContractLineLinks(integrationId: string): .innerJoin(contractLines, eq(pax8ContractLineLinks.contractLineId, contractLines.id)) .where(and(eq(pax8ContractLineLinks.integrationId, integrationId), eq(pax8ContractLineLinks.syncEnabled, true))); - let applied = 0; + let observed = 0; let skipped = 0; for (const row of rows) { - if (row.lineType !== 'manual' || !row.subscriptionOrgId || row.subscriptionOrgId !== row.lineOrgId || row.linkOrgId !== row.lineOrgId) { + if (!row.quantityKnown || row.lineType !== 'manual' || !row.subscriptionOrgId || row.subscriptionOrgId !== row.lineOrgId || row.linkOrgId !== row.lineOrgId) { skipped++; continue; } const now = new Date(); - await db.update(contractLines) - .set({ manualQuantity: row.quantity }) - .where(and(eq(contractLines.id, row.contractLineId), eq(contractLines.lineType, 'manual' as never))); await db.update(pax8ContractLineLinks) - .set({ lastAppliedQuantity: row.quantity, lastAppliedAt: now, updatedAt: now }) + .set({ lastObservedQuantity: row.quantity, lastObservedAt: now, updatedAt: now }) .where(eq(pax8ContractLineLinks.id, row.linkId)); - applied++; + observed++; } - return { applied, skipped }; + return { observed, skipped }; } export interface Pax8SyncResult { @@ -269,7 +283,7 @@ export interface Pax8SyncResult { companies: number; subscriptions: number; products: number; - appliedContractLines: number; + observedContractLines: number; skippedContractLines: number; } @@ -314,7 +328,7 @@ export async function syncPax8Integration(integrationId: string): Promise ({ + stagePax8OrderFromQuoteMock: vi.fn(), +})); + +vi.mock('./quoteToPax8Order', () => ({ + stagePax8OrderFromQuote: stagePax8OrderFromQuoteMock, +})); + // Controllable Drizzle chain mock (same pattern as quoteService.test.ts / // invoiceService.test.ts): every builder method returns the same chain; a // query resolves when awaited (the chain is a thenable that yields the next @@ -97,7 +105,11 @@ function queueAcceptHappyPath(quoteOverrides: Record = {}) { } describe('acceptQuote deposit snapshot', () => { - beforeEach(() => { results.length = 0; vi.clearAllMocks(); }); + beforeEach(() => { + results.length = 0; + vi.clearAllMocks(); + stagePax8OrderFromQuoteMock.mockResolvedValue({ orderId: null, lineCount: 0 }); + }); it('snapshots quote.depositAmount onto the issued invoice as depositDue when a deposit is configured', async () => { queueAcceptHappyPath({ depositType: 'percent', depositPercent: '30.00', depositAmount: '300.00' }); @@ -118,4 +130,28 @@ describe('acceptQuote deposit snapshot', () => { const setMock = (db as unknown as Chain).set; expect(setMock.mock.calls[0]![0]).not.toHaveProperty('depositDue'); }); + + it('stages Phase 5 before the final quote read and exposes the order id', async () => { + const { quote, line } = queueAcceptHappyPath(); + stagePax8OrderFromQuoteMock.mockResolvedValue({ orderId: 'pax8-order-1', lineCount: 1 }); + + const result = await acceptQuote(baseParams); + + expect(stagePax8OrderFromQuoteMock).toHaveBeenCalledWith({ + quoteId: quote.id, + orgId: quote.orgId, + partnerId: quote.partnerId, + contractIds: [], + contractLineLinks: [], + lines: [{ + id: line.id, + catalogItemId: null, + quantity: line.quantity, + recurrence: line.recurrence, + customerVisible: line.customerVisible, + }], + actorUserId: null, + }); + expect(result.pax8OrderId).toBe('pax8-order-1'); + }); }); diff --git a/apps/api/src/services/quoteAcceptService.ts b/apps/api/src/services/quoteAcceptService.ts index 0c975a8c51..c808ee89c3 100644 --- a/apps/api/src/services/quoteAcceptService.ts +++ b/apps/api/src/services/quoteAcceptService.ts @@ -12,7 +12,8 @@ import { isQuoteExpired } from './quoteExpiry'; import { emitInvoiceEvent } from './invoiceEvents'; import { enqueueInvoicePdfRender } from '../jobs/invoiceWorker'; import { buildContractSpecsFromQuote } from './quoteToContract'; -import { createContractWithLines } from './contractService'; +import { createContractWithLinesDetailed } from './contractService'; +import { stagePax8OrderFromQuote } from './quoteToPax8Order'; export interface AcceptQuoteParams { quoteId: string; @@ -26,6 +27,15 @@ export interface AcceptQuoteParams { type QuoteRow = typeof quotes.$inferSelect; +export interface AcceptQuoteResult { + quote: QuoteRow; + acceptanceId: string; + invoiceId: string; + invoiceIssued: boolean; + contractIds: string[]; + pax8OrderId: string | null; +} + /** * Shared accept pipeline for both the portal and public paths. The CALLER is * responsible for establishing the DB access context: portal handlers run under @@ -45,7 +55,7 @@ type QuoteRow = typeof quotes.$inferSelect; */ export async function acceptQuote( params: AcceptQuoteParams -): Promise<{ quote: QuoteRow; acceptanceId: string; invoiceId: string; invoiceIssued: boolean; contractIds: string[] }> { +): Promise { // FOR UPDATE: serialize concurrent accepts on the same quote (we're already in // the caller's transaction). Without the row lock two READ COMMITTED accepts // both pass the status guard and each create an invoice (atom-1/C2). @@ -234,6 +244,7 @@ export async function acceptQuote( terms: quote.terms ?? null, }, lines.map((l) => ({ + sourceQuoteLineId: l.id, recurrence: l.recurrence, customerVisible: l.customerVisible, name: l.name ?? null, @@ -249,16 +260,52 @@ export async function acceptQuote( ); const contractIds: string[] = []; + const contractLineLinks: Array<{ quoteLineId: string; contractLineId: string }> = []; for (const spec of contractSpecs) { - const contract = await createContractWithLines(spec); - contractIds.push(contract.id); + const created = await createContractWithLinesDetailed(spec); + contractIds.push(created.contract.id); + for (const line of created.lines) { + if (line.sourceQuoteLineId) { + contractLineLinks.push({ + quoteLineId: line.sourceQuoteLineId, + contractLineId: line.id, + }); + } + } } + // Phase 5: stage any Pax8-backed fulfillment in this exact transaction, + // alongside the Phase 4 contracts it references. Nothing is sent to Pax8 + // here: customer acceptance records intent; a technician supplies the + // provisioning details and explicitly submits it later. + const pax8Staged = await stagePax8OrderFromQuote({ + quoteId: quote.id, + orgId: quote.orgId, + partnerId: quote.partnerId, + contractIds, + contractLineLinks, + lines: lines.map((line) => ({ + id: line.id, + catalogItemId: line.catalogItemId ?? null, + quantity: line.quantity, + recurrence: line.recurrence, + customerVisible: line.customerVisible, + })), + actorUserId: params.actorUserId ?? null, + }); + const [updated] = await db.select().from(quotes).where(eq(quotes.id, quote.id)).limit(1); // invoiceIssued mirrors the `oneTime.length > 0` branch above that flips the // invoice to status='sent' with a real number; a $0/no-one-time accept leaves // the invoice unissued. The caller emits lifecycle side effects post-commit. - return { quote: updated!, acceptanceId: acceptance!.id, invoiceId: invoice!.id, invoiceIssued: oneTime.length > 0, contractIds }; + return { + quote: updated!, + acceptanceId: acceptance!.id, + invoiceId: invoice!.id, + invoiceIssued: oneTime.length > 0, + contractIds, + pax8OrderId: pax8Staged.orderId, + }; } /** diff --git a/apps/api/src/services/quoteLifecycle.test.ts b/apps/api/src/services/quoteLifecycle.test.ts index b85e71164f..a2664147c2 100644 --- a/apps/api/src/services/quoteLifecycle.test.ts +++ b/apps/api/src/services/quoteLifecycle.test.ts @@ -174,6 +174,7 @@ describe('sendQuote deposit validation', () => { }]); queueResult([]); // blocks queueResult([]); // lines — none at all, so dueOnAcceptanceTotal is $0 + queueResult([]); // no staged Pax8 order await expect(sendQuote('q1', actor)).rejects.toMatchObject({ status: 409, code: 'DEPOSIT_INVALID' }); }); @@ -185,6 +186,7 @@ describe('sendQuote deposit validation', () => { }]); queueResult([]); // blocks queueResult([{ quantity: '1', unitPrice: '1000.00', taxable: true, customerVisible: true, recurrence: 'one_time', depositEligible: false }]); + queueResult([]); // no staged Pax8 order await expect(sendQuote('q1', actor)).rejects.toMatchObject({ status: 409, code: 'DEPOSIT_INVALID' }); }); @@ -201,6 +203,7 @@ describe('sendQuote deposit validation', () => { queueResult([]); // blocks // A one-time line exists (so dueOnAcceptance > 0) but NONE are depositEligible. queueResult([{ quantity: '1', unitPrice: '1000.00', taxable: true, customerVisible: true, recurrence: 'one_time', depositEligible: false }]); + queueResult([]); // no staged Pax8 order await expect(sendQuote('q1', actor)).rejects.toMatchObject({ status: 409, code: 'DEPOSIT_INVALID' }); }); @@ -212,6 +215,7 @@ describe('sendQuote deposit validation', () => { }]); queueResult([]); // blocks queueResult([]); // lines + queueResult([]); // no staged Pax8 order // Proves the deposit gate is skipped for depositType 'none' — the failure // that surfaces is the pre-existing status guard, never DEPOSIT_INVALID. @@ -259,6 +263,7 @@ describe('sendQuote customer-facing PDF', () => { }]); queueResult([]); // blocks queueResult([visibleLine, internalLine]); // lines + queueResult([]); // no staged Pax8 order queueResult([{ id: 'p1', name: 'Acme MSP', billingTermsAndConditions: null, invoiceFooter: null }]); // partnerRow (reused for partner name) queueResult([{ name: 'Customer Co', taxId: null, billingContact: { email: 'billing@customer.example' } }]); // org (billing snapshot + recipient) @@ -306,6 +311,7 @@ describe('sendQuote bill-to snapshot', () => { queueResult([quote]); // getQuote: quote queueResult([]); // getQuote: blocks queueResult([{ quantity: '1', unitPrice: '100.00', taxable: false, customerVisible: true, recurrence: 'one_time', depositEligible: false, lineTotal: '100.00' }]); // getQuote: lines + queueResult([]); // getQuote: no staged Pax8 order queueResult([{ id: 'p1', name: 'Acme MSP', billingTermsAndConditions: null, invoiceFooter: null }]); // partnerRow (reused for partner name) queueResult([org]); // org (billing snapshot + recipient) queueResult([{ id: 'q1' }]); // update ... returning (claimed) diff --git a/apps/api/src/services/quoteService.siteScope.test.ts b/apps/api/src/services/quoteService.siteScope.test.ts index 11398000d2..aa4eb342bf 100644 --- a/apps/api/src/services/quoteService.siteScope.test.ts +++ b/apps/api/src/services/quoteService.siteScope.test.ts @@ -51,6 +51,7 @@ describe('quoteService site-axis guard', () => { queueResult([{ id: 'q1', orgId: 'org1', partnerId: 'p1', status: 'draft', siteId: null, taxRate: null, depositType: 'none', depositPercent: null }]); // quote row queueResult([]); // blocks queueResult([]); // lines + queueResult([]); // staged Pax8 order const { quote } = await svc.getQuote('q1', unrestricted); expect(quote.id).toBe('q1'); }); diff --git a/apps/api/src/services/quoteService.test.ts b/apps/api/src/services/quoteService.test.ts index b7ac7e331c..fa3cbb98f5 100644 --- a/apps/api/src/services/quoteService.test.ts +++ b/apps/api/src/services/quoteService.test.ts @@ -141,6 +141,7 @@ describe('quoteService deposits', () => { queueResult([{ id: 'q1', orgId: 'org1', taxRate: '0.10000', depositType: 'percent', depositPercent: '30.00' }]); // quote queueResult([]); // blocks queueResult([{ quantity: '1', unitPrice: '1000.00', taxable: true, customerVisible: true, recurrence: 'one_time', depositEligible: false, itemType: 'hardware' }]); // lines + queueResult([]); // no staged Pax8 order const { quote } = await svc.getQuote('q1', actor); @@ -150,6 +151,31 @@ describe('quoteService deposits', () => { ]); }); + it('getQuote returns the persisted staged Pax8 order summary for reloads', async () => { + queueResult([{ id: 'q1', orgId: 'org1', partnerId: 'p1', taxRate: null, depositType: 'none', depositPercent: null }]); + queueResult([]); // blocks + queueResult([]); // quote lines + queueResult([{ pax8OrderId: 'order-1' }]); + queueResult([{ count: 3 }]); + + const detail = await svc.getQuote('q1', actor); + + expect(detail.pax8OrderId).toBe('order-1'); + expect(detail.pax8OrderLineCount).toBe(3); + }); + + it('getQuote returns a null staged-order summary when acceptance staged no Pax8 order', async () => { + queueResult([{ id: 'q1', orgId: 'org1', partnerId: 'p1', taxRate: null, depositType: 'none', depositPercent: null }]); + queueResult([]); // blocks + queueResult([]); // quote lines + queueResult([]); // no Pax8 order for this quote/tenant + + const detail = await svc.getQuote('q1', actor); + + expect(detail.pax8OrderId).toBeNull(); + expect(detail.pax8OrderLineCount).toBe(0); + }); + it('listQuotes left-joins the converted invoice and flattens invoiceDepositDue/invoiceAmountPaid onto each row', async () => { // The chain mock yields queued rows regardless of shape; queue the joined // projection shape the real select({ quote, invoiceDepositDue, invoiceAmountPaid }) returns. diff --git a/apps/api/src/services/quoteService.ts b/apps/api/src/services/quoteService.ts index a4d03e4854..56a239309e 100644 --- a/apps/api/src/services/quoteService.ts +++ b/apps/api/src/services/quoteService.ts @@ -1,10 +1,11 @@ -import { and, desc, eq, inArray, lt, or, sql } from 'drizzle-orm'; +import { and, count, desc, eq, inArray, lt, or, sql } from 'drizzle-orm'; import { randomUUID } from 'node:crypto'; import { db, runOutsideDbContext, withSystemDbAccessContext } from '../db'; import { quotes, quoteLines, quoteBlocks, quoteImages } from '../db/schema/quotes'; import { invoices } from '../db/schema/invoices'; import { organizations, partners } from '../db/schema/orgs'; import { catalogItems } from '../db/schema/catalog'; +import { pax8OrderLines, pax8Orders } from '../db/schema/pax8Orders'; import { computeLineTotal, resolveEffectiveTaxRate } from './invoiceMath'; import { computeQuoteTotals, validateQuoteDeposit, toQuoteDepositConfig, type QuoteLineForMath } from './quoteMath'; import { QuoteServiceError, type QuoteActor } from './quoteTypes'; @@ -331,6 +332,22 @@ export async function getQuote(id: string, actor: QuoteActor) { assertQuoteAccess(actor, q); const blocks = await db.select().from(quoteBlocks).where(eq(quoteBlocks.quoteId, id)).orderBy(quoteBlocks.sortOrder); const lines = await db.select().from(quoteLines).where(eq(quoteLines.quoteId, id)).orderBy(quoteLines.sortOrder); + // Quote acceptance returns the staged order id once, but the technician may + // reload or open the converted quote later. Keep discoverability in the quote + // read model itself. The quote access check runs first, and the lookup repeats + // the partner + org axes in addition to relying on the tables' forced RLS. + const [pax8OrderSummary] = await db.select({ pax8OrderId: pax8Orders.id }).from(pax8Orders).where(and( + eq(pax8Orders.sourceQuoteId, id), + eq(pax8Orders.partnerId, q.partnerId), + eq(pax8Orders.orgId, q.orgId), + )).orderBy(desc(pax8Orders.createdAt)).limit(1); + const [pax8OrderLineSummary] = pax8OrderSummary + ? await db.select({ count: count(pax8OrderLines.id) }).from(pax8OrderLines).where(and( + eq(pax8OrderLines.orderId, pax8OrderSummary.pax8OrderId), + eq(pax8OrderLines.partnerId, q.partnerId), + eq(pax8OrderLines.orgId, q.orgId), + )) + : []; // dueOnAcceptanceTotal is a derived (non-persisted) figure: the amount accept // actually invoices (one-time lines only — recurring is deferred to the Phase 4 // contract). Computed from the canonical quoteMath so it stays penny-consistent @@ -348,7 +365,10 @@ export async function getQuote(id: string, actor: QuoteActor) { depositDueTotal: totals.depositDueTotal, categoryBreakdown: totals.categoryBreakdown, }, - blocks, lines, + blocks, + lines, + pax8OrderId: pax8OrderSummary?.pax8OrderId ?? null, + pax8OrderLineCount: Number(pax8OrderLineSummary?.count ?? 0), }; } diff --git a/apps/api/src/services/quoteToContract.test.ts b/apps/api/src/services/quoteToContract.test.ts index 263b8d9b2a..f4374bc8dc 100644 --- a/apps/api/src/services/quoteToContract.test.ts +++ b/apps/api/src/services/quoteToContract.test.ts @@ -12,6 +12,7 @@ const quote: QuoteForContract = { function line(over: Partial): QuoteLineForContract { return { + sourceQuoteLineId: 'quote-line-1', recurrence: 'monthly', customerVisible: true, name: null, @@ -182,7 +183,7 @@ describe('buildContractSpecsFromQuote', () => { expect(specs[0]!.currencyCode).toBe('USD'); }); - it('drops catalogItemId so the contract bills the frozen quote price, not the live catalog price', () => { + it('drops catalogItemId to keep the frozen quote price and carries an in-memory source reference', () => { const specs = buildContractSpecsFromQuote( quote, [line({ recurrence: 'monthly', catalogItemId: 'cat-123', unitPrice: '42.00' })], @@ -191,6 +192,7 @@ describe('buildContractSpecsFromQuote', () => { ); expect(specs[0]!.lines[0]!.catalogItemId).toBeNull(); expect(specs[0]!.lines[0]!.unitPrice).toBe('42.00'); + expect(specs[0]!.lines[0]!.sourceQuoteLineId).toBe('quote-line-1'); }); it('annual line with termMonths=12 gets endDate; monthly line with termMonths=null gets endDate===null', () => { diff --git a/apps/api/src/services/quoteToContract.ts b/apps/api/src/services/quoteToContract.ts index 35a9e6ea98..f6e1f5f74b 100644 --- a/apps/api/src/services/quoteToContract.ts +++ b/apps/api/src/services/quoteToContract.ts @@ -12,6 +12,7 @@ export interface QuoteForContract { } export interface QuoteLineForContract { + sourceQuoteLineId: string; recurrence: 'one_time' | 'monthly' | 'annual'; customerVisible: boolean; name: string | null; @@ -32,6 +33,8 @@ export interface NewContractLineSpec { manualQuantity?: string | null; siteId?: string | null; sortOrder?: number; + /** In-memory Phase 4 → Phase 5 correlation only; never persisted. */ + sourceQuoteLineId?: string | null; } export interface NewContractSpec { @@ -105,7 +108,12 @@ export function buildContractSpecsFromQuote( unitPrice: l.unitPrice, manualQuantity: l.quantity, taxable: l.taxable, - catalogItemId: null, // drop the catalog link so billing uses the frozen quote price, not the live catalog price + // Deliberately drop the catalog link: generated invoices treat linked + // contract lines as live catalog pricing. The accepted quote price is + // frozen and must never be re-resolved later. + catalogItemId: null, + // Non-persisted correlation consumed immediately by acceptQuote Phase 5. + sourceQuoteLineId: l.sourceQuoteLineId, sortOrder: i, })), }); diff --git a/apps/api/src/services/quoteToPax8Order.test.ts b/apps/api/src/services/quoteToPax8Order.test.ts new file mode 100644 index 0000000000..943f636cd6 --- /dev/null +++ b/apps/api/src/services/quoteToPax8Order.test.ts @@ -0,0 +1,231 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + PAX8_COMPANY_MAPPING_REQUIRED_ERROR, + createQuoteToPax8OrderService, + type QuoteToPax8OrderRepository, + type StagePax8OrderInput, +} from './quoteToPax8Order'; + +const ORDER_ID = '11111111-1111-4111-8111-111111111111'; +const PARTNER_ID = '22222222-2222-4222-8222-222222222222'; +const ORG_ID = '33333333-3333-4333-8333-333333333333'; +const QUOTE_ID = '44444444-4444-4444-8444-444444444444'; +const INTEGRATION_ID = '55555555-5555-4555-8555-555555555555'; + +function line(overrides: Partial = {}) { + return { + id: '66666666-6666-4666-8666-666666666666', + catalogItemId: '77777777-7777-4777-8777-777777777777', + quantity: '3.00', + recurrence: 'monthly' as const, + customerVisible: true, + ...overrides, + }; +} + +const baseInput: StagePax8OrderInput = { + quoteId: QUOTE_ID, + orgId: ORG_ID, + partnerId: PARTNER_ID, + contractIds: ['88888888-8888-4888-8888-888888888888'], + contractLineLinks: [{ + quoteLineId: '66666666-6666-4666-8666-666666666666', + contractLineId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + }], + lines: [line()], + actorUserId: '99999999-9999-4999-8999-999999999999', +}; + +function makeRepository() { + const repo: QuoteToPax8OrderRepository = { + findActiveProductMappings: vi.fn().mockResolvedValue([{ + integrationId: INTEGRATION_ID, + catalogItemId: baseInput.lines[0]!.catalogItemId!, + pax8ProductId: 'product-1', + }]), + findCompanyMappings: vi.fn().mockResolvedValue([]), + findCreatedContractLines: vi.fn().mockResolvedValue([{ + id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + }]), + insertOrder: vi.fn().mockResolvedValue(undefined), + insertOrderLines: vi.fn().mockResolvedValue(undefined), + }; + return repo; +} + +describe('stagePax8OrderFromQuote', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns null and writes nothing when the quote has no Pax8-backed lines', async () => { + const repo = makeRepository(); + vi.mocked(repo.findActiveProductMappings).mockResolvedValue([]); + const stage = createQuoteToPax8OrderService({ repository: repo, randomUUID: () => ORDER_ID }); + + await expect(stage(baseInput)).resolves.toEqual({ orderId: null, lineCount: 0 }); + expect(repo.insertOrder).not.toHaveBeenCalled(); + expect(repo.insertOrderLines).not.toHaveBeenCalled(); + }); + + it('stages one new_subscription line per backed line with stable order and exact billing terms', async () => { + const repo = makeRepository(); + const annual = line({ + id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + catalogItemId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + quantity: '12.50', + recurrence: 'annual', + }); + const oneTime = line({ + id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + catalogItemId: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', + quantity: '2.00', + recurrence: 'one_time', + }); + vi.mocked(repo.findActiveProductMappings).mockResolvedValue([ + { integrationId: INTEGRATION_ID, catalogItemId: baseInput.lines[0]!.catalogItemId!, pax8ProductId: 'monthly-product' }, + { integrationId: INTEGRATION_ID, catalogItemId: annual.catalogItemId!, pax8ProductId: 'annual-product' }, + { integrationId: INTEGRATION_ID, catalogItemId: oneTime.catalogItemId!, pax8ProductId: 'one-time-product' }, + ]); + vi.mocked(repo.findCreatedContractLines).mockResolvedValue([ + { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + { id: 'ffffffff-ffff-4fff-8fff-ffffffffffff' }, + ]); + const stage = createQuoteToPax8OrderService({ repository: repo, randomUUID: () => ORDER_ID }); + + await expect(stage({ + ...baseInput, + lines: [baseInput.lines[0]!, annual, oneTime], + contractLineLinks: [ + ...baseInput.contractLineLinks, + { quoteLineId: annual.id, contractLineId: 'ffffffff-ffff-4fff-8fff-ffffffffffff' }, + ], + })) + .resolves.toEqual({ orderId: ORDER_ID, lineCount: 3 }); + + expect(repo.insertOrder).toHaveBeenCalledWith(expect.objectContaining({ + id: ORDER_ID, + source: 'quote', + sourceQuoteId: QUOTE_ID, + integrationId: INTEGRATION_ID, + partnerId: PARTNER_ID, + orgId: ORG_ID, + dedupeKey: `order:${ORDER_ID}`, + status: 'awaiting_details', + createdBy: baseInput.actorUserId, + })); + expect(repo.insertOrderLines).toHaveBeenCalledWith([ + expect.objectContaining({ action: 'new_subscription', billingTerm: 'Monthly', quantity: '3.00', sortOrder: 0, provisioningDetails: [] }), + expect.objectContaining({ action: 'new_subscription', billingTerm: 'Annual', quantity: '12.50', sortOrder: 1, provisioningDetails: [] }), + expect.objectContaining({ action: 'new_subscription', billingTerm: 'One-Time', quantity: '2.00', sortOrder: 2, provisioningDetails: [] }), + ]); + }); + + it('stages an unmapped organization with a null company id and the safe actionable error', async () => { + const repo = makeRepository(); + const stage = createQuoteToPax8OrderService({ repository: repo, randomUUID: () => ORDER_ID }); + + await stage(baseInput); + + expect(repo.insertOrder).toHaveBeenCalledWith(expect.objectContaining({ + pax8CompanyId: null, + error: PAX8_COMPANY_MAPPING_REQUIRED_ERROR, + })); + }); + + it('captures the exact company id when one unignored mapping exists', async () => { + const repo = makeRepository(); + vi.mocked(repo.findCompanyMappings).mockResolvedValue([{ pax8CompanyId: 'company-1' }]); + const stage = createQuoteToPax8OrderService({ repository: repo, randomUUID: () => ORDER_ID }); + + await stage(baseInput); + + expect(repo.findCompanyMappings).toHaveBeenCalledWith(PARTNER_ID, ORG_ID, INTEGRATION_ID); + expect(repo.insertOrder).toHaveBeenCalledWith(expect.objectContaining({ pax8CompanyId: 'company-1', error: null })); + }); + + it('treats ambiguous company mappings as unresolved without blocking acceptance', async () => { + const repo = makeRepository(); + vi.mocked(repo.findCompanyMappings).mockResolvedValue([ + { pax8CompanyId: 'company-1' }, + { pax8CompanyId: 'company-2' }, + ]); + const stage = createQuoteToPax8OrderService({ repository: repo, randomUUID: () => ORDER_ID }); + + await stage(baseInput); + + expect(repo.insertOrder).toHaveBeenCalledWith(expect.objectContaining({ + pax8CompanyId: null, + error: PAX8_COMPANY_MAPPING_REQUIRED_ERROR, + })); + }); + + it('attaches only Phase 4 contract lines, claims duplicates once, and never attaches one-time lines', async () => { + const repo = makeRepository(); + const duplicate = line({ id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' }); + const oneTime = line({ id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', recurrence: 'one_time' }); + vi.mocked(repo.findCreatedContractLines).mockResolvedValue([ + { id: 'contract-line-1' }, + { id: 'contract-line-2' }, + ]); + const stage = createQuoteToPax8OrderService({ repository: repo, randomUUID: () => ORDER_ID }); + + const links = [ + { quoteLineId: baseInput.lines[0]!.id, contractLineId: 'contract-line-1' }, + { quoteLineId: duplicate.id, contractLineId: 'contract-line-2' }, + ]; + await stage({ ...baseInput, lines: [baseInput.lines[0]!, duplicate, oneTime], contractLineLinks: links }); + + expect(repo.findCreatedContractLines).toHaveBeenCalledWith( + PARTNER_ID, + ORG_ID, + baseInput.contractIds, + ['contract-line-1', 'contract-line-2'], + ); + const inserted = vi.mocked(repo.insertOrderLines).mock.calls[0]![0]; + expect(inserted.map((row) => row.contractLineId)).toEqual(['contract-line-1', 'contract-line-2', null]); + }); + + it('fails closed when a recurring backed line cannot match a Phase 4 contract line', async () => { + const repo = makeRepository(); + vi.mocked(repo.findCreatedContractLines).mockResolvedValue([]); + const stage = createQuoteToPax8OrderService({ repository: repo, randomUUID: () => ORDER_ID }); + + await expect(stage(baseInput)).rejects.toMatchObject({ status: 409 }); + expect(repo.insertOrder).not.toHaveBeenCalled(); + }); + + it('fails closed when one catalog item has multiple active Pax8 product mappings', async () => { + const repo = makeRepository(); + vi.mocked(repo.findActiveProductMappings).mockResolvedValue([ + { integrationId: INTEGRATION_ID, catalogItemId: baseInput.lines[0]!.catalogItemId!, pax8ProductId: 'product-1' }, + { integrationId: INTEGRATION_ID, catalogItemId: baseInput.lines[0]!.catalogItemId!, pax8ProductId: 'product-2' }, + ]); + const stage = createQuoteToPax8OrderService({ repository: repo, randomUUID: () => ORDER_ID }); + + await expect(stage(baseInput)).rejects.toMatchObject({ status: 409 }); + expect(repo.insertOrder).not.toHaveBeenCalled(); + }); + + it('fails closed rather than combining mappings from different active integrations', async () => { + const repo = makeRepository(); + vi.mocked(repo.findActiveProductMappings).mockResolvedValue([ + { integrationId: INTEGRATION_ID, catalogItemId: baseInput.lines[0]!.catalogItemId!, pax8ProductId: 'product-1' }, + { integrationId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', catalogItemId: baseInput.lines[0]!.catalogItemId!, pax8ProductId: 'product-2' }, + ]); + const stage = createQuoteToPax8OrderService({ repository: repo, randomUUID: () => ORDER_ID }); + + await expect(stage(baseInput)).rejects.toMatchObject({ status: 409 }); + expect(repo.insertOrder).not.toHaveBeenCalled(); + }); + + it('ignores hidden, catalog-less, and foreign-partner lines returned without a scoped mapping', async () => { + const repo = makeRepository(); + const hidden = line({ id: 'hidden', customerVisible: false }); + const catalogless = line({ id: 'catalogless', catalogItemId: null }); + vi.mocked(repo.findActiveProductMappings).mockResolvedValue([]); + const stage = createQuoteToPax8OrderService({ repository: repo, randomUUID: () => ORDER_ID }); + + await expect(stage({ ...baseInput, lines: [hidden, catalogless] })) + .resolves.toEqual({ orderId: null, lineCount: 0 }); + expect(repo.findActiveProductMappings).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/services/quoteToPax8Order.ts b/apps/api/src/services/quoteToPax8Order.ts new file mode 100644 index 0000000000..e1c7a6b34f --- /dev/null +++ b/apps/api/src/services/quoteToPax8Order.ts @@ -0,0 +1,270 @@ +import { randomUUID } from 'node:crypto'; +import { and, asc, eq, inArray } from 'drizzle-orm'; +import { db } from '../db'; +import { + contracts, + contractLines, + pax8CompanyMappings, + pax8Integrations, + pax8OrderLines, + pax8Orders, + pax8ProductMappings, +} from '../db/schema'; +import { buildDedupeKey } from './pax8OrderService'; +import { QuoteServiceError } from './quoteTypes'; + +export const PAX8_COMPANY_MAPPING_REQUIRED_ERROR = + 'Organization is not mapped to a Pax8 company — map it before submitting.'; + +type QuoteLineRecurrence = 'monthly' | 'annual' | 'one_time'; + +export interface StagePax8OrderInput { + quoteId: string; + orgId: string; + partnerId: string; + contractIds: string[]; + contractLineLinks: Array<{ quoteLineId: string; contractLineId: string }>; + lines: Array<{ + id: string; + catalogItemId: string | null; + quantity: string; + recurrence: QuoteLineRecurrence; + customerVisible: boolean; + }>; + actorUserId: string | null; +} + +interface ActiveProductMapping { + integrationId: string; + catalogItemId: string; + pax8ProductId: string; +} + +interface CompanyMapping { + pax8CompanyId: string; +} + +interface CreatedContractLine { + id: string; +} + +type OrderInsert = typeof pax8Orders.$inferInsert; +type OrderLineInsert = typeof pax8OrderLines.$inferInsert; + +export interface QuoteToPax8OrderRepository { + findActiveProductMappings(partnerId: string, catalogItemIds: string[]): Promise; + findCompanyMappings(partnerId: string, orgId: string, integrationId: string): Promise; + findCreatedContractLines( + partnerId: string, + orgId: string, + contractIds: string[], + contractLineIds: string[], + ): Promise; + insertOrder(value: OrderInsert): Promise; + insertOrderLines(values: OrderLineInsert[]): Promise; +} + +export const quoteToPax8OrderRepository: QuoteToPax8OrderRepository = { + async findActiveProductMappings(partnerId, catalogItemIds) { + if (catalogItemIds.length === 0) return []; + return db + .select({ + integrationId: pax8ProductMappings.integrationId, + catalogItemId: pax8ProductMappings.catalogItemId, + pax8ProductId: pax8ProductMappings.pax8ProductId, + }) + .from(pax8ProductMappings) + .innerJoin(pax8Integrations, and( + eq(pax8Integrations.id, pax8ProductMappings.integrationId), + eq(pax8Integrations.partnerId, pax8ProductMappings.partnerId), + eq(pax8Integrations.isActive, true), + )) + .where(and( + eq(pax8ProductMappings.partnerId, partnerId), + inArray(pax8ProductMappings.catalogItemId, catalogItemIds), + )) as Promise; + }, + + async findCompanyMappings(partnerId, orgId, integrationId) { + return db + .select({ pax8CompanyId: pax8CompanyMappings.pax8CompanyId }) + .from(pax8CompanyMappings) + .innerJoin(pax8Integrations, and( + eq(pax8Integrations.id, pax8CompanyMappings.integrationId), + eq(pax8Integrations.partnerId, pax8CompanyMappings.partnerId), + eq(pax8Integrations.isActive, true), + )) + .where(and( + eq(pax8CompanyMappings.partnerId, partnerId), + eq(pax8CompanyMappings.integrationId, integrationId), + eq(pax8CompanyMappings.orgId, orgId), + eq(pax8CompanyMappings.ignored, false), + )); + }, + + async findCreatedContractLines(partnerId, orgId, contractIds, contractLineIds) { + if (contractIds.length === 0 || contractLineIds.length === 0) return []; + return db + .select({ id: contractLines.id }) + .from(contractLines) + .innerJoin(contracts, eq(contracts.id, contractLines.contractId)) + .where(and( + inArray(contracts.id, contractIds), + inArray(contractLines.id, contractLineIds), + eq(contracts.partnerId, partnerId), + eq(contracts.orgId, orgId), + eq(contractLines.orgId, orgId), + eq(contractLines.lineType, 'manual'), + )) + .orderBy(asc(contracts.id), asc(contractLines.sortOrder), asc(contractLines.id)); + }, + + async insertOrder(value) { + await db.insert(pax8Orders).values(value); + }, + + async insertOrderLines(values) { + if (values.length > 0) await db.insert(pax8OrderLines).values(values); + }, +}; + +function billingTermFor(recurrence: QuoteLineRecurrence): 'Monthly' | 'Annual' | 'One-Time' { + if (recurrence === 'monthly') return 'Monthly'; + if (recurrence === 'annual') return 'Annual'; + return 'One-Time'; +} + +interface QuoteToPax8OrderDependencies { + repository: QuoteToPax8OrderRepository; + randomUUID: () => string; +} + +export function createQuoteToPax8OrderService(deps: QuoteToPax8OrderDependencies) { + return async function stage(input: StagePax8OrderInput): Promise<{ orderId: string | null; lineCount: number }> { + const candidateLines = input.lines.filter( + (line) => line.customerVisible && line.catalogItemId !== null, + ); + if (candidateLines.length === 0) return { orderId: null, lineCount: 0 }; + + const catalogItemIds = [...new Set(candidateLines.map((line) => line.catalogItemId!))]; + const mappings = await deps.repository.findActiveProductMappings(input.partnerId, catalogItemIds); + + const mappingByCatalogItem = new Map(); + const integrationIds = new Set(); + for (const mapping of mappings) { + integrationIds.add(mapping.integrationId); + if (mappingByCatalogItem.has(mapping.catalogItemId)) { + throw new QuoteServiceError( + 'Pax8 product mapping is ambiguous for an accepted quote line.', + 409, + 'INVALID_STATE', + ); + } + mappingByCatalogItem.set(mapping.catalogItemId, mapping); + } + if (integrationIds.size > 1) { + throw new QuoteServiceError( + 'Accepted quote lines resolve to more than one active Pax8 integration.', + 409, + 'INVALID_STATE', + ); + } + + const backedLines = candidateLines.flatMap((line) => { + const mapping = mappingByCatalogItem.get(line.catalogItemId!); + return mapping ? [{ line, mapping }] : []; + }); + if (backedLines.length === 0) return { orderId: null, lineCount: 0 }; + + const integrationId = backedLines[0]!.mapping.integrationId; + const companyMappings = await deps.repository.findCompanyMappings( + input.partnerId, + input.orgId, + integrationId, + ); + const companyMapping = companyMappings.length === 1 ? companyMappings[0]! : null; + + const recurringBackedLines = backedLines.filter(({ line }) => line.recurrence !== 'one_time'); + const requestedContractLineIds = input.contractLineLinks.map((link) => link.contractLineId); + const createdContractLines = recurringBackedLines.length > 0 + ? await deps.repository.findCreatedContractLines( + input.partnerId, + input.orgId, + input.contractIds, + requestedContractLineIds, + ) + : []; + const verifiedContractLineIds = new Set(createdContractLines.map((line) => line.id)); + const contractLineByQuoteLine = new Map(); + const claimedContractLineIds = new Set(); + for (const link of input.contractLineLinks) { + if (contractLineByQuoteLine.has(link.quoteLineId) || claimedContractLineIds.has(link.contractLineId)) { + throw new QuoteServiceError( + 'Phase 4 produced an ambiguous contract-line correlation for a quote line.', + 409, + 'INVALID_STATE', + ); + } + if (!verifiedContractLineIds.has(link.contractLineId)) continue; + contractLineByQuoteLine.set(link.quoteLineId, link.contractLineId); + claimedContractLineIds.add(link.contractLineId); + } + + const orderId = deps.randomUUID(); + + // Every recurring backed line must have an exact, verified Phase-4 source + // correlation. This avoids both live catalog linkage and positional joins + // against pre-existing/foreign contract rows. + for (const { line } of recurringBackedLines) { + if (!contractLineByQuoteLine.has(line.id)) { + throw new QuoteServiceError( + 'A Pax8-backed recurring quote line did not match its newly created contract line.', + 409, + 'INVALID_STATE', + ); + } + } + + await deps.repository.insertOrder({ + id: orderId, + integrationId, + partnerId: input.partnerId, + orgId: input.orgId, + pax8CompanyId: companyMapping?.pax8CompanyId ?? null, + status: 'awaiting_details', + source: 'quote', + sourceQuoteId: input.quoteId, + dedupeKey: buildDedupeKey(orderId), + error: companyMapping ? null : PAX8_COMPANY_MAPPING_REQUIRED_ERROR, + createdBy: input.actorUserId, + }); + + const orderLines: OrderLineInsert[] = backedLines.map(({ line, mapping }, sortOrder) => { + const contractLine = line.recurrence === 'one_time' + ? null + : contractLineByQuoteLine.get(line.id)!; + return { + orderId, + partnerId: input.partnerId, + orgId: input.orgId, + action: 'new_subscription', + pax8ProductId: mapping.pax8ProductId, + catalogItemId: line.catalogItemId, + billingTerm: billingTermFor(line.recurrence), + quantity: line.quantity, + provisioningDetails: [], + contractLineId: contractLine, + sourceQuoteLineId: line.id, + sortOrder, + }; + }); + await deps.repository.insertOrderLines(orderLines); + + return { orderId, lineCount: orderLines.length }; + }; +} + +export const stagePax8OrderFromQuote = createQuoteToPax8OrderService({ + repository: quoteToPax8OrderRepository, + randomUUID, +}); diff --git a/apps/api/src/services/tenantCascade.ts b/apps/api/src/services/tenantCascade.ts index 02d147a03b..34b2981768 100644 --- a/apps/api/src/services/tenantCascade.ts +++ b/apps/api/src/services/tenantCascade.ts @@ -225,6 +225,8 @@ const CORE_ORG_CASCADE_DELETE_ORDER: ReadonlyArray = Object.freeze([ 'patch_jobs', 'pax8_company_mappings', 'pax8_contract_line_links', + 'pax8_order_lines', + 'pax8_orders', 'pax8_subscription_snapshots', 'peripheral_events', 'peripheral_policies', diff --git a/apps/docs/src/content/docs/features/distributor-integrations.mdx b/apps/docs/src/content/docs/features/distributor-integrations.mdx index caf419007d..21482aa61e 100644 --- a/apps/docs/src/content/docs/features/distributor-integrations.mdx +++ b/apps/docs/src/content/docs/features/distributor-integrations.mdx @@ -38,7 +38,7 @@ Pax8 connects with your Pax8 **OAuth client credentials**. Once connected, Breez - **Display name** — a label for this connection (defaults to `Pax8`). - **Client ID** — your Pax8 OAuth client ID. - **Client secret** — your Pax8 OAuth client secret. - - **Webhook secret** *(optional)* — used to verify inbound Pax8 webhooks. + - **Webhook secret** *(optional)* — stored with the integration, but not required by the ordering or scheduled-sync workflow. 3. Click **Connect Pax8**. Secrets are stored encrypted; once saved, each field shows a **Configured** badge. When updating an existing connection you can leave a secret field blank to keep the stored value. @@ -48,28 +48,74 @@ Pax8 connects with your Pax8 **OAuth client credentials**. Once connected, Breez **What syncs from Pax8** - **Companies** — your Pax8 client companies, with status, shown in the **Company mapping** table. Map each Pax8 company to a Breeze organization (or leave it unmapped). - **License subscriptions** — pulled per company, including product name, vendor, quantity, unit cost, unit price, currency, billing term, and status. -- **Contract-line linkage** — once a company is mapped to an organization, each subscription can be linked to a contract line so quantities flow into recurring billing. +- **Contract-line linkage** — once a company is mapped to an organization, each subscription can be linked to a manual contract line. Breeze keeps the billing quantity on that contract line and records the Pax8 quantity separately as an observation. -**Subscription actions** + + +**Subscription links on the Integrations page** -A subscription only becomes linkable after its company is mapped to a Breeze organization. Each subscription row then offers: +A subscription only becomes linkable after its company is mapped to a Breeze organization. On **Integrations → Distributors → Pax8**, each subscription row offers: | Action | Description | |--------|-------------| -| **Link** | Connect the subscription to a contract line (or create a new manual line) | -| **Change** | Re-point the subscription to a different contract line | -| **Pause** | Stop automatic quantity sync while keeping the link intact | -| **Resume** | Re-enable automatic quantity sync | +| **Link for observation** | Connect the subscription to a contract line (or create a new manual line) | +| **Change link** | Re-point the subscription to a different contract line | +| **Pause observations** | Stop recording Pax8 quantity observations for this link while keeping the link intact | +| **Resume observations** | Resume recording observations and drift detection for this link | | **Unlink** | Remove the connection between the subscription and the contract line | -When sync is enabled on a link, Pax8 subscription quantities are pushed to the linked contract line on each sync. Pausing stops future quantity updates but leaves the last-synced quantity in place so billing isn't affected unexpectedly. +These Integrations-page actions manage the contract-line link and whether scheduled sync records observations; that table's **Pax8 reported** column is not a Breeze billing quantity. To compare quantities or stage an order action, open the mapped organization's **Pax8** tab. The organization tab shows **Breeze quantity** (the authoritative manual contract-line billing quantity), **Pax8 reported** (the latest known observation), and a **Drift** badge when both values are known and disagree. A missing or unknown Pax8 quantity is not treated as zero and does not produce a drift warning. + +### Prepare an organization for ordering + +Ordering is managed from the organization's **Pax8** settings tab, but remains a partner-account operation: + +1. Sync the integration so the Pax8 company, subscriptions, and product mappings are current. +2. Open **Settings → Organizations → _organization_ → Pax8**, or go directly to `/settings/organizations/#pax8`. +3. Map exactly one Pax8 company to the organization. +4. Confirm the mapped company is **Active** in Pax8 and has primary **Admin**, **Billing**, and **Technical** contacts. The organization tab shows the Pax8 company status. When the mapping is not order-ready, it shows one combined warning; it does not show contact details or separate indicators for the three contact roles. +5. Link recurring subscriptions to the corresponding manual contract lines. A linked line is required before the organization tab can stage a quantity change or cancellation. + +The **New order** action stays unavailable until the company mapping is order-ready and at least one active Product Catalog item is mapped to a Pax8 product. + +### Build and submit a direct order + +The organization tab keeps one mutable direct order per organization. Returning to **New order** reopens that draft until it is submitted. + + +1. Click **New order** and choose an active, Pax8-mapped catalog product. + +2. Set its billing term and quantity. If Pax8 reports commitment choices, select one. Complete any product-specific provisioning fields that you know; Breeze leaves all of these fields optional because Pax8 does not identify required fields in its metadata. + +3. Add the product to the order. You can edit its commitment and provisioning details or remove the line while the order is still mutable. + +4. To change an existing subscription, enter the target **Breeze quantity** and click **Stage change**. To cancel, click **Cancel** and confirm the warning. Breeze checks the subscription's active commitment dependencies before staging either action and blocks increases, decreases, or early cancellation that Pax8 says are not allowed. If the active commitment cannot be determined unambiguously, refresh the Pax8 data and resolve that ambiguity before ordering. + +5. Click **Review & submit**. Breeze first sends a mock Pax8 order for new-subscription lines. If Pax8 returns validation details, Breeze shows the raw message beside the affected line so you can add or correct provisioning values. Quantity changes and cancellations are dependency-checked when staged; Pax8's mock-order preflight applies only to new subscriptions. + +6. After preflight passes, Breeze submits each action and records the result on every line. Successful quantity changes and cancellations update the linked manual contract-line quantity (`0` for a completed cancellation); successful new subscriptions update their linked line when one exists. + + + + +The order history shows direct and quote-originated orders with their current status. Selecting an order changes the URL hash to `#pax8/`, so the same order can be bookmarked and browser Back/Forward navigation works. + +### Complete an order staged from a quote + +Accepting a quote that contains Pax8-backed catalog items creates the invoice and contracts as usual, then stages a Pax8 order in the **same database transaction**. Acceptance does **not** submit anything to Pax8 and a missing company mapping never blocks the customer from accepting. + +On a converted quote, technicians see **“This quote staged a Pax8 order (N items) awaiting provisioning details”**. Follow **Open Pax8 order** to the organization's deep link, map an order-ready company if needed, then review each new-subscription line. The catalog item, quote source, and linked recurring contract line are preserved; you only complete the commitment and provisioning details. Run preflight and submit it using the same flow as a direct order. One-time quote lines can be ordered, but are not linked to a recurring contract line. diff --git a/apps/docs/src/content/docs/features/integrations.mdx b/apps/docs/src/content/docs/features/integrations.mdx index 55c03de6fa..939efa9123 100644 --- a/apps/docs/src/content/docs/features/integrations.mdx +++ b/apps/docs/src/content/docs/features/integrations.mdx @@ -338,13 +338,13 @@ Each subscription row offers these actions: | Action | Description | Requires MFA | |--------|-------------|:------------:| -| **Link** | Connect the subscription to an existing contract line, or create a new manual line on the spot | Yes | +| **Link for observation** | Connect the subscription to an existing contract line, or create a new manual line on the spot, so Pax8-reported quantities can be compared with Breeze billing | Yes | | **Change** | Re-point the subscription to a different contract line | Yes | -| **Pause** | Stop automatic quantity sync while keeping the link intact | No | -| **Resume** | Re-enable automatic quantity sync | No | +| **Pause observations** | Stop recording new Pax8 quantity observations while keeping the link intact | Yes | +| **Resume observations** | Resume recording Pax8 quantity observations and surfacing drift | Yes | | **Unlink** | Remove the connection between the subscription and the contract line | Yes | -When **Keep quantity in sync** is enabled on a link, Pax8 subscription quantities are pushed to the linked contract line on each Pax8 sync. Pausing stops future syncs but leaves the last-synced quantity in place so billing is not affected unexpectedly. +When **Track Pax8 reported quantity for drift** is enabled on a link, each Pax8 sync records the latest known quantity as an observation and surfaces any difference from the linked manual contract-line quantity. Breeze's order ledger and linked manual quantity remain authoritative for billing; observation sync never overwrites that billing quantity. Pausing stops new observations and drift updates while leaving the link and billing quantity intact. Beyond subscription sync, you can also search the Pax8 catalog and import individual products into your [Product Catalog](/features/product-catalog/#pax8) -- see Product Catalog for that workflow. diff --git a/apps/docs/src/content/docs/reference/api.mdx b/apps/docs/src/content/docs/reference/api.mdx index 3e5b134f29..4919b4c0cc 100644 --- a/apps/docs/src/content/docs/reference/api.mdx +++ b/apps/docs/src/content/docs/reference/api.mdx @@ -771,6 +771,7 @@ Selected catalog and billing operations. Resource CRUD follows the standard REST | `POST` | `/invoices/bulk-delete` | Delete multiple draft invoices | | `POST` | `/quotes/bulk-send` | Send multiple draft quotes | | `POST` | `/quotes/bulk-delete` | Delete multiple draft quotes | +| `GET` | `/quotes/:id` | Get quote detail, blocks, lines, branding, and any staged Pax8 order summary (`pax8OrderId`, `pax8OrderLineCount`) | | `POST` | `/contracts/bulk-cancel` | Cancel multiple contracts | | `POST` | `/contracts/bulk-delete` | Delete multiple draft contracts | @@ -778,11 +779,210 @@ Bulk endpoints accept a list of IDs and return per-item results, so partial succ ### Distributor Integrations -| Method | Path | Description | -|---|---|---| -| `GET` | `/pax8/subscriptions` | List Pax8 subscriptions | -| `POST` | `/pax8/subscriptions/link` | Link (or re-point) a Pax8 subscription to a contract line — requires MFA | -| `DELETE` | `/pax8/subscriptions/link` | Unlink a Pax8 subscription from its contract line (idempotent) — requires MFA | +Pax8 integration and ordering routes require partner or system scope plus `billing:manage`. An organization-scoped token is refused even when it can access the target organization. Partner-scoped callers use the partner from their token. System-scoped callers supply `partnerId` only on routes that accept it and require it for system scope. Company map and subscription link/unlink routes instead derive the partner from their tenant-scoped `integrationId`. Every mutation is MFA-gated and audited. + +| Method | Path | MFA | Description | +|---|---|---|---| +| `GET` | `/pax8/integration` | No | Get the active integration and credential-presence/sync-status fields | +| `POST` | `/pax8/integration` | Yes | Create or update the integration; queues an initial sync when active | +| `POST` | `/pax8/integration/test` | Yes | Test the active integration; returns `{ success, data? | error? }` | +| `POST` | `/pax8/sync` | Yes | Queue a company, subscription, and product sync; returns `{ queued, jobId, integrationId }` | +| `GET` | `/pax8/companies` | No | List company mappings and non-PII ordering-readiness flags | +| `POST` | `/pax8/companies/map` | Yes | Map, unmap, or ignore a Pax8 company | +| `GET` | `/pax8/subscriptions` | No | List subscription observations, contract-line linkage, and Breeze billing quantities | +| `POST` | `/pax8/subscriptions/link` | Yes | Link or re-point a subscription snapshot to a manual contract line | +| `DELETE` | `/pax8/subscriptions/link` | Yes | Unlink a subscription snapshot (idempotent) | +| `GET` | `/pax8/products` | No | List up to 200 active catalog items mapped to Pax8 products | +| `GET` | `/pax8/products/:productId/provision-details` | No | Read live Pax8 provisioning-field metadata for a product | +| `GET` | `/pax8/products/:productId/dependencies` | No | Read live Pax8 commitment/dependency rules for a product | +| `GET` | `/pax8/orders` | No | List orders; optional `orgId` returns that organization's history | +| `GET` | `/pax8/orders/:id` | No | Get `{ order, lines }` for an accessible order | +| `POST` | `/pax8/orders` | Yes | Create or reuse the organization's mutable direct order | +| `POST` | `/pax8/orders/:id/lines` | Yes | Stage a new subscription, quantity change, or cancellation on a mutable direct order | +| `PATCH` | `/pax8/orders/:id/lines/:lineId` | Yes | Update commitment/provisioning values on a mutable new-subscription line, including a quote-staged line | +| `DELETE` | `/pax8/orders/:id/lines/:lineId` | Yes | Remove a line from a mutable direct order | +| `POST` | `/pax8/orders/:id/preflight` | Yes | Mock-validate new-subscription lines with Pax8; returns the raw Pax8 body on `422` | +| `POST` | `/pax8/orders/:id/submit` | Yes | Claim and execute the order once; returns per-line outcomes | +| `POST` | `/pax8/orders/:id/reconcile` | Yes | Read Pax8 state to classify uncertain writes; never issues a Pax8 write | +| `GET` | `/pax8/drift?integrationId=:id` | No | List known Pax8 quantity observations that differ from linked Breeze manual quantities | + +#### Company mapping and subscription quantities + +`GET /pax8/companies` returns `{ data, integrationId }`. Each row includes the mapping fields plus these readiness booleans: + +```json +{ + "statusActive": true, + "primaryAdminReady": true, + "primaryBillingReady": true, + "primaryTechnicalReady": true, + "orderReady": true +} +``` + +`orderReady` is true only when the company is Active and the synchronized metadata contains primary Admin, Billing, and Technical contacts. Contact PII is not returned. `POST /pax8/companies/map` accepts: + +```json +{ + "integrationId": "11111111-1111-4111-8111-111111111111", + "pax8CompanyId": "pax8-company-id", + "orgId": "22222222-2222-4222-8222-222222222222", + "ignored": false +} +``` + +Set `orgId` to `null` to unmap it. A direct order requires exactly one non-ignored, order-ready mapping. Quote acceptance can stage an order without a mapping; preflight and submit resolve and validate the mapping later. + +`GET /pax8/subscriptions` supports `integrationId`, `orgId`, `unmappedOnly`, and `limit` (1–500, default 100). Important quantity and linkage fields are: + +| Field | Meaning | +|---|---| +| `breezeQuantity` | The linked manual contract line's billing quantity, or `null` when not linked/available | +| `quantity` | Pax8's latest reported quantity string; inspect `quantityKnown` before using it | +| `quantityKnown` | `false` when Pax8 did not provide credible quantity evidence; an unknown value is not zero | +| `contractLineId` | The linked Breeze contract line, when present | +| `syncEnabled` | Whether scheduled sync records quantity observations for this link; it does not authorize overwriting billing quantity | +| `lastObservedQuantity`, `lastObservedAt` | Last recorded known Pax8 observation for a sync-enabled link | +| `activeCommitmentId`, `activeCommitmentAmbiguous` | Evidence used to select the commitment that governs quantity and cancellation actions | + + + +`GET /pax8/drift` requires `integrationId` and returns `{ data }`, where each row contains `contractLineId`, `orgId`, `pax8SubscriptionId`, `productName`, `breezeQuantity`, and `pax8Quantity`. Results are limited to sync-enabled links on manual contract lines whose Pax8 quantity is known and differs from Breeze. The endpoint performs no Pax8 call and no write. + +#### Product metadata + +`GET /pax8/products` returns local, active mappings as `{ data: [{ pax8ProductId, catalogItemId, catalogName, catalogSku, catalogDescription, productName, vendorSkuId, billingFrequency, commitmentTermMonths }] }`. It does not call Pax8. Provisioning details and dependencies do call the configured Pax8 API. + +`GET /pax8/products/:productId/provision-details`: + +```json +{ + "data": [{ + "key": "msDomain", + "label": "Microsoft domain", + "description": null, + "valueType": "Input", + "possibleValues": null + }] +} +``` + +`GET /pax8/products/:productId/dependencies`: + +```json +{ + "data": { + "commitments": [{ + "id": "commitment-id", + "term": "Annual", + "allowForQuantityIncrease": true, + "allowForQuantityDecrease": false, + "allowForEarlyCancellation": false, + "cancellationFeeApplied": false + }] + } +} +``` + +Provision fields may have `valueType` `Input`, `Single-Value`, or `Multi-Value`. Their submitted representation is `{ "key": "...", "values": ["..."] }`. Pax8 does not expose reliable requiredness metadata, so clients should not invent required fields; preflight is authoritative. + +#### Order authoring + +`POST /pax8/orders` accepts `{ "orgId": "" }` and returns the order in `{ data }` with `201`. If that organization already has a mutable direct order, the same order is returned. Quote-originated orders are created by quote acceptance, not this endpoint. + +Order status is one of `draft`, `awaiting_details`, `ready`, `submitting`, `completed`, `partially_failed`, `failed`, or `cancelled`. Each line has a `submitState` of `pending`, `in_flight`, `succeeded`, `failed`, or `needs_reconcile`. `GET /pax8/orders?orgId=` returns the organization's complete, newest-first history (up to 100); without `orgId`, it returns the accessible non-terminal partner queue. `GET /pax8/orders/:id` returns `{ data: { order, lines } }`. + +Stage lines on a mutable **direct** order with `POST /pax8/orders/:id/lines`. The body is strict—unknown fields are rejected—and depends on `action`. Public callers cannot add lines to a quote-staged order. + +New subscription—`pax8ProductId`, `catalogItemId`, `billingTerm`, and a quantity greater than zero are required: + +```json +{ + "action": "new_subscription", + "pax8ProductId": "product-id", + "catalogItemId": "33333333-3333-4333-8333-333333333333", + "billingTerm": "Monthly", + "commitmentTermId": "commitment-id", + "quantity": "12", + "provisioningDetails": [{ "key": "msDomain", "values": ["example"] }] +} +``` + +The `pax8ProductId` and `catalogItemId` must be the exact active tuple returned by `GET /pax8/products`; both the Pax8 integration and catalog item must still be active when the line is staged and again when it is submitted. Do not mix a Pax8 product ID with a different catalog item. A direct new-subscription request cannot supply `contractLineId`. + +Existing subscription quantity—`targetSubscriptionId` and a quantity of zero or greater are required: + +```json +{ + "action": "change_quantity", + "targetSubscriptionId": "subscription-id", + "quantity": "15" +} +``` + +Cancellation—`targetSubscriptionId` is required. Cancellation is immediate, not scheduled: + +```json +{ + "action": "cancel", + "targetSubscriptionId": "subscription-id" +} +``` + +For immediate cancellation, omit `cancelDate` as shown. If a caller includes it, use the current UTC calendar date (`YYYY-MM-DD`); future-dated cancellation is unsupported and rejected with `422` so Breeze cannot set the billing quantity to zero before Pax8 cancellation takes effect. + +For `change_quantity` and `cancel`, do **not** send `contractLineId`. The strict public schema rejects caller-controlled linkage. Breeze derives exactly one linked manual contract line from `targetSubscriptionId`, uses its manual quantity as billing truth, and fails closed if the link or quantity is missing or changes during authorization. + +Valid billing terms are `Monthly`, `Annual`, `2-Year`, `3-Year`, `One-Time`, `Trial`, and `Activation`. Quantity changes and cancellations are rejected with `422` when the active commitment cannot be determined or does not allow that action. A target subscription must belong to the order's organization. Only one change or cancellation may target a given subscription in an order. + +The line-create response is `{ data: }` with `201`. `PATCH /pax8/orders/:id/lines/:lineId` accepts at least one of `commitmentTermId` (string or `null`) and `provisioningDetails`; it applies only to a mutable `new_subscription` line and deliberately preserves its quote/catalog/contract linkage. Public add and delete operations are direct-order-only. Quote-staged lines are immutable in membership: clients cannot add or delete them, and may only complete their commitment/provisioning values through this PATCH. Deleting a mutable direct-order line returns `{ "removed": true }`. + +#### Preflight, submit, and reconciliation + +`POST /pax8/orders/:id/preflight` sends a Pax8 mock order for the order's `new_subscription` lines. An order containing only quantity changes/cancellations returns `{ "ok": true }` without a mock call because those actions were dependency-checked while staged. Success is `{ "ok": true }`; validation failure is HTTP `422` with Pax8's raw JSON or text body so clients can associate `details[]` with the relevant line. + +`POST /pax8/orders/:id/submit` claims the order and its pending lines before making any external write. A concurrent submit loses the claim with `409`. The response is HTTP `200` even when some work needs attention: + +```json +{ + "orderId": "55555555-5555-4555-8555-555555555555", + "status": "partially_failed", + "lines": [{ + "lineId": "66666666-6666-4666-8666-666666666666", + "submitState": "needs_reconcile", + "error": "Pax8 outcome was uncertain" + }] +} +``` + +Treat the returned `status` and every line's `submitState` as the result—not HTTP `200` alone. Successful linked actions update only manual contract-line quantities; cancellation records `0`. A definitive Pax8 rejection becomes `failed`. A timeout, transport error, or server response whose write outcome is uncertain becomes `needs_reconcile`. + + + +Call `POST /pax8/orders/:id/reconcile` instead. It performs read-only Pax8 order/subscription lookups and returns `{ "resolved": , "stillUnknown": }`. It never repeats the write. When `stillUnknown` is nonzero, leave the order for manual investigation. + +#### Quote-staged orders + +Accepting a Pax8-backed quote stages its order transactionally without sending anything to Pax8. The authenticated `GET /quotes/:id` response persists discoverability across reloads: + +```json +{ + "data": { + "quote": { "id": "...", "status": "converted" }, + "blocks": [], + "lines": [], + "branding": {}, + "pax8OrderId": "55555555-5555-4555-8555-555555555555", + "pax8OrderLineCount": 2 + } +} +``` + +`pax8OrderId` is `null` and `pax8OrderLineCount` is `0` when no Pax8-backed order was staged. Use the ID with `GET /pax8/orders/:id`, then complete provisioning and submit through the normal endpoints. ## Tickets diff --git a/apps/web/src/components/billing/quotes/QuoteDetail.pax8Order.test.tsx b/apps/web/src/components/billing/quotes/QuoteDetail.pax8Order.test.tsx new file mode 100644 index 0000000000..4d8b98f1d6 --- /dev/null +++ b/apps/web/src/components/billing/quotes/QuoteDetail.pax8Order.test.tsx @@ -0,0 +1,73 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import QuoteDetail from './QuoteDetail'; +import type { QuoteDetail as QuoteDetailData } from './quoteTypes'; + +type Perm = { resource: string; action: string }; +const state = vi.hoisted(() => ({ permissions: [{ resource: 'quotes', action: 'read' }] as Perm[] })); + +vi.mock('../../../stores/auth', () => ({ + fetchWithAuth: vi.fn(), + registerOrgIdProvider: vi.fn(), + useAuthStore: Object.assign( + (selector: (s: { user: { permissions: Perm[] } }) => unknown) => + selector({ user: { permissions: state.permissions } }), + { getState: () => ({ tokens: null }) }, + ), +})); +vi.mock('@/lib/navigation', () => ({ navigateTo: vi.fn() })); +vi.mock('../../shared/Toast', () => ({ showToast: vi.fn() })); + +const ORG_ID = 'aa0e43c8-1111-2222-3333-444455556666'; +const ORDER_ID = 'bb0e43c8-1111-2222-3333-444455556666'; + +function convertedDetail(overrides: Partial = {}): QuoteDetailData { + return { + quote: { + id: 'q-1', quoteNumber: 'Q-1', partnerId: 'p-1', orgId: ORG_ID, siteId: null, status: 'converted', + currencyCode: 'USD', issueDate: null, expiryDate: null, subtotal: '0.00', taxRate: null, + taxTotal: '0.00', total: '0.00', oneTimeTotal: '0.00', monthlyRecurringTotal: '0.00', + annualRecurringTotal: '0.00', dueOnAcceptanceTotal: '0.00', + billToName: 'Acme Inc.', introNotes: null, terms: null, termsAndConditions: null, sellerSnapshot: null, + acceptedAt: '2026-07-14T00:00:00Z', declinedAt: null, convertedAt: '2026-07-14T00:00:00Z', + convertedInvoiceId: 'inv-1', sentAt: '2026-07-13T00:00:00Z', viewedAt: null, + createdBy: null, createdAt: '2026-07-13T00:00:00Z', updatedAt: '2026-07-14T00:00:00Z', + }, + blocks: [], + lines: [], + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + state.permissions = [{ resource: 'quotes', action: 'read' }]; +}); + +describe('QuoteDetail — staged Pax8 order', () => { + it('renders the reload-derived staged-order summary and deep link for a converted quote', async () => { + render(); + await waitFor(() => expect(screen.getByTestId('quote-detail')).toBeInTheDocument()); + + const panel = screen.getByTestId('quote-staged-pax8-order'); + expect(panel).toHaveTextContent('This quote staged a Pax8 order (3 items) awaiting provisioning details'); + expect(screen.getByTestId('quote-staged-pax8-order-link')).toHaveAttribute( + 'href', + `/settings/organizations/${ORG_ID}#pax8/${ORDER_ID}`, + ); + }); + + it('renders no staged-order panel when the persisted quote read model has no staged order', async () => { + render(); + await waitFor(() => expect(screen.getByTestId('quote-detail')).toBeInTheDocument()); + expect(screen.queryByTestId('quote-staged-pax8-order')).not.toBeInTheDocument(); + }); + + it('does not surface a staged-order panel before the quote is converted', async () => { + const detail = convertedDetail({ pax8OrderId: ORDER_ID, pax8OrderLineCount: 1 }); + render(); + await waitFor(() => expect(screen.getByTestId('quote-detail')).toBeInTheDocument()); + expect(screen.queryByTestId('quote-staged-pax8-order')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/billing/quotes/QuoteDetail.tsx b/apps/web/src/components/billing/quotes/QuoteDetail.tsx index b522b3e49d..380062b21c 100644 --- a/apps/web/src/components/billing/quotes/QuoteDetail.tsx +++ b/apps/web/src/components/billing/quotes/QuoteDetail.tsx @@ -187,6 +187,24 @@ export default function QuoteDetail({ detail, onChanged, actionsInHeader }: Prop )} + {quote.status === 'converted' && detail.pax8OrderId && ( + + )} + {/* Recurring + totals summary — the rail's anchor (shadow + large figure). */}

{t('quotes.detail.totals.title')}

diff --git a/apps/web/src/components/billing/quotes/quoteTypes.ts b/apps/web/src/components/billing/quotes/quoteTypes.ts index 1880f97d76..51ef16a55a 100644 --- a/apps/web/src/components/billing/quotes/quoteTypes.ts +++ b/apps/web/src/components/billing/quotes/quoteTypes.ts @@ -149,6 +149,10 @@ export interface QuoteDetail { blocks: QuoteBlock[]; lines: QuoteLine[]; branding?: QuoteBranding; + /** Persisted fulfillment staged during acceptance. Included in the detail + * read model so technicians can discover the order after a reload. */ + pax8OrderId?: string | null; + pax8OrderLineCount?: number; } export const STATUS_LABELS: Record = { diff --git a/apps/web/src/components/integrations/LinkSubscriptionPicker.test.tsx b/apps/web/src/components/integrations/LinkSubscriptionPicker.test.tsx index f494756d52..3fd43f4b15 100644 --- a/apps/web/src/components/integrations/LinkSubscriptionPicker.test.tsx +++ b/apps/web/src/components/integrations/LinkSubscriptionPicker.test.tsx @@ -61,6 +61,25 @@ beforeEach(() => { }); describe("LinkSubscriptionPicker", () => { + it("explains that observation tracking detects drift without overwriting billing", async () => { + render( + , + ); + + expect( + screen.getByText( + "Track Pax8 reported quantity for drift (never overwrite Breeze billing)", + ), + ).toBeInTheDocument(); + expect(screen.getByText("Link subscription")).toBeInTheDocument(); + expect(screen.queryByText(/keep quantity in sync/i)).toBeNull(); + }); + it("links to an existing manual line", async () => { render( { fireEvent.change(screen.getByTestId("pax8-link-new-price"), { target: { value: "36.00" }, }); + const billingQuantity = screen.getByRole("textbox", { + name: "Breeze billing quantity", + }); + expect(billingQuantity).toHaveValue(""); + expect(billingQuantity).not.toHaveValue("5"); + expect(billingQuantity).toHaveAttribute("aria-invalid", "true"); + expect(screen.getByText("Enter a Breeze billing quantity.")).toBeInTheDocument(); + fireEvent.change(billingQuantity, { target: { value: "7.25" } }); fireEvent.click(screen.getByTestId("pax8-link-submit")); await waitFor(() => expect(addContractLine).toHaveBeenCalled()); const [cid, lineBody] = addContractLine.mock.calls[0]; @@ -119,13 +146,75 @@ describe("LinkSubscriptionPicker", () => { expect(lineBody).toMatchObject({ lineType: "manual", unitPrice: "36.00", - manualQuantity: "5", + manualQuantity: "7.25", taxable: false, }); const linkBody = JSON.parse(fetchWithAuth.mock.calls[0][1].body); expect(linkBody.contractLineId).toBe("line-new"); }); + it("never defaults billing from the Pax8-reported quantity", async () => { + render( + , + ); + await waitFor(() => screen.getByTestId("pax8-link-contract")); + fireEvent.change(screen.getByTestId("pax8-link-contract"), { + target: { value: "c1" }, + }); + await waitFor(() => screen.getByTestId("pax8-link-line")); + fireEvent.change(screen.getByTestId("pax8-link-line"), { + target: { value: "__new__" }, + }); + fireEvent.change(screen.getByTestId("pax8-link-new-price"), { + target: { value: "36.00" }, + }); + + expect(screen.getByTestId("pax8-link-new-quantity")).toHaveValue(""); + expect(screen.getByTestId("pax8-link-submit")).toBeDisabled(); + expect(addContractLine).not.toHaveBeenCalled(); + }); + + it("rejects blank, negative, and invalid billing quantities but accepts explicit zero", async () => { + render( + , + ); + await waitFor(() => screen.getByTestId("pax8-link-contract")); + fireEvent.change(screen.getByTestId("pax8-link-contract"), { + target: { value: "c1" }, + }); + await waitFor(() => screen.getByTestId("pax8-link-line")); + fireEvent.change(screen.getByTestId("pax8-link-line"), { + target: { value: "__new__" }, + }); + fireEvent.change(screen.getByTestId("pax8-link-new-price"), { + target: { value: "36.00" }, + }); + const quantity = screen.getByTestId("pax8-link-new-quantity"); + + for (const bad of ["", "-1", "abc", "1.234"]) { + fireEvent.change(quantity, { target: { value: bad } }); + expect(quantity).toHaveAttribute("aria-invalid", "true"); + expect(screen.getByTestId("pax8-link-submit")).toBeDisabled(); + } + fireEvent.change(quantity, { target: { value: "0" } }); + expect(quantity).toHaveAttribute("aria-invalid", "false"); + expect(screen.getByTestId("pax8-link-submit")).toBeEnabled(); + fireEvent.click(screen.getByTestId("pax8-link-submit")); + + await waitFor(() => expect(addContractLine).toHaveBeenCalled()); + expect(addContractLine.mock.calls[0][1].manualQuantity).toBe("0"); + }); + it("keeps submit disabled for an invalid new-line price", async () => { render( { fireEvent.change(screen.getByTestId("pax8-link-line"), { target: { value: "__new__" }, }); + fireEvent.change(screen.getByTestId("pax8-link-new-quantity"), { + target: { value: "1" }, + }); // Garbage and over-precise values must not satisfy MONEY_RE. for (const bad of ["abc", "36.999", ""]) { fireEvent.change(screen.getByTestId("pax8-link-new-price"), { @@ -178,13 +270,15 @@ describe("LinkSubscriptionPicker", () => { fireEvent.change(screen.getByTestId("pax8-link-line"), { target: { value: "__new__" }, }); - // Seeded to a clean 2-decimal value and submit is immediately enabled. + // The price may be seeded, but billing quantity must still be explicit. expect( (screen.getByTestId("pax8-link-new-price") as HTMLInputElement).value, ).toBe("42.50"); - expect( - (screen.getByTestId("pax8-link-submit") as HTMLButtonElement).disabled, - ).toBe(false); + expect(screen.getByTestId("pax8-link-submit")).toBeDisabled(); + fireEvent.change(screen.getByTestId("pax8-link-new-quantity"), { + target: { value: "4" }, + }); + expect(screen.getByTestId("pax8-link-submit")).toBeEnabled(); fireEvent.click(screen.getByTestId("pax8-link-submit")); await waitFor(() => expect(addContractLine).toHaveBeenCalled()); expect(addContractLine.mock.calls[0][1]).toMatchObject({ diff --git a/apps/web/src/components/integrations/LinkSubscriptionPicker.tsx b/apps/web/src/components/integrations/LinkSubscriptionPicker.tsx index 95d9357f34..e05f9a25f7 100644 --- a/apps/web/src/components/integrations/LinkSubscriptionPicker.tsx +++ b/apps/web/src/components/integrations/LinkSubscriptionPicker.tsx @@ -64,6 +64,7 @@ export default function LinkSubscriptionPicker({ const [newPrice, setNewPrice] = useState(() => toPriceInput(subscription.unitPrice), ); + const [newQuantity, setNewQuantity] = useState(""); const [syncEnabled, setSyncEnabled] = useState(true); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -113,11 +114,18 @@ export default function LinkSubscriptionPicker({ }, []); const newPriceValid = MONEY_RE.test(newPrice.trim()); + const newQuantityValue = newQuantity.trim(); + const newQuantityValid = MONEY_RE.test(newQuantityValue); + const newQuantityError = newQuantityValue === "" + ? t("linkSubscriptionPicker.billingQuantityRequired") + : !newQuantityValid + ? t("linkSubscriptionPicker.billingQuantityInvalid") + : null; const canSubmit = !busy && contractId !== "" && lineId !== "" && - (lineId !== NEW_LINE || (newDesc.trim() !== "" && newPriceValid)); + (lineId !== NEW_LINE || (newDesc.trim() !== "" && newPriceValid && newQuantityValid)); const submit = useCallback(async () => { if (!canSubmit) return; @@ -132,7 +140,7 @@ export default function LinkSubscriptionPicker({ description: newDesc.trim(), unitPrice: newPrice.trim(), taxable: false, - manualQuantity: String(subscription.quantity ?? 0), + manualQuantity: newQuantityValue, }), errorFallback: t( "linkSubscriptionPicker.couldNotCreateTheContractLine", @@ -180,6 +188,7 @@ export default function LinkSubscriptionPicker({ contractId, newDesc, newPrice, + newQuantityValue, subscription, integrationId, syncEnabled, @@ -248,7 +257,7 @@ export default function LinkSubscriptionPicker({
{lineId === NEW_LINE && ( -
+
+
+ + setNewQuantity(e.target.value)} + aria-invalid={!newQuantityValid} + aria-describedby="pax8-link-new-quantity-help pax8-link-new-quantity-error" + data-testid="pax8-link-new-quantity" + className="h-9 w-full rounded-md border bg-background px-3 text-sm aria-invalid:border-destructive" + /> + + {newQuantityError && ( + + )} +
)} @@ -277,7 +310,7 @@ export default function LinkSubscriptionPicker({ onChange={(e) => setSyncEnabled(e.target.checked)} data-testid="pax8-link-sync" /> - {t("linkSubscriptionPicker.keepQuantityInSync")} + {t("linkSubscriptionPicker.trackQuantityForDrift")}
diff --git a/apps/web/src/components/integrations/Pax8Integration.link.test.tsx b/apps/web/src/components/integrations/Pax8Integration.link.test.tsx index 6af45756ff..a338975ef4 100644 --- a/apps/web/src/components/integrations/Pax8Integration.link.test.tsx +++ b/apps/web/src/components/integrations/Pax8Integration.link.test.tsx @@ -108,6 +108,34 @@ beforeEach(() => { }); describe("Pax8Integration subscription actions", () => { + it("describes links as observation tracking without promising billing quantity sync", async () => { + routeFetch(true); + render(); + + await waitFor(() => + screen.getByTestId("pax8-subscription-sub-linked"), + ); + expect(screen.getByText("Pax8 reported")).toBeInTheDocument(); + expect(screen.getByText("observing quantity")).toBeInTheDocument(); + expect(screen.getByText("Pause observations")).toBeInTheDocument(); + expect(screen.getByText("Link for observation")).toBeInTheDocument(); + expect( + screen.getByText(/Linking never overwrites the Breeze billing quantity/i), + ).toBeInTheDocument(); + expect(screen.queryByText(/sync quantities automatically/i)).toBeNull(); + }); + + it("labels a disabled observation link as paused and offers resume", async () => { + routeFetch(false); + render(); + + await waitFor(() => + screen.getByTestId("pax8-subscription-sub-linked"), + ); + expect(screen.getByText("observation paused")).toBeInTheDocument(); + expect(screen.getByText("Resume observations")).toBeInTheDocument(); + }); + it("unlinks a linked subscription via DELETE", async () => { routeFetch(true); render(); @@ -137,8 +165,8 @@ describe("Pax8Integration subscription actions", () => { await waitFor(() => screen.getByTestId("mock-picker-done")); }); - it("pauses sync by POSTing syncEnabled:false for a currently-syncing link", async () => { - routeFetch(true); // sub-linked starts syncEnabled: true + it("pauses observations by POSTing syncEnabled:false for an observed link", async () => { + routeFetch(true); // sub-linked starts with observation tracking enabled render(); await waitFor(() => screen.getByTestId("pax8-subscription-togglesync-sub-linked"), diff --git a/apps/web/src/components/integrations/Pax8Integration.tsx b/apps/web/src/components/integrations/Pax8Integration.tsx index 4565e11c34..a6e3b8f271 100644 --- a/apps/web/src/components/integrations/Pax8Integration.tsx +++ b/apps/web/src/components/integrations/Pax8Integration.tsx @@ -392,10 +392,10 @@ export default function Pax8Integration() { syncEnabled: !sub.syncEnabled, }), }), - errorFallback: t("pax8Integration.couldNotUpdateSync"), + errorFallback: t("pax8Integration.couldNotUpdateObservationTracking"), successMessage: sub.syncEnabled - ? t("pax8Integration.syncPaused") - : t("pax8Integration.syncResumed"), + ? t("pax8Integration.observationsPaused") + : t("pax8Integration.observationsResumed"), onUnauthorized: UNAUTHORIZED, }); void reloadSubscriptions(); @@ -407,7 +407,10 @@ export default function Pax8Integration() { }); return; } - handleActionError(err, t("pax8Integration.couldNotUpdateSync")); + handleActionError( + err, + t("pax8Integration.couldNotUpdateObservationTracking"), + ); } }, [integration, reloadSubscriptions], @@ -823,7 +826,7 @@ export default function Pax8Integration() {

{t( - "pax8Integration.licenseSubscriptionsPulledFromPax8LinkASubscription", + "pax8Integration.subscriptionObservationDescription", )}

{subscriptions.length === 0 ? ( @@ -888,8 +891,8 @@ export default function Pax8Integration() {
{sub.syncEnabled - ? t("pax8Integration.syncing") - : t("pax8Integration.linked")} + ? t("pax8Integration.observingQuantity") + : t("pax8Integration.observationPaused")} )} diff --git a/apps/web/src/components/organizations/Pax8OrderBuilder.test.tsx b/apps/web/src/components/organizations/Pax8OrderBuilder.test.tsx new file mode 100644 index 0000000000..0c6b6f8cf8 --- /dev/null +++ b/apps/web/src/components/organizations/Pax8OrderBuilder.test.tsx @@ -0,0 +1,331 @@ +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Pax8ProvisioningForm } from './Pax8ProvisioningForm'; +import { + PAX8_BILLING_TERM_I18N_KEYS, + PAX8_ORDER_ACTION_I18N_KEYS, + PAX8_ORDER_STATUS_I18N_KEYS, + PAX8_SUBMIT_STATE_I18N_KEYS, + extractPax8PreflightErrors, +} from './pax8OrderUi'; +import Pax8OrderBuilder from './Pax8OrderBuilder'; +import type { Pax8OrderBundle } from '../../lib/api/pax8Orders'; +import { getProductDependencies, getProvisionDetails, preflightPax8Order, submitPax8Order } from '../../lib/api/pax8Orders'; +import es419 from '../../locales/es-419/settings.json'; +import { i18n, loadLocale } from '../../lib/i18n'; + +vi.mock('../../lib/api/pax8Orders', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + preflightPax8Order: vi.fn(), + submitPax8Order: vi.fn(), + getProvisionDetails: vi.fn(), + getProductDependencies: vi.fn(), + }; +}); + +beforeEach(async () => { + vi.clearAllMocks(); + await i18n.changeLanguage('en'); +}); + +afterEach(async () => { + await act(() => i18n.changeLanguage('en')); +}); + +const product = { + pax8ProductId: 'prod-1', catalogItemId: 'cat-1', catalogName: 'Microsoft 365', catalogSku: null, + catalogDescription: null, productName: 'Microsoft 365', vendorSkuId: null, + billingFrequency: 'monthly', commitmentTermMonths: null, +}; + +function orderBundle({ + source = 'direct', + status = 'draft', + submitStates = ['pending'], +}: { + source?: 'direct' | 'quote'; + status?: Pax8OrderBundle['order']['status']; + submitStates?: Pax8OrderBundle['lines'][number]['submitState'][]; +} = {}): Pax8OrderBundle { + return { + order: { + id: '44444444-4444-4444-8444-444444444444', integrationId: 'integration-1', + partnerId: 'partner-1', orgId: 'org-1', pax8CompanyId: 'company-1', status, source, + sourceQuoteId: source === 'quote' ? 'quote-1' : null, pax8OrderId: null, error: null, + submittedAt: null, createdAt: '2026-07-14T00:00:00Z', updatedAt: '2026-07-14T00:00:00Z', + }, + lines: submitStates.map((submitState, index) => ({ + id: `line-${index + 1}`, orderId: '44444444-4444-4444-8444-444444444444', + action: 'new_subscription', submitState, pax8ProductId: 'prod-1', catalogItemId: 'cat-1', + billingTerm: 'Monthly', commitmentTermId: null, quantity: '1.00', provisioningDetails: [], + targetSubscriptionId: null, resultSubscriptionId: null, contractLineId: `contract-${index + 1}`, + sourceQuoteLineId: source === 'quote' ? `quote-line-${index + 1}` : null, error: null, sortOrder: index, + })), + }; +} + +describe('Pax8 provisioning details', () => { + it('renders Single-Value fields with exactly the Pax8 possible values', () => { + render( + , + ); + + const select = screen.getByTestId('pax8-provision-region') as HTMLSelectElement; + expect([...select.options].map((option) => option.value)).toEqual(['US', 'CA']); + expect(select.required).toBe(false); + }); + + it('clears an optional Single-Value field without adding a synthetic select option', async () => { + const onChange = vi.fn(); + function Harness() { + const [value, setValue] = useState>([]); + return { setValue(next); onChange(next); }} + />; + } + render(); + + const select = screen.getByTestId('pax8-provision-region') as HTMLSelectElement; + await userEvent.selectOptions(select, 'CA'); + expect([...select.options].map((option) => option.value)).toEqual(['US', 'CA']); + await userEvent.click(screen.getByRole('button', { name: /clear region/i })); + + expect(onChange).toHaveBeenLastCalledWith([]); + expect(select.value).toBe(''); + expect([...select.options].map((option) => option.value)).toEqual(['US', 'CA']); + }); + + it('keeps every field optional and supports an accessible native multiselect', async () => { + const onChange = vi.fn(); + function Harness() { + const [value, setValue] = useState>([]); + return { setValue(next); onChange(next); }} />; + } + render(); + + expect(screen.getByTestId('pax8-provision-alias')).not.toBeRequired(); + const multi = screen.getByTestId('pax8-provision-features') as HTMLSelectElement; + expect(multi.multiple).toBe(true); + expect(multi).not.toBeRequired(); + await userEvent.selectOptions(multi, ['A', 'B']); + expect(onChange).toHaveBeenCalledWith(expect.arrayContaining([ + { key: 'features', values: ['A', 'B'] }, + ])); + }); +}); + +describe('Pax8 preflight errors', () => { + it('preserves raw 422 details and assigns lineItemNumber messages inline', () => { + const parsed = extractPax8PreflightErrors({ + details: [ + { lineItemNumber: 2, message: 'Tenant domain must be supplied.' }, + { message: 'Company billing contact is incomplete.' }, + ], + }); + + expect(parsed.byLine.get(2)).toEqual(['Tenant domain must be supplied.']); + expect(parsed.order).toEqual(['Company billing contact is incomplete.']); + }); + + it('renders raw 422 messages against the line and never calls submit', async () => { + vi.mocked(preflightPax8Order).mockResolvedValue(new Response(JSON.stringify({ + details: [{ lineItemNumber: 1, message: 'Tenant domain must be supplied.' }], + }), { status: 422, headers: { 'content-type': 'application/json' } })); + + render(); + + await userEvent.click(screen.getByTestId('pax8-submit')); + expect(await screen.findByTestId('pax8-line-error-line-1')).toHaveTextContent('Tenant domain must be supplied.'); + expect(submitPax8Order).not.toHaveBeenCalled(); + }); + + it('correlates a raw lineItemNumber 2 error to the second sorted line', async () => { + vi.mocked(preflightPax8Order).mockResolvedValue(new Response(JSON.stringify({ + details: [{ lineItemNumber: 2, message: 'Second line needs a tenant domain.' }], + }), { status: 422, headers: { 'content-type': 'application/json' } })); + + const line = (id: string, sortOrder: number) => ({ + id, orderId: '44444444-4444-4444-8444-444444444444', action: 'new_subscription' as const, + submitState: 'pending' as const, pax8ProductId: 'prod-1', catalogItemId: 'cat-1', billingTerm: 'Monthly', + commitmentTermId: null, quantity: '1.00', provisioningDetails: [], targetSubscriptionId: null, + resultSubscriptionId: null, contractLineId: `contract-${id}`, sourceQuoteLineId: null, + error: null, sortOrder, + }); + render(); + + await userEvent.click(screen.getByTestId('pax8-submit')); + expect(await screen.findByTestId('pax8-line-error-line-2')).toHaveTextContent('Second line needs a tenant domain.'); + expect(screen.queryByTestId('pax8-line-error-line-1')).not.toBeInTheDocument(); + expect(submitPax8Order).not.toHaveBeenCalled(); + }); +}); + +describe('Pax8 order mutation affordances', () => { + it('keeps accepted-quote lines immutable while allowing provisioning edits', async () => { + vi.mocked(getProvisionDetails).mockResolvedValue(new Response(JSON.stringify({ data: [] }), { + status: 200, headers: { 'content-type': 'application/json' }, + })); + vi.mocked(getProductDependencies).mockResolvedValue(new Response(JSON.stringify({ data: { commitments: [] } }), { + status: 200, headers: { 'content-type': 'application/json' }, + })); + render(); + + expect(screen.queryByTestId('pax8-product-select')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /remove line/i })).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: /edit details/i })); + expect(await screen.findByTestId('pax8-staged-line-editor')).toBeInTheDocument(); + }); + + it('keeps add and remove controls for mutable direct orders', () => { + render(); + + expect(screen.getByTestId('pax8-product-select')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /remove line/i })).toBeEnabled(); + }); + + it.each([ + ['a partially-failed parent with a needs-reconcile line', 'partially_failed', ['needs_reconcile']], + ['a submitting parent with an in-flight line', 'submitting', ['in_flight']], + ['a submitting order whose lines are all pending', 'submitting', ['pending', 'pending']], + ] as const)('offers recovery for %s', (_label, status, submitStates) => { + render(); + + expect(screen.getByTestId('pax8-reconcile')).toBeEnabled(); + expect(screen.queryByTestId('pax8-submit')).not.toBeInTheDocument(); + }); + + it.each([ + ['draft', ['in_flight']], + ['awaiting_details', ['needs_reconcile']], + ['ready', ['needs_reconcile']], + ['partially_failed', ['in_flight']], + ['partially_failed', ['failed']], + ['submitting', ['needs_reconcile']], + ] as const)('does not offer recovery for malformed %s parent state', (status, submitStates) => { + render(); + + expect(screen.queryByTestId('pax8-reconcile')).not.toBeInTheDocument(); + expect(screen.queryByTestId('pax8-submit')).not.toBeInTheDocument(); + }); + + it('allows a safely reset ready order to be resubmitted without reopening authoring', () => { + render(); + + expect(screen.getByTestId('pax8-submit')).toBeEnabled(); + expect(screen.queryByTestId('pax8-product-select')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /remove line/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /edit details/i })).not.toBeInTheDocument(); + }); + + it('uses a WCAG-AA dark recovery treatment with visible hover and focus states', () => { + render(); + + expect(screen.getByTestId('pax8-reconcile')).toHaveClass( + 'bg-amber-800', 'text-white', 'hover:bg-amber-900', 'focus-visible:ring-2', + ); + }); + + it('does not offer false recovery for submitting orders with a completed line', () => { + render(); + + expect(screen.queryByTestId('pax8-reconcile')).not.toBeInTheDocument(); + expect(screen.queryByTestId('pax8-submit')).not.toBeInTheDocument(); + }); +}); + +describe('Pax8 wire-enum localization', () => { + it('maps every wire value to a translated key without changing the request values', () => { + expect(Object.keys(PAX8_ORDER_STATUS_I18N_KEYS)).toEqual([ + 'draft', 'awaiting_details', 'ready', 'submitting', 'completed', 'partially_failed', 'failed', 'cancelled', + ]); + expect(Object.keys(PAX8_ORDER_ACTION_I18N_KEYS)).toEqual(['new_subscription', 'change_quantity', 'cancel']); + expect(Object.keys(PAX8_SUBMIT_STATE_I18N_KEYS)).toEqual(['pending', 'in_flight', 'succeeded', 'failed', 'needs_reconcile']); + expect(Object.keys(PAX8_BILLING_TERM_I18N_KEYS)).toEqual(['Monthly', 'Annual', '2-Year', '3-Year', 'One-Time', 'Trial', 'Activation']); + + const translated = es419.pax8.enums; + expect(translated.orderStatus.awaitingDetails).toBe('Esperando detalles'); + expect(translated.lineAction.newSubscription).toBe('Nueva suscripción'); + expect(translated.submitState.inFlight).toBe('En curso'); + expect(translated.billingTerm.oneTime).toBe('Pago único'); + expect(PAX8_BILLING_TERM_I18N_KEYS['One-Time']).toBe('pax8.enums.billingTerm.oneTime'); + }); + + it('renders localized labels while retaining exact Pax8 billing values', async () => { + await loadLocale('es-419'); + await act(() => i18n.changeLanguage('es-419')); + render(); + + expect(screen.getByText('Esperando detalles')).toBeInTheDocument(); + expect(screen.getByText('Nueva suscripción')).toBeInTheDocument(); + expect(screen.getByText('En curso')).toBeInTheDocument(); + const terms = screen.getByRole('combobox', { name: 'Plazo de facturación' }) as HTMLSelectElement; + expect([...terms.options].map((option) => option.value)).toEqual([ + 'Monthly', 'Annual', '2-Year', '3-Year', 'One-Time', 'Trial', 'Activation', + ]); + expect([...terms.options].map((option) => option.text)).toContain('Pago único'); + }); +}); diff --git a/apps/web/src/components/organizations/Pax8OrderBuilder.tsx b/apps/web/src/components/organizations/Pax8OrderBuilder.tsx new file mode 100644 index 0000000000..a692c0ae27 --- /dev/null +++ b/apps/web/src/components/organizations/Pax8OrderBuilder.tsx @@ -0,0 +1,365 @@ +import '@/lib/i18n'; +import { useEffect, useMemo, useState } from 'react'; +import { AlertTriangle, ArrowLeft, CheckCircle2, Plus, RefreshCw, Send, Trash2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { PAX8_BILLING_TERMS } from '@breeze/shared'; +import { ActionError, handleActionError, runAction } from '../../lib/runAction'; +import { navigateTo } from '../../lib/navigation'; +import { + addPax8OrderLine, + getProductDependencies, + getProvisionDetails, + preflightPax8Order, + reconcilePax8Order, + readData, + removePax8OrderLine, + submitPax8Order, + updatePax8OrderLine, + type Pax8OrderBundle, + type Pax8OrderLine, + type Pax8ProductDependencies, + type Pax8ProductOption, + type Pax8ProvisionField, + type ProvisioningValue, +} from '../../lib/api/pax8Orders'; +import { Pax8ProvisioningForm } from './Pax8ProvisioningForm'; +import { + PAX8_BILLING_TERM_I18N_KEYS, + PAX8_ORDER_ACTION_I18N_KEYS, + PAX8_ORDER_STATUS_I18N_KEYS, + PAX8_SUBMIT_STATE_I18N_KEYS, + displayQuantity, + extractPax8PreflightErrors, + type PreflightErrors, +} from './pax8OrderUi'; + +const onUnauthorized = () => void navigateTo('/login', { replace: true }); +const authoringStatuses = new Set(['draft', 'awaiting_details']); +const submittableStatuses = new Set(['draft', 'awaiting_details', 'ready']); + +function lineLabel(line: Pax8OrderLine, products: Pax8ProductOption[], fallback: string): string { + const product = products.find((candidate) => candidate.pax8ProductId === line.pax8ProductId); + return product?.catalogName || line.pax8ProductId || line.targetSubscriptionId || fallback; +} + +function statusClasses(status: string): string { + if (status === 'succeeded' || status === 'completed') return 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'; + if (status === 'failed' || status === 'partially_failed') return 'border-destructive/40 bg-destructive/10 text-destructive'; + if (status === 'needs_reconcile' || status === 'in_flight' || status === 'submitting') return 'border-amber-500/40 bg-amber-500/10 text-amber-800 dark:text-amber-200'; + return 'border-border bg-muted text-muted-foreground'; +} + +export default function Pax8OrderBuilder({ + bundle, + products, + onReload, + onBack, +}: { + bundle: Pax8OrderBundle; + products: Pax8ProductOption[]; + onReload: () => Promise; + onBack: () => void; +}) { + const { t } = useTranslation('settings'); + const { order, lines } = bundle; + const mutable = authoringStatuses.has(order.status); + const directMutable = mutable && order.source === 'direct'; + const hasInFlight = lines.some((line) => line.submitState === 'in_flight'); + const hasNeedsReconcile = lines.some((line) => line.submitState === 'needs_reconcile'); + const allPending = lines.length > 0 && lines.every((line) => line.submitState === 'pending'); + const canReconcile = (order.status === 'submitting' && (hasInFlight || allPending)) + || (order.status === 'partially_failed' && hasNeedsReconcile); + const canSubmit = submittableStatuses.has(order.status) + && lines.every((line) => line.submitState === 'pending'); + const [selectedProductId, setSelectedProductId] = useState(''); + const [quantity, setQuantity] = useState('1'); + const [billingTerm, setBillingTerm] = useState<(typeof PAX8_BILLING_TERMS)[number]>('Monthly'); + const [commitmentTermId, setCommitmentTermId] = useState(''); + const [details, setDetails] = useState([]); + const [fields, setFields] = useState([]); + const [dependencies, setDependencies] = useState({ commitments: [] }); + const [metadataLoading, setMetadataLoading] = useState(false); + const [metadataError, setMetadataError] = useState(false); + const [metadataVersion, setMetadataVersion] = useState(0); + const [busy, setBusy] = useState(null); + const [preflightErrors, setPreflightErrors] = useState({ byLine: new Map(), order: [] }); + const [editingLineId, setEditingLineId] = useState(null); + const [editDetails, setEditDetails] = useState([]); + const [editCommitmentId, setEditCommitmentId] = useState(''); + const [editFields, setEditFields] = useState([]); + const [editDependencies, setEditDependencies] = useState({ commitments: [] }); + + const selectedProduct = useMemo( + () => products.find((product) => product.pax8ProductId === selectedProductId) ?? null, + [products, selectedProductId], + ); + + useEffect(() => { + if (!selectedProductId) { + setFields([]); + setDependencies({ commitments: [] }); + setDetails([]); + setCommitmentTermId(''); + return; + } + let active = true; + setMetadataLoading(true); + setMetadataError(false); + Promise.all([ + getProvisionDetails(selectedProductId).then((response) => readData(response, t('pax8.errors.loadProduct'))), + getProductDependencies(selectedProductId).then((response) => readData(response, t('pax8.errors.loadProduct'))), + ]).then(([nextFields, nextDependencies]) => { + if (!active) return; + setFields(nextFields); + setDependencies(nextDependencies); + setCommitmentTermId(nextDependencies.commitments.length === 1 ? nextDependencies.commitments[0]!.id : ''); + }).catch(() => { + if (!active) return; + setFields([]); + setDependencies({ commitments: [] }); + setMetadataError(true); + }).finally(() => { if (active) setMetadataLoading(false); }); + return () => { active = false; }; + }, [selectedProductId, metadataVersion, t]); + + const mutation = async (key: string, options: Parameters>[0], after = true): Promise => { + setBusy(key); + try { + const result = await runAction(options); + if (after) await onReload(); + return result; + } catch (error) { + handleActionError(error, options.errorFallback); + return null; + } finally { + setBusy(null); + } + }; + + const addProduct = async () => { + if (!selectedProduct || !(Number(quantity) > 0)) return; + const result = await mutation('add', { + request: () => addPax8OrderLine(order.id, { + action: 'new_subscription', + pax8ProductId: selectedProduct.pax8ProductId, + catalogItemId: selectedProduct.catalogItemId, + billingTerm, + quantity, + ...(commitmentTermId ? { commitmentTermId } : {}), + provisioningDetails: details, + }), + parseSuccess: (value) => (value as { data: Pax8OrderLine }).data, + successMessage: t('pax8.toasts.lineAdded'), + errorFallback: t('pax8.errors.addLine'), + onUnauthorized, + }); + if (result) { + setSelectedProductId(''); + setQuantity('1'); + setDetails([]); + setPreflightErrors({ byLine: new Map(), order: [] }); + } + }; + + const beginEdit = async (line: Pax8OrderLine) => { + if (!line.pax8ProductId) return; + setBusy(`edit-load-${line.id}`); + try { + const [nextFields, nextDependencies] = await Promise.all([ + getProvisionDetails(line.pax8ProductId).then((response) => readData(response, t('pax8.errors.loadProduct'))), + getProductDependencies(line.pax8ProductId).then((response) => readData(response, t('pax8.errors.loadProduct'))), + ]); + setEditFields(nextFields); + setEditDependencies(nextDependencies); + setEditDetails(Array.isArray(line.provisioningDetails) ? line.provisioningDetails : []); + setEditCommitmentId(line.commitmentTermId ?? ''); + setEditingLineId(line.id); + } catch { + setPreflightErrors({ byLine: new Map(), order: [t('pax8.errors.loadProduct')] }); + } finally { + setBusy(null); + } + }; + + const saveLine = async () => { + if (!editingLineId) return; + const result = await mutation('edit-save', { + request: () => updatePax8OrderLine(order.id, editingLineId, { + commitmentTermId: editCommitmentId || null, + provisioningDetails: editDetails, + }), + parseSuccess: (value) => (value as { data: Pax8OrderLine }).data, + successMessage: t('pax8.toasts.lineUpdated'), + errorFallback: t('pax8.errors.updateLine'), + onUnauthorized, + }); + if (result) setEditingLineId(null); + }; + + const removeLine = async (lineId: string) => { + const result = await mutation<{ removed: boolean }>(`remove-${lineId}`, { + request: () => removePax8OrderLine(order.id, lineId), + successMessage: t('pax8.toasts.lineRemoved'), + errorFallback: t('pax8.errors.removeLine'), + onUnauthorized, + }); + if (result) setPreflightErrors({ byLine: new Map(), order: [] }); + }; + + const preflightAndSubmit = async () => { + setBusy('submit'); + setPreflightErrors({ byLine: new Map(), order: [] }); + try { + await runAction({ + request: () => preflightPax8Order(order.id), + successMessage: t('pax8.toasts.preflightPassed'), + errorFallback: t('pax8.errors.preflight'), + onUnauthorized, + }); + const result = await runAction<{ status: string }>({ + request: () => submitPax8Order(order.id), + successMessage: (response) => response.status === 'completed' + ? t('pax8.toasts.submitted') + : t('pax8.toasts.submitNeedsAttention'), + errorFallback: t('pax8.errors.submit'), + onUnauthorized, + }); + await onReload(); + return result; + } catch (error) { + if (error instanceof ActionError && error.status === 422) { + setPreflightErrors(extractPax8PreflightErrors(error.body)); + } + handleActionError(error, t('pax8.errors.submit')); + return null; + } finally { + setBusy(null); + } + }; + + const reconcile = async () => { + await mutation('reconcile', { + request: () => reconcilePax8Order(order.id), + successMessage: t('pax8.toasts.reconciled'), + errorFallback: t('pax8.errors.reconcile'), + onUnauthorized, + }); + }; + + return ( +
+
+
+ +

{t('pax8.order.title')}

+

+ {order.source === 'quote' ? t('pax8.order.quoteSource') : t('pax8.order.directSource')} +

+
+ + {t(/* i18n-dynamic */ PAX8_ORDER_STATUS_I18N_KEYS[order.status])} + +
+ + {order.error && ( +
+ {order.error} +
+ )} + + {directMutable && ( +
+

{t('pax8.order.addProduct')}

+
+ + + +
+ {selectedProduct && ( +
+ {metadataLoading ?

{t('pax8.states.loadingProduct')}

: metadataError ? ( +
+

{t('pax8.errors.loadProduct')}

+ +
+ ) : ( + <> + {dependencies.commitments.length > 0 && ( + + )} + + + )} + +
+ )} +
+ )} + +
+ + + + + + {lines.length === 0 ? : lines.map((line) => { + const messages = preflightErrors.byLine.get(line.sortOrder + 1) ?? []; + return ( + + + + + + + + ); + })} + +
{t('pax8.order.item')}{t('pax8.order.action')}{t('pax8.order.quantity')}{t('pax8.order.state')}{t('pax8.order.actions')}
{t('pax8.order.empty')}
{lineLabel(line, products, t('pax8.order.unknownItem'))}{messages.map((message) =>

{message}

)}{line.error &&

{line.error}

}
{t(/* i18n-dynamic */ PAX8_ORDER_ACTION_I18N_KEYS[line.action])}{displayQuantity(line.quantity)}{t(/* i18n-dynamic */ PAX8_SUBMIT_STATE_I18N_KEYS[line.submitState])}
+ {mutable && line.action === 'new_subscription' && } + {directMutable && } +
+
+ + {editingLineId && ( +
+

{t('pax8.order.editProvisioning')}

+ {editDependencies.commitments.length > 0 && } + +
+
+ )} + + {preflightErrors.order.length > 0 &&
{preflightErrors.order.map((message) =>

{message}

)}
} + +
+ {canReconcile ? : canSubmit && } + {order.status === 'completed' && {t('pax8.order.completed')}} +
+
+ ); +} diff --git a/apps/web/src/components/organizations/Pax8OrgTab.test.tsx b/apps/web/src/components/organizations/Pax8OrgTab.test.tsx new file mode 100644 index 0000000000..680ed29b2d --- /dev/null +++ b/apps/web/src/components/organizations/Pax8OrgTab.test.tsx @@ -0,0 +1,322 @@ +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import Pax8OrgTab, { Pax8SubscriptionTable } from './Pax8OrgTab'; +import { + listPax8Companies, + listPax8Orders, + listPax8Products, + listPax8Subscriptions, + getProductDependencies, + addPax8OrderLine, + getPax8Order, + preflightPax8Order, + submitPax8Order, +} from '../../lib/api/pax8Orders'; +import { i18n, loadLocale } from '../../lib/i18n'; + +vi.mock('../../lib/api/pax8Orders', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listPax8Companies: vi.fn(), + listPax8Orders: vi.fn(), + listPax8Products: vi.fn(), + listPax8Subscriptions: vi.fn(), + getProductDependencies: vi.fn(), + addPax8OrderLine: vi.fn(), + getPax8Order: vi.fn(), + preflightPax8Order: vi.fn(), + submitPax8Order: vi.fn(), + }; +}); + +const response = (payload: unknown) => Promise.resolve(new Response(JSON.stringify(payload), { + status: 200, headers: { 'content-type': 'application/json' }, +})); + +const order = (id: string, orgId = 'org-1') => ({ + id, + integrationId: 'integration-1', + partnerId: 'partner-1', + orgId, + pax8CompanyId: 'company-1', + status: 'draft', + source: 'direct', + sourceQuoteId: null, + pax8OrderId: null, + error: null, + submittedAt: null, + createdAt: '2026-07-14T00:00:00Z', + updatedAt: '2026-07-14T00:00:00Z', +} as const); + +beforeEach(async () => { + vi.clearAllMocks(); + window.location.hash = '#pax8'; + await i18n.changeLanguage('en'); +}); + +afterEach(async () => { + await act(() => i18n.changeLanguage('en')); +}); + +describe('Pax8 subscription ledger display', () => { + it('uses Breeze quantity as primary and never invents Pax8 zero', () => { + render( + , + ); + + expect(screen.getByTestId('pax8-breeze-quantity-snapshot-1')).toHaveTextContent('12'); + expect(screen.getByTestId('pax8-reported-quantity-snapshot-1')).toHaveTextContent('Not reported'); + expect(screen.queryByTestId('pax8-drift-snapshot-1')).not.toBeInTheDocument(); + }); + + it('shows drift only when Pax8 reported a known disagreement', () => { + render( + , + ); + + expect(screen.getByTestId('pax8-drift-snapshot-2')).toBeInTheDocument(); + }); +}); + +describe('Pax8 organization mapping state', () => { + it('teaches the next mapping action when the org is unmapped', async () => { + vi.mocked(listPax8Companies).mockImplementation(() => response({ + data: [{ + pax8CompanyId: 'company-1', pax8CompanyName: 'Acme', status: 'Active', + mappedOrgId: null, mappedOrgName: null, ignored: false, lastSeenAt: null, + }], + integrationId: 'integration-1', + })); + vi.mocked(listPax8Subscriptions).mockImplementation(() => response({ data: [], integrationId: 'integration-1' })); + vi.mocked(listPax8Orders).mockImplementation(() => response({ data: [] })); + vi.mocked(listPax8Products).mockImplementation(() => response({ data: [] })); + + render(); + + expect(await screen.findByTestId('pax8-mapping-empty')).toHaveTextContent(/map this organization/i); + expect(screen.getByRole('combobox', { name: /pax8 company/i })).toBeInTheDocument(); + expect(screen.getByTestId('pax8-new-order')).toBeDisabled(); + }); + + it('blocks a quantity increase when the active commitment forbids it', async () => { + vi.mocked(listPax8Companies).mockImplementation(() => response({ + data: [{ + pax8CompanyId: 'company-1', pax8CompanyName: 'Acme', status: 'Active', + mappedOrgId: 'org-1', mappedOrgName: 'Acme', ignored: false, lastSeenAt: null, + }], integrationId: 'integration-1', + })); + vi.mocked(listPax8Subscriptions).mockImplementation(() => response({ + data: [{ + id: 'snapshot-1', pax8SubscriptionId: 'sub-1', productId: 'prod-1', productName: 'Microsoft 365', + status: 'Active', breezeQuantity: '5.00', quantity: '5.00', quantityKnown: true, + lastSeenAt: '2026-07-14T00:00:00Z', contractLineId: 'contract-line-1', + }], integrationId: 'integration-1', + })); + vi.mocked(listPax8Orders).mockImplementation(() => response({ data: [] })); + vi.mocked(listPax8Products).mockImplementation(() => response({ data: [{ + pax8ProductId: 'prod-1', catalogItemId: 'cat-1', catalogName: 'Microsoft 365', + }] })); + vi.mocked(getProductDependencies).mockImplementation(() => response({ data: { commitments: [{ + id: 'commit-1', term: 'Annual', allowForQuantityIncrease: false, + allowForQuantityDecrease: true, allowForEarlyCancellation: true, cancellationFeeApplied: false, + }] } })); + + render(); + const quantity = await screen.findByRole('spinbutton', { name: /target quantity/i }); + await userEvent.clear(quantity); + await userEvent.type(quantity, '6'); + await userEvent.click(screen.getByRole('button', { name: /stage change/i })); + + expect(await screen.findByRole('alert')).toHaveTextContent(/does not allow quantity increases/i); + expect(addPax8OrderLine).not.toHaveBeenCalled(); + }); + + it('uses the snapshot active commitment when multiple dependency commitments exist', async () => { + vi.mocked(listPax8Companies).mockImplementation(() => response({ data: [{ + pax8CompanyId: 'company-1', pax8CompanyName: 'Acme', status: 'Active', mappedOrgId: 'org-1', + mappedOrgName: 'Acme', ignored: false, lastSeenAt: null, orderReady: true, + primaryAdminReady: true, primaryBillingReady: true, primaryTechnicalReady: true, + }], integrationId: 'integration-1' })); + vi.mocked(listPax8Subscriptions).mockImplementation(() => response({ data: [{ + id: 'snapshot-1', pax8SubscriptionId: 'sub-1', productId: 'prod-1', productName: 'Microsoft 365', + status: 'Active', breezeQuantity: '5.00', quantity: '5.00', quantityKnown: true, + activeCommitmentId: 'active', activeCommitmentAmbiguous: false, + lastSeenAt: '2026-07-14T00:00:00Z', contractLineId: 'contract-line-1', + }], integrationId: 'integration-1' })); + vi.mocked(listPax8Orders).mockImplementation(() => response({ data: [{ + id: '44444444-4444-4444-8444-444444444444', integrationId: 'integration-1', partnerId: 'partner-1', + orgId: 'org-1', pax8CompanyId: 'company-1', status: 'draft', source: 'direct', sourceQuoteId: null, + pax8OrderId: null, error: null, submittedAt: null, createdAt: '2026-07-14T00:00:00Z', updatedAt: '2026-07-14T00:00:00Z', + }] })); + vi.mocked(listPax8Products).mockImplementation(() => response({ data: [{ + pax8ProductId: 'prod-1', catalogItemId: 'cat-1', catalogName: 'Microsoft 365', + }] })); + vi.mocked(getProductDependencies).mockImplementation(() => response({ data: { commitments: [ + { id: 'other', term: 'Annual', allowForQuantityIncrease: false, allowForQuantityDecrease: false, allowForEarlyCancellation: false, cancellationFeeApplied: false }, + { id: 'active', term: 'Monthly', allowForQuantityIncrease: true, allowForQuantityDecrease: true, allowForEarlyCancellation: true, cancellationFeeApplied: false }, + ] } })); + vi.mocked(addPax8OrderLine).mockImplementation(() => response({ data: { id: 'line-1' } })); + + render(); + const quantity = await screen.findByRole('spinbutton', { name: /target quantity/i }); + await userEvent.clear(quantity); + await userEvent.type(quantity, '6'); + await userEvent.click(screen.getByRole('button', { name: /stage change/i })); + + await vi.waitFor(() => expect(addPax8OrderLine).toHaveBeenCalledWith( + '44444444-4444-4444-8444-444444444444', + { action: 'change_quantity', targetSubscriptionId: 'sub-1', quantity: '6' }, + )); + }); + + it('fails closed when the snapshot active commitment forbids the requested action', async () => { + vi.mocked(listPax8Companies).mockImplementation(() => response({ data: [{ + pax8CompanyId: 'company-1', pax8CompanyName: 'Acme', status: 'Active', mappedOrgId: 'org-1', + mappedOrgName: 'Acme', ignored: false, lastSeenAt: null, orderReady: true, + primaryAdminReady: true, primaryBillingReady: true, primaryTechnicalReady: true, + }], integrationId: 'integration-1' })); + vi.mocked(listPax8Subscriptions).mockImplementation(() => response({ data: [{ + id: 'snapshot-1', pax8SubscriptionId: 'sub-1', productId: 'prod-1', productName: 'Microsoft 365', + status: 'Active', breezeQuantity: '5.00', quantity: '5.00', quantityKnown: true, + activeCommitmentId: 'blocked', activeCommitmentAmbiguous: false, + lastSeenAt: '2026-07-14T00:00:00Z', contractLineId: 'contract-line-1', + }], integrationId: 'integration-1' })); + vi.mocked(listPax8Orders).mockImplementation(() => response({ data: [] })); + vi.mocked(listPax8Products).mockImplementation(() => response({ data: [] })); + vi.mocked(getProductDependencies).mockImplementation(() => response({ data: { commitments: [ + { id: 'allowed', term: 'Annual', allowForQuantityIncrease: true, allowForQuantityDecrease: true, allowForEarlyCancellation: true, cancellationFeeApplied: false }, + { id: 'blocked', term: 'Monthly', allowForQuantityIncrease: false, allowForQuantityDecrease: true, allowForEarlyCancellation: true, cancellationFeeApplied: false }, + ] } })); + + render(); + const quantity = await screen.findByRole('spinbutton', { name: /target quantity/i }); + await userEvent.clear(quantity); + await userEvent.type(quantity, '6'); + await userEvent.click(screen.getByRole('button', { name: /stage change/i })); + + expect(await screen.findByRole('alert')).toHaveTextContent(/does not allow quantity increases/i); + expect(addPax8OrderLine).not.toHaveBeenCalled(); + }); + + it('stages cancellation by subscription identity without sending tenant linkage', async () => { + vi.mocked(listPax8Companies).mockImplementation(() => response({ data: [{ + pax8CompanyId: 'company-1', pax8CompanyName: 'Acme', status: 'Active', mappedOrgId: 'org-1', + mappedOrgName: 'Acme', ignored: false, lastSeenAt: null, orderReady: true, + primaryAdminReady: true, primaryBillingReady: true, primaryTechnicalReady: true, + }], integrationId: 'integration-1' })); + vi.mocked(listPax8Subscriptions).mockImplementation(() => response({ data: [{ + id: 'snapshot-1', pax8SubscriptionId: 'sub-1', productId: 'prod-1', productName: 'Microsoft 365', + status: 'Active', breezeQuantity: '5.00', quantity: '5.00', quantityKnown: true, + activeCommitmentId: 'active', activeCommitmentAmbiguous: false, + lastSeenAt: '2026-07-14T00:00:00Z', contractLineId: 'contract-line-1', + }], integrationId: 'integration-1' })); + vi.mocked(listPax8Orders).mockImplementation(() => response({ data: [order('44444444-4444-4444-8444-444444444444')] })); + vi.mocked(listPax8Products).mockImplementation(() => response({ data: [{ + pax8ProductId: 'prod-1', catalogItemId: 'cat-1', catalogName: 'Microsoft 365', + }] })); + vi.mocked(getProductDependencies).mockImplementation(() => response({ data: { commitments: [{ + id: 'active', term: 'Monthly', allowForQuantityIncrease: true, allowForQuantityDecrease: true, + allowForEarlyCancellation: true, cancellationFeeApplied: false, + }] } })); + vi.mocked(addPax8OrderLine).mockImplementation(() => response({ data: { id: 'line-1' } })); + + render(); + await userEvent.click(await screen.findByRole('button', { name: 'Cancel' })); + await userEvent.click(screen.getByRole('button', { name: 'Stage cancellation' })); + + await vi.waitFor(() => expect(addPax8OrderLine).toHaveBeenCalledWith( + '44444444-4444-4444-8444-444444444444', + { action: 'cancel', targetSubscriptionId: 'sub-1' }, + )); + }); + + it('fails closed for a deep-linked order belonging to another organization', async () => { + window.location.hash = '#pax8/44444444-4444-4444-8444-444444444444'; + vi.mocked(listPax8Companies).mockImplementation(() => response({ data: [], integrationId: 'integration-1' })); + vi.mocked(listPax8Subscriptions).mockImplementation(() => response({ data: [], integrationId: 'integration-1' })); + vi.mocked(listPax8Orders).mockImplementation(() => response({ data: [] })); + vi.mocked(listPax8Products).mockImplementation(() => response({ data: [] })); + vi.mocked(getPax8Order).mockImplementation(() => response({ data: { + order: { + id: '44444444-4444-4444-8444-444444444444', integrationId: 'integration-1', partnerId: 'partner-1', + orgId: 'org-2', pax8CompanyId: 'company-2', status: 'draft', source: 'direct', sourceQuoteId: null, + pax8OrderId: null, error: null, submittedAt: null, createdAt: '2026-07-14T00:00:00Z', updatedAt: '2026-07-14T00:00:00Z', + }, lines: [], + } })); + + render(); + + expect(await screen.findByRole('alert')).toHaveTextContent(/organization/i); + expect(screen.queryByTestId('pax8-submit')).not.toBeInTheDocument(); + expect(preflightPax8Order).not.toHaveBeenCalled(); + expect(submitPax8Order).not.toHaveBeenCalled(); + }); + + it('writes exact order hashes and follows root/order hash navigation', async () => { + const firstId = '44444444-4444-4444-8444-444444444444'; + const secondId = '55555555-5555-4555-8555-555555555555'; + vi.mocked(listPax8Companies).mockImplementation(() => response({ data: [], integrationId: 'integration-1' })); + vi.mocked(listPax8Subscriptions).mockImplementation(() => response({ data: [], integrationId: 'integration-1' })); + vi.mocked(listPax8Orders).mockImplementation(() => response({ data: [order(firstId)] })); + vi.mocked(listPax8Products).mockImplementation(() => response({ data: [] })); + vi.mocked(getPax8Order).mockImplementation((id) => response({ + data: { order: order(id), lines: [] }, + })); + + render(); + + await userEvent.click(await screen.findByRole('button', { name: /direct order/i })); + expect(window.location.hash).toBe(`#pax8/${firstId}`); + expect(await screen.findByTestId('pax8-order-builder')).toBeInTheDocument(); + expect(getPax8Order).toHaveBeenLastCalledWith(firstId); + + await userEvent.click(screen.getByRole('button', { name: /back to pax8/i })); + expect(window.location.hash).toBe('#pax8'); + expect(await screen.findByTestId('pax8-org-tab')).toBeInTheDocument(); + + act(() => { + window.location.hash = `#pax8/${secondId}`; + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + expect(await screen.findByTestId('pax8-order-builder')).toBeInTheDocument(); + expect(getPax8Order).toHaveBeenLastCalledWith(secondId); + }); + + it('localizes order-history status instead of displaying the wire enum', async () => { + await loadLocale('es-419'); + await act(() => i18n.changeLanguage('es-419')); + vi.mocked(listPax8Companies).mockImplementation(() => response({ data: [], integrationId: 'integration-1' })); + vi.mocked(listPax8Subscriptions).mockImplementation(() => response({ data: [], integrationId: 'integration-1' })); + vi.mocked(listPax8Orders).mockImplementation(() => response({ data: [order('44444444-4444-4444-8444-444444444444')] })); + vi.mocked(listPax8Products).mockImplementation(() => response({ data: [] })); + + render(); + + expect(await screen.findByText('Borrador')).toBeInTheDocument(); + expect(screen.queryByText('draft')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/organizations/Pax8OrgTab.tsx b/apps/web/src/components/organizations/Pax8OrgTab.tsx new file mode 100644 index 0000000000..d98103a9ef --- /dev/null +++ b/apps/web/src/components/organizations/Pax8OrgTab.tsx @@ -0,0 +1,250 @@ +import '@/lib/i18n'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { AlertTriangle, Building2, PackagePlus, RefreshCw } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { navigateTo } from '../../lib/navigation'; +import { ActionError, handleActionError, runAction } from '../../lib/runAction'; +import { useHashState } from '../../lib/useHashState'; +import { TableSkeleton } from '../billing/shared/TableSkeleton'; +import { + addPax8OrderLine, + createPax8Order, + getPax8Order, + getProductDependencies, + listPax8Companies, + listPax8Orders, + listPax8Products, + listPax8Subscriptions, + mapPax8Company, + readData, + type Pax8Commitment, + type Pax8Company, + type Pax8Order, + type Pax8OrderBundle, + type Pax8OrderLine, + type Pax8ProductDependencies, + type Pax8ProductOption, + type Pax8Subscription, +} from '../../lib/api/pax8Orders'; +import Pax8OrderBuilder from './Pax8OrderBuilder'; +import { PAX8_ORDER_STATUS_I18N_KEYS, displayQuantity } from './pax8OrderUi'; + +const onUnauthorized = () => void navigateTo('/login', { replace: true }); +const mutableStatuses = new Set(['draft', 'awaiting_details']); + +function selectedOrderFromHash(hash: string): string | undefined { + const match = /^pax8\/([0-9a-f-]{36})$/i.exec(hash); + return match?.[1]; +} + +function isKnownDrift(subscription: Pax8Subscription): boolean { + return subscription.quantityKnown + && subscription.breezeQuantity != null + && Number(subscription.breezeQuantity) !== Number(subscription.quantity); +} + +export function Pax8SubscriptionTable({ + subscriptions, + onChangeQuantity, + onCancel, + busyId = null, +}: { + subscriptions: Pax8Subscription[]; + onChangeQuantity: (subscription: Pax8Subscription, quantity: string) => void; + onCancel: (subscription: Pax8Subscription) => void; + busyId?: string | null; +}) { + const { t } = useTranslation('settings'); + const [drafts, setDrafts] = useState>({}); + if (subscriptions.length === 0) { + return
{t('pax8.subscriptions.empty')}
; + } + return ( +
+ + + {subscriptions.map((subscription) => { + const unavailable = !subscription.productId || !subscription.contractLineId || subscription.breezeQuantity == null; + const quantity = drafts[subscription.id] ?? subscription.breezeQuantity ?? ''; + return + + + + + + + ; + })} +
{t('pax8.subscriptions.product')}{t('pax8.subscriptions.breeze')}{t('pax8.subscriptions.pax8')}{t('pax8.subscriptions.status')}{t('pax8.subscriptions.observed')}{t('pax8.subscriptions.actions')}

{subscription.productName || subscription.productId || t('pax8.subscriptions.unknownProduct')}

{subscription.pax8SubscriptionId}

{subscription.breezeQuantity == null ? t('pax8.subscriptions.notLinked') : displayQuantity(subscription.breezeQuantity)}{subscription.quantityKnown ? displayQuantity(subscription.quantity) : t('pax8.subscriptions.notReported')}{isKnownDrift(subscription) && {t('pax8.subscriptions.drift')}}{subscription.status || t('pax8.subscriptions.unknownStatus')}{subscription.lastSeenAt ? new Date(subscription.lastSeenAt).toLocaleString() : t('pax8.subscriptions.neverObserved')}
setDrafts((current) => ({ ...current, [subscription.id]: event.target.value }))} className="h-8 w-20 rounded-md border bg-background px-2 text-right tabular-nums disabled:opacity-50"/>
{unavailable &&

{t('pax8.subscriptions.actionUnavailable')}

}
+
+ ); +} + +export default function Pax8OrgTab({ orgId }: { orgId: string }) { + const { t } = useTranslation('settings'); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [integrationId, setIntegrationId] = useState(null); + const [companies, setCompanies] = useState([]); + const [subscriptions, setSubscriptions] = useState([]); + const [orders, setOrders] = useState([]); + const [products, setProducts] = useState([]); + const [selectedOrderId, setSelectedOrderId] = useHashState(null, selectedOrderFromHash); + const [bundle, setBundle] = useState(null); + const [bundleLoading, setBundleLoading] = useState(false); + const [mappingChoice, setMappingChoice] = useState(''); + const [busy, setBusy] = useState(null); + const [actionError, setActionError] = useState(null); + const [cancelCandidate, setCancelCandidate] = useState<{ subscription: Pax8Subscription; commitment: Pax8Commitment } | null>(null); + + const load = useCallback(async () => { + setLoading(true); + setLoadError(null); + try { + const [companiesResponse, subscriptionsResponse, ordersResponse, productsResponse] = await Promise.all([ + listPax8Companies(), listPax8Subscriptions(orgId), listPax8Orders(orgId), listPax8Products(), + ]); + const companyPayload = await companiesResponse.json().catch(() => null) as { data?: Pax8Company[]; integrationId?: string | null; error?: string } | null; + if (!companiesResponse.ok) throw new Error(companyPayload?.error || t('pax8.errors.load')); + const subscriptionPayload = await subscriptionsResponse.json().catch(() => null) as { data?: Pax8Subscription[]; integrationId?: string | null; error?: string } | null; + if (!subscriptionsResponse.ok) throw new Error(subscriptionPayload?.error || t('pax8.errors.load')); + const [nextOrders, nextProducts] = await Promise.all([ + readData(ordersResponse, t('pax8.errors.load')), + readData(productsResponse, t('pax8.errors.load')), + ]); + setCompanies(Array.isArray(companyPayload?.data) ? companyPayload.data : []); + setIntegrationId(companyPayload?.integrationId ?? subscriptionPayload?.integrationId ?? null); + setSubscriptions(Array.isArray(subscriptionPayload?.data) ? subscriptionPayload.data : []); + setOrders(nextOrders.slice(0, 100)); + setProducts(nextProducts.slice(0, 200)); + } catch (error) { + setLoadError(error instanceof Error ? error.message : t('pax8.errors.load')); + } finally { + setLoading(false); + } + }, [orgId, t]); + + const loadBundle = useCallback(async () => { + if (!selectedOrderId) { setBundle(null); return; } + setBundleLoading(true); + try { + const loaded = await getPax8Order(selectedOrderId) + .then((response) => readData(response, t('pax8.errors.loadOrder'))); + if (loaded.order.orgId !== orgId) throw new Error(t('pax8.errors.foreignOrder')); + setBundle(loaded); + } catch (error) { + setActionError(error instanceof Error ? error.message : t('pax8.errors.loadOrder')); + setBundle(null); + } finally { setBundleLoading(false); } + }, [orgId, selectedOrderId, t]); + + useEffect(() => { void load(); }, [load]); + useEffect(() => { void loadBundle(); }, [loadBundle]); + + const mappedCompany = companies.find((company) => company.mappedOrgId === orgId && !company.ignored) ?? null; + const mappingOptions = companies.filter((company) => !company.ignored && (!company.mappedOrgId || company.mappedOrgId === orgId)); + const mappingReady = mappedCompany?.orderReady === true; + + const selectOrder = (id: string | null) => { + setSelectedOrderId(id); + const hash = id ? `#pax8/${id}` : '#pax8'; + if (window.location.hash !== hash) window.location.hash = hash; + }; + + const mapCompany = async () => { + if (!integrationId || !mappingChoice) return; + setBusy('mapping'); + try { + await runAction({ request: () => mapPax8Company({ integrationId, pax8CompanyId: mappingChoice, orgId }), successMessage: t('pax8.toasts.companyMapped'), errorFallback: t('pax8.errors.mapCompany'), onUnauthorized }); + setMappingChoice(''); + await load(); + } catch (error) { handleActionError(error, t('pax8.errors.mapCompany')); } + finally { setBusy(null); } + }; + + const ensureDraft = async (): Promise => { + const existing = orders.find((order) => order.source === 'direct' && mutableStatuses.has(order.status)); + if (existing) return existing; + try { + return await runAction({ request: () => createPax8Order(orgId), parseSuccess: (value) => (value as { data: Pax8Order }).data, successMessage: t('pax8.toasts.draftReady'), errorFallback: t('pax8.errors.createDraft'), onUnauthorized }); + } catch (error) { handleActionError(error, t('pax8.errors.createDraft')); return null; } + }; + + const singleCommitment = async (subscription: Pax8Subscription): Promise => { + if (!subscription.productId) return null; + const dependencies = await getProductDependencies(subscription.productId).then((response) => readData(response, t('pax8.errors.loadProduct'))); + if (subscription.activeCommitmentAmbiguous) { + setActionError(t('pax8.subscriptions.commitmentUnavailable')); + return null; + } + if (subscription.activeCommitmentId) { + const matches = dependencies.commitments.filter( + (commitment) => commitment.id === subscription.activeCommitmentId, + ); + if (matches.length === 1) return matches[0]!; + setActionError(t('pax8.subscriptions.commitmentUnavailable')); + return null; + } + if (dependencies.commitments.length === 1) return dependencies.commitments[0]!; + setActionError(t('pax8.subscriptions.commitmentUnavailable')); + return null; + }; + + const stageQuantity = async (subscription: Pax8Subscription, target: string) => { + setBusy(subscription.id); setActionError(null); + try { + const commitment = await singleCommitment(subscription); + if (!commitment) return; + const current = Number(subscription.breezeQuantity); + const requested = Number(target); + const allowed = requested > current ? commitment.allowForQuantityIncrease : commitment.allowForQuantityDecrease; + if (!allowed) { setActionError(requested > current ? t('pax8.subscriptions.increaseBlocked') : t('pax8.subscriptions.decreaseBlocked')); return; } + const order = await ensureDraft(); if (!order) return; + await runAction({ request: () => addPax8OrderLine(order.id, { action: 'change_quantity', targetSubscriptionId: subscription.pax8SubscriptionId, quantity: target }), parseSuccess: (value) => (value as { data: Pax8OrderLine }).data, successMessage: t('pax8.toasts.changeStaged'), errorFallback: t('pax8.errors.stageChange'), onUnauthorized }); + await load(); selectOrder(order.id); + } catch (error) { if (!(error instanceof ActionError)) setActionError(error instanceof Error ? error.message : t('pax8.errors.stageChange')); handleActionError(error, t('pax8.errors.stageChange')); } + finally { setBusy(null); } + }; + + const prepareCancel = async (subscription: Pax8Subscription) => { + setBusy(subscription.id); setActionError(null); + try { + const commitment = await singleCommitment(subscription); + if (!commitment) return; + if (!commitment.allowForEarlyCancellation) { setActionError(t('pax8.subscriptions.cancelBlocked')); return; } + setCancelCandidate({ subscription, commitment }); + } catch (error) { setActionError(error instanceof Error ? error.message : t('pax8.errors.stageCancel')); } + finally { setBusy(null); } + }; + + const confirmCancel = async () => { + if (!cancelCandidate) return; + setBusy(cancelCandidate.subscription.id); + try { + const order = await ensureDraft(); if (!order) return; + await runAction({ request: () => addPax8OrderLine(order.id, { action: 'cancel', targetSubscriptionId: cancelCandidate.subscription.pax8SubscriptionId }), parseSuccess: (value) => (value as { data: Pax8OrderLine }).data, successMessage: t('pax8.toasts.cancelStaged'), errorFallback: t('pax8.errors.stageCancel'), onUnauthorized }); + setCancelCandidate(null); await load(); selectOrder(order.id); + } catch (error) { handleActionError(error, t('pax8.errors.stageCancel')); } + finally { setBusy(null); } + }; + + if (loading) return
; + if (loadError) return

{loadError}

; + if (selectedOrderId) { + if (bundleLoading) return
; + if (bundle) return { await Promise.all([load(), loadBundle()]); }} onBack={() => selectOrder(null)} />; + } + + return
+

{t('pax8.title')}

{t('pax8.description')}

+ +

{t('pax8.mapping.title')}

{mappedCompany ? <>

{mappedCompany.pax8CompanyName}

{mappedCompany.status || t('pax8.mapping.unknownStatus')}

{!mappingReady &&

{t('pax8.mapping.activeRequired')}

} :

{integrationId ? t('pax8.mapping.empty') : t('pax8.mapping.noIntegration')}

{integrationId &&
}
}
+ + {actionError &&
{actionError}
} + {cancelCandidate &&

{t('pax8.subscriptions.confirmCancel', { product: cancelCandidate.subscription.productName || cancelCandidate.subscription.pax8SubscriptionId })}

{cancelCandidate.commitment.cancellationFeeApplied &&

{t('pax8.subscriptions.feeWarning')}

}
} + +

{t('pax8.subscriptions.title')}

{t('pax8.subscriptions.description')}

void stageQuantity(subscription, next)} onCancel={(subscription) => void prepareCancel(subscription)} busyId={busy}/>
+ +

{t('pax8.orders.title')}

{t('pax8.orders.description')}

{orders.length === 0 ?
{t('pax8.orders.empty')}
:
{orders.map((order) => )}
}
+
; +} diff --git a/apps/web/src/components/organizations/Pax8ProvisioningForm.tsx b/apps/web/src/components/organizations/Pax8ProvisioningForm.tsx new file mode 100644 index 0000000000..8ba3b65b18 --- /dev/null +++ b/apps/web/src/components/organizations/Pax8ProvisioningForm.tsx @@ -0,0 +1,110 @@ +import '@/lib/i18n'; +import { useTranslation } from 'react-i18next'; +import type { Pax8ProvisionField, ProvisioningValue } from '../../lib/api/pax8Orders'; + +export function Pax8ProvisioningForm({ + fields, + value, + onChange, + disabled = false, +}: { + fields: Pax8ProvisionField[]; + value: ProvisioningValue[]; + onChange: (value: ProvisioningValue[]) => void; + disabled?: boolean; +}) { + const { t } = useTranslation('settings'); + const current = new Map(value.map((item) => [item.key, item.values])); + + const update = (key: string, values: string[]) => { + const next = value.filter((item) => item.key !== key); + const meaningful = values.filter((item) => item !== ''); + if (meaningful.length > 0) next.push({ key, values: meaningful }); + onChange(next); + }; + + if (fields.length === 0) { + return

{t('pax8.provisioning.none')}

; + } + + return ( +
+ {fields.map((field) => { + const id = `pax8-provision-${field.key}`; + const values = current.get(field.key) ?? []; + const possibleValues = field.possibleValues ?? []; + return ( +
+ + {field.valueType === 'Single-Value' ? ( +
+ + {values.length > 0 && ( + + )} +
+ ) : field.valueType === 'Multi-Value' ? ( + + ) : field.valueType === 'Input' ? ( + update(field.key, [event.target.value])} + className="h-10 w-full rounded-md border bg-background px-3 text-sm focus:outline-hidden focus:ring-2 focus:ring-ring disabled:opacity-50" + /> + ) : ( +

+ {t('pax8.provisioning.unsupported')} +

+ )} + {field.description && ( +

{field.description}

+ )} +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/organizations/pax8OrderUi.ts b/apps/web/src/components/organizations/pax8OrderUi.ts new file mode 100644 index 0000000000..ad015fe951 --- /dev/null +++ b/apps/web/src/components/organizations/pax8OrderUi.ts @@ -0,0 +1,86 @@ +import type { Pax8BillingTerm } from '@breeze/shared'; +import type { Pax8OrderAction, Pax8OrderStatus, Pax8OrderLine } from '../../lib/api/pax8Orders'; + +export const PAX8_ORDER_STATUS_I18N_KEYS: Record = { + draft: 'pax8.enums.orderStatus.draft', + awaiting_details: 'pax8.enums.orderStatus.awaitingDetails', + ready: 'pax8.enums.orderStatus.ready', + submitting: 'pax8.enums.orderStatus.submitting', + completed: 'pax8.enums.orderStatus.completed', + partially_failed: 'pax8.enums.orderStatus.partiallyFailed', + failed: 'pax8.enums.orderStatus.failed', + cancelled: 'pax8.enums.orderStatus.cancelled', +}; + +export const PAX8_ORDER_ACTION_I18N_KEYS: Record = { + new_subscription: 'pax8.enums.lineAction.newSubscription', + change_quantity: 'pax8.enums.lineAction.changeQuantity', + cancel: 'pax8.enums.lineAction.cancel', +}; + +export const PAX8_SUBMIT_STATE_I18N_KEYS: Record = { + pending: 'pax8.enums.submitState.pending', + in_flight: 'pax8.enums.submitState.inFlight', + succeeded: 'pax8.enums.submitState.succeeded', + failed: 'pax8.enums.submitState.failed', + needs_reconcile: 'pax8.enums.submitState.needsReconcile', +}; + +export const PAX8_BILLING_TERM_I18N_KEYS: Record = { + Monthly: 'pax8.enums.billingTerm.monthly', + Annual: 'pax8.enums.billingTerm.annual', + '2-Year': 'pax8.enums.billingTerm.twoYear', + '3-Year': 'pax8.enums.billingTerm.threeYear', + 'One-Time': 'pax8.enums.billingTerm.oneTime', + Trial: 'pax8.enums.billingTerm.trial', + Activation: 'pax8.enums.billingTerm.activation', +}; + +export interface PreflightErrors { + byLine: Map; + order: string[]; +} + +function record(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null; +} + +/** Pax8 varies detail keys; preserve readable raw messages and fail safely. */ +export function extractPax8PreflightErrors(body: unknown): PreflightErrors { + const result: PreflightErrors = { byLine: new Map(), order: [] }; + const root = record(body); + const details = Array.isArray(root?.details) ? root.details : []; + for (const raw of details) { + const detail = record(raw); + if (!detail) continue; + const message = [detail.message, detail.detail, detail.error, detail.description] + .find((candidate) => typeof candidate === 'string' && candidate.trim()) as string | undefined; + if (!message) continue; + const numberValue = detail.lineItemNumber ?? detail.line_item_number; + const lineItemNumber = typeof numberValue === 'number' + ? numberValue + : typeof numberValue === 'string' && /^\d+$/.test(numberValue) + ? Number(numberValue) + : null; + if (lineItemNumber === null) { + result.order.push(message); + } else { + const messages = result.byLine.get(lineItemNumber) ?? []; + messages.push(message); + result.byLine.set(lineItemNumber, messages); + } + } + if (details.length === 0 && typeof root?.error === 'string' && root.error.trim()) { + result.order.push(root.error); + } + return result; +} + +export function displayQuantity(value: string | null | undefined): string { + if (value == null || value.trim() === '') return '—'; + const parsed = Number(value); + if (!Number.isFinite(parsed)) return value; + return parsed.toLocaleString(undefined, { maximumFractionDigits: 2 }); +} diff --git a/apps/web/src/components/settings/OrgSettingsPage.test.tsx b/apps/web/src/components/settings/OrgSettingsPage.test.tsx index 9e6467eb82..fda3ac344d 100644 --- a/apps/web/src/components/settings/OrgSettingsPage.test.tsx +++ b/apps/web/src/components/settings/OrgSettingsPage.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -32,6 +32,7 @@ vi.mock('./OrgSecuritySettings', () => ({ default: () =>
})); vi.mock('./OrgRemoteAccessSettings', () => ({ default: () =>
})); vi.mock('./OrgTicketSettingsEditor', () => ({ default: () =>
})); +vi.mock('../organizations/Pax8OrgTab', () => ({ default: ({ orgId }: { orgId: string }) =>
{orgId}
})); const fetchWithAuthMock = vi.mocked(fetchWithAuth); const useOrgStoreMock = vi.mocked(useOrgStore); @@ -336,6 +337,27 @@ describe('OrgSettingsPage sidebar nav & save-state honesty', () => { expect(screen.getByTestId('remote-access')).not.toBeNull(); }); + it('keeps a selected Pax8 order deep link active through hashchange and back navigation', async () => { + window.location.hash = '#pax8/44444444-4444-4444-8444-444444444444'; + render(); + + const link = await screen.findByRole('link', { name: /^pax8$/i }); + expect(link.getAttribute('aria-current')).toBe('page'); + expect(screen.getByTestId('pax8-org-tab')).toHaveTextContent('org-1'); + + act(() => { + window.location.hash = '#general'; + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + await screen.findByTestId('org-name-input'); + + act(() => { + window.location.hash = '#pax8/55555555-5555-4555-8555-555555555555'; + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + expect(await screen.findByTestId('pax8-org-tab')).toBeInTheDocument(); + }); + it('offers the compact section select for narrow viewports', async () => { render(); diff --git a/apps/web/src/components/settings/OrgSettingsPage.tsx b/apps/web/src/components/settings/OrgSettingsPage.tsx index 46e070c611..06a367b11b 100644 --- a/apps/web/src/components/settings/OrgSettingsPage.tsx +++ b/apps/web/src/components/settings/OrgSettingsPage.tsx @@ -15,6 +15,7 @@ import { Globe, Monitor, Paintbrush, + PackageOpen, ScrollText, Shield, Ticket @@ -38,10 +39,11 @@ import { fetchWithAuth } from '../../stores/auth'; import { navigateTo } from '@/lib/navigation'; import { runAction, ActionError } from '@/lib/runAction'; import { formatDate, formatTime as formatUserTime } from '@/lib/dateTimeFormat'; +import Pax8OrgTab from '../organizations/Pax8OrgTab'; type TabKey = | 'general' | 'branding' | 'portal' | 'notifications' | 'security' - | 'approval-security' | 'event-logs' | 'remote-access' | 'ticketing' | 'contracts' | 'billing'; + | 'approval-security' | 'event-logs' | 'remote-access' | 'ticketing' | 'contracts' | 'billing' | 'pax8'; // Grouped sidebar definition — same anatomy as PartnerSettingsPage (shared // SettingsSectionNav). Hashes are the section keys (already kebab-case). @@ -52,6 +54,7 @@ const TAB_GROUPS: (Omit & { items: (SettingsNavGroup[ { key: 'general', hash: 'general', label: 'orgSettingsPage.nav.general', description: 'orgSettingsPage.nav.generalDescription', icon: Building2 }, { key: 'contracts', hash: 'contracts', label: 'orgSettingsPage.nav.contracts', description: 'orgSettingsPage.nav.contractsDescription', icon: FileSignature }, { key: 'billing', hash: 'billing', label: 'orgSettingsPage.nav.billing', description: 'orgSettingsPage.nav.billingDescription', icon: CreditCard }, + { key: 'pax8', hash: 'pax8', label: 'orgSettingsPage.nav.pax8', description: 'orgSettingsPage.nav.pax8Description', icon: PackageOpen }, ], }, { @@ -85,7 +88,8 @@ const TAB_BY_KEY = Object.fromEntries(ALL_TABS.map(t => [t.key, t])) as Record
) : null; + case 'pax8': + return effectiveOrgId ? ( +
+ +
+ ) : null; case 'general': default: return ( diff --git a/apps/web/src/lib/api/pax8Orders.test.ts b/apps/web/src/lib/api/pax8Orders.test.ts new file mode 100644 index 0000000000..4465432c75 --- /dev/null +++ b/apps/web/src/lib/api/pax8Orders.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; +import { fetchWithAuth } from '../../stores/auth'; +import { + addPax8OrderLine, + type AddPax8OrderLineRequest, + listPax8Orders, + readData, + updatePax8OrderLine, + type UpdatePax8OrderLineRequest, +} from './pax8Orders'; + +vi.mock('../../stores/auth', () => ({ fetchWithAuth: vi.fn() })); + +beforeEach(() => vi.clearAllMocks()); + +describe('Pax8 ordering API client', () => { + it('does not expose tenant linkage as client-controlled add-line input', () => { + expectTypeOf().not.toHaveProperty('contractLineId'); + }); + + it('limits staged-line PATCH input to provisioning fields', () => { + expectTypeOf().not.toHaveProperty('contractLineId'); + expectTypeOf().not.toHaveProperty('action'); + expectTypeOf().not.toHaveProperty('quantity'); + expectTypeOf().not.toHaveProperty('pax8ProductId'); + }); + it('scopes the order list to the selected organization', () => { + vi.mocked(fetchWithAuth).mockResolvedValue(new Response()); + void listPax8Orders('org/one'); + expect(fetchWithAuth).toHaveBeenCalledWith('/pax8/orders?orgId=org%2Fone'); + }); + + it('sends only the staged-line editable fields through PATCH', () => { + vi.mocked(fetchWithAuth).mockResolvedValue(new Response()); + void updatePax8OrderLine('order-1', 'line-1', { + commitmentTermId: 'commit-1', + provisioningDetails: [{ key: 'domain', values: ['acme.example'] }], + }); + expect(fetchWithAuth).toHaveBeenCalledWith('/pax8/orders/order-1/lines/line-1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + commitmentTermId: 'commit-1', + provisioningDetails: [{ key: 'domain', values: ['acme.example'] }], + }), + }); + }); + + it('does not send a client-selected contract line when staging a subscription change', () => { + vi.mocked(fetchWithAuth).mockResolvedValue(new Response()); + void addPax8OrderLine('order-1', { + action: 'change_quantity', + targetSubscriptionId: 'subscription-1', + quantity: '12', + }); + expect(fetchWithAuth).toHaveBeenCalledWith('/pax8/orders/order-1/lines', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'change_quantity', + targetSubscriptionId: 'subscription-1', + quantity: '12', + }), + }); + }); + + it('fails closed on a successful response without the expected data envelope', async () => { + const response = new Response(JSON.stringify({ ok: true }), { + status: 200, headers: { 'content-type': 'application/json' }, + }); + await expect(readData(response, 'Malformed Pax8 response')).rejects.toThrow('Malformed Pax8 response'); + }); +}); diff --git a/apps/web/src/lib/api/pax8Orders.ts b/apps/web/src/lib/api/pax8Orders.ts new file mode 100644 index 0000000000..20f1dc2a33 --- /dev/null +++ b/apps/web/src/lib/api/pax8Orders.ts @@ -0,0 +1,160 @@ +import { fetchWithAuth } from '../../stores/auth'; +import type { Pax8BillingTerm } from '@breeze/shared'; + +export type Pax8OrderStatus = + | 'draft' | 'awaiting_details' | 'ready' | 'submitting' | 'completed' + | 'partially_failed' | 'failed' | 'cancelled'; +export type Pax8OrderAction = 'new_subscription' | 'change_quantity' | 'cancel'; + +export interface ProvisioningValue { key: string; values: string[] } +export interface Pax8ProvisionField { + key: string; + label: string | null; + description: string | null; + valueType: 'Input' | 'Single-Value' | 'Multi-Value' | null; + possibleValues: string[] | null; +} +export interface Pax8Commitment { + id: string; + term: string | null; + allowForQuantityIncrease: boolean; + allowForQuantityDecrease: boolean; + allowForEarlyCancellation: boolean; + cancellationFeeApplied: boolean; +} +export interface Pax8ProductDependencies { commitments: Pax8Commitment[] } + +export interface Pax8ProductOption { + pax8ProductId: string; + catalogItemId: string; + catalogName: string; + catalogSku: string | null; + catalogDescription: string | null; + productName: string | null; + vendorSkuId: string | null; + billingFrequency: string | null; + commitmentTermMonths: number | null; +} + +export interface Pax8Order { + id: string; + integrationId: string; + partnerId: string; + orgId: string; + pax8CompanyId: string | null; + status: Pax8OrderStatus; + source: 'direct' | 'quote'; + sourceQuoteId: string | null; + pax8OrderId: string | null; + error: string | null; + submittedAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface Pax8OrderLine { + id: string; + orderId: string; + action: Pax8OrderAction; + submitState: 'pending' | 'in_flight' | 'succeeded' | 'failed' | 'needs_reconcile'; + pax8ProductId: string | null; + catalogItemId: string | null; + billingTerm: string | null; + commitmentTermId: string | null; + quantity: string | null; + provisioningDetails: ProvisioningValue[]; + targetSubscriptionId: string | null; + resultSubscriptionId: string | null; + contractLineId: string | null; + sourceQuoteLineId: string | null; + error: string | null; + sortOrder: number; +} + +export interface Pax8OrderBundle { order: Pax8Order; lines: Pax8OrderLine[] } +export interface AddPax8OrderLineRequest { + action: Pax8OrderAction; + pax8ProductId?: string; + catalogItemId?: string; + billingTerm?: Pax8BillingTerm; + commitmentTermId?: string; + quantity?: string; + provisioningDetails?: ProvisioningValue[]; + targetSubscriptionId?: string; + cancelDate?: string; +} +export interface UpdatePax8OrderLineRequest { + commitmentTermId?: string | null; + provisioningDetails?: ProvisioningValue[]; +} +export interface Pax8Company { + pax8CompanyId: string; + pax8CompanyName: string; + status: string | null; + mappedOrgId: string | null; + mappedOrgName: string | null; + ignored: boolean; + lastSeenAt: string | null; + statusActive?: boolean; + primaryAdminReady?: boolean; + primaryBillingReady?: boolean; + primaryTechnicalReady?: boolean; + orderReady?: boolean; +} +export interface Pax8Subscription { + id: string; + pax8SubscriptionId: string; + productId: string | null; + productName: string | null; + status: string | null; + billingTerm?: string | null; + breezeQuantity: string | null; + quantity: string; + quantityKnown: boolean; + lastSeenAt: string | null; + contractLineId?: string | null; + activeCommitmentId?: string | null; + activeCommitmentAmbiguous?: boolean; +} + +const JSON_HEADERS = { 'Content-Type': 'application/json' }; +const json = (body: unknown): RequestInit => ({ headers: JSON_HEADERS, body: JSON.stringify(body) }); + +export async function readData(response: Response, fallback: string): Promise { + const payload = await response.json().catch(() => null) as { data?: T; error?: string } | null; + if (!response.ok) throw new Error(payload?.error || fallback); + if (!payload || !Object.prototype.hasOwnProperty.call(payload, 'data')) throw new Error(fallback); + return payload.data as T; +} + +export const listPax8Companies = () => fetchWithAuth('/pax8/companies'); +export const listPax8Products = () => fetchWithAuth('/pax8/products'); +export const listPax8Orders = (orgId: string) => + fetchWithAuth(`/pax8/orders?orgId=${encodeURIComponent(orgId)}`); +export const getPax8Order = (orderId: string) => + fetchWithAuth(`/pax8/orders/${encodeURIComponent(orderId)}`); +export const listPax8Subscriptions = (orgId: string) => + fetchWithAuth(`/pax8/subscriptions?orgId=${encodeURIComponent(orgId)}&limit=100`); +export const getProvisionDetails = (productId: string) => + fetchWithAuth(`/pax8/products/${encodeURIComponent(productId)}/provision-details`); +export const getProductDependencies = (productId: string) => + fetchWithAuth(`/pax8/products/${encodeURIComponent(productId)}/dependencies`); + +export const mapPax8Company = (body: { integrationId: string; pax8CompanyId: string; orgId: string }) => + fetchWithAuth('/pax8/companies/map', { method: 'POST', ...json(body) }); +export const createPax8Order = (orgId: string) => + fetchWithAuth('/pax8/orders', { method: 'POST', ...json({ orgId }) }); +export const addPax8OrderLine = (orderId: string, body: AddPax8OrderLineRequest) => + fetchWithAuth(`/pax8/orders/${encodeURIComponent(orderId)}/lines`, { method: 'POST', ...json(body) }); +export const updatePax8OrderLine = (orderId: string, lineId: string, body: UpdatePax8OrderLineRequest) => + fetchWithAuth(`/pax8/orders/${encodeURIComponent(orderId)}/lines/${encodeURIComponent(lineId)}`, { + method: 'PATCH', ...json(body), + }); +export const removePax8OrderLine = (orderId: string, lineId: string) => + fetchWithAuth(`/pax8/orders/${encodeURIComponent(orderId)}/lines/${encodeURIComponent(lineId)}`, { method: 'DELETE' }); +export const preflightPax8Order = (orderId: string) => + fetchWithAuth(`/pax8/orders/${encodeURIComponent(orderId)}/preflight`, { method: 'POST' }); +export const submitPax8Order = (orderId: string) => + fetchWithAuth(`/pax8/orders/${encodeURIComponent(orderId)}/submit`, { method: 'POST' }); +export const reconcilePax8Order = (orderId: string) => + fetchWithAuth(`/pax8/orders/${encodeURIComponent(orderId)}/reconcile`, { method: 'POST' }); diff --git a/apps/web/src/lib/i18n/extractionQuality.test.ts b/apps/web/src/lib/i18n/extractionQuality.test.ts index 782f2cd9cd..8ccdadbec7 100644 --- a/apps/web/src/lib/i18n/extractionQuality.test.ts +++ b/apps/web/src/lib/i18n/extractionQuality.test.ts @@ -55,6 +55,66 @@ describe('i18n extraction quality', () => { ); }); + it('keeps Pax8 contract links observation-only in every locale', () => { + const locales = ['en', 'de-DE', 'es-419', 'fr-FR', 'pt-BR']; + const requiredPax8Keys = [ + 'subscriptionObservationDescription', + 'observingQuantity', + 'observationPaused', + 'pauseObservations', + 'resumeObservations', + ]; + const removedPax8Keys = [ + 'licenseSubscriptionsPulledFromPax8LinkASubscription', + 'syncPaused', + 'syncResumed', + 'syncing', + 'linked', + 'pause', + 'resume', + ]; + const forbiddenPromises = [ + /sync quantities automatically/i, + /Mengen automatisch zu synchronisieren/i, + /sincronizar las cantidades automáticamente/i, + /synchroniser automatiquement les quantités/i, + /sincronizar quantidades automaticamente/i, + /keep quantity in sync/i, + /Halten Sie die Menge synchron/i, + /Mantenga la cantidad sincronizada/i, + /Gardez la quantité synchronisée/i, + /Mantenha a quantidade sincronizada/i, + ]; + + for (const locale of locales) { + const catalog = JSON.parse( + readSource(`locales/${locale}/integrations.json`), + ) as { + pax8Integration: Record; + linkSubscriptionPicker: Record; + }; + for (const key of requiredPax8Keys) { + expect(catalog.pax8Integration[key], `${locale}: missing ${key}`).toBeTruthy(); + } + for (const key of removedPax8Keys) { + expect(catalog.pax8Integration, `${locale}: stale ${key}`).not.toHaveProperty(key); + } + expect( + catalog.linkSubscriptionPicker.trackQuantityForDrift, + `${locale}: missing drift label`, + ).toBeTruthy(); + expect(catalog.linkSubscriptionPicker).not.toHaveProperty('keepQuantityInSync'); + + const pax8Copy = JSON.stringify({ + pax8Integration: catalog.pax8Integration, + linkSubscriptionPicker: catalog.linkSubscriptionPicker, + }); + for (const promise of forbiddenPromises) { + expect(pax8Copy, `${locale}: ${promise}`).not.toMatch(promise); + } + } + }); + it('provides complete singular and plural backup sentences', () => { expect(i18n.t('backup:backupOverviewContent.alreadyRunningCount', { lng: 'en', count: 1 })) .toBe('1 device is already running a backup.'); diff --git a/apps/web/src/locales/de-DE/billing.json b/apps/web/src/locales/de-DE/billing.json index ea9142a90d..7be6bcf67d 100644 --- a/apps/web/src/locales/de-DE/billing.json +++ b/apps/web/src/locales/de-DE/billing.json @@ -566,6 +566,11 @@ "sent": "Gesendet", "viewed": "Gesehen" }, + "pax8Order": { + "message_one": "Dieses Angebot hat einen Pax8-Auftrag ({{count}} Position) bereitgestellt, für den noch Bereitstellungsdetails fehlen", + "message_other": "Dieses Angebot hat einen Pax8-Auftrag ({{count}} Positionen) bereitgestellt, für den noch Bereitstellungsdetails fehlen", + "open": "Pax8-Auftrag öffnen" + }, "pricing": "Preise", "proposalImageAlt": "Angebotsbild", "table": { diff --git a/apps/web/src/locales/de-DE/integrations.json b/apps/web/src/locales/de-DE/integrations.json index d254aacc37..4ccbccef26 100644 --- a/apps/web/src/locales/de-DE/integrations.json +++ b/apps/web/src/locales/de-DE/integrations.json @@ -268,7 +268,7 @@ "couldNotCreateTheContractLine": "Die Vertragsposition konnte nicht erstellt werden.", "couldNotLinkTheSubscription": "Das Abonnement konnte nicht verknüpft werden.", "mfaRequiredHint": "Diese Änderung erfordert MFA. Richten Sie MFA in Ihrem Profil ein oder überprüfen Sie es und versuchen Sie es dann erneut.", - "subscriptionLinked": "Abo verlinkt", + "subscriptionLinked": "Abonnement zur Mengenbeobachtung verknüpft", "contract": "Vertrag", "selectAContract": "Wählen Sie einen Vertrag aus…", "line": "Linie", @@ -276,8 +276,12 @@ "newManualLine": "+ Neue manuelle Zeile", "lineDescription": "Zeilenbeschreibung", "unitPriceEG3600": "Stückpreis (z. B. 36,00)", - "keepQuantityInSync": "Halten Sie die Menge synchron", - "link": "Link" + "billingQuantity": "Breeze-Abrechnungsmenge", + "billingQuantityHelp": "Geben Sie die Menge ein, die Breeze für die Abrechnung verwendet. Die von Pax8 gemeldete Menge dient nur zur Beobachtung.", + "billingQuantityRequired": "Geben Sie eine Breeze-Abrechnungsmenge ein.", + "billingQuantityInvalid": "Geben Sie null oder eine positive Zahl mit höchstens zwei Dezimalstellen ein.", + "trackQuantityForDrift": "Von Pax8 gemeldete Menge auf Abweichungen prüfen (Breeze-Abrechnung nie überschreiben)", + "linkSubscription": "Abonnement verknüpfen" }, "m365Integration": { "connectionVerifiedAndSaved": "Verbindung überprüft und gespeichert.", @@ -491,7 +495,7 @@ "failedToMapThePax8Company": "Das Pax8-Unternehmen konnte nicht zugeordnet werden.", "couldNotUnlinkTheSubscription": "Die Verknüpfung des Abonnements konnte nicht aufgehoben werden.", "subscriptionUnlinked": "Abonnement nicht verknüpft", - "couldNotUpdateSync": "Die Synchronisierung konnte nicht aktualisiert werden.", + "couldNotUpdateObservationTracking": "Die Beobachtung der Pax8-Menge konnte nicht aktualisiert werden.", "pax8": "Pax8", "thePax8DistributorIntegrationIsAvailableToPartner": "Die Pax8-Distributor-Integration ist nur für Partnerkonten verfügbar.", "connectPax8ToSyncCompaniesAndLicenseSubscriptions": "Verbinden Sie Pax8, um Unternehmen und Lizenzabonnements mit Breeze zu synchronisieren, und ordnen Sie dann jedes Pax8-Unternehmen einer Breeze-Organisation zur Lizenzabrechnung zu.", @@ -509,31 +513,31 @@ "lastSync": "Letzte Synchronisierung", "lastError": "Letzter Fehler", "companyMapping": "Firmenkartierung", - "mapEachPax8CompanyToABreezeOrganization": "Ordnen Sie jedes Pax8-Unternehmen einer Breeze-Organisation zu. Abonnements werden zur Lizenzabrechnung mit der zugeordneten Organisation synchronisiert.", + "mapEachPax8CompanyToABreezeOrganization": "Ordnen Sie jedes Pax8-Unternehmen einer Breeze-Organisation zu. Abonnements werden der zugeordneten Organisation für Abrechnungsprüfung und Bestellungen zugeordnet.", "noPax8CompaniesYetRunASyncTo": "Noch keine Pax8-Unternehmen. Führen Sie eine Synchronisierung durch, um Unternehmen aus Pax8 abzurufen.", "pax8Company": "Firma Pax8", "breezeOrganization": "Breeze-Organisation", "unmapped": "Nicht zugeordnet", "subscriptions": "Abonnements", - "licenseSubscriptionsPulledFromPax8LinkASubscription": "Von Pax8 abgerufene Lizenzabonnements. Verknüpfen Sie ein Abonnement mit einer Vertragsposition aus dem Vertrag der Organisation, um Mengen automatisch zu synchronisieren.", + "subscriptionObservationDescription": "Von Pax8 gemeldete Lizenzabonnements. Verknüpfen Sie ein Abonnement mit einer manuellen Vertragsposition, um die von Pax8 beobachtete Menge mit der Breeze-Abrechnungsmenge zu vergleichen. Die Verknüpfung überschreibt niemals die Breeze-Abrechnungsmenge.", "noSubscriptionsYetRunASyncToPull": "Noch keine Abonnements. Führen Sie eine Synchronisierung durch, um Abonnements von Pax8 abzurufen.", "product": "Produkt", "company": "Unternehmen", - "qty": "Menge", + "qty": "Von Pax8 gemeldet", "unitCost": "Stückkosten", "statusActions": "Status / Aktionen", "mapCompanyFirst": "Zuerst das Unternehmen kartieren", - "change": "Veränderung", + "changeLink": "Verknüpfung ändern", "unlink": "Verknüpfung aufheben", - "link": "Link", + "linkForObservation": "Zur Beobachtung verknüpfen", "failedToLoadPax8Integration": "Die Pax8-Integration konnte nicht geladen werden", "mfaRequiredHint": "Diese Änderung erfordert MFA. Richten Sie MFA in Ihrem Profil ein oder überprüfen Sie es und versuchen Sie es dann erneut.", "integrationUpdated": "Pax8-Integration aktualisiert", "integrationConnected": "Pax8-Integration verbunden", "companyMapped": "Pax8-Unternehmen kartiert", "companyUnmapped": "Pax8-Unternehmen nicht zugeordnet", - "syncPaused": "Synchronisierung angehalten", - "syncResumed": "Die Synchronisierung wurde fortgesetzt", + "observationsPaused": "Pax8-Mengenbeobachtungen pausiert", + "observationsResumed": "Pax8-Mengenbeobachtungen fortgesetzt", "storedSecret": "•••••••••• (gespeichert)", "clientSecretPlaceholder": "Pax8-Client-Geheimnis", "webhookSecretPlaceholder": "Pax8-Webhook-Signaturgeheimnis", @@ -543,10 +547,10 @@ "connectPax8": "Pax8 anschließen", "never": "Niemals", "notYetRun": "Noch nicht ausgeführt", - "syncing": "Synchronisierung", - "linked": "verlinkt", - "pause": "Pausieren", - "resume": "Fortsetzen" + "observingQuantity": "Menge wird beobachtet", + "observationPaused": "Beobachtung pausiert", + "pauseObservations": "Beobachtungen pausieren", + "resumeObservations": "Beobachtungen fortsetzen" }, "quickbooksCustomerImport": { "failedToLoadQuickBooksCustomers": "QuickBooks-Kunden konnten nicht geladen werden.", diff --git a/apps/web/src/locales/de-DE/settings.json b/apps/web/src/locales/de-DE/settings.json index 6cc72debb6..ca62b3559d 100644 --- a/apps/web/src/locales/de-DE/settings.json +++ b/apps/web/src/locales/de-DE/settings.json @@ -2354,6 +2354,8 @@ "contractsDescription": "Wiederkehrende Vereinbarungen", "billing": "Abrechnung", "billingDescription": "Steuer- und Rechnungsadresse", + "pax8": "Pax8-Marktplatz", + "pax8Description": "Abonnements und Bestellungen", "portalBranding": "Portal & Branding", "branding": "Branding", "brandingDescription": "Portalthema und Visuals", @@ -2422,6 +2424,56 @@ "saveType": "Der Organisationstyp konnte nicht gespeichert werden" } }, + "pax8": { + "title": "Pax8-Abonnements", "description": "Abrechenbare Mengen prüfen, Änderungen vormerken und Pax8-Bestellungen senden.", + "actions": { "newOrder": "Neue Bestellung", "retry": "Erneut versuchen" }, + "states": { "loadingProduct": "Produktanforderungen werden geladen…" }, + "products": { "emptyReason": "Vor der Bestellung aktive Katalogprodukte Pax8 zuordnen." }, + "enums": { + "orderStatus": { "draft": "Entwurf", "awaitingDetails": "Details ausstehend", "ready": "Bereit", "submitting": "Wird gesendet", "completed": "Abgeschlossen", "partiallyFailed": "Teilweise fehlgeschlagen", "failed": "Fehlgeschlagen", "cancelled": "Storniert" }, + "lineAction": { "newSubscription": "Neues Abonnement", "changeQuantity": "Menge ändern", "cancel": "Abonnement kündigen" }, + "submitState": { "pending": "Ausstehend", "inFlight": "In Bearbeitung", "succeeded": "Erfolgreich", "failed": "Fehlgeschlagen", "needsReconcile": "Abgleich erforderlich" }, + "billingTerm": { "monthly": "Monatlich", "annual": "Jährlich", "twoYear": "2 Jahre", "threeYear": "3 Jahre", "oneTime": "Einmalig", "trial": "Testphase", "activation": "Aktivierung" } + }, + "mapping": { + "title": "Unternehmenszuordnung", "company": "Pax8-Unternehmen", "choose": "Unternehmen auswählen", "map": "Unternehmen zuordnen", + "empty": "Diese Organisation vor der Bestellung ihrem Pax8-Unternehmen zuordnen.", "noIntegration": "Zuerst Pax8 in den Partnerintegrationen verbinden und synchronisieren.", + "unknownStatus": "Status nicht gemeldet", "activeRequired": "Das zugeordnete Pax8-Unternehmen muss aktiv sein und primäre Admin-, Abrechnungs- und Technikkontakte haben." + }, + "subscriptions": { + "title": "Abonnement-Ledger", "description": "Breeze-Mengen sind für die Abrechnung maßgeblich. Pax8-Werte sind die zuletzt beobachteten Werte.", + "empty": "Für diese Organisation wurden keine Pax8-Abonnements beobachtet.", "product": "Produkt", "breeze": "Breeze-Menge", "pax8": "Pax8 gemeldet", + "status": "Zustand", "observed": "Zuletzt beobachtet", "actions": "Aktionen", "unknownProduct": "Unbekanntes Produkt", "unknownStatus": "Unbekannt", + "neverObserved": "Nie", "notLinked": "Nicht verknüpft", "notReported": "Nicht gemeldet", "drift": "Abweichung", + "targetQuantity": "Zielmenge für {{product}}", "stageChange": "Änderung vormerken", "cancel": "Kündigen", + "actionUnavailable": "Dieses Abonnement vor einer Änderung mit einer manuellen Vertragszeile verknüpfen.", + "commitmentUnavailable": "Die aktive Bindung kann nicht sicher ermittelt werden. Pax8-Daten vor der Bestellung aktualisieren.", + "increaseBlocked": "Diese Bindung erlaubt keine Mengenerhöhung.", "decreaseBlocked": "Diese Bindung erlaubt keine Mengenreduzierung.", + "cancelBlocked": "Diese Bindung erlaubt keine vorzeitige Kündigung.", "confirmCancel": "Kündigung für {{product}} vormerken?", + "feeWarning": "Pax8 meldet eine Gebühr für die vorzeitige Kündigung.", "confirm": "Kündigung vormerken", "keep": "Abonnement behalten" + }, + "orders": { "title": "Bestellungen", "description": "Offene Arbeit und Bestellverlauf dieser Organisation.", "empty": "Noch keine Pax8-Bestellungen.", "quoteOrder": "Angebotserfüllung", "directOrder": "Direktbestellung" }, + "order": { + "title": "Bestellung erstellen", "back": "Zurück zu Pax8", "quoteSource": "Aus einem angenommenen Angebot vorgemerkt", "directSource": "Direkte Organisationsbestellung", + "addProduct": "Produkt hinzufügen", "product": "Produkt", "chooseProduct": "Zugeordnetes Produkt auswählen", "billingTerm": "Abrechnungszeitraum", "quantity": "Menge", + "commitment": "Bindung", "noCommitmentSelected": "Keine Bindung ausgewählt", "addToOrder": "Zur Bestellung hinzufügen", + "item": "Position", "action": "Aktion", "state": "Status", "actions": "Aktionen", "empty": "Diese Bestellung enthält noch keine Positionen.", "unknownItem": "Unbekannte Position", + "editDetails": "Details bearbeiten", "remove": "Position entfernen", "editProvisioning": "Bereitstellungsdetails", "saveDetails": "Details speichern", "cancelEdit": "Abbrechen", + "reviewSubmit": "Vorprüfung und senden", "reconcile": "Abgleichen", "completed": "Bestellung abgeschlossen" + }, + "provisioning": { "optional": "Freiwillig", "none": "Dieses Produkt meldet keine Bereitstellungsfelder.", "unsupported": "Pax8 hat einen nicht unterstützten Feldtyp zurückgegeben.", "clear": "{{field}} löschen", "clearButton": "Löschen" }, + "toasts": { + "companyMapped": "Pax8-Unternehmen zugeordnet", "draftReady": "Pax8-Entwurf bereit", "changeStaged": "Mengenänderung vorgemerkt", "cancelStaged": "Kündigung vorgemerkt", + "lineAdded": "Produkt zur Bestellung hinzugefügt", "lineUpdated": "Bereitstellungsdetails gespeichert", "lineRemoved": "Bestellposition entfernt", + "preflightPassed": "Pax8-Vorprüfung bestanden", "submitted": "Pax8-Bestellung gesendet", "submitNeedsAttention": "Einige Positionen benötigen Aufmerksamkeit", "reconciled": "Pax8-Bestellung abgeglichen" + }, + "errors": { + "load": "Pax8-Daten konnten nicht geladen werden.", "loadOrder": "Die Pax8-Bestellung konnte nicht geladen werden.", "foreignOrder": "Diese Pax8-Bestellung gehört zu einer anderen Organisation.", "loadProduct": "Produktanforderungen konnten nicht geladen werden.", + "mapCompany": "Das Pax8-Unternehmen konnte nicht zugeordnet werden.", "createDraft": "Der Pax8-Entwurf konnte nicht erstellt werden.", "stageChange": "Die Mengenänderung konnte nicht vorgemerkt werden.", + "stageCancel": "Die Kündigung konnte nicht vorgemerkt werden.", "addLine": "Das Produkt konnte nicht hinzugefügt werden.", "updateLine": "Bereitstellungsdetails konnten nicht gespeichert werden.", + "removeLine": "Die Bestellposition konnte nicht entfernt werden.", "preflight": "Pax8-Vorprüfung fehlgeschlagen.", "submit": "Die Pax8-Bestellung konnte nicht gesendet werden.", "reconcile": "Die Pax8-Bestellung konnte nicht abgeglichen werden." + } + }, "orgTicketSettingsEditor": { "loading": "Ticketeinstellungen werden geladen…", "errors": { diff --git a/apps/web/src/locales/en/billing.json b/apps/web/src/locales/en/billing.json index 7d0a8df56c..3aaa5d7ab0 100644 --- a/apps/web/src/locales/en/billing.json +++ b/apps/web/src/locales/en/billing.json @@ -566,6 +566,11 @@ "sent": "Sent", "viewed": "Viewed" }, + "pax8Order": { + "message_one": "This quote staged a Pax8 order ({{count}} item) awaiting provisioning details", + "message_other": "This quote staged a Pax8 order ({{count}} items) awaiting provisioning details", + "open": "Open Pax8 order" + }, "pricing": "Pricing", "proposalImageAlt": "Proposal image", "table": { diff --git a/apps/web/src/locales/en/integrations.json b/apps/web/src/locales/en/integrations.json index 0a87f0148e..2f505f787b 100644 --- a/apps/web/src/locales/en/integrations.json +++ b/apps/web/src/locales/en/integrations.json @@ -268,7 +268,7 @@ "couldNotCreateTheContractLine": "Could not create the contract line.", "couldNotLinkTheSubscription": "Could not link the subscription.", "mfaRequiredHint": "This change requires MFA. Set up or verify MFA in your profile, then retry.", - "subscriptionLinked": "Subscription linked", + "subscriptionLinked": "Subscription linked for quantity observation", "contract": "Contract", "selectAContract": "Select a contract…", "line": "Line", @@ -276,8 +276,12 @@ "newManualLine": "+ New manual line", "lineDescription": "Line description", "unitPriceEG3600": "Unit price (e.g. 36.00)", - "keepQuantityInSync": "Keep quantity in sync ", - "link": "Link" + "billingQuantity": "Breeze billing quantity", + "billingQuantityHelp": "Enter the quantity Breeze will use for billing. Pax8 reported quantity is observation-only.", + "billingQuantityRequired": "Enter a Breeze billing quantity.", + "billingQuantityInvalid": "Enter zero or a positive number with up to two decimal places.", + "trackQuantityForDrift": "Track Pax8 reported quantity for drift (never overwrite Breeze billing)", + "linkSubscription": "Link subscription" }, "m365Integration": { "connectionVerifiedAndSaved": "Connection verified and saved.", @@ -491,7 +495,7 @@ "failedToMapThePax8Company": "Failed to map the Pax8 company.", "couldNotUnlinkTheSubscription": "Could not unlink the subscription.", "subscriptionUnlinked": "Subscription unlinked", - "couldNotUpdateSync": "Could not update sync.", + "couldNotUpdateObservationTracking": "Could not update Pax8 quantity observation tracking.", "pax8": "Pax8", "thePax8DistributorIntegrationIsAvailableToPartner": "The Pax8 distributor integration is available to partner accounts only. ", "connectPax8ToSyncCompaniesAndLicenseSubscriptions": "Connect Pax8 to sync companies and license subscriptions into Breeze, then map each Pax8 company to a Breeze organization for license billing. ", @@ -509,31 +513,31 @@ "lastSync": "Last sync", "lastError": "Last error", "companyMapping": "Company mapping", - "mapEachPax8CompanyToABreezeOrganization": "Map each Pax8 company to a Breeze organization. Subscriptions sync to the mapped org for license billing. ", + "mapEachPax8CompanyToABreezeOrganization": "Map each Pax8 company to a Breeze organization. Subscriptions are associated with the mapped organization for billing review and ordering. ", "noPax8CompaniesYetRunASyncTo": "No Pax8 companies yet. Run a sync to pull companies from Pax8. ", "pax8Company": "Pax8 company", "breezeOrganization": "Breeze organization", "unmapped": "Unmapped", "subscriptions": "Subscriptions", - "licenseSubscriptionsPulledFromPax8LinkASubscription": "License subscriptions pulled from Pax8. Link a subscription to a contract line from the organization's contract to sync quantities automatically. ", + "subscriptionObservationDescription": "License subscriptions reported by Pax8. Link a subscription to a manual contract line to compare Pax8's observed quantity with Breeze's billing quantity. Linking never overwrites the Breeze billing quantity. ", "noSubscriptionsYetRunASyncToPull": "No subscriptions yet. Run a sync to pull subscriptions from Pax8. ", "product": "Product", "company": "Company", - "qty": "Qty", + "qty": "Pax8 reported", "unitCost": "Unit cost", "statusActions": "Status / actions", "mapCompanyFirst": "Map company first", - "change": "Change", + "changeLink": "Change link", "unlink": "Unlink", - "link": "Link", + "linkForObservation": "Link for observation", "failedToLoadPax8Integration": "Failed to load Pax8 integration", "mfaRequiredHint": "This change requires MFA. Set up or verify MFA in your profile, then retry.", "integrationUpdated": "Pax8 integration updated", "integrationConnected": "Pax8 integration connected", "companyMapped": "Pax8 company mapped", "companyUnmapped": "Pax8 company unmapped", - "syncPaused": "Sync paused", - "syncResumed": "Sync resumed", + "observationsPaused": "Pax8 quantity observations paused", + "observationsResumed": "Pax8 quantity observations resumed", "storedSecret": "•••••••••• (stored)", "clientSecretPlaceholder": "Pax8 client secret", "webhookSecretPlaceholder": "Pax8 webhook signing secret", @@ -543,10 +547,10 @@ "connectPax8": "Connect Pax8", "never": "Never", "notYetRun": "Not yet run", - "syncing": "syncing", - "linked": "linked", - "pause": "Pause", - "resume": "Resume" + "observingQuantity": "observing quantity", + "observationPaused": "observation paused", + "pauseObservations": "Pause observations", + "resumeObservations": "Resume observations" }, "quickbooksCustomerImport": { "failedToLoadQuickBooksCustomers": "Failed to load QuickBooks customers.", diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json index 5987c36baf..7673249dac 100644 --- a/apps/web/src/locales/en/settings.json +++ b/apps/web/src/locales/en/settings.json @@ -2407,6 +2407,8 @@ "contractsDescription": "Recurring agreements", "billing": "Billing", "billingDescription": "Tax and billing address", + "pax8": "Pax8", + "pax8Description": "Subscriptions and ordering", "portalBranding": "Portal & Branding", "branding": "Branding", "brandingDescription": "Portal theme and visuals", @@ -2475,6 +2477,60 @@ "saveType": "Failed to save organization type" } }, + "pax8": { + "title": "Pax8 subscriptions", + "description": "Review billable quantities, stage changes, and submit Pax8 orders.", + "actions": { "newOrder": "New order", "retry": "Try again" }, + "states": { "loadingProduct": "Loading product requirements…" }, + "products": { "emptyReason": "Map active catalog products to Pax8 before ordering." }, + "enums": { + "orderStatus": { "draft": "Draft", "awaitingDetails": "Awaiting details", "ready": "Ready", "submitting": "Submitting", "completed": "Completed", "partiallyFailed": "Partially failed", "failed": "Failed", "cancelled": "Cancelled" }, + "lineAction": { "newSubscription": "New subscription", "changeQuantity": "Change quantity", "cancel": "Cancel subscription" }, + "submitState": { "pending": "Pending", "inFlight": "In progress", "succeeded": "Succeeded", "failed": "Failed", "needsReconcile": "Needs reconciliation" }, + "billingTerm": { "monthly": "Monthly", "annual": "Annual", "twoYear": "2-year", "threeYear": "3-year", "oneTime": "One-time", "trial": "Trial", "activation": "Activation" } + }, + "mapping": { + "title": "Company mapping", "company": "Pax8 company", "choose": "Choose a company", "map": "Map company", + "empty": "Map this organization to its Pax8 company before ordering.", "noIntegration": "Connect and sync Pax8 in partner integrations first.", + "unknownStatus": "Status not reported", "activeRequired": "The mapped Pax8 company must be Active with primary Admin, Billing, and Technical contacts before an order can be staged." + }, + "subscriptions": { + "title": "Subscription ledger", "description": "Breeze quantities are authoritative for billing. Pax8 values are last-known observations.", + "empty": "No Pax8 subscriptions have been observed for this organization.", "product": "Product", "breeze": "Breeze quantity", "pax8": "Pax8 reported", + "status": "Status", "observed": "Last observed", "actions": "Actions", "unknownProduct": "Unknown product", "unknownStatus": "Unknown", + "neverObserved": "Never", "notLinked": "Not linked", "notReported": "Not reported", "drift": "Drift", + "targetQuantity": "Target quantity for {{product}}", "stageChange": "Stage change", "cancel": "Cancel", + "actionUnavailable": "Link this subscription to a manual contract line before changing it.", + "commitmentUnavailable": "The active commitment cannot be identified safely. Refresh Pax8 data before ordering.", + "increaseBlocked": "This commitment does not allow quantity increases.", "decreaseBlocked": "This commitment does not allow quantity decreases.", + "cancelBlocked": "This commitment does not allow early cancellation.", "confirmCancel": "Stage cancellation for {{product}}?", + "feeWarning": "Pax8 reports that an early-cancellation fee applies.", "confirm": "Stage cancellation", "keep": "Keep subscription" + }, + "orders": { + "title": "Orders", "description": "Open staged work and order history for this organization.", "empty": "No Pax8 orders yet.", + "quoteOrder": "Quote fulfillment order", "directOrder": "Direct order" + }, + "order": { + "title": "Order builder", "back": "Back to Pax8", "quoteSource": "Staged from an accepted quote", "directSource": "Direct organization order", + "addProduct": "Add product", "product": "Product", "chooseProduct": "Choose a mapped product", "billingTerm": "Billing term", "quantity": "Quantity", + "commitment": "Commitment", "noCommitmentSelected": "No commitment selected", "addToOrder": "Add to order", + "item": "Item", "action": "Action", "state": "State", "actions": "Actions", "empty": "This order has no lines yet.", "unknownItem": "Unknown item", + "editDetails": "Edit details", "remove": "Remove line", "editProvisioning": "Provisioning details", "saveDetails": "Save details", "cancelEdit": "Cancel", + "reviewSubmit": "Preflight and submit", "reconcile": "Reconcile", "completed": "Order completed" + }, + "provisioning": { "optional": "Optional", "none": "This product reports no provisioning fields.", "unsupported": "Pax8 returned an unsupported field type.", "clear": "Clear {{field}}", "clearButton": "Clear" }, + "toasts": { + "companyMapped": "Pax8 company mapped", "draftReady": "Pax8 draft ready", "changeStaged": "Quantity change staged", "cancelStaged": "Cancellation staged", + "lineAdded": "Product added to the order", "lineUpdated": "Provisioning details saved", "lineRemoved": "Order line removed", + "preflightPassed": "Pax8 preflight passed", "submitted": "Pax8 order submitted", "submitNeedsAttention": "Pax8 order finished with items that need attention", "reconciled": "Pax8 order reconciled" + }, + "errors": { + "load": "Pax8 data could not be loaded.", "loadOrder": "The Pax8 order could not be loaded.", "foreignOrder": "This Pax8 order belongs to another organization.", "loadProduct": "Product requirements could not be loaded.", + "mapCompany": "The Pax8 company could not be mapped.", "createDraft": "The Pax8 draft could not be created.", "stageChange": "The quantity change could not be staged.", + "stageCancel": "The cancellation could not be staged.", "addLine": "The product could not be added.", "updateLine": "Provisioning details could not be saved.", + "removeLine": "The order line could not be removed.", "preflight": "Pax8 preflight failed.", "submit": "The Pax8 order could not be submitted.", "reconcile": "The Pax8 order could not be reconciled." + } + }, "orgTicketSettingsEditor": { "loading": "Loading ticket settings…", "errors": { diff --git a/apps/web/src/locales/es-419/billing.json b/apps/web/src/locales/es-419/billing.json index d9063ab319..d24b18b36a 100644 --- a/apps/web/src/locales/es-419/billing.json +++ b/apps/web/src/locales/es-419/billing.json @@ -566,6 +566,11 @@ "sent": "Enviado", "viewed": "Visto" }, + "pax8Order": { + "message_one": "Esta cotización preparó un pedido de Pax8 ({{count}} artículo) pendiente de los detalles de aprovisionamiento", + "message_other": "Esta cotización preparó un pedido de Pax8 ({{count}} artículos) pendiente de los detalles de aprovisionamiento", + "open": "Abrir pedido de Pax8" + }, "pricing": "Precios", "proposalImageAlt": "Imagen de propuesta", "table": { diff --git a/apps/web/src/locales/es-419/integrations.json b/apps/web/src/locales/es-419/integrations.json index 380b1fc1c0..47894ab95a 100644 --- a/apps/web/src/locales/es-419/integrations.json +++ b/apps/web/src/locales/es-419/integrations.json @@ -268,7 +268,7 @@ "couldNotCreateTheContractLine": "No se pudo crear la línea de contrato.", "couldNotLinkTheSubscription": "No se pudo vincular la suscripción.", "mfaRequiredHint": "Este cambio requiere MFA. Configure o verifique MFA en su perfil y luego vuelva a intentarlo.", - "subscriptionLinked": "Suscripción vinculada", + "subscriptionLinked": "Suscripción vinculada para observar cantidades", "contract": "Contrato", "selectAContract": "Seleccione un contrato…", "line": "Línea", @@ -276,8 +276,12 @@ "newManualLine": "+ Nueva línea manual", "lineDescription": "Descripción de línea", "unitPriceEG3600": "Precio unitario (por ejemplo, 36,00)", - "keepQuantityInSync": "Mantenga la cantidad sincronizada", - "link": "Enlace" + "billingQuantity": "Cantidad de facturación de Breeze", + "billingQuantityHelp": "Ingresa la cantidad que Breeze usará para facturar. La cantidad informada por Pax8 es solo una observación.", + "billingQuantityRequired": "Ingresa una cantidad de facturación de Breeze.", + "billingQuantityInvalid": "Ingresa cero o un número positivo con hasta dos decimales.", + "trackQuantityForDrift": "Supervisar la cantidad informada por Pax8 para detectar diferencias (nunca sobrescribe la facturación de Breeze)", + "linkSubscription": "Vincular suscripción" }, "m365Integration": { "connectionVerifiedAndSaved": "Conexión verificada y guardada.", @@ -491,7 +495,7 @@ "failedToMapThePax8Company": "No se pudo mapear la compañía Pax8.", "couldNotUnlinkTheSubscription": "No se pudo desvincular la suscripción.", "subscriptionUnlinked": "Suscripción desvinculada", - "couldNotUpdateSync": "No se pudo actualizar la sincronización.", + "couldNotUpdateObservationTracking": "No se pudo actualizar el seguimiento de cantidades observadas de Pax8.", "pax8": "Pax8", "thePax8DistributorIntegrationIsAvailableToPartner": "La integración del distribuidor Pax8 está disponible solo para cuentas de socios.", "connectPax8ToSyncCompaniesAndLicenseSubscriptions": "Conecte Pax8 para sincronizar empresas y suscripciones de licencias en Breeze, luego asigne cada empresa Pax8 a una organización Breeze para la facturación de licencias.", @@ -509,31 +513,31 @@ "lastSync": "Última sincronización", "lastError": "último error", "companyMapping": "Mapeo de la empresa", - "mapEachPax8CompanyToABreezeOrganization": "Asigne cada empresa Pax8 a una organización Breeze. Las suscripciones se sincronizan con la organización asignada para la facturación de licencias.", + "mapEachPax8CompanyToABreezeOrganization": "Asigne cada empresa Pax8 a una organización Breeze. Las suscripciones quedan asociadas con la organización asignada para la revisión de facturación y los pedidos.", "noPax8CompaniesYetRunASyncTo": "Aún no hay empresas de Pax8. Ejecute una sincronización para extraer empresas de Pax8.", "pax8Company": "empresa Pax8", "breezeOrganization": "organización Breeze", "unmapped": "no mapeado", "subscriptions": "Suscripciones", - "licenseSubscriptionsPulledFromPax8LinkASubscription": "Suscripciones de licencia extraídas de Pax8. Vincule una suscripción a una línea de contrato del contrato de la organización para sincronizar las cantidades automáticamente.", + "subscriptionObservationDescription": "Suscripciones de licencia informadas por Pax8. Vincule una suscripción a una línea de contrato manual para comparar la cantidad observada por Pax8 con la cantidad de facturación de Breeze. La vinculación nunca sobrescribe la cantidad de facturación de Breeze.", "noSubscriptionsYetRunASyncToPull": "Aún no hay suscripciones. Ejecute una sincronización para extraer suscripciones de Pax8.", "product": "Producto", "company": "Compañía", - "qty": "Cantidad", + "qty": "Informada por Pax8", "unitCost": "Costo unitario", "statusActions": "Estado / acciones", "mapCompanyFirst": "Mapa de la empresa primero", - "change": "Cambiar", + "changeLink": "Cambiar vínculo", "unlink": "Desconectar", - "link": "Enlace", + "linkForObservation": "Vincular para observación", "failedToLoadPax8Integration": "No se pudo cargar la integración de Pax8", "mfaRequiredHint": "Este cambio requiere MFA. Configure o verifique MFA en su perfil y luego vuelva a intentarlo.", "integrationUpdated": "Integración Pax8 actualizada", "integrationConnected": "Integración Pax8 conectada", "companyMapped": "Empresa Pax8 mapeada", "companyUnmapped": "Compañía Pax8 no asignada", - "syncPaused": "Sincronización pausada", - "syncResumed": "Sincronización reanudada", + "observationsPaused": "Observaciones de cantidad de Pax8 en pausa", + "observationsResumed": "Observaciones de cantidad de Pax8 reanudadas", "storedSecret": "•••••••••• (almacenado)", "clientSecretPlaceholder": "Secreto del cliente Pax8", "webhookSecretPlaceholder": "Secreto de firma del webhook Pax8", @@ -543,10 +547,10 @@ "connectPax8": "Conectar Pax8", "never": "Nunca", "notYetRun": "Aún no se ha ejecutado", - "syncing": "sincronizando", - "linked": "vinculado", - "pause": "Pausa", - "resume": "Reanudar" + "observingQuantity": "observando cantidad", + "observationPaused": "observación en pausa", + "pauseObservations": "Pausar observaciones", + "resumeObservations": "Reanudar observaciones" }, "quickbooksCustomerImport": { "failedToLoadQuickBooksCustomers": "No se pudieron cargar los clientes QuickBooks.", diff --git a/apps/web/src/locales/es-419/settings.json b/apps/web/src/locales/es-419/settings.json index c315c99700..71d09ee0d2 100644 --- a/apps/web/src/locales/es-419/settings.json +++ b/apps/web/src/locales/es-419/settings.json @@ -2354,6 +2354,8 @@ "contractsDescription": "Acuerdos recurrentes", "billing": "Facturación", "billingDescription": "Dirección fiscal y de facturación", + "pax8": "Marketplace Pax8", + "pax8Description": "Suscripciones y pedidos", "portalBranding": "Portal y marca", "branding": "Marca", "brandingDescription": "Tema del portal y elementos visuales", @@ -2422,6 +2424,55 @@ "saveType": "No se pudo guardar el tipo de organización" } }, + "pax8": { + "title": "Suscripciones de Pax8", "description": "Revisa cantidades facturables, prepara cambios y envía pedidos de Pax8.", + "actions": { "newOrder": "Nuevo pedido", "retry": "Reintentar" }, "states": { "loadingProduct": "Cargando requisitos del producto…" }, + "products": { "emptyReason": "Asocia productos activos del catálogo con Pax8 antes de pedir." }, + "enums": { + "orderStatus": { "draft": "Borrador", "awaitingDetails": "Esperando detalles", "ready": "Listo", "submitting": "Enviando", "completed": "Completado", "partiallyFailed": "Falló parcialmente", "failed": "Falló", "cancelled": "Cancelado" }, + "lineAction": { "newSubscription": "Nueva suscripción", "changeQuantity": "Cambiar cantidad", "cancel": "Cancelar suscripción" }, + "submitState": { "pending": "Pendiente", "inFlight": "En curso", "succeeded": "Correcto", "failed": "Falló", "needsReconcile": "Requiere conciliación" }, + "billingTerm": { "monthly": "Mensual", "annual": "Anual", "twoYear": "2 años", "threeYear": "3 años", "oneTime": "Pago único", "trial": "Prueba", "activation": "Activación" } + }, + "mapping": { + "title": "Asociación de empresa", "company": "Empresa de Pax8", "choose": "Elegir una empresa", "map": "Asociar empresa", + "empty": "Asocia esta organización con su empresa de Pax8 antes de pedir.", "noIntegration": "Primero conecta y sincroniza Pax8 en las integraciones del socio.", + "unknownStatus": "Estado no informado", "activeRequired": "La empresa asociada de Pax8 debe estar activa y tener contactos principales de Administración, Facturación y Soporte técnico." + }, + "subscriptions": { + "title": "Registro de suscripciones", "description": "Las cantidades de Breeze son la fuente de facturación. Los valores de Pax8 son observaciones recientes.", + "empty": "No se observaron suscripciones de Pax8 para esta organización.", "product": "Producto", "breeze": "Cantidad de Breeze", "pax8": "Informado por Pax8", + "status": "Estado", "observed": "Última observación", "actions": "Acciones", "unknownProduct": "Producto desconocido", "unknownStatus": "Desconocido", + "neverObserved": "Nunca", "notLinked": "Sin vínculo", "notReported": "No informado", "drift": "Diferencia", + "targetQuantity": "Cantidad objetivo para {{product}}", "stageChange": "Preparar cambio", "cancel": "Cancelar", + "actionUnavailable": "Vincula esta suscripción a una línea manual de contrato antes de cambiarla.", + "commitmentUnavailable": "No se puede identificar con seguridad el compromiso activo. Actualiza los datos de Pax8.", + "increaseBlocked": "Este compromiso no permite aumentar la cantidad.", "decreaseBlocked": "Este compromiso no permite reducir la cantidad.", + "cancelBlocked": "Este compromiso no permite cancelación anticipada.", "confirmCancel": "¿Preparar la cancelación de {{product}}?", + "feeWarning": "Pax8 informa que se aplica una tarifa por cancelación anticipada.", "confirm": "Preparar cancelación", "keep": "Conservar suscripción" + }, + "orders": { "title": "Pedidos", "description": "Trabajo preparado e historial de pedidos de esta organización.", "empty": "Aún no hay pedidos de Pax8.", "quoteOrder": "Pedido de cumplimiento de cotización", "directOrder": "Pedido directo" }, + "order": { + "title": "Preparar pedido", "back": "Volver a Pax8", "quoteSource": "Preparado desde una cotización aceptada", "directSource": "Pedido directo de la organización", + "addProduct": "Agregar producto", "product": "Producto", "chooseProduct": "Elegir un producto asociado", "billingTerm": "Plazo de facturación", "quantity": "Cantidad", + "commitment": "Compromiso", "noCommitmentSelected": "Sin compromiso seleccionado", "addToOrder": "Agregar al pedido", + "item": "Elemento", "action": "Acción", "state": "Estado", "actions": "Acciones", "empty": "Este pedido aún no tiene líneas.", "unknownItem": "Elemento desconocido", + "editDetails": "Editar detalles", "remove": "Quitar línea", "editProvisioning": "Detalles de aprovisionamiento", "saveDetails": "Guardar detalles", "cancelEdit": "Cancelar", + "reviewSubmit": "Validar y enviar", "reconcile": "Conciliar", "completed": "Pedido completado" + }, + "provisioning": { "optional": "Opcional", "none": "Este producto no informa campos de aprovisionamiento.", "unsupported": "Pax8 devolvió un tipo de campo no compatible.", "clear": "Borrar {{field}}", "clearButton": "Borrar" }, + "toasts": { + "companyMapped": "Empresa de Pax8 asociada", "draftReady": "Borrador de Pax8 listo", "changeStaged": "Cambio de cantidad preparado", "cancelStaged": "Cancelación preparada", + "lineAdded": "Producto agregado al pedido", "lineUpdated": "Detalles de aprovisionamiento guardados", "lineRemoved": "Línea de pedido eliminada", + "preflightPassed": "Validación de Pax8 aprobada", "submitted": "Pedido de Pax8 enviado", "submitNeedsAttention": "Algunos elementos necesitan atención", "reconciled": "Pedido de Pax8 conciliado" + }, + "errors": { + "load": "No se pudieron cargar los datos de Pax8.", "loadOrder": "No se pudo cargar el pedido de Pax8.", "foreignOrder": "Este pedido de Pax8 pertenece a otra organización.", "loadProduct": "No se pudieron cargar los requisitos del producto.", + "mapCompany": "No se pudo asociar la empresa de Pax8.", "createDraft": "No se pudo crear el borrador de Pax8.", "stageChange": "No se pudo preparar el cambio de cantidad.", + "stageCancel": "No se pudo preparar la cancelación.", "addLine": "No se pudo agregar el producto.", "updateLine": "No se pudieron guardar los detalles de aprovisionamiento.", + "removeLine": "No se pudo quitar la línea del pedido.", "preflight": "Falló la validación de Pax8.", "submit": "No se pudo enviar el pedido de Pax8.", "reconcile": "No se pudo conciliar el pedido de Pax8." + } + }, "orgTicketSettingsEditor": { "loading": "Cargando configuración de tickets…", "errors": { diff --git a/apps/web/src/locales/fr-FR/billing.json b/apps/web/src/locales/fr-FR/billing.json index c869b8d164..343d64c490 100644 --- a/apps/web/src/locales/fr-FR/billing.json +++ b/apps/web/src/locales/fr-FR/billing.json @@ -566,6 +566,11 @@ "sent": "Envoyé", "viewed": "Vu" }, + "pax8Order": { + "message_one": "Ce devis a préparé une commande Pax8 ({{count}} article) en attente des détails de provisionnement", + "message_other": "Ce devis a préparé une commande Pax8 ({{count}} articles) en attente des détails de provisionnement", + "open": "Ouvrir la commande Pax8" + }, "pricing": "Tarification", "proposalImageAlt": "Image de la proposition", "table": { diff --git a/apps/web/src/locales/fr-FR/integrations.json b/apps/web/src/locales/fr-FR/integrations.json index b4be22d34e..3e75958297 100644 --- a/apps/web/src/locales/fr-FR/integrations.json +++ b/apps/web/src/locales/fr-FR/integrations.json @@ -268,7 +268,7 @@ "couldNotCreateTheContractLine": "Impossible de créer la ligne de contrat.", "couldNotLinkTheSubscription": "Impossible de lier l’abonnement.", "mfaRequiredHint": "Ce changement nécessite MFA. Configurez ou vérifiez MFA dans votre profil, puis réessayez.", - "subscriptionLinked": "Abonnement lié", + "subscriptionLinked": "Abonnement lié pour l’observation des quantités", "contract": "Contrat", "selectAContract": "Choisissez un contrat...", "line": "Ligne", @@ -276,8 +276,12 @@ "newManualLine": "+ Nouvelle gamme manuelle", "lineDescription": "Description de la ligne", "unitPriceEG3600": "Prix unitaire (par exemple 36,00)", - "keepQuantityInSync": "Gardez la quantité synchronisée ", - "link": "Lier" + "billingQuantity": "Quantité facturée par Breeze", + "billingQuantityHelp": "Saisissez la quantité que Breeze utilisera pour la facturation. La quantité communiquée par Pax8 est une simple observation.", + "billingQuantityRequired": "Saisissez une quantité à facturer par Breeze.", + "billingQuantityInvalid": "Saisissez zéro ou un nombre positif avec au plus deux décimales.", + "trackQuantityForDrift": "Suivre la quantité déclarée par Pax8 pour détecter les écarts (sans jamais écraser la facturation Breeze)", + "linkSubscription": "Lier l’abonnement" }, "m365Integration": { "connectionVerifiedAndSaved": "Connexion vérifiée et enregistrée.", @@ -491,7 +495,7 @@ "failedToMapThePax8Company": "Impossible d’associer la société Pax8.", "couldNotUnlinkTheSubscription": "Je n’ai pas pu délier l’abonnement.", "subscriptionUnlinked": "Abonnement non lié", - "couldNotUpdateSync": "Impossible de mettre à jour la synchronisation.", + "couldNotUpdateObservationTracking": "Impossible de mettre à jour le suivi des quantités observées par Pax8.", "pax8": "Pax8", "thePax8DistributorIntegrationIsAvailableToPartner": "L’intégration du distributeur Pax8 est disponible uniquement pour les comptes partenaires. ", "connectPax8ToSyncCompaniesAndLicenseSubscriptions": "Connectez Pax8 pour synchroniser les entreprises et licencier les abonnements dans Breeze, puis associer chaque société Pax8 vers une organisation Breeze pour la facturation des licences. ", @@ -509,31 +513,31 @@ "lastSync": "Dernière synchronisation", "lastError": "Dernière erreur", "companyMapping": "Cartographie de l’entreprise", - "mapEachPax8CompanyToABreezeOrganization": "Associez chaque compagnie Pax8 à une organisation Breeze. Les abonnements se synchronisent avec l’organisation cartographique pour la facturation des licences. ", + "mapEachPax8CompanyToABreezeOrganization": "Associez chaque société Pax8 à une organisation Breeze. Les abonnements sont associés à l’organisation choisie pour le contrôle de la facturation et les commandes. ", "noPax8CompaniesYetRunASyncTo": "Pas encore de sociétés Pax8. Faites une synchronisation pour extraire des entreprises de Pax8. ", "pax8Company": "Société Pax8", "breezeOrganization": "Breeze organisation", "unmapped": "Non associée", "subscriptions": "Abonnements", - "licenseSubscriptionsPulledFromPax8LinkASubscription": "Abonnements de licence tirés de Pax8. Associer un abonnement à une ligne contractuelle du contrat de l’organisation pour synchroniser automatiquement les quantités. ", + "subscriptionObservationDescription": "Abonnements de licence déclarés par Pax8. Liez un abonnement à une ligne de contrat manuelle pour comparer la quantité observée par Pax8 à la quantité facturée par Breeze. La liaison n’écrase jamais la quantité de facturation Breeze. ", "noSubscriptionsYetRunASyncToPull": "Pas encore d’abonnement. Faites une synchronisation pour récupérer les abonnements de Pax8. ", "product": "Produit", "company": "Entreprise", - "qty": "Quantité", + "qty": "Déclarée par Pax8", "unitCost": "Coût unitaire", "statusActions": "Statut / actions", "mapCompanyFirst": "Compagnie de cartes d’abord", - "change": "Changement", + "changeLink": "Modifier la liaison", "unlink": "Délier", - "link": "Lier", + "linkForObservation": "Lier pour observation", "failedToLoadPax8Integration": "Impossible de charger l’intégration Pax8", "mfaRequiredHint": "Ce changement nécessite MFA. Configurez ou vérifiez MFA dans votre profil, puis réessayez.", "integrationUpdated": "Mise à jour de l’intégration Pax8", "integrationConnected": "Intégration Pax8 connectée", "companyMapped": "Société Pax8 cartographiée", "companyUnmapped": "Entreprise Pax8 non associée", - "syncPaused": "Synchronisation mise en pause", - "syncResumed": "Synchronisation reprise", + "observationsPaused": "Observations de quantité Pax8 mises en pause", + "observationsResumed": "Observations de quantité Pax8 reprises", "storedSecret": "•••••••••• (stocké)", "clientSecretPlaceholder": "Secret client Pax8", "webhookSecretPlaceholder": "Secret de signature du webhook Pax8", @@ -543,10 +547,10 @@ "connectPax8": "Connecter Pax8", "never": "Jamais", "notYetRun": "Pas encore en route", - "syncing": "Synchronisation", - "linked": "Lien", - "pause": "Mettre en pause", - "resume": "Reprendre" + "observingQuantity": "quantité observée", + "observationPaused": "observation en pause", + "pauseObservations": "Mettre les observations en pause", + "resumeObservations": "Reprendre les observations" }, "quickbooksCustomerImport": { "failedToLoadQuickBooksCustomers": "Impossible de charger les clients QuickBooks.", diff --git a/apps/web/src/locales/fr-FR/settings.json b/apps/web/src/locales/fr-FR/settings.json index dcee2e92bd..513af867e7 100644 --- a/apps/web/src/locales/fr-FR/settings.json +++ b/apps/web/src/locales/fr-FR/settings.json @@ -2354,6 +2354,8 @@ "contractsDescription": "Accords récurrents", "billing": "Facturation", "billingDescription": "Adresse fiscale et de facturation", + "pax8": "Marché Pax8", + "pax8Description": "Abonnements et commandes", "portalBranding": "Portail & Branding", "branding": "Image de marque", "brandingDescription": "Thème du portail et visuels", @@ -2422,6 +2424,55 @@ "saveType": "Impossible d’enregistrer le type d’organisation" } }, + "pax8": { + "title": "Abonnements Pax8", "description": "Vérifiez les quantités facturables, préparez les changements et envoyez les commandes Pax8.", + "actions": { "newOrder": "Nouvelle commande", "retry": "Réessayer" }, "states": { "loadingProduct": "Chargement des exigences du produit…" }, + "products": { "emptyReason": "Associez des produits actifs du catalogue à Pax8 avant de commander." }, + "enums": { + "orderStatus": { "draft": "Brouillon", "awaitingDetails": "En attente de détails", "ready": "Prête", "submitting": "Envoi en cours", "completed": "Terminée", "partiallyFailed": "Échec partiel", "failed": "Échec", "cancelled": "Annulée" }, + "lineAction": { "newSubscription": "Nouvel abonnement", "changeQuantity": "Modifier la quantité", "cancel": "Résilier l’abonnement" }, + "submitState": { "pending": "En attente", "inFlight": "En cours", "succeeded": "Réussie", "failed": "Échec", "needsReconcile": "Rapprochement requis" }, + "billingTerm": { "monthly": "Mensuelle", "annual": "Annuelle", "twoYear": "2 ans", "threeYear": "3 ans", "oneTime": "Paiement unique", "trial": "Essai", "activation": "Mise en service" } + }, + "mapping": { + "title": "Association de l’entreprise", "company": "Entreprise Pax8", "choose": "Choisir une entreprise", "map": "Associer l’entreprise", + "empty": "Associez cette organisation à son entreprise Pax8 avant de commander.", "noIntegration": "Connectez et synchronisez d’abord Pax8 dans les intégrations partenaire.", + "unknownStatus": "État non communiqué", "activeRequired": "L’entreprise Pax8 associée doit être active et avoir des contacts principaux Administration, Facturation et Technique." + }, + "subscriptions": { + "title": "Registre des abonnements", "description": "Les quantités Breeze font foi pour la facturation. Les valeurs Pax8 sont les dernières observations.", + "empty": "Aucun abonnement Pax8 observé pour cette organisation.", "product": "Produit", "breeze": "Quantité Breeze", "pax8": "Valeur Pax8", + "status": "État", "observed": "Dernière observation", "actions": "Opérations", "unknownProduct": "Produit inconnu", "unknownStatus": "Inconnu", + "neverObserved": "Jamais", "notLinked": "Non lié", "notReported": "Non communiqué", "drift": "Écart", + "targetQuantity": "Quantité cible pour {{product}}", "stageChange": "Préparer le changement", "cancel": "Résilier", + "actionUnavailable": "Liez cet abonnement à une ligne de contrat manuelle avant de le modifier.", + "commitmentUnavailable": "L’engagement actif ne peut pas être identifié de façon sûre. Actualisez les données Pax8.", + "increaseBlocked": "Cet engagement n’autorise pas l’augmentation de quantité.", "decreaseBlocked": "Cet engagement n’autorise pas la réduction de quantité.", + "cancelBlocked": "Cet engagement n’autorise pas la résiliation anticipée.", "confirmCancel": "Préparer la résiliation de {{product}} ?", + "feeWarning": "Pax8 signale des frais de résiliation anticipée.", "confirm": "Préparer la résiliation", "keep": "Conserver l’abonnement" + }, + "orders": { "title": "Commandes", "description": "Travail préparé et historique des commandes de cette organisation.", "empty": "Aucune commande Pax8.", "quoteOrder": "Commande issue d’un devis", "directOrder": "Commande directe" }, + "order": { + "title": "Préparer la commande", "back": "Retour à Pax8", "quoteSource": "Préparée depuis un devis accepté", "directSource": "Commande directe de l’organisation", + "addProduct": "Ajouter un produit", "product": "Produit", "chooseProduct": "Choisir un produit associé", "billingTerm": "Période de facturation", "quantity": "Quantité", + "commitment": "Engagement", "noCommitmentSelected": "Aucun engagement sélectionné", "addToOrder": "Ajouter à la commande", + "item": "Élément", "action": "Opération", "state": "État", "actions": "Opérations", "empty": "Cette commande ne contient encore aucune ligne.", "unknownItem": "Élément inconnu", + "editDetails": "Modifier les détails", "remove": "Supprimer la ligne", "editProvisioning": "Détails de provisionnement", "saveDetails": "Enregistrer", "cancelEdit": "Annuler", + "reviewSubmit": "Prévalider et envoyer", "reconcile": "Rapprocher", "completed": "Commande terminée" + }, + "provisioning": { "optional": "Facultatif", "none": "Ce produit ne fournit aucun champ de provisionnement.", "unsupported": "Pax8 a renvoyé un type de champ non pris en charge.", "clear": "Effacer {{field}}", "clearButton": "Effacer" }, + "toasts": { + "companyMapped": "Entreprise Pax8 associée", "draftReady": "Brouillon Pax8 prêt", "changeStaged": "Changement de quantité préparé", "cancelStaged": "Résiliation préparée", + "lineAdded": "Produit ajouté à la commande", "lineUpdated": "Détails de provisionnement enregistrés", "lineRemoved": "Ligne de commande supprimée", + "preflightPassed": "Prévalidation Pax8 réussie", "submitted": "Commande Pax8 envoyée", "submitNeedsAttention": "Certains éléments nécessitent une intervention", "reconciled": "Commande Pax8 rapprochée" + }, + "errors": { + "load": "Impossible de charger les données Pax8.", "loadOrder": "Impossible de charger la commande Pax8.", "foreignOrder": "Cette commande Pax8 appartient à une autre organisation.", "loadProduct": "Impossible de charger les exigences du produit.", + "mapCompany": "Impossible d’associer l’entreprise Pax8.", "createDraft": "Impossible de créer le brouillon Pax8.", "stageChange": "Impossible de préparer le changement de quantité.", + "stageCancel": "Impossible de préparer la résiliation.", "addLine": "Impossible d’ajouter le produit.", "updateLine": "Impossible d’enregistrer les détails de provisionnement.", + "removeLine": "Impossible de supprimer la ligne de commande.", "preflight": "La prévalidation Pax8 a échoué.", "submit": "Impossible d’envoyer la commande Pax8.", "reconcile": "Impossible de rapprocher la commande Pax8." + } + }, "orgTicketSettingsEditor": { "loading": "Chargement des paramètres du ticket...", "errors": { diff --git a/apps/web/src/locales/pt-BR/billing.json b/apps/web/src/locales/pt-BR/billing.json index d3deced3f3..c27966ce78 100644 --- a/apps/web/src/locales/pt-BR/billing.json +++ b/apps/web/src/locales/pt-BR/billing.json @@ -566,6 +566,11 @@ "sent": "Enviado", "viewed": "Visualizado" }, + "pax8Order": { + "message_one": "Esta cotação preparou um pedido da Pax8 ({{count}} item) aguardando os detalhes de provisionamento", + "message_other": "Esta cotação preparou um pedido da Pax8 ({{count}} itens) aguardando os detalhes de provisionamento", + "open": "Abrir pedido da Pax8" + }, "pricing": "Preços", "proposalImageAlt": "Imagem da proposta", "table": { diff --git a/apps/web/src/locales/pt-BR/integrations.json b/apps/web/src/locales/pt-BR/integrations.json index bd7237d5ac..b6af832bea 100644 --- a/apps/web/src/locales/pt-BR/integrations.json +++ b/apps/web/src/locales/pt-BR/integrations.json @@ -268,7 +268,7 @@ "couldNotCreateTheContractLine": "Não foi possível criar a linha de contrato.", "couldNotLinkTheSubscription": "Não foi possível vincular a assinatura.", "mfaRequiredHint": "Esta alteração exige MFA. Configure ou verifique o MFA no seu perfil e tente novamente.", - "subscriptionLinked": "Assinatura vinculada", + "subscriptionLinked": "Assinatura vinculada para observação de quantidades", "contract": "Contrato", "selectAContract": "Selecione um contrato…", "line": "Linha", @@ -276,8 +276,12 @@ "newManualLine": "+ Nova linha manual", "lineDescription": "Descrição da linha", "unitPriceEG3600": "Preço unitário (por exemplo, 36,00)", - "keepQuantityInSync": "Mantenha a quantidade sincronizada ", - "link": "Vincular" + "billingQuantity": "Quantidade de faturamento do Breeze", + "billingQuantityHelp": "Informe a quantidade que o Breeze usará no faturamento. A quantidade informada pela Pax8 é apenas uma observação.", + "billingQuantityRequired": "Informe uma quantidade de faturamento do Breeze.", + "billingQuantityInvalid": "Informe zero ou um número positivo com até duas casas decimais.", + "trackQuantityForDrift": "Acompanhar a quantidade informada pelo Pax8 para detectar divergências (nunca sobrescreve a cobrança do Breeze)", + "linkSubscription": "Vincular assinatura" }, "m365Integration": { "connectionVerifiedAndSaved": "Conexão verificada e salva.", @@ -491,7 +495,7 @@ "failedToMapThePax8Company": "Falha ao mapear a empresa Pax8.", "couldNotUnlinkTheSubscription": "Não foi possível desvincular a assinatura.", "subscriptionUnlinked": "Assinatura desvinculada", - "couldNotUpdateSync": "Não foi possível atualizar a sincronização.", + "couldNotUpdateObservationTracking": "Não foi possível atualizar o acompanhamento das quantidades observadas pelo Pax8.", "pax8": "Pax8", "thePax8DistributorIntegrationIsAvailableToPartner": "A integração do distribuidor Pax8 está disponível apenas para contas de parceiros. ", "connectPax8ToSyncCompaniesAndLicenseSubscriptions": "Conecte o Pax8 para sincronizar empresas e assinaturas de licenças no Breeze e, em seguida, mapeie cada empresa do Pax8 para uma organização do Breeze para cobrança de licenças. ", @@ -509,31 +513,31 @@ "lastSync": "Última sincronização", "lastError": "Último erro", "companyMapping": "Mapeamento da empresa", - "mapEachPax8CompanyToABreezeOrganization": "Mapeie cada empresa Pax8 para uma organização Breeze. As assinaturas são sincronizadas com a organização mapeada para cobrança de licenças. ", + "mapEachPax8CompanyToABreezeOrganization": "Mapeie cada empresa Pax8 para uma organização Breeze. As assinaturas ficam associadas à organização mapeada para revisão de cobrança e pedidos. ", "noPax8CompaniesYetRunASyncTo": "Nenhuma empresa Pax8 ainda. Execute uma sincronização para retirar empresas do Pax8. ", "pax8Company": "Empresa Pax8", "breezeOrganization": "Organização Brisa", "unmapped": "Não mapeado", "subscriptions": "Assinaturas", - "licenseSubscriptionsPulledFromPax8LinkASubscription": "Assinaturas de licença retiradas do Pax8. Vincule uma assinatura a uma linha de contrato do contrato da organização para sincronizar quantidades automaticamente. ", + "subscriptionObservationDescription": "Assinaturas de licença informadas pelo Pax8. Vincule uma assinatura a uma linha de contrato manual para comparar a quantidade observada pelo Pax8 com a quantidade de cobrança do Breeze. A vinculação nunca sobrescreve a quantidade de cobrança do Breeze. ", "noSubscriptionsYetRunASyncToPull": "Ainda não há assinaturas. Execute uma sincronização para obter assinaturas do Pax8. ", "product": "Produto", "company": "Empresa", - "qty": "Quantidade", + "qty": "Informada pelo Pax8", "unitCost": "Custo unitário", "statusActions": "Status/ações", "mapCompanyFirst": "Mapeie a empresa primeiro", - "change": "Mudar", + "changeLink": "Alterar vínculo", "unlink": "Desvincular", - "link": "Vincular", + "linkForObservation": "Vincular para observação", "failedToLoadPax8Integration": "Falha ao carregar a integração do Pax8", "mfaRequiredHint": "Esta alteração exige MFA. Configure ou verifique o MFA no seu perfil e tente novamente.", "integrationUpdated": "Integração do Pax8 atualizada", "integrationConnected": "Integração do Pax8 conectada", "companyMapped": "Empresa do Pax8 mapeada", "companyUnmapped": "Mapeamento da empresa do Pax8 removido", - "syncPaused": "Sincronização pausada", - "syncResumed": "Sincronização retomada", + "observationsPaused": "Observações de quantidade do Pax8 pausadas", + "observationsResumed": "Observações de quantidade do Pax8 retomadas", "storedSecret": "•••••••••• (armazenado)", "clientSecretPlaceholder": "Segredo do cliente Pax8", "webhookSecretPlaceholder": "Segredo de assinatura do webhook do Pax8", @@ -543,10 +547,10 @@ "connectPax8": "Conectar ao Pax8", "never": "Nunca", "notYetRun": "Ainda não executado", - "syncing": "sincronizando", - "linked": "vinculado", - "pause": "Pausar", - "resume": "Retomar" + "observingQuantity": "observando quantidade", + "observationPaused": "observação pausada", + "pauseObservations": "Pausar observações", + "resumeObservations": "Retomar observações" }, "quickbooksCustomerImport": { "failedToLoadQuickBooksCustomers": "Falha ao carregar clientes do QuickBooks.", diff --git a/apps/web/src/locales/pt-BR/settings.json b/apps/web/src/locales/pt-BR/settings.json index 5c1008cfd6..5a75cd5406 100644 --- a/apps/web/src/locales/pt-BR/settings.json +++ b/apps/web/src/locales/pt-BR/settings.json @@ -2407,6 +2407,8 @@ "contractsDescription": "Acordos recorrentes", "billing": "Cobrança", "billingDescription": "Endereço fiscal e de cobrança", + "pax8": "Marketplace Pax8", + "pax8Description": "Assinaturas e pedidos", "portalBranding": "Portal e identidade visual", "branding": "Identidade visual", "brandingDescription": "Tema e recursos visuais do portal", @@ -2475,6 +2477,28 @@ "saveType": "Falha ao salvar o tipo de organização" } }, + "pax8": { + "title": "Assinaturas Pax8", "description": "Revise quantidades faturáveis, prepare alterações e envie pedidos Pax8.", + "actions": { "newOrder": "Novo pedido", "retry": "Tentar novamente" }, "states": { "loadingProduct": "Carregando requisitos do produto…" }, + "products": { "emptyReason": "Mapeie produtos ativos do catálogo para a Pax8 antes de pedir." }, + "enums": { + "orderStatus": { "draft": "Rascunho", "awaitingDetails": "Aguardando detalhes", "ready": "Pronto", "submitting": "Enviando", "completed": "Concluído", "partiallyFailed": "Falha parcial", "failed": "Falhou", "cancelled": "Cancelado" }, + "lineAction": { "newSubscription": "Nova assinatura", "changeQuantity": "Alterar quantidade", "cancel": "Cancelar assinatura" }, + "submitState": { "pending": "Pendente", "inFlight": "Em andamento", "succeeded": "Concluído", "failed": "Falhou", "needsReconcile": "Requer reconciliação" }, + "billingTerm": { "monthly": "Mensal", "annual": "Anual", "twoYear": "2 anos", "threeYear": "3 anos", "oneTime": "Pagamento único", "trial": "Avaliação", "activation": "Ativação" } + }, + "mapping": { "title": "Mapeamento da empresa", "company": "Empresa Pax8", "choose": "Escolher uma empresa", "map": "Mapear empresa", "empty": "Mapeie esta organização para sua empresa Pax8 antes de pedir.", "noIntegration": "Primeiro conecte e sincronize a Pax8 nas integrações do parceiro.", "unknownStatus": "Status não informado", "activeRequired": "A empresa Pax8 mapeada deve estar ativa e ter contatos principais Administrativo, Faturamento e Técnico." }, + "subscriptions": { + "title": "Registro de assinaturas", "description": "As quantidades do Breeze são a fonte do faturamento. Os valores Pax8 são as observações mais recentes.", + "empty": "Nenhuma assinatura Pax8 foi observada para esta organização.", "product": "Produto", "breeze": "Quantidade Breeze", "pax8": "Informado pela Pax8", "status": "Situação", "observed": "Última observação", "actions": "Ações", "unknownProduct": "Produto desconhecido", "unknownStatus": "Desconhecido", "neverObserved": "Nunca", "notLinked": "Não vinculado", "notReported": "Não informado", "drift": "Divergência", + "targetQuantity": "Quantidade desejada para {{product}}", "stageChange": "Preparar alteração", "cancel": "Cancelar", "actionUnavailable": "Vincule esta assinatura a uma linha manual de contrato antes de alterá-la.", "commitmentUnavailable": "Não foi possível identificar com segurança o compromisso ativo. Atualize os dados Pax8.", "increaseBlocked": "Este compromisso não permite aumentar a quantidade.", "decreaseBlocked": "Este compromisso não permite reduzir a quantidade.", "cancelBlocked": "Este compromisso não permite cancelamento antecipado.", "confirmCancel": "Preparar o cancelamento de {{product}}?", "feeWarning": "A Pax8 informa que há uma taxa de cancelamento antecipado.", "confirm": "Preparar cancelamento", "keep": "Manter assinatura" + }, + "orders": { "title": "Pedidos", "description": "Trabalho preparado e histórico de pedidos desta organização.", "empty": "Ainda não há pedidos Pax8.", "quoteOrder": "Pedido de proposta", "directOrder": "Pedido direto" }, + "order": { "title": "Preparar pedido", "back": "Voltar para Pax8", "quoteSource": "Preparado a partir de uma proposta aceita", "directSource": "Pedido direto da organização", "addProduct": "Adicionar produto", "product": "Produto", "chooseProduct": "Escolher um produto mapeado", "billingTerm": "Período de cobrança", "quantity": "Quantidade", "commitment": "Compromisso", "noCommitmentSelected": "Nenhum compromisso selecionado", "addToOrder": "Adicionar ao pedido", "item": "Produto", "action": "Ação", "state": "Estado", "actions": "Ações", "empty": "Este pedido ainda não tem linhas.", "unknownItem": "Item desconhecido", "editDetails": "Editar detalhes", "remove": "Remover linha", "editProvisioning": "Detalhes de provisionamento", "saveDetails": "Salvar detalhes", "cancelEdit": "Cancelar", "reviewSubmit": "Validar e enviar", "reconcile": "Reconciliar", "completed": "Pedido concluído" }, + "provisioning": { "optional": "Opcional", "none": "Este produto não informa campos de provisionamento.", "unsupported": "A Pax8 retornou um tipo de campo não compatível.", "clear": "Limpar {{field}}", "clearButton": "Limpar" }, + "toasts": { "companyMapped": "Empresa Pax8 mapeada", "draftReady": "Rascunho Pax8 pronto", "changeStaged": "Alteração de quantidade preparada", "cancelStaged": "Cancelamento preparado", "lineAdded": "Produto adicionado ao pedido", "lineUpdated": "Detalhes de provisionamento salvos", "lineRemoved": "Linha do pedido removida", "preflightPassed": "Validação Pax8 aprovada", "submitted": "Pedido Pax8 enviado", "submitNeedsAttention": "Alguns itens precisam de atenção", "reconciled": "Pedido Pax8 reconciliado" }, + "errors": { "load": "Não foi possível carregar os dados Pax8.", "loadOrder": "Não foi possível carregar o pedido Pax8.", "foreignOrder": "Este pedido Pax8 pertence a outra organização.", "loadProduct": "Não foi possível carregar os requisitos do produto.", "mapCompany": "Não foi possível mapear a empresa Pax8.", "createDraft": "Não foi possível criar o rascunho Pax8.", "stageChange": "Não foi possível preparar a alteração de quantidade.", "stageCancel": "Não foi possível preparar o cancelamento.", "addLine": "Não foi possível adicionar o produto.", "updateLine": "Não foi possível salvar os detalhes de provisionamento.", "removeLine": "Não foi possível remover a linha do pedido.", "preflight": "A validação Pax8 falhou.", "submit": "Não foi possível enviar o pedido Pax8.", "reconcile": "Não foi possível reconciliar o pedido Pax8." } + }, "orgTicketSettingsEditor": { "loading": "Carregando configurações de chamados…", "errors": { diff --git a/docs/superpowers/plans/2026-07-13-pax8-ordering.md b/docs/superpowers/plans/2026-07-13-pax8-ordering.md new file mode 100644 index 0000000000..063c303c64 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-pax8-ordering.md @@ -0,0 +1,1431 @@ +# Pax8 Ordering Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let an MSP technician place, change, and cancel Pax8 subscriptions from inside Breeze — either directly on a customer's org page, or as the fulfillment step of a quote the customer approved. + +**Architecture:** Two new partner-axis tables (`pax8_orders`, `pax8_order_lines`) form a staged intent ledger. Both authoring paths (org tab, quote acceptance) write a draft order; a technician submits it. Submit runs an `isMock` dry-run, claims each line in a committed transaction before the HTTP call, and on success writes the resulting quantity onto the linked contract line in the same transaction. Breeze — not Pax8 — is the source of truth for billable seat counts. + +**Tech Stack:** Hono (API), Drizzle ORM, PostgreSQL + RLS, Vitest, React islands (web), hand-written SQL migrations. + +**Spec:** `docs/superpowers/specs/2026-07-13-pax8-ordering-design.md` + +## Global Constraints + +- **Read the spec first.** Every task assumes its "Pax8 API contract" section, especially the four documented defects in Pax8's own OpenAPI. Do not trust the vendor spec over `isMock`. +- **Tenancy: partner-axis (RLS shape 3).** Both new tables get `partner_id NOT NULL` with policies on `public.breeze_has_partner_access(partner_id)`. `org_id` is a linkage column, NOT a tenancy axis, and must be excluded from org auto-discovery in the contract test. +- **Migrations are idempotent and never edited once shipped.** `IF NOT EXISTS` / `DO $$`. No inner `BEGIN;`/`COMMIT;` — `autoMigrate` wraps each file in a transaction. +- **`PUT /v1/subscriptions/{id}` sends `quantity` and nothing else.** `price`, `partnerCost`, and `currencyCode` are writable on that endpoint; a read-modify-write would overwrite the customer's rate. +- **Never blind-retry a write.** A timeout or 5xx means *unknown*, not *failed*. The line goes to `needs_reconcile`. +- **No new DB enums.** Follow the existing `pax8_*` convention: `varchar` columns with SQL `CHECK` constraints, plus TypeScript union types. (Drizzle `pgEnum` + `z.enum(x.enumValues)` breaks the schema mocks used by unit tests.) +- **Permissions:** `PERMISSIONS.BILLING_MANAGE` on all routes; `requireMfa()` on every write, matching `routes/pax8.ts`. +- **Money in `numeric(12,2)`**, quantities included, matching `pax8_subscription_snapshots.quantity`. + +--- + +## File Structure + +**Create:** +| File | Responsibility | +|---|---| +| `apps/api/migrations/2026-07-13-a-pax8-ordering.sql` | Both tables, CHECKs, indexes, composite FKs, RLS | +| `apps/api/src/db/schema/pax8Orders.ts` | Drizzle definitions for the two new tables | +| `packages/shared/src/types/pax8-enums.ts` | SSOT for action / status / billing-term unions | +| `apps/api/src/services/pax8OrderService.ts` | Draft authoring: create, add/remove lines, validate preconditions | +| `apps/api/src/services/pax8OrderSubmit.ts` | The submit pipeline + reconcile. The money-critical file | +| `apps/api/src/routes/pax8Orders.ts` | HTTP surface, mounted under the existing `/api/v1/pax8` | +| `apps/api/src/services/pax8Drift.ts` | Ledger-vs-Pax8 drift comparison (replaces the quantity push) | +| `apps/web/src/components/organizations/Pax8OrgTab.tsx` | Org-page tab shell | +| `apps/web/src/components/organizations/Pax8OrderBuilder.tsx` | Add product, provisioning form, review & submit | +| `apps/web/src/lib/api/pax8Orders.ts` | Web API client | + +**Modify:** +| File | Change | +|---|---| +| `apps/api/src/services/pax8Client.ts` | Add write methods; `requestJson` currently hardcodes GET | +| `apps/api/src/services/pax8SyncService.ts:232-265` | `applyEnabledPax8ContractLineLinks` stops writing `contract_lines` | +| `apps/api/src/services/quoteAcceptService.ts` | New Phase 5: stage the order in-transaction | +| `apps/api/src/db/schema/index.ts` | Export the new schema module | +| `apps/api/src/index.ts:919` | Mount `pax8OrderRoutes` | +| `apps/api/src/services/tenantCascade.ts:226` | Add both tables (alphabetical order is load-bearing) | +| `apps/api/src/__tests__/integration/rls-coverage.integration.test.ts` | `PARTNER_TENANT_TABLES` + org-discovery exclusion | + +--- + +## Task 1: Schema + migration + tenancy registration + +**Files:** +- Create: `apps/api/migrations/2026-07-13-a-pax8-ordering.sql` +- Create: `apps/api/src/db/schema/pax8Orders.ts` +- Create: `packages/shared/src/types/pax8-enums.ts` +- Modify: `apps/api/src/db/schema/index.ts` +- Modify: `apps/api/src/services/tenantCascade.ts:226` +- Modify: `apps/api/src/__tests__/integration/rls-coverage.integration.test.ts` +- Test: `apps/api/src/__tests__/integration/pax8OrdersPartnerRls.integration.test.ts` + +**Interfaces:** +- Produces: `pax8Orders`, `pax8OrderLines` Drizzle tables. `PAX8_ORDER_ACTIONS`, `PAX8_ORDER_STATUSES`, `PAX8_SUBMIT_STATES`, `PAX8_BILLING_TERMS` const arrays and their `Pax8OrderAction` / `Pax8OrderStatus` / `Pax8SubmitState` / `Pax8BillingTerm` union types. + +- [ ] **Step 1: Write the shared enums (SSOT)** + +Create `packages/shared/src/types/pax8-enums.ts`: + +```ts +// Pax8 order vocabularies. Append-only — order is load-bearing for any UI that +// renders these in sequence, and DB CHECK constraints mirror these lists. + +export const PAX8_ORDER_ACTIONS = ['new_subscription', 'change_quantity', 'cancel'] as const; +export type Pax8OrderAction = (typeof PAX8_ORDER_ACTIONS)[number]; + +export const PAX8_ORDER_STATUSES = [ + 'draft', + 'awaiting_details', + 'ready', + 'submitting', + 'completed', + 'partially_failed', + 'failed', + 'cancelled', +] as const; +export type Pax8OrderStatus = (typeof PAX8_ORDER_STATUSES)[number]; + +export const PAX8_SUBMIT_STATES = [ + 'pending', + 'in_flight', + 'succeeded', + 'failed', + 'needs_reconcile', +] as const; +export type Pax8SubmitState = (typeof PAX8_SUBMIT_STATES)[number]; + +// Verbatim from Pax8's CreateLineItem.billingTerm enum. These strings are sent +// on the wire exactly as written — do not lowercase or reformat them. +export const PAX8_BILLING_TERMS = ['Monthly', 'Annual', '2-Year', '3-Year', 'One-Time', 'Trial', 'Activation'] as const; +export type Pax8BillingTerm = (typeof PAX8_BILLING_TERMS)[number]; + +export const PAX8_ORDER_SOURCES = ['direct', 'quote'] as const; +export type Pax8OrderSource = (typeof PAX8_ORDER_SOURCES)[number]; +``` + +Export it from `packages/shared/src/types/index.ts` alongside the existing type exports (follow whatever re-export form that file already uses). + +- [ ] **Step 2: Write the migration** + +Create `apps/api/migrations/2026-07-13-a-pax8-ordering.sql`: + +```sql +-- Pax8 ordering: staged intent ledger. +-- Pax8 has no idempotency key and no order status field, so THIS TABLE — not +-- Pax8 — is the record of whether money was spent. A line is claimed +-- (submit_state='in_flight') in a committed txn before the HTTP call. +-- Partner-axis (RLS shape 3), matching the five existing pax8_* tables: +-- org_id is a linkage column, never the tenancy axis. + +CREATE TABLE IF NOT EXISTS pax8_orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + integration_id UUID NOT NULL, + partner_id UUID NOT NULL REFERENCES partners(id), + org_id UUID NOT NULL, + pax8_company_id VARCHAR(64) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'draft', + source VARCHAR(10) NOT NULL DEFAULT 'direct', + source_quote_id UUID REFERENCES quotes(id) ON DELETE SET NULL, + dedupe_key VARCHAR(120) NOT NULL, + pax8_order_id VARCHAR(64), + error TEXT, + created_by UUID REFERENCES users(id), + submitted_by UUID REFERENCES users(id), + submitted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT pax8_orders_status_chk CHECK (status IN ( + 'draft','awaiting_details','ready','submitting','completed','partially_failed','failed','cancelled')), + CONSTRAINT pax8_orders_source_chk CHECK (source IN ('direct','quote')), + CONSTRAINT pax8_orders_integration_partner_fkey + FOREIGN KEY (integration_id, partner_id) + REFERENCES pax8_integrations(id, partner_id) ON DELETE CASCADE, + CONSTRAINT pax8_orders_org_partner_fkey + FOREIGN KEY (org_id, partner_id) + REFERENCES organizations(id, partner_id) ON DELETE CASCADE +); + +-- The idempotency guard. A concurrent submit of the same intent loses this race. +CREATE UNIQUE INDEX IF NOT EXISTS pax8_orders_dedupe_key_uq + ON pax8_orders(partner_id, dedupe_key); +CREATE INDEX IF NOT EXISTS pax8_orders_partner_idx ON pax8_orders(partner_id); +CREATE INDEX IF NOT EXISTS pax8_orders_org_idx ON pax8_orders(org_id); +CREATE INDEX IF NOT EXISTS pax8_orders_status_idx ON pax8_orders(partner_id, status); +CREATE INDEX IF NOT EXISTS pax8_orders_quote_idx ON pax8_orders(source_quote_id); +-- Target for the order_lines composite FK. +CREATE UNIQUE INDEX IF NOT EXISTS pax8_orders_id_partner_idx ON pax8_orders(id, partner_id); + +CREATE TABLE IF NOT EXISTS pax8_order_lines ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + order_id UUID NOT NULL, + partner_id UUID NOT NULL REFERENCES partners(id), + org_id UUID NOT NULL, + action VARCHAR(20) NOT NULL, + submit_state VARCHAR(20) NOT NULL DEFAULT 'pending', + pax8_product_id VARCHAR(64), + catalog_item_id UUID, + billing_term VARCHAR(20), + commitment_term_id VARCHAR(64), + quantity NUMERIC(12,2), + provisioning_details JSONB NOT NULL DEFAULT '[]'::jsonb, + target_subscription_id VARCHAR(64), + cancel_date DATE, + result_subscription_id VARCHAR(64), + contract_line_id UUID, + source_quote_line_id UUID, + error TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT pax8_order_lines_action_chk CHECK (action IN ( + 'new_subscription','change_quantity','cancel')), + CONSTRAINT pax8_order_lines_state_chk CHECK (submit_state IN ( + 'pending','in_flight','succeeded','failed','needs_reconcile')), + -- Each action carries a different payload; enforce the shape rather than + -- trusting the service layer. A cancel with a quantity is a bug, not data. + CONSTRAINT pax8_order_lines_action_payload_chk CHECK ( + (action = 'new_subscription' + AND pax8_product_id IS NOT NULL AND billing_term IS NOT NULL + AND quantity IS NOT NULL AND quantity > 0 AND target_subscription_id IS NULL) + OR (action = 'change_quantity' + AND target_subscription_id IS NOT NULL AND quantity IS NOT NULL AND quantity >= 0) + OR (action = 'cancel' + AND target_subscription_id IS NOT NULL AND quantity IS NULL) + ), + CONSTRAINT pax8_order_lines_order_partner_fkey + FOREIGN KEY (order_id, partner_id) + REFERENCES pax8_orders(id, partner_id) ON DELETE CASCADE, + CONSTRAINT pax8_order_lines_org_partner_fkey + FOREIGN KEY (org_id, partner_id) + REFERENCES organizations(id, partner_id) ON DELETE CASCADE, + CONSTRAINT pax8_order_lines_catalog_item_partner_fkey + FOREIGN KEY (catalog_item_id, partner_id) + REFERENCES catalog_items(id, partner_id) ON DELETE SET NULL, + CONSTRAINT pax8_order_lines_contract_line_org_fkey + FOREIGN KEY (contract_line_id, org_id) + REFERENCES contract_lines(id, org_id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS pax8_order_lines_order_idx ON pax8_order_lines(order_id); +CREATE INDEX IF NOT EXISTS pax8_order_lines_partner_idx ON pax8_order_lines(partner_id); +CREATE INDEX IF NOT EXISTS pax8_order_lines_org_idx ON pax8_order_lines(org_id); +CREATE INDEX IF NOT EXISTS pax8_order_lines_contract_line_idx ON pax8_order_lines(contract_line_id); +-- Finds lines stranded mid-flight (crash between claim and result). +CREATE INDEX IF NOT EXISTS pax8_order_lines_inflight_idx + ON pax8_order_lines(submit_state) WHERE submit_state IN ('in_flight','needs_reconcile'); + +ALTER TABLE pax8_orders ENABLE ROW LEVEL SECURITY; +ALTER TABLE pax8_orders FORCE ROW LEVEL SECURITY; +ALTER TABLE pax8_order_lines ENABLE ROW LEVEL SECURITY; +ALTER TABLE pax8_order_lines FORCE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS breeze_partner_isolation_select ON pax8_orders; +DROP POLICY IF EXISTS breeze_partner_isolation_insert ON pax8_orders; +DROP POLICY IF EXISTS breeze_partner_isolation_update ON pax8_orders; +DROP POLICY IF EXISTS breeze_partner_isolation_delete ON pax8_orders; +CREATE POLICY breeze_partner_isolation_select ON pax8_orders + FOR SELECT USING (public.breeze_has_partner_access(partner_id)); +CREATE POLICY breeze_partner_isolation_insert ON pax8_orders + FOR INSERT WITH CHECK (public.breeze_has_partner_access(partner_id)); +CREATE POLICY breeze_partner_isolation_update ON pax8_orders + FOR UPDATE USING (public.breeze_has_partner_access(partner_id)) + WITH CHECK (public.breeze_has_partner_access(partner_id)); +CREATE POLICY breeze_partner_isolation_delete ON pax8_orders + FOR DELETE USING (public.breeze_has_partner_access(partner_id)); + +DROP POLICY IF EXISTS breeze_partner_isolation_select ON pax8_order_lines; +DROP POLICY IF EXISTS breeze_partner_isolation_insert ON pax8_order_lines; +DROP POLICY IF EXISTS breeze_partner_isolation_update ON pax8_order_lines; +DROP POLICY IF EXISTS breeze_partner_isolation_delete ON pax8_order_lines; +CREATE POLICY breeze_partner_isolation_select ON pax8_order_lines + FOR SELECT USING (public.breeze_has_partner_access(partner_id)); +CREATE POLICY breeze_partner_isolation_insert ON pax8_order_lines + FOR INSERT WITH CHECK (public.breeze_has_partner_access(partner_id)); +CREATE POLICY breeze_partner_isolation_update ON pax8_order_lines + FOR UPDATE USING (public.breeze_has_partner_access(partner_id)) + WITH CHECK (public.breeze_has_partner_access(partner_id)); +CREATE POLICY breeze_partner_isolation_delete ON pax8_order_lines + FOR DELETE USING (public.breeze_has_partner_access(partner_id)); +``` + +- [ ] **Step 3: Write the Drizzle schema** + +Create `apps/api/src/db/schema/pax8Orders.ts`: + +```ts +import { + pgTable, uuid, varchar, text, timestamp, jsonb, numeric, date, integer, + index, uniqueIndex, foreignKey, +} from 'drizzle-orm/pg-core'; +import { partners, organizations } from './orgs'; +import { users } from './users'; +import { catalogItems } from './catalog'; +import { contractLines } from './contracts'; +import { quotes } from './quotes'; +import { pax8Integrations } from './pax8'; + +export const pax8Orders = pgTable('pax8_orders', { + id: uuid('id').primaryKey().defaultRandom(), + integrationId: uuid('integration_id').notNull(), + partnerId: uuid('partner_id').notNull().references(() => partners.id), + orgId: uuid('org_id').notNull(), + pax8CompanyId: varchar('pax8_company_id', { length: 64 }).notNull(), + status: varchar('status', { length: 20 }).notNull().default('draft'), + source: varchar('source', { length: 10 }).notNull().default('direct'), + sourceQuoteId: uuid('source_quote_id').references(() => quotes.id, { onDelete: 'set null' }), + dedupeKey: varchar('dedupe_key', { length: 120 }).notNull(), + pax8OrderId: varchar('pax8_order_id', { length: 64 }), + error: text('error'), + createdBy: uuid('created_by').references(() => users.id), + submittedBy: uuid('submitted_by').references(() => users.id), + submittedAt: timestamp('submitted_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}, (table) => ({ + dedupeKeyIdx: uniqueIndex('pax8_orders_dedupe_key_uq').on(table.partnerId, table.dedupeKey), + idPartnerIdx: uniqueIndex('pax8_orders_id_partner_idx').on(table.id, table.partnerId), + partnerIdx: index('pax8_orders_partner_idx').on(table.partnerId), + orgIdx: index('pax8_orders_org_idx').on(table.orgId), + statusIdx: index('pax8_orders_status_idx').on(table.partnerId, table.status), + quoteIdx: index('pax8_orders_quote_idx').on(table.sourceQuoteId), + integrationPartnerFk: foreignKey({ + columns: [table.integrationId, table.partnerId], + foreignColumns: [pax8Integrations.id, pax8Integrations.partnerId], + name: 'pax8_orders_integration_partner_fkey', + }).onDelete('cascade'), + orgPartnerFk: foreignKey({ + columns: [table.orgId, table.partnerId], + foreignColumns: [organizations.id, organizations.partnerId], + name: 'pax8_orders_org_partner_fkey', + }).onDelete('cascade'), +})); + +export const pax8OrderLines = pgTable('pax8_order_lines', { + id: uuid('id').primaryKey().defaultRandom(), + orderId: uuid('order_id').notNull(), + partnerId: uuid('partner_id').notNull().references(() => partners.id), + orgId: uuid('org_id').notNull(), + action: varchar('action', { length: 20 }).notNull(), + submitState: varchar('submit_state', { length: 20 }).notNull().default('pending'), + pax8ProductId: varchar('pax8_product_id', { length: 64 }), + catalogItemId: uuid('catalog_item_id'), + billingTerm: varchar('billing_term', { length: 20 }), + commitmentTermId: varchar('commitment_term_id', { length: 64 }), + quantity: numeric('quantity', { precision: 12, scale: 2 }), + provisioningDetails: jsonb('provisioning_details').notNull().default([]), + targetSubscriptionId: varchar('target_subscription_id', { length: 64 }), + cancelDate: date('cancel_date'), + resultSubscriptionId: varchar('result_subscription_id', { length: 64 }), + contractLineId: uuid('contract_line_id'), + sourceQuoteLineId: uuid('source_quote_line_id'), + error: text('error'), + sortOrder: integer('sort_order').notNull().default(0), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}, (table) => ({ + orderIdx: index('pax8_order_lines_order_idx').on(table.orderId), + partnerIdx: index('pax8_order_lines_partner_idx').on(table.partnerId), + orgIdx: index('pax8_order_lines_org_idx').on(table.orgId), + contractLineIdx: index('pax8_order_lines_contract_line_idx').on(table.contractLineId), + orderPartnerFk: foreignKey({ + columns: [table.orderId, table.partnerId], + foreignColumns: [pax8Orders.id, pax8Orders.partnerId], + name: 'pax8_order_lines_order_partner_fkey', + }).onDelete('cascade'), + orgPartnerFk: foreignKey({ + columns: [table.orgId, table.partnerId], + foreignColumns: [organizations.id, organizations.partnerId], + name: 'pax8_order_lines_org_partner_fkey', + }).onDelete('cascade'), + catalogItemPartnerFk: foreignKey({ + columns: [table.catalogItemId, table.partnerId], + foreignColumns: [catalogItems.id, catalogItems.partnerId], + name: 'pax8_order_lines_catalog_item_partner_fkey', + }).onDelete('set null'), + contractLineOrgFk: foreignKey({ + columns: [table.contractLineId, table.orgId], + foreignColumns: [contractLines.id, contractLines.orgId], + name: 'pax8_order_lines_contract_line_org_fkey', + }).onDelete('set null'), +})); +``` + +Add `export * from './pax8Orders';` to `apps/api/src/db/schema/index.ts`, directly after the existing pax8 export (~line 100). + +- [ ] **Step 4: Register tenancy** + +In `apps/api/src/services/tenantCascade.ts`, add to the cascade list in **alphabetical order** (it sorts by `localeCompare`; getting this wrong breaks the cascade contract test). `pax8_order_lines` and `pax8_orders` sort *before* `pax8_company_mappings`? No — check: `pax8_company_mappings` < `pax8_contract_line_links` < `pax8_order_lines` < `pax8_orders` < `pax8_subscription_snapshots`. Insert both between `pax8_contract_line_links` and `pax8_subscription_snapshots`: + +```ts + 'pax8_company_mappings', + 'pax8_contract_line_links', + 'pax8_order_lines', + 'pax8_orders', + 'pax8_subscription_snapshots', +``` + +In `apps/api/src/__tests__/integration/rls-coverage.integration.test.ts`: + +Add both to `PARTNER_TENANT_TABLES` (the `Map`), next to the other pax8 entries: + +```ts + ['pax8_orders', 'partner_id'], + ['pax8_order_lines', 'partner_id'], +``` + +And add both to the org-auto-discovery exclusion set (the `Set` that already lists `'pax8_company_mappings'`, `'pax8_subscription_snapshots'`, `'pax8_contract_line_links'`), extending the existing comment block: + +```ts + 'pax8_company_mappings', + 'pax8_subscription_snapshots', + 'pax8_contract_line_links', + // pax8_orders / pax8_order_lines (2026-07-13, ordering): same shape — org_id + // is the customer the order is FOR, not the tenancy axis. Ordering is an + // MSP-side act; an org-scoped token must never see one. + 'pax8_orders', + 'pax8_order_lines', +``` + +- [ ] **Step 5: Write the failing RLS test** + +Create `apps/api/src/__tests__/integration/pax8OrdersPartnerRls.integration.test.ts`. Copy the structure of the existing `pax8-rls.integration.test.ts` (same file directory) — reuse its fixture helpers verbatim rather than inventing new ones. The suite must prove: + +```ts +it('rejects a cross-partner forged order insert with 42501', async () => { + // Partner A's context, forging partner B's partner_id. + await expect( + withPartnerContext(partnerA.id, () => + db.insert(pax8Orders).values({ + integrationId: integrationB.id, + partnerId: partnerB.id, + orgId: orgB.id, + pax8CompanyId: 'forged-co', + dedupeKey: 'forge-test-1', + }), + ), + ).rejects.toMatchObject({ code: '42501' }); +}); + +it('hides another partner\'s orders from SELECT', async () => { + const rows = await withPartnerContext(partnerA.id, () => + db.select().from(pax8Orders).where(eq(pax8Orders.id, orderB.id)), + ); + expect(rows).toHaveLength(0); +}); + +it('rejects a second order with the same (partner_id, dedupe_key)', async () => { + await expect( + withPartnerContext(partnerA.id, () => + db.insert(pax8Orders).values({ ...validOrderA, dedupeKey: existingOrderA.dedupeKey }), + ), + ).rejects.toMatchObject({ code: '23505' }); +}); + +it('rejects a cancel line carrying a quantity (action payload CHECK)', async () => { + await expect( + withPartnerContext(partnerA.id, () => + db.insert(pax8OrderLines).values({ + orderId: orderA.id, partnerId: partnerA.id, orgId: orgA.id, + action: 'cancel', targetSubscriptionId: 'sub-1', quantity: '5.00', + }), + ), + ).rejects.toMatchObject({ code: '23514' }); +}); +``` + +**Do not memoize the partner fixtures across tests** — a shared memoized fixture is how a forge test goes vacuously green (it ends up forging against itself). + +- [ ] **Step 6: Run the tests — expect failure** + +```bash +cd apps/api && pnpm vitest run --config vitest.integration.config.ts pax8OrdersPartnerRls +``` +Expected: FAIL — `relation "pax8_orders" does not exist`. + +- [ ] **Step 7: Apply the migration and re-run** + +```bash +export DATABASE_URL="postgresql://breeze:breeze@localhost:5432/breeze" +cd apps/api && pnpm db:migrate && pnpm vitest run --config vitest.integration.config.ts pax8OrdersPartnerRls +``` +Expected: PASS, 4 tests. + +- [ ] **Step 8: Verify as the unprivileged role** + +```bash +docker exec -it breeze-postgres psql -U breeze_app -d breeze \ + -c "INSERT INTO pax8_orders (integration_id, partner_id, org_id, pax8_company_id, dedupe_key) VALUES (gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), 'x', 'x');" +``` +Expected: `ERROR: new row violates row-level security policy for table "pax8_orders"`. + +- [ ] **Step 9: Check for drift, run the contract test, commit** + +```bash +pnpm db:check-drift +cd apps/api && pnpm vitest run --config vitest.integration.config.ts rls-coverage +git add apps/api/migrations/2026-07-13-a-pax8-ordering.sql apps/api/src/db/schema/pax8Orders.ts apps/api/src/db/schema/index.ts packages/shared/src/types/pax8-enums.ts packages/shared/src/types/index.ts apps/api/src/services/tenantCascade.ts apps/api/src/__tests__/integration/rls-coverage.integration.test.ts apps/api/src/__tests__/integration/pax8OrdersPartnerRls.integration.test.ts +git commit -m "feat(pax8): add pax8_orders + pax8_order_lines staged intent ledger" +``` + +--- + +## Task 2: Pax8 write client + +**Files:** +- Modify: `apps/api/src/services/pax8Client.ts` +- Test: `apps/api/src/services/pax8Client.test.ts` + +**Interfaces:** +- Consumes: nothing from Task 1. +- Produces, on `Pax8Client`: + - `createOrder(input: Pax8CreateOrderInput, opts?: { isMock?: boolean }): Promise` + - `updateSubscriptionQuantity(subscriptionId: string, quantity: number): Promise` + - `cancelSubscription(subscriptionId: string, cancelDate?: string | null): Promise` + - `getProvisionDetails(productId: string): Promise` + - `getProductDependencies(productId: string): Promise` + - Types `Pax8CreateOrderInput`, `Pax8OrderLineInput`, `Pax8OrderResult`, `Pax8ProvisionDetail`, `Pax8Commitment`, `Pax8ProductDependencies`. + +- [ ] **Step 1: Write the failing tests** + +Append to `apps/api/src/services/pax8Client.test.ts` (create it if absent, following the existing service-test style — inject a stub `fetch` via `Pax8ClientOptions.fetch`): + +```ts +import { describe, it, expect, vi } from 'vitest'; +import { Pax8Client, Pax8ApiError } from './pax8Client'; + +function clientWith(fetchImpl: ReturnType) { + return new Pax8Client({ + credentials: { clientId: 'id', clientSecret: 'secret', accessToken: 'tok', accessTokenExpiresAt: new Date(Date.now() + 3_600_000) }, + fetch: fetchImpl as never, + }); +} + +function jsonResponse(body: unknown, status = 200) { + return { ok: status < 400, status, json: async () => body, text: async () => JSON.stringify(body) } as Response; +} + +describe('Pax8Client.updateSubscriptionQuantity', () => { + it('sends ONLY quantity — never price, partnerCost, or currencyCode', async () => { + const doFetch = vi.fn().mockResolvedValue(jsonResponse({ id: 'sub-1', quantity: 11 })); + await clientWith(doFetch).updateSubscriptionQuantity('sub-1', 11); + + const [url, init] = doFetch.mock.calls[0]; + expect(url).toBe('https://api.pax8.com/v1/subscriptions/sub-1'); + expect(init.method).toBe('PUT'); + // The whole point: PUT is a partial update and price IS writable. A body + // with any extra key can silently overwrite the customer's rate. + expect(JSON.parse(init.body)).toEqual({ quantity: 11 }); + }); +}); + +describe('Pax8Client.cancelSubscription', () => { + it('DELETEs with no body and no cancelDate when none given', async () => { + const doFetch = vi.fn().mockResolvedValue({ ok: true, status: 204, text: async () => '' } as Response); + await clientWith(doFetch).cancelSubscription('sub-9'); + + const [url, init] = doFetch.mock.calls[0]; + expect(url).toBe('https://api.pax8.com/v1/subscriptions/sub-9'); + expect(init.method).toBe('DELETE'); + expect(init.body).toBeUndefined(); + }); + + it('passes cancelDate as a query param', async () => { + const doFetch = vi.fn().mockResolvedValue({ ok: true, status: 204, text: async () => '' } as Response); + await clientWith(doFetch).cancelSubscription('sub-9', '2026-09-01'); + expect(doFetch.mock.calls[0][0]).toBe('https://api.pax8.com/v1/subscriptions/sub-9?cancelDate=2026-09-01'); + }); +}); + +describe('Pax8Client.createOrder', () => { + it('posts companyId + lineItems and sets isMock when asked', async () => { + const doFetch = vi.fn().mockResolvedValue(jsonResponse({ id: 'ord-1', lineItems: [{ id: 'li-1', subscriptionId: 'sub-1' }] })); + const res = await clientWith(doFetch).createOrder({ + companyId: 'co-1', + lineItems: [{ + lineItemNumber: 1, + productId: 'prod-1', + quantity: 5, + billingTerm: 'Monthly', + provisioningDetails: [{ key: 'msDomain', values: ['acme'] }], + }], + }, { isMock: true }); + + const [url, init] = doFetch.mock.calls[0]; + expect(url).toBe('https://api.pax8.com/v1/orders?isMock=true'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body)).toEqual({ + companyId: 'co-1', + lineItems: [{ + lineItemNumber: 1, productId: 'prod-1', quantity: 5, billingTerm: 'Monthly', + provisioningDetails: [{ key: 'msDomain', values: ['acme'] }], + }], + }); + expect(res.pax8OrderId).toBe('ord-1'); + expect(res.lineItems[0].subscriptionId).toBe('sub-1'); + }); + + it('surfaces Pax8 422 details verbatim on Pax8ApiError.body', async () => { + const body = { status: 422, message: 'Invalid order', details: [{ message: 'msDomain is required' }] }; + const doFetch = vi.fn().mockResolvedValue({ ok: false, status: 422, text: async () => JSON.stringify(body) } as Response); + await expect(clientWith(doFetch).createOrder({ companyId: 'co-1', lineItems: [] })) + .rejects.toMatchObject({ name: 'Pax8ApiError', status: 422 }); + }); +}); + +describe('Pax8Client.getProvisionDetails', () => { + it('returns the discoverable field descriptors', async () => { + const doFetch = vi.fn().mockResolvedValue(jsonResponse({ content: [ + { key: 'msCustExists', label: 'Existing Microsoft account?', valueType: 'Single-Value', possibleValues: ['No', 'Yes'] }, + { key: 'msDomain', label: 'Domain prefix', valueType: 'Input', possibleValues: null }, + ] })); + const details = await clientWith(doFetch).getProvisionDetails('prod-1'); + expect(doFetch.mock.calls[0][0]).toBe('https://api.pax8.com/v1/products/prod-1/provision-details'); + expect(details).toHaveLength(2); + expect(details[1]).toMatchObject({ key: 'msDomain', valueType: 'Input', possibleValues: null }); + }); +}); +``` + +- [ ] **Step 2: Run — expect failure** + +```bash +cd apps/api && pnpm vitest run src/services/pax8Client.test.ts +``` +Expected: FAIL — `client.updateSubscriptionQuantity is not a function`. + +- [ ] **Step 3: Generalize `requestJson` to take a method and body** + +`requestJson` at `pax8Client.ts:300` hardcodes a GET (it never sets `method`). Replace it with a method-aware version, keeping the existing GET call sites working by defaulting to GET: + +```ts + private async requestJson( + path: string, + query: Record = {}, + init: { method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; body?: unknown } = {}, + ): Promise { + const token = await this.getAccessToken(); + const url = new URL(`${this.apiBaseUrl}${path.startsWith('/') ? path : `/${path}`}`); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) url.searchParams.set(key, String(value)); + } + + const method = init.method ?? 'GET'; + const headers: Record = { + authorization: `Bearer ${token}`, + accept: 'application/json', + }; + if (init.body !== undefined) headers['content-type'] = 'application/json'; + + const res = await this.doFetch(url.toString(), { + method, + headers, + body: init.body === undefined ? undefined : JSON.stringify(init.body), + timeoutMs: DEFAULT_TIMEOUT_MS, + } as RequestInit & { timeoutMs?: number }); + + if (!res.ok) { + const body = await res.text().catch(() => ''); + // Pax8 puts per-line-item validation failures in `details[]`. Keep the raw + // body — the UI shows it verbatim rather than guessing at what's wrong, + // because requiredness is NOT discoverable from their spec. + throw new Pax8ApiError(`Pax8 API returned ${res.status}`, res.status, body.slice(0, 4000)); + } + if (res.status === 204) return null; + return res.json(); + } +``` + +Note the two changes beyond the method: a 204 returns `null` (cancel returns no content), and the error body slice grows from 500 to 4000 chars so a multi-line `details[]` survives. + +- [ ] **Step 4: Add the write methods and their types** + +Add near the other exported interfaces: + +```ts +export interface Pax8ProvisioningDetailInput { + key: string; + values: string[]; // ALWAYS an array, even for a single scalar input. +} + +export interface Pax8OrderLineInput { + lineItemNumber: number; + productId: string; + quantity: number; + billingTerm: string; + commitmentTermId?: string; + provisioningDetails?: Pax8ProvisioningDetailInput[]; +} + +export interface Pax8CreateOrderInput { + companyId: string; + lineItems: Pax8OrderLineInput[]; + orderedBy?: 'Pax8 Partner' | 'Customer' | 'Pax8'; + orderedByUserEmail?: string; +} + +export interface Pax8OrderResult { + pax8OrderId: string | null; + lineItems: Array<{ lineItemNumber: number | null; productId: string | null; subscriptionId: string | null }>; +} + +export interface Pax8ProvisionDetail { + key: string; + label: string | null; + description: string | null; + valueType: 'Input' | 'Single-Value' | 'Multi-Value' | null; + possibleValues: string[] | null; +} + +export interface Pax8Commitment { + id: string; + term: string | null; + allowForQuantityIncrease: boolean; + allowForQuantityDecrease: boolean; + allowForEarlyCancellation: boolean; + cancellationFeeApplied: boolean; +} + +export interface Pax8ProductDependencies { + commitments: Pax8Commitment[]; +} +``` + +And the methods on `Pax8Client`: + +```ts + /** + * POST /v1/orders. Pax8 has NO idempotency key — calling this twice creates + * two real, billable orders, and Order.createdDate is a DATE (not a timestamp) + * so you cannot tell them apart afterward. Callers MUST claim their intent row + * in a committed transaction before invoking this, and MUST NOT retry on + * timeout. See pax8OrderSubmit.ts. + * + * `isMock: true` validates without touching Pax8's database. It is the ONLY + * machine-checkable oracle for whether provisioningDetails are complete, + * because their provision-details endpoint does not expose requiredness. + */ + async createOrder(input: Pax8CreateOrderInput, opts: { isMock?: boolean } = {}): Promise { + const payload = await this.requestJson( + '/orders', + opts.isMock ? { isMock: true } : {}, + { method: 'POST', body: input }, + ); + const record = asRecord(payload); + const lineItems = extractArray(record?.lineItems).map((raw) => { + const li = asRecord(raw); + return { + lineItemNumber: li ? firstNumber(li, ['lineItemNumber']) : null, + productId: li ? firstString(li, ['productId', 'product_id']) : null, + subscriptionId: li ? firstString(li, ['subscriptionId', 'subscription_id']) : null, + }; + }); + return { pax8OrderId: record ? firstString(record, ['id', 'orderId']) : null, lineItems }; + } + + /** + * PUT /v1/subscriptions/{id}. Despite the verb this is a PARTIAL update, and + * `price`, `partnerCost`, `currencyCode`, `startDate`, and `endDate` are all + * writable. We send `quantity` and nothing else — a read-modify-write would + * re-send pricing and can overwrite the customer's rate. Do not "helpfully" + * add fields to this body. + */ + async updateSubscriptionQuantity(subscriptionId: string, quantity: number): Promise { + await this.requestJson( + `/subscriptions/${encodeURIComponent(subscriptionId)}`, + {}, + { method: 'PUT', body: { quantity } }, + ); + } + + /** DELETE /v1/subscriptions/{id}. No body. Cancel is terminal — Pax8 exposes no reactivate. */ + async cancelSubscription(subscriptionId: string, cancelDate?: string | null): Promise { + await this.requestJson( + `/subscriptions/${encodeURIComponent(subscriptionId)}`, + cancelDate ? { cancelDate } : {}, + { method: 'DELETE' }, + ); + } + + async getProvisionDetails(productId: string): Promise { + const payload = await this.requestJson(`/products/${encodeURIComponent(productId)}/provision-details`); + return extractArray(payload).map((raw): Pax8ProvisionDetail | null => { + const r = asRecord(raw); + const key = r ? firstString(r, ['key']) : null; + if (!r || !key) return null; + const valueType = firstString(r, ['valueType']); + const possible = Array.isArray(r.possibleValues) + ? r.possibleValues.filter((v): v is string => typeof v === 'string') + : null; + return { + key, + label: firstString(r, ['label']), + description: firstString(r, ['description']), + valueType: (valueType as Pax8ProvisionDetail['valueType']) ?? null, + possibleValues: possible, + }; + }).filter((d): d is Pax8ProvisionDetail => d !== null); + } + + async getProductDependencies(productId: string): Promise { + const payload = await this.requestJson(`/products/${encodeURIComponent(productId)}/dependencies`); + const root = asRecord(payload); + const commitments = extractArray(root?.commitmentDependencies).map((raw): Pax8Commitment | null => { + const r = asRecord(raw); + const id = r ? firstString(r, ['id']) : null; + if (!r || !id) return null; + return { + id, + term: firstString(r, ['term']), + allowForQuantityIncrease: r.allowForQuantityIncrease === true, + allowForQuantityDecrease: r.allowForQuantityDecrease === true, + allowForEarlyCancellation: r.allowForEarlyCancellation === true, + cancellationFeeApplied: r.cancellationFeeApplied === true, + }; + }).filter((c): c is Pax8Commitment => c !== null); + return { commitments }; + } +``` + +- [ ] **Step 5: Run — expect pass** + +```bash +cd apps/api && pnpm vitest run src/services/pax8Client.test.ts +``` +Expected: PASS. Then re-run the existing pax8 suites to prove the `requestJson` change didn't break the read paths: + +```bash +cd apps/api && pnpm vitest run pax8 +``` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add apps/api/src/services/pax8Client.ts apps/api/src/services/pax8Client.test.ts +git commit -m "feat(pax8): add order/subscription write methods to Pax8Client" +``` + +--- + +## Task 3: Draft order authoring service + +**Files:** +- Create: `apps/api/src/services/pax8OrderService.ts` +- Test: `apps/api/src/services/pax8OrderService.test.ts` + +**Interfaces:** +- Consumes: `pax8Orders`, `pax8OrderLines` (Task 1); `Pax8OrderAction`, `Pax8BillingTerm` (Task 1). +- Produces: + - `getOrCreateDraftOrder(input: { partnerId, orgId, actorUserId }): Promise` + - `addOrderLine(input: AddOrderLineInput): Promise` + - `removeOrderLine(input: { partnerId, orderId, lineId }): Promise<{ removed: boolean }>` + - `getOrderWithLines(input: { partnerId, orderId }): Promise<{ order: Pax8OrderRow; lines: Pax8OrderLineRow[] }>` + - `Pax8OrderError` (class with `.status`) + - `buildDedupeKey(orderId: string): string` + +- [ ] **Step 1: Write the failing tests** + +Create `apps/api/src/services/pax8OrderService.test.ts`. Mock `../db` following the Drizzle-mock pattern already used by the sibling service tests (see `breeze-testing` skill; the schema proxy mock needs a `has` trap). Cover: + +```ts +describe('getOrCreateDraftOrder', () => { + it('throws 409 when the org has no Pax8 company mapping', async () => { + mockCompanyMappingLookup(null); + await expect(getOrCreateDraftOrder({ partnerId: 'p1', orgId: 'o1', actorUserId: 'u1' })) + .rejects.toMatchObject({ status: 409, message: expect.stringContaining('not mapped to a Pax8 company') }); + }); + + it('reuses the existing open draft rather than creating a second one', async () => { + mockCompanyMappingLookup({ pax8CompanyId: 'co-1', integrationId: 'i1' }); + mockExistingDraft({ id: 'ord-existing', status: 'draft' }); + const order = await getOrCreateDraftOrder({ partnerId: 'p1', orgId: 'o1', actorUserId: 'u1' }); + expect(order.id).toBe('ord-existing'); + expect(insertSpy).not.toHaveBeenCalled(); + }); +}); + +describe('addOrderLine', () => { + it('rejects a change_quantity whose commitment forbids a decrease', async () => { + mockSubscriptionSnapshot({ pax8SubscriptionId: 'sub-1', quantity: '10.00', orgId: 'o1' }); + mockDependencies({ commitments: [{ id: 'c1', allowForQuantityDecrease: false, allowForQuantityIncrease: true }] }); + await expect(addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'change_quantity', + targetSubscriptionId: 'sub-1', quantity: '5.00', + })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('decrease') }); + }); + + it('rejects a line targeting a subscription in a different org', async () => { + mockSubscriptionSnapshot({ pax8SubscriptionId: 'sub-1', orgId: 'OTHER-ORG' }); + await expect(addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'cancel', targetSubscriptionId: 'sub-1', + })).rejects.toMatchObject({ status: 403 }); + }); + + it('refuses to modify an order that is not draft/awaiting_details', async () => { + mockOrder({ id: 'ord-1', status: 'submitting' }); + await expect(addOrderLine({ partnerId: 'p1', orderId: 'ord-1', action: 'cancel', targetSubscriptionId: 'sub-1' })) + .rejects.toMatchObject({ status: 409 }); + }); +}); + +describe('buildDedupeKey', () => { + it('is stable for the same order', () => { + expect(buildDedupeKey('ord-1')).toBe(buildDedupeKey('ord-1')); + }); +}); +``` + +- [ ] **Step 2: Run — expect failure (module not found)** + +```bash +cd apps/api && pnpm vitest run src/services/pax8OrderService.test.ts +``` + +- [ ] **Step 3: Implement the service** + +Create `apps/api/src/services/pax8OrderService.ts`. Key behaviors, in order: + +```ts +import { and, eq, inArray } from 'drizzle-orm'; +import { db } from '../db'; +import { pax8Orders, pax8OrderLines, pax8CompanyMappings, pax8SubscriptionSnapshots } from '../db/schema'; +import type { Pax8OrderAction, Pax8BillingTerm } from '@breeze/shared'; + +export class Pax8OrderError extends Error { + constructor(message: string, public readonly status: 400 | 403 | 404 | 409 | 422) { + super(message); + this.name = 'Pax8OrderError'; + } +} + +export type Pax8OrderRow = typeof pax8Orders.$inferSelect; +export type Pax8OrderLineRow = typeof pax8OrderLines.$inferSelect; + +/** Stable per-order. The unique index on (partner_id, dedupe_key) is what makes + * a concurrent submit lose the race — see pax8OrderSubmit.claimLine. */ +export function buildDedupeKey(orderId: string): string { + return `order:${orderId}`; +} + +const MUTABLE_STATUSES = new Set(['draft', 'awaiting_details']); +``` + +`getOrCreateDraftOrder` must: +1. Look up the org's `pax8_company_mappings` row (by `partnerId` + `orgId`, `ignored = false`). If absent or `orgId` unset → `Pax8OrderError('This organization is not mapped to a Pax8 company. Map it before ordering.', 409)`. +2. Return the existing order for that org whose status is in `MUTABLE_STATUSES`, if one exists. +3. Otherwise insert one with `status: 'draft'`, `source: 'direct'`, `dedupeKey: buildDedupeKey()` — generate the id client-side (`crypto.randomUUID()`) so the dedupe key can reference it in the same insert. + +`addOrderLine` must, for every action: +1. Load the order; reject if its status is not in `MUTABLE_STATUSES` → 409. +2. For `change_quantity` and `cancel`: load the `pax8_subscription_snapshots` row by `(integrationId, pax8SubscriptionId)`. Reject if missing (404) or if its `orgId` ≠ the order's `orgId` (403 — this is the cross-tenant guard, do not skip it). +3. For `change_quantity`: fetch `getProductDependencies(productId)` via `createPax8ClientForIntegration`, and if the new quantity is below the snapshot quantity, require `allowForQuantityDecrease`; if above, require `allowForQuantityIncrease`. Reject with 422 naming the direction. +4. For `cancel`: require `allowForEarlyCancellation`; reject 422 otherwise. +5. For `new_subscription`: require `pax8ProductId`, a `billingTerm` in `PAX8_BILLING_TERMS`, and `quantity > 0`. +6. Insert the line. The DB `CHECK` constraint is the backstop; these checks exist to produce a readable message instead of a 23514. + +The Pax8 HTTP calls in steps 3–4 must run via `runOutsideDbContext(...)` — never hold a pooled connection across an HTTP round-trip (#1697). + +- [ ] **Step 4: Run — expect pass** + +```bash +cd apps/api && pnpm vitest run src/services/pax8OrderService.test.ts +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/services/pax8OrderService.ts apps/api/src/services/pax8OrderService.test.ts +git commit -m "feat(pax8): draft order authoring service with commitment guards" +``` + +--- + +## Task 4: The submit pipeline + +This is the money-critical file. Every rule in it exists because Pax8 has no idempotency key. + +**Files:** +- Create: `apps/api/src/services/pax8OrderSubmit.ts` +- Test: `apps/api/src/services/pax8OrderSubmit.test.ts` +- Test: `apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts` + +**Interfaces:** +- Consumes: `Pax8Client.createOrder / updateSubscriptionQuantity / cancelSubscription` (Task 2); `pax8Orders`, `pax8OrderLines` (Task 1); `Pax8OrderError` (Task 3). +- Produces: + - `preflightOrder(input: { partnerId, orderId }): Promise<{ ok: true } | { ok: false; errorBody: string }>` + - `submitOrder(input: { partnerId, orderId, actorUserId }): Promise` where `SubmitResult = { orderId: string; status: Pax8OrderStatus; lines: Array<{ lineId: string; submitState: Pax8SubmitState; error: string | null }> }` + - `reconcileOrder(input: { partnerId, orderId }): Promise<{ resolved: number; stillUnknown: number }>` + +- [ ] **Step 1: Write the failing unit tests** + +Create `apps/api/src/services/pax8OrderSubmit.test.ts`: + +```ts +describe('submitOrder', () => { + it('runs the isMock preflight BEFORE any real write, and aborts on 422', async () => { + const client = stubClient(); + client.createOrder.mockRejectedValueOnce(new Pax8ApiError('Pax8 API returned 422', 422, '{"details":[{"message":"msDomain is required"}]}')); + + const res = await submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1' }); + + // Exactly ONE call — the mock. The real order was never attempted. + expect(client.createOrder).toHaveBeenCalledTimes(1); + expect(client.createOrder.mock.calls[0][1]).toEqual({ isMock: true }); + expect(res.status).toBe('failed'); + expect(res.lines[0].error).toContain('msDomain is required'); + }); + + it('marks a line needs_reconcile — NOT failed — when the write times out', async () => { + const client = stubClient(); + client.createOrder + .mockResolvedValueOnce({ pax8OrderId: null, lineItems: [] }) // isMock preflight OK + .mockRejectedValueOnce(Object.assign(new Error('aborted'), { name: 'AbortError' })); + + const res = await submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1' }); + + expect(res.lines[0].submitState).toBe('needs_reconcile'); + // A timeout means UNKNOWN. Retrying here is how you buy the licenses twice. + expect(client.createOrder).toHaveBeenCalledTimes(2); // preflight + the one real attempt + }); + + it('does not re-send a line already in_flight', async () => { + mockOrderLines([{ id: 'l1', submitState: 'in_flight', action: 'new_subscription' }]); + const client = stubClient(); + await expect(submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1' })) + .rejects.toMatchObject({ status: 409 }); + expect(client.createOrder).not.toHaveBeenCalled(); + }); + + it('records partially_failed when the POST succeeds but a PUT 422s', async () => { + const client = stubClient(); + client.createOrder + .mockResolvedValueOnce({ pax8OrderId: null, lineItems: [] }) + .mockResolvedValueOnce({ pax8OrderId: 'ord-x', lineItems: [{ lineItemNumber: 1, productId: 'prod-1', subscriptionId: 'sub-new' }] }); + client.updateSubscriptionQuantity.mockRejectedValueOnce(new Pax8ApiError('Pax8 API returned 422', 422, '{"message":"seat decrease not allowed"}')); + + const res = await submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1' }); + + expect(res.status).toBe('partially_failed'); + expect(res.lines.find((l) => l.lineId === 'line-new')!.submitState).toBe('succeeded'); + expect(res.lines.find((l) => l.lineId === 'line-change')!.submitState).toBe('failed'); + }); +}); +``` + +- [ ] **Step 2: Write the failing integration test (real Postgres)** + +Create `apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts`. This one proves the atomicity claim that a mocked test cannot: + +```ts +it('writes the contract-line quantity in the SAME transaction that marks the line succeeded', async () => { + // A new_subscription line linked to a manual contract line, quantity 7. + const { orderId, lineId, contractLineId } = await seedOrderWithContractLine({ quantity: '7.00' }); + + await submitOrder({ partnerId: partner.id, orderId, actorUserId: user.id }); + + const [line] = await db.select().from(pax8OrderLines).where(eq(pax8OrderLines.id, lineId)); + const [cl] = await db.select().from(contractLines).where(eq(contractLines.id, contractLineId)); + expect(line.submitState).toBe('succeeded'); + expect(cl.manualQuantity).toBe('7.00'); // ordering and billing are ONE act +}); + +it('leaves the contract line untouched when the order fails', async () => { + const { orderId, contractLineId } = await seedOrderWithContractLine({ quantity: '7.00', failWith: 422 }); + await submitOrder({ partnerId: partner.id, orderId, actorUserId: user.id }); + const [cl] = await db.select().from(contractLines).where(eq(contractLines.id, contractLineId)); + expect(cl.manualQuantity).toBeNull(); +}); + +it('rejects a concurrent second submit via the dedupe_key unique index', async () => { + const { orderId } = await seedOrderWithContractLine({ quantity: '3.00' }); + const results = await Promise.allSettled([ + submitOrder({ partnerId: partner.id, orderId, actorUserId: user.id }), + submitOrder({ partnerId: partner.id, orderId, actorUserId: user.id }), + ]); + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1); +}); +``` + +- [ ] **Step 3: Run both — expect failure** + +```bash +cd apps/api && pnpm vitest run src/services/pax8OrderSubmit.test.ts +cd apps/api && pnpm vitest run --config vitest.integration.config.ts pax8OrderSubmit +``` + +- [ ] **Step 4: Implement `preflightOrder`** + +Builds the `Pax8CreateOrderInput` from the order's `new_subscription` lines (assigning `lineItemNumber` from `sort_order + 1`) and calls `client.createOrder(input, { isMock: true })`. Returns `{ ok: false, errorBody }` on `Pax8ApiError` with the raw `.body`, `{ ok: true }` otherwise. An order with no `new_subscription` lines skips the preflight and returns `{ ok: true }` — `isMock` only validates orders, not subscription updates. + +- [ ] **Step 5: Implement `submitOrder`** + +The sequence, and none of it is negotiable: + +``` +1. Load order + lines. Reject 409 if status not in {draft, awaiting_details, ready} + or if ANY line is already in_flight (a crashed prior submit — force reconcile). +2. Flip order -> 'submitting' in a COMMITTED txn. If the (partner_id, dedupe_key) + unique index rejects, a concurrent submit won it: throw 409. +3. Preflight: preflightOrder(). On failure, mark every new_subscription line + 'failed' with the raw body, order -> 'failed', return. NOTHING was sent. +4. Claim ALL lines: submit_state 'pending' -> 'in_flight', COMMITTED, before any + HTTP call. If the process dies after this, the lines are visibly in_flight and + a human must reconcile — which is exactly the intent. +5. runOutsideDbContext(...) around the HTTP work: + a. All new_subscription lines -> ONE client.createOrder(input) (no isMock) + b. Each change_quantity line -> client.updateSubscriptionQuantity(subId, qty) + c. Each cancel line -> client.cancelSubscription(subId, cancelDate) + Classify each outcome: + - resolved OK -> succeeded + - Pax8ApiError with a status 4xx -> failed (Pax8 definitively rejected it) + - anything else (timeout, 5xx, + network, no status) -> needs_reconcile (UNKNOWN — never retry) +6. Persist results. For EACH succeeded line, in the SAME transaction: + - set result_subscription_id (matched from the createOrder response by + lineItemNumber, falling back to productId) + - if contract_line_id is set AND action != 'cancel': + UPDATE contract_lines SET manual_quantity = + WHERE id = contract_line_id AND line_type = 'manual' + - if action = 'cancel' AND contract_line_id is set: + UPDATE contract_lines SET manual_quantity = '0' + Order status: all succeeded -> 'completed'; any needs_reconcile or a mix -> + 'partially_failed'; all failed -> 'failed'. +``` + +The `contract_lines` write is guarded on `line_type = 'manual'` exactly as `applyEnabledPax8ContractLineLinks` does today — a `per_seat`/`per_device` line resolves its quantity at bill time and must not be overwritten. + +- [ ] **Step 6: Implement `reconcileOrder`** + +For each `needs_reconcile` line, fetch `GET /v1/orders?companyId=` and `GET /v1/subscriptions?companyId=` (the client's existing `listSubscriptions`, filtered) and match on `productId` + `quantity`. A match → the write landed: mark `succeeded` and run the same contract-line write as step 6 above. No match → mark `failed`. **`reconcileOrder` never issues a write to Pax8.** It only reads and re-classifies. + +Note in a comment that `Order.createdDate` is a date, not a timestamp, so same-day disambiguation is coarse — this is why reconcile is human-triggered and not automatic. + +- [ ] **Step 7: Run both suites — expect pass** + +```bash +cd apps/api && pnpm vitest run src/services/pax8OrderSubmit.test.ts +cd apps/api && pnpm vitest run --config vitest.integration.config.ts pax8OrderSubmit +``` + +- [ ] **Step 8: Commit** + +```bash +git add apps/api/src/services/pax8OrderSubmit.ts apps/api/src/services/pax8OrderSubmit.test.ts apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts +git commit -m "feat(pax8): order submit pipeline with isMock preflight and no-blind-retry" +``` + +--- + +## Task 5: HTTP routes + +**Files:** +- Create: `apps/api/src/routes/pax8Orders.ts` +- Modify: `apps/api/src/index.ts` (near line 919, where `pax8Routes` is mounted) +- Test: `apps/api/src/routes/pax8Orders.test.ts` + +**Interfaces:** +- Consumes: every export of Tasks 3 and 4. +- Produces: `pax8OrderRoutes` (a `Hono` instance), mounted at `/api/v1/pax8/orders`. + +Routes, all `requireScope('partner', 'system')` + `BILLING_MANAGE`, writes additionally `requireMfa()`: + +| Method | Path | Purpose | +|---|---|---| +| GET | `/orders?orgId=` | List orders for an org (or all pending for the partner) | +| GET | `/orders/:id` | Order + lines | +| POST | `/orders` | `getOrCreateDraftOrder` | +| POST | `/orders/:id/lines` | `addOrderLine` | +| DELETE | `/orders/:id/lines/:lineId` | `removeOrderLine` | +| POST | `/orders/:id/preflight` | `preflightOrder` — returns the raw Pax8 422 body on failure | +| POST | `/orders/:id/submit` | `submitOrder` (MFA) | +| POST | `/orders/:id/reconcile` | `reconcileOrder` (MFA) | +| GET | `/products/:productId/provision-details` | Proxy for the dynamic form | +| GET | `/products/:productId/dependencies` | Commitment terms + the allowFor* flags | + +Copy the `resolvePartnerId(auth, requested)` helper from `routes/pax8.ts:31` verbatim — an org-scoped token must be refused with *"Pax8 ordering is managed at partner scope"*, exactly as the existing routes refuse it. Every write calls `writeRouteAudit(...)` following the pattern already in `routes/pax8.ts`. + +Map `Pax8OrderError.status` onto the HTTP status; map `Pax8ApiError` to a 502 carrying the raw body. + +- [ ] **Step 1: Write the failing route tests** — assert the org-scope refusal (403), that `POST /orders/:id/submit` without MFA is rejected, and that a `Pax8OrderError(…, 409)` surfaces as a 409 with its message. +- [ ] **Step 2: Run — expect failure.** `cd apps/api && pnpm vitest run src/routes/pax8Orders.test.ts` +- [ ] **Step 3: Implement the routes.** +- [ ] **Step 4: Mount** in `apps/api/src/index.ts` next to the existing `app.route('/api/v1/pax8', pax8Routes)`: `app.route('/api/v1/pax8', pax8OrderRoutes)`. Hono merges the two routers on the same prefix; keep the order-routes mount *after* the existing one so the more specific `/orders/*` paths are unambiguous. +- [ ] **Step 5: Run — expect pass.** +- [ ] **Step 6: Commit.** `git commit -m "feat(pax8): order routes"` + +--- + +## Task 6: Stage the order on quote acceptance + +**Files:** +- Modify: `apps/api/src/services/quoteAcceptService.ts` (after the Phase 4 contract loop, ~line 250-255) +- Create: `apps/api/src/services/quoteToPax8Order.ts` +- Test: `apps/api/src/services/quoteToPax8Order.test.ts` +- Test: `apps/api/src/__tests__/integration/quoteAcceptPax8Order.integration.test.ts` + +**Interfaces:** +- Consumes: `pax8Orders`, `pax8OrderLines` (Task 1); `buildDedupeKey` (Task 3). +- Produces: `stagePax8OrderFromQuote(input: StagePax8OrderInput): Promise<{ orderId: string | null; lineCount: number }>` — returns `{ orderId: null, lineCount: 0 }` when the quote has no Pax8-backed lines. + +- [ ] **Step 1: Write the failing tests** + +`quoteToPax8Order.test.ts`: + +```ts +it('returns null when the quote has no Pax8-backed lines', async () => { + const res = await stagePax8OrderFromQuote({ ...baseInput, lines: [{ catalogItemId: 'cat-plain', ... }] }); + expect(res.orderId).toBeNull(); +}); + +it('stages one new_subscription line per Pax8-backed quote line', async () => { ... }); + +it('attaches the contract line created by Phase 4, matched on catalog_item_id', async () => { ... }); + +it('leaves contract_line_id null for a one_time Pax8 line', async () => { + // one_time lines bill on the invoice and produce NO contract line. + const res = await stagePax8OrderFromQuote({ ...baseInput, lines: [pax8Line({ recurrence: 'one_time' })] }); + expect(stagedLines[0].contractLineId).toBeNull(); +}); + +it('claims each contract line at most once when two quote lines share a catalog item', async () => { ... }); +``` + +`quoteAcceptPax8Order.integration.test.ts` (real Postgres) — the one that matters: + +```ts +it('stages the order INSIDE the accept transaction, atomic with the contracts', async () => { + const quote = await seedSentQuoteWithPax8Line(); + const res = await acceptQuote({ quoteId: quote.id, signerName: 'A Customer' }); + + const [order] = await db.select().from(pax8Orders).where(eq(pax8Orders.sourceQuoteId, quote.id)); + expect(order.status).toBe('awaiting_details'); + expect(order.source).toBe('quote'); + + const [line] = await db.select().from(pax8OrderLines).where(eq(pax8OrderLines.orderId, order.id)); + expect(line.action).toBe('new_subscription'); + expect(line.contractLineId).not.toBeNull(); // wired to the contract Phase 4 made + expect(res.contractIds).toContain(contractIdOf(line.contractLineId)); +}); + +it('stages nothing when the accept rolls back', async () => { + const quote = await seedSentQuoteWithPax8Line(); + await expect(acceptQuoteWithForcedFailureAfterPhase5(quote.id)).rejects.toThrow(); + const orders = await db.select().from(pax8Orders).where(eq(pax8Orders.sourceQuoteId, quote.id)); + expect(orders).toHaveLength(0); // the whole point of staging in-transaction +}); +``` + +- [ ] **Step 2: Run — expect failure.** + +- [ ] **Step 3: Implement `stagePax8OrderFromQuote`** + +It must: +1. Resolve which quote lines are Pax8-backed: join `catalog_item_id` → `pax8_product_mappings.catalog_item_id` (scoped to the partner's active integration). A line with no mapping is not Pax8-backed and is skipped. +2. Return early with `{ orderId: null, lineCount: 0 }` if none. +3. Require the quote's org to have a `pax8_company_mappings` row. If it doesn't, **do not throw** — a missing mapping must never block a customer's acceptance. Stage the order anyway with `status: 'awaiting_details'` and set `order.error` to *"Organization is not mapped to a Pax8 company — map it before submitting."* The technician fixes it before submitting. +4. Re-read `contract_lines` for the contracts Phase 4 just created (`inArray(contractLines.contractId, contractIds)`), and match each Pax8-backed quote line to a contract line on `catalog_item_id`, claiming each contract line at most once (a `Set` of claimed ids). A `one_time` line gets `contractLineId: null`. +5. Insert the `pax8_orders` row (`source: 'quote'`, `sourceQuoteId`, `status: 'awaiting_details'`, `dedupeKey: buildDedupeKey(id)`) and its lines, mapping `quantity` from the quote line's `quantity`, `billingTerm` from the quote line's `recurrence` (`monthly` → `'Monthly'`, `annual` → `'Annual'`, `one_time` → `'One-Time'`), and `pax8ProductId` from the mapping. +6. Leave `provisioningDetails` as `[]` — that is precisely what the technician supplies before submit. + +- [ ] **Step 4: Wire it into `acceptQuote` as Phase 5** + +In `quoteAcceptService.ts`, immediately after the `for (const spec of contractSpecs)` loop that populates `contractIds`, and **before** the final `SELECT` that builds the return value: + +```ts + // Phase 5: stage a Pax8 order for any Pax8-backed lines. IN-TRANSACTION, + // alongside Phase 4 — a staged order that references contract lines which + // rolled back would be unfixable. Nothing is sent to Pax8 here: the customer's + // approval stages the order, a technician submits it. Provisioning details are + // the technician's job (the customer cannot supply an M365 tenant domain). + const pax8Staged = await stagePax8OrderFromQuote({ + quoteId: quote.id, + orgId: quote.orgId, + partnerId: quote.partnerId, + contractIds, + lines: lines.map((l) => ({ + id: l.id, + catalogItemId: l.catalogItemId ?? null, + quantity: l.quantity, + recurrence: l.recurrence, + customerVisible: l.customerVisible, + })), + actorUserId: params.actorUserId ?? null, + }); +``` + +Add `pax8OrderId: pax8Staged.orderId` to the returned object so the accept response can surface it, and extend the `AcceptQuoteResult` type accordingly. Update `emitAcceptInvoiceIssued`'s callers only if they destructure the result exhaustively (they don't — it takes a `Pick`, so no change is needed). + +- [ ] **Step 5: Run both suites, plus the full quote suite** (this touches the accept path — regressions here break every quote): + +```bash +cd apps/api && pnpm vitest run quote +cd apps/api && pnpm vitest run --config vitest.integration.config.ts quoteAcceptPax8Order +``` +Expected: PASS, with no pre-existing quote test broken. + +- [ ] **Step 6: Commit.** `git commit -m "feat(pax8): stage a Pax8 order on quote acceptance (in-transaction Phase 5)"` + +--- + +## Task 7: Demote the nightly quantity push to drift detection + +This changes shipped behavior. See the spec's "billing-truth problem". + +**Files:** +- Modify: `apps/api/src/services/pax8SyncService.ts:232-265` +- Create: `apps/api/src/services/pax8Drift.ts` +- Test: `apps/api/src/services/pax8Drift.test.ts` +- Modify: `apps/api/src/services/pax8SyncService.test.ts` (the existing apply-links tests now assert the opposite) + +**Interfaces:** +- Produces: `detectPax8Drift(integrationId: string): Promise` where `Pax8DriftRow = { contractLineId: string; orgId: string; pax8SubscriptionId: string; productName: string | null; breezeQuantity: string; pax8Quantity: string }`. + +- [ ] **Step 1: Write the failing test** + +```ts +// NOTE: applyEnabledPax8ContractLineLinks is RENAMED to +// recordPax8SubscriptionObservations in Step 3. Write the test against the new +// name; it will not compile until the rename lands, which is the point. +describe('recordPax8SubscriptionObservations', () => { + it('NO LONGER writes contract_lines.manual_quantity', async () => { + // Pax8's Subscription.quantity is stale and does not match what Pax8 invoices. + // Breeze's order ledger is the source of truth. This sync must never write it. + await recordPax8SubscriptionObservations('int-1'); + expect(contractLinesUpdateSpy).not.toHaveBeenCalled(); + }); +}); + +describe('detectPax8Drift', () => { + it('reports a link whose Pax8 quantity differs from the contract line', async () => { + mockLink({ contractLineId: 'cl-1', manualQuantity: '5.00', snapshotQuantity: '8.00' }); + const drift = await detectPax8Drift('int-1'); + expect(drift).toEqual([expect.objectContaining({ + contractLineId: 'cl-1', breezeQuantity: '5.00', pax8Quantity: '8.00', + })]); + }); + + it('reports nothing when they agree', async () => { + mockLink({ contractLineId: 'cl-1', manualQuantity: '5.00', snapshotQuantity: '5.00' }); + expect(await detectPax8Drift('int-1')).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run — expect failure** (the current implementation *does* write). + +- [ ] **Step 3: Rewrite `applyEnabledPax8ContractLineLinks`** + +Delete the `db.update(contractLines)` call entirely. Keep the link-row bookkeeping (`lastAppliedQuantity` / `lastAppliedAt` become "last *observed*") and rename the function to `recordPax8SubscriptionObservations`. + +**Sweep every consumer of the renamed output before calling this done** — the `Pax8SyncResult` field rename (`appliedContractLines` → `observedContractLines`) is an output-contract change, and its readers live outside this file: + +```bash +grep -rn "applyEnabledPax8ContractLineLinks\|appliedContractLines" apps/ packages/ +``` + +Known call sites: `pax8SyncService.ts:317` (inside `syncPax8Integration`), the `Pax8SyncResult` interface at `pax8SyncService.ts:267-274`, `jobs/pax8SyncWorker.ts` (logs the counts), and `routes/pax8.ts` (`POST /sync` returns them to the UI). If `apps/web/src/components/integrations/Pax8Integration.tsx` renders the count, update it too. + +Leave a comment at the top explaining *why*, in full: + +```ts +/** + * Records what Pax8 currently REPORTS for each linked subscription. It does NOT + * write contract_lines.manual_quantity — that was the old behavior and it was a + * billing bug: Pax8's API Subscription.quantity is stale and does not match the + * seat counts Pax8 actually invoices the partner for, so every sync_enabled link + * was feeding a wrong number into the contract billing sweep and out onto the + * customer's invoice. + * + * Breeze's order ledger (pax8_orders / pax8_order_lines) is the source of truth + * for billable quantity: we know what the customer has because every add, change, + * and cancel went through us. Pax8 is now only a DRIFT DETECTOR — see + * detectPax8Drift(), which surfaces the disagreement (someone changed seats in + * the Pax8 portal, bypassing Breeze) instead of silently overwriting the bill. + */ +``` + +- [ ] **Step 4: Implement `detectPax8Drift`** in `pax8Drift.ts` — the same join the old function used, returning the rows where `contract_lines.manual_quantity IS DISTINCT FROM pax8_subscription_snapshots.quantity` rather than updating them. + +- [ ] **Step 5: Expose it** — add `GET /api/v1/pax8/drift?integrationId=` to `routes/pax8Orders.ts` (read perm, no MFA). + +- [ ] **Step 6: Run the full pax8 suite** — the existing `pax8SyncService.test.ts` assertions about applying quantities must be inverted, not deleted, so the regression stays pinned. + +```bash +cd apps/api && pnpm vitest run pax8 +``` + +- [ ] **Step 7: Commit.** + +```bash +git commit -m "fix(pax8): stop billing customers off Pax8's stale subscription quantity + +Pax8's API Subscription.quantity does not match the seat counts Pax8 +invoices. The nightly sync was pushing it into contract_lines.manual_quantity, +which the contract billing sweep then invoiced the customer from. Breeze's +order ledger is now the source of truth; the sync only detects drift." +``` + +--- + +## Task 8: Web — the Pax8 tab on the org page + +**Files:** +- Create: `apps/web/src/lib/api/pax8Orders.ts` +- Create: `apps/web/src/components/organizations/Pax8OrgTab.tsx` +- Create: `apps/web/src/components/organizations/Pax8OrderBuilder.tsx` +- Create: `apps/web/src/components/organizations/Pax8ProvisioningForm.tsx` +- Modify: the org detail page's tab registry (follow `DeviceDetails.tsx` — tabs key off `window.location.hash`, never a query param) +- Test: `apps/web/src/components/organizations/Pax8OrderBuilder.test.tsx` + +All mutations go through `runAction` (`apps/web/src/lib/runAction.ts`) — the `no-silent-mutations` test enforces it. + +The provisioning form renders from `GET /pax8/products/:id/provision-details`: `valueType: 'Input'` → text field; `'Single-Value'` → select over `possibleValues`; `'Multi-Value'` → multiselect. **Every field is optional in the UI** — requiredness is not discoverable from Pax8, so the form must not invent it. The Submit button runs the preflight first and renders Pax8's raw `details[]` inline against the offending line when it fails. That error text is the only reliable statement of what's missing. + +The subscription list shows Breeze's ledger quantity as the primary number, the Pax8-reported quantity secondary, and a drift badge when they disagree. + +- [ ] **Step 1: Write the failing component test** — assert that a `Single-Value` provisioning field renders a `