From 37ebe063f9243382f05b1d1735291d2b3f1396b6 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Sat, 31 Oct 2026 11:40:40 -0600 Subject: [PATCH 01/12] feat(pax8): optional claim-time verification hook for approved submits (#6785) Verify approved inputs inside the locked claim transaction, capture the integration and durable billing baselines, reopen provably unsent claims, and preserve remote success with billing_conflict when submit or reconcile loses the quantity CAS. Relax the existing baseline constraint for every line action (B1). Co-Authored-By: Claude Fable 5.1 --- ...00-pax8-order-line-baseline-any-action.sql | 4 + apps/api/src/db/schema/pax8.test.ts | 10 + apps/api/src/db/schema/pax8Orders.ts | 3 + .../actionIntents/effectDigest.w15b.test.ts | 179 ++++++++++++++++++ apps/api/src/services/pax8OrderSubmit.test.ts | 54 ++++++ apps/api/src/services/pax8OrderSubmit.ts | 37 +++- .../pax8OrderSubmitRepository.test.ts | 128 ++++++++++++- .../src/services/pax8OrderSubmitRepository.ts | 103 +++++++--- apps/api/src/services/pax8SyncService.ts | 6 +- 9 files changed, 489 insertions(+), 35 deletions(-) create mode 100644 apps/api/migrations/2027-01-18-120000-pax8-order-line-baseline-any-action.sql create mode 100644 apps/api/src/services/actionIntents/effectDigest.w15b.test.ts diff --git a/apps/api/migrations/2027-01-18-120000-pax8-order-line-baseline-any-action.sql b/apps/api/migrations/2027-01-18-120000-pax8-order-line-baseline-any-action.sql new file mode 100644 index 00000000000..4b90516f235 --- /dev/null +++ b/apps/api/migrations/2027-01-18-120000-pax8-order-line-baseline-any-action.sql @@ -0,0 +1,4 @@ +-- Preserve the approved billing baseline for every line action across reconciliation. +ALTER TABLE pax8_order_lines DROP CONSTRAINT IF EXISTS pax8_order_lines_authorized_baseline_chk; +ALTER TABLE pax8_order_lines ADD CONSTRAINT pax8_order_lines_authorized_baseline_chk + CHECK (authorized_baseline_quantity IS NULL OR authorized_baseline_quantity >= 0); diff --git a/apps/api/src/db/schema/pax8.test.ts b/apps/api/src/db/schema/pax8.test.ts index adc7039a4d8..fb9b8abee67 100644 --- a/apps/api/src/db/schema/pax8.test.ts +++ b/apps/api/src/db/schema/pax8.test.ts @@ -33,3 +33,13 @@ describe('Pax8 quantity authorization baseline', () => { expect(migration).toMatch(/action = 'change_quantity'/i); }); }); + + +it('permits a durable approved billing baseline for every Pax8 line action', () => { + const migration = readFileSync(fileURLToPath(new URL( + '../../../migrations/2027-01-18-120000-pax8-order-line-baseline-any-action.sql', import.meta.url, + )), 'utf8'); + expect(migration).toMatch(/DROP CONSTRAINT IF EXISTS pax8_order_lines_authorized_baseline_chk/i); + expect(migration).toMatch(/CHECK \(authorized_baseline_quantity IS NULL OR authorized_baseline_quantity >= 0\)/i); + expect(migration).not.toMatch(/action\s*=|\b(?:INSERT|UPDATE|DELETE)\b/i); +}); diff --git a/apps/api/src/db/schema/pax8Orders.ts b/apps/api/src/db/schema/pax8Orders.ts index 12515a31c22..b0c3b90e61e 100644 --- a/apps/api/src/db/schema/pax8Orders.ts +++ b/apps/api/src/db/schema/pax8Orders.ts @@ -74,6 +74,9 @@ export const pax8OrderLines = pgTable('pax8_order_lines', { billingTerm: varchar('billing_term', { length: 20 }), commitmentTermId: varchar('commitment_term_id', { length: 64 }), quantity: numeric('quantity', { precision: 12, scale: 2 }), + // Linked contract line manual_quantity observed inside the claim transaction, + // for every action; null when no contract line is linked. Both submit and + // reconcile compare against this durable approved baseline before billing. authorizedBaselineQuantity: numeric('authorized_baseline_quantity', { precision: 12, scale: 2 }), provisioningDetails: jsonb('provisioning_details').notNull().default([]), targetSubscriptionId: varchar('target_subscription_id', { length: 64 }), diff --git a/apps/api/src/services/actionIntents/effectDigest.w15b.test.ts b/apps/api/src/services/actionIntents/effectDigest.w15b.test.ts new file mode 100644 index 00000000000..4269272c81b --- /dev/null +++ b/apps/api/src/services/actionIntents/effectDigest.w15b.test.ts @@ -0,0 +1,179 @@ +import { getTableColumns, getTableName } from 'drizzle-orm'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Database } from '../../db'; +import { computeEffectDigestOutcome, effectDigestResolverKey, requiresPinnedEffectDigest } from './effectDigest'; +import { hashPax8OrderMaterial, pax8OrderDigestMaterial } from './pax8OrderDigest'; + +const ORDER = '10000000-0000-4000-8000-000000000001'; +const PARTNER = '10000000-0000-4000-8000-000000000002'; +const ORG = '10000000-0000-4000-8000-000000000003'; +type Row = Record; +let rows: Record; +let database: Database; +let writes: ReturnType; +const first = (table: string) => rows[table]![0]!; + +beforeEach(() => { + rows = { + pax8_orders: [{ id: ORDER, partnerId: PARTNER, orgId: ORG, integrationId: 'fixture-integration', source: 'quote', sourceQuoteId: 'fixture-quote', status: 'draft', pax8CompanyId: null }], + pax8_order_lines: [{ id: 'fixture-line', orderId: ORDER, partnerId: PARTNER, orgId: ORG, action: 'change_quantity', submitState: 'pending', sortOrder: 0, pax8ProductId: 'fixture-product', catalogItemId: 'fixture-item', billingTerm: 'Monthly', commitmentTermId: 'fixture-term', quantity: '8.00', authorizedBaselineQuantity: '5.00', targetSubscriptionId: 'fixture-subscription', cancelDate: null, contractLineId: 'fixture-contract-line', provisioningDetails: [{ key: 'domain', values: ['example.test'] }] }], + pax8_integrations: [{ id: 'fixture-integration', partnerId: PARTNER, apiBaseUrl: 'https://vendor.example/v1', tokenUrl: 'https://vendor.example/token', isActive: true, clientIdEncrypted: 'fixture-id-ciphertext', clientSecretEncrypted: 'fixture-secret-ciphertext', accessTokenEncrypted: 'fixture-token', accessTokenExpiresAt: new Date() }], + pax8_company_mappings: [{ id: 'fixture-company-mapping', partnerId: PARTNER, integrationId: 'fixture-integration', orgId: ORG, pax8CompanyId: 'fixture-company', pax8CompanyName: 'Fixture Company', status: 'Active', ignored: false, metadata: { contacts: [{ types: ['Admin', 'Billing', 'Technical'].map(type => ({ type, primary: true })) }] } }], + pax8_subscription_snapshots: [{ id: 'fixture-snapshot', orgId: ORG, productId: 'fixture-product', productName: 'Fixture Subscription', quantity: '5.00', quantityKnown: true, unitCost: '7.50', unitPrice: '11.00', currencyCode: 'USD', billingTerm: 'Monthly', status: 'Active', raw: { commitmentTermId: 'fixture-term' } }], + pax8_contract_line_links: [{ contractLineId: 'fixture-contract-line', syncEnabled: true }], + pax8_product_mappings: [{ pax8ProductId: 'fixture-product', productName: 'Fixture Product', vendorSkuId: 'fixture-sku', catalogItemId: 'fixture-item' }], + contract_lines: [{ id: 'fixture-contract-line', contractId: 'fixture-contract', manualQuantity: '5.00', unitPrice: '15.00', lineType: 'manual', catalogItemId: 'fixture-item' }], + contracts: [{ id: 'fixture-contract', currencyCode: 'CAD' }], + catalog_items: [{ id: 'fixture-item', name: 'Fixture Item', sku: 'fixture-item-sku', itemType: 'software', isActive: true, costBasis: '6.00', costCurrency: 'USD', taxable: true }], + catalog_item_prices: [{ itemId: 'fixture-item', currencyCode: 'CAD', unitPrice: '16.00' }], + catalog_item_org_pricing: [{ catalogItemId: 'fixture-item', currencyCode: 'CAD', unitPrice: '14.00' }], + }; + writes = vi.fn(() => { throw new Error('digest must never write'); }); + const select = vi.fn((shape?: Record) => { + let table: Parameters[0]; + const chain: Record = {}; + chain.from = (value: typeof table) => { table = value; return chain; }; + for (const method of ['where', 'limit', 'orderBy']) chain[method] = () => chain; + chain.then = (resolve: (value: unknown) => unknown) => { + const columns = getTableColumns(table); + const result = (rows[getTableName(table)] ?? []).map(row => shape + ? Object.fromEntries(Object.entries(shape).map(([key, column]) => { + const sourceKey = Object.keys(columns).find(name => columns[name] === column); + return [key, sourceKey === undefined ? undefined : row[sourceKey]]; + })) : row); + return resolve(result); + }; + return chain; + }); + database = { select, update: writes, insert: writes, delete: writes } as unknown as Database; +}); + +async function digest() { + const material = await pax8OrderDigestMaterial(database, ORDER); + if (!material) throw new Error('missing fixture order'); + return hashPax8OrderMaterial(material); +} + +describe('Pax8 submit local effect material (W15 Q2/Q4/Q5/Q7)', () => { + it.each([ + ['line quantity', 'pax8_order_lines', 'quantity', '9.00'], + ['sortOrder / outbound lineItemNumber', 'pax8_order_lines', 'sortOrder', 1], + ['provisioning details', 'pax8_order_lines', 'provisioningDetails', [{ key: 'domain', values: ['changed.example.test'] }]], + ['commitmentTermId', 'pax8_order_lines', 'commitmentTermId', 'fixture-other-term'], + ['snapshot unitCost', 'pax8_subscription_snapshots', 'unitCost', '8.00'], + ['derived active commitment', 'pax8_subscription_snapshots', 'raw', { commitmentTermId: 'fixture-other-term' }], + ['manual billing quantity', 'contract_lines', 'manualQuantity', '6.00'], + ['contract unitPrice', 'contract_lines', 'unitPrice', '17.00'], + ['contract currency', 'contracts', 'currencyCode', 'USD'], + ['contract-currency catalog price', 'catalog_item_prices', 'unitPrice', '18.00'], + ['org catalog price override', 'catalog_item_org_pricing', 'unitPrice', '19.00'], + ['company mapping', 'pax8_company_mappings', 'pax8CompanyId', 'fixture-other-company'], + ['company name shown on card', 'pax8_company_mappings', 'pax8CompanyName', 'Changed Fixture'], + ['company readiness', 'pax8_company_mappings', 'metadata', {}], + ['integration URL', 'pax8_integrations', 'apiBaseUrl', 'https://other-vendor.example/v1'], + ['integration token URL', 'pax8_integrations', 'tokenUrl', 'https://other-vendor.example/token'], + ['credential ciphertext', 'pax8_integrations', 'clientSecretEncrypted', 'fixture-changed-ciphertext'], + ['product name shown on card', 'pax8_product_mappings', 'productName', 'Changed Fixture Product'], + ['contract link', 'pax8_contract_line_links', 'contractLineId', 'fixture-other-contract-line'], + ['syncEnabled', 'pax8_contract_line_links', 'syncEnabled', false], + ])('changes when %s changes', async (_name, table, field, value) => { + const before = await digest(); + first(table as string)[field as string] = value; + expect(await digest()).not.toBe(before); + expect(writes).not.toHaveBeenCalled(); + }); + + it.each(['manualQuantity', 'unitPrice'])('quote-created new lines pin linked contract %s', async field => { + Object.assign(first('pax8_order_lines'), { action: 'new_subscription', targetSubscriptionId: null, authorizedBaselineQuantity: null }); + const before = await digest(); + first('contract_lines')[field] = '21.00'; + expect(await digest()).not.toBe(before); + }); + + it('pins catalog item / product mapping for new lines', async () => { + Object.assign(first('pax8_order_lines'), { action: 'new_subscription', targetSubscriptionId: null }); + const before = await digest(); + first('catalog_items').isActive = false; + expect(await digest()).not.toBe(before); + first('catalog_items').isActive = true; + first('pax8_product_mappings').catalogItemId = 'fixture-other-item'; + expect(await digest()).not.toBe(before); + }); + + it('does not change for sync timestamps, token refresh, irrelevant raw or metadata', async () => { + const before = await digest(); + first('pax8_subscription_snapshots').lastSeenAt = new Date('2026-10-31'); + first('pax8_subscription_snapshots').updatedAt = new Date('2026-10-31'); + first('pax8_subscription_snapshots').raw = { commitmentTermId: 'fixture-term', unrelated: 'noise' }; + first('pax8_integrations').accessTokenEncrypted = 'fixture-refreshed-token'; + first('pax8_integrations').accessTokenExpiresAt = new Date('2026-11-01'); + first('pax8_company_mappings').metadata = { ...(first('pax8_company_mappings').metadata as object), irrelevant: true }; + expect(await digest()).toBe(before); + }); + + it.each(['draft', 'awaiting_details', 'ready'])('claim from %s does not change its own digest', async status => { + first('pax8_orders').status = status; + const before = await digest(); + Object.assign(first('pax8_orders'), { status: 'submitting', pax8CompanyId: 'fixture-company', submittedAt: new Date(), updatedAt: new Date() }); + expect(await digest()).toBe(before); + first('pax8_order_lines').submitState = 'in_flight'; + expect(await digest()).not.toBe(before); + }); + + it.each(['new_subscription', 'cancel'])('claim baseline capture is normalized for %s', async action => { + Object.assign(first('pax8_order_lines'), { action, authorizedBaselineQuantity: null }); + const before = await digest(); + first('pax8_order_lines').authorizedBaselineQuantity = '5.00'; + expect(await digest()).toBe(before); + first('contract_lines').manualQuantity = '6.00'; + expect(await digest()).not.toBe(before); + }); + + it('canonicalizes provisioning object-key order', async () => { + const before = await digest(); + first('pax8_order_lines').provisioningDetails = [{ values: ['example.test'], key: 'domain' }]; + expect(await digest()).toBe(before); + }); + + it('pins absent rows as null and detects a row appearing or disappearing', async () => { + const before = await digest(); + rows.contract_lines = []; + expect(await digest()).not.toBe(before); + rows.catalog_item_prices = []; + const absent = await digest(); + rows.catalog_item_prices = [{ itemId: 'fixture-item', currencyCode: 'CAD', unitPrice: '22.00' }]; + // No linked contract remains, so contract-currency prices are no longer used. + expect(await digest()).toBe(absent); + }); + + it('returns only opaque credential identity, derived raw evidence and no timestamp churn', async () => { + const material = await pax8OrderDigestMaterial(database, ORDER); + const json = JSON.stringify(material); + for (const forbidden of ['fixture-id-ciphertext', 'fixture-secret-ciphertext', 'fixture-token', 'Encrypted', '"raw"', '"metadata"', 'lastSeenAt', 'updatedAt']) { + expect(json).not.toContain(forbidden); + } + expect(json).toContain('credentialGeneration'); + expect(json).toContain('fixture-term'); + }); +}); + +describe('manage_pax8_orders:submit resolver and mandatory pin', () => { + it('requires a submit pin and registers an on-demand resolver', () => { + expect(requiresPinnedEffectDigest('manage_pax8_orders', { action: 'submit' })).toBe(true); + expect(requiresPinnedEffectDigest('manage_pax8_orders', { action: 'reconcile' })).toBe(false); + expect(effectDigestResolverKey('manage_pax8_orders', 'submit')).toBe('manage_pax8_orders:submit'); + }); + + it('resolver hashes the exact preview material envelope', async () => { + const result = await computeEffectDigestOutcome('manage_pax8_orders', { action: 'submit', orderId: ORDER }, database); + expect(result).toEqual({ kind: 'pinned', digest: await digest() }); + }); + + it('returns MISSING_ARG and TARGET_ABSENT without risking a malformed UUID query', async () => { + expect(await computeEffectDigestOutcome('manage_pax8_orders', { action: 'submit' }, database)).toEqual({ kind: 'unresolved', reason: 'missing_arg' }); + expect(await computeEffectDigestOutcome('manage_pax8_orders', { action: 'submit', orderId: 'bad-id' }, database)).toEqual({ kind: 'unresolved', reason: 'target_absent' }); + rows.pax8_orders = []; + expect(await pax8OrderDigestMaterial(database, ORDER)).toBeNull(); + expect(await computeEffectDigestOutcome('manage_pax8_orders', { action: 'submit', orderId: ORDER }, database)).toEqual({ kind: 'unresolved', reason: 'target_absent' }); + }); +}); diff --git a/apps/api/src/services/pax8OrderSubmit.test.ts b/apps/api/src/services/pax8OrderSubmit.test.ts index c60905488c7..9bd996ef831 100644 --- a/apps/api/src/services/pax8OrderSubmit.test.ts +++ b/apps/api/src/services/pax8OrderSubmit.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { RunOutsideDbContextFn } from '../db'; +import { Pax8IntegrationGenerationChangedError } from './pax8SyncService'; import { Pax8ApiError } from './pax8Client'; import { createPax8OrderSubmitService, @@ -98,6 +99,7 @@ function setup( claimOrder: vi.fn().mockResolvedValue(claimed), createClient: vi.fn().mockResolvedValue(client), claimLines: vi.fn().mockResolvedValue(undefined), + reopenUnsentOrder: vi.fn().mockResolvedValue({ orderId: 'ord-1', status: 'draft', lines: [] }), persistPreflightFailure: vi.fn().mockImplementation(async (_bundle, errorBody) => resultFor(claimed.lines.map((line) => ({ lineId: line.id, @@ -592,3 +594,55 @@ describe('reconcileOrder', () => { ); }); }); + + +describe('approved submit options', () => { + it('verifyClaim refuses drift before any client or line claim', async () => { + const { service, repository, client } = setup(); + const verifyClaim = vi.fn().mockRejectedValue(new Error('PREVIEW_MISMATCH')); + vi.mocked(repository.claimOrder).mockImplementation(async (input) => { + await input.verifyClaim?.(bundle(), {} as never); + return bundle(); + }); + await expect(service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1', verifyClaim })) + .rejects.toThrow('PREVIEW_MISMATCH'); + expect(repository.createClient).not.toHaveBeenCalled(); + expect(repository.claimLines).not.toHaveBeenCalled(); + expect(client.createOrder).not.toHaveBeenCalled(); + }); + + it.each(['draft', 'awaiting_details', 'ready'] as const)('verifies an unchanged %s order before isMock', async (status) => { + const { service, repository, client } = setup(); + const verifyClaim = vi.fn().mockResolvedValue(undefined); + vi.mocked(repository.claimOrder).mockImplementation(async (input) => { + const claimed = { ...bundle(), preClaimStatus: status }; + await input.verifyClaim?.(claimed, {} as never); + return claimed; + }); + await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1', verifyClaim }); + expect(verifyClaim).toHaveBeenCalledOnce(); + expect(verifyClaim).toHaveBeenCalledBefore(client.createOrder); + }); + + it('reopens a failed post-claim isMock without terminally failing or sending real writes', async () => { + const { service, repository, client } = setup(); + client.createOrder.mockRejectedValueOnce(new Pax8ApiError('validation', 422, 'invalid provisioning')); + const result = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1', onPreflightFailure: 'reopen' }); + expect(result.status).toBe('draft'); + expect(result.refusalCode).toBe('PREFLIGHT_FAILED'); + expect(repository.reopenUnsentOrder).toHaveBeenCalledWith(expect.anything(), 'invalid provisioning'); + expect(repository.persistPreflightFailure).not.toHaveBeenCalled(); + expect(repository.claimLines).not.toHaveBeenCalled(); + expect(client.createOrder).toHaveBeenCalledTimes(1); + }); + + it('reopens an integration-generation refusal before any HTTP', async () => { + const { service, repository, client } = setup(); + vi.mocked(repository.createClient).mockRejectedValue(new Pax8IntegrationGenerationChangedError()); + const result = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1', onPreflightFailure: 'reopen' }); + expect(result.refusalCode).toBe('INTEGRATION_CHANGED'); + expect(repository.reopenUnsentOrder).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('integration configuration changed')); + expect(repository.persistPreflightFailure).not.toHaveBeenCalled(); + expect(client.createOrder).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/services/pax8OrderSubmit.ts b/apps/api/src/services/pax8OrderSubmit.ts index 992bf409a65..1933623e891 100644 --- a/apps/api/src/services/pax8OrderSubmit.ts +++ b/apps/api/src/services/pax8OrderSubmit.ts @@ -1,5 +1,6 @@ import type { Pax8OrderStatus, Pax8SubmitState } from '@breeze/shared'; -import { runOutsideDbContext } from '../db'; +import { runOutsideDbContext, type Database } from '../db'; +import { Pax8IntegrationGenerationChangedError, type Pax8IntegrationSnapshot } from './pax8SyncService'; import type { Pax8OrderLineRow, Pax8OrderRow } from './pax8OrderService'; import { Pax8OrderError } from './pax8OrderService'; import { @@ -15,6 +16,19 @@ import { pax8OrderSubmitRepository } from './pax8OrderSubmitRepository'; export interface SubmitBundle { order: Pax8OrderRow; lines: Pax8OrderLineRow[]; + /** Captured inside the claim transaction; never exposed in tool output. */ + integrationGeneration?: Pax8IntegrationSnapshot; + preClaimStatus?: 'draft' | 'awaiting_details' | 'ready'; +} + +export type VerifyClaim = (bundle: SubmitBundle, database: Database) => Promise; + +export interface SubmitOrderInput { + partnerId: string; + orderId: string; + actorUserId: string; + verifyClaim?: VerifyClaim; + onPreflightFailure?: 'terminal' | 'reopen'; } export interface SubmitLineOutcome { @@ -27,14 +41,17 @@ export interface SubmitLineOutcome { export interface SubmitResult { orderId: string; status: Pax8OrderStatus; + refusalCode?: 'INTEGRATION_CHANGED' | 'PREFLIGHT_FAILED'; + error?: string; 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; + claimOrder(input: SubmitOrderInput): Promise; createClient(bundle: SubmitBundle): Promise; claimLines(bundle: SubmitBundle): Promise; + reopenUnsentOrder(bundle: SubmitBundle, error: string): Promise; persistPreflightFailure(bundle: SubmitBundle, errorBody: string): Promise; persistSubmitResults( bundle: SubmitBundle, @@ -352,17 +369,27 @@ export function createPax8OrderSubmitService(deps: ServiceDeps) { return preflightBundle(bundle, client, deps.runOutsideDbContext); }, - async submitOrder(input: { partnerId: string; orderId: string; actorUserId: string }): Promise { + async submitOrder(input: SubmitOrderInput): Promise { const bundle = await deps.repository.claimOrder(input); + const preflightFailure = async ( + error: string, + refusalCode: 'INTEGRATION_CHANGED' | 'PREFLIGHT_FAILED' = 'PREFLIGHT_FAILED', + ): Promise => { + if (input.onPreflightFailure !== 'reopen') return deps.repository.persistPreflightFailure(bundle, error); + const clipped = error.slice(0, 2048); + const result = await deps.repository.reopenUnsentOrder(bundle, clipped); + return { ...result, refusalCode, error: clipped }; + }; let client: Pax8Client; try { client = await deps.repository.createClient(bundle); } catch (error) { - return deps.repository.persistPreflightFailure(bundle, errorText(error)); + return preflightFailure(errorText(error), error instanceof Pax8IntegrationGenerationChangedError + ? 'INTEGRATION_CHANGED' : 'PREFLIGHT_FAILED'); } const preflight = await preflightBundle(bundle, client, deps.runOutsideDbContext); if (!preflight.ok) { - return deps.repository.persistPreflightFailure(bundle, preflight.errorBody); + return preflightFailure(preflight.errorBody); } await deps.repository.claimLines(bundle); const execution = await deps.runOutsideDbContext(() => executeWrites(bundle, client)); diff --git a/apps/api/src/services/pax8OrderSubmitRepository.test.ts b/apps/api/src/services/pax8OrderSubmitRepository.test.ts index 298a6052c77..7824a186dbc 100644 --- a/apps/api/src/services/pax8OrderSubmitRepository.test.ts +++ b/apps/api/src/services/pax8OrderSubmitRepository.test.ts @@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({ db: { select: vi.fn(), update: vi.fn() }, validateLines: vi.fn(), events: [] as string[], + createClient: vi.fn(), })); vi.mock('../db', () => ({ @@ -31,7 +32,10 @@ vi.mock('./pax8OrderService', () => { }; }); -vi.mock('./pax8SyncService', () => ({ createPax8ClientForIntegration: vi.fn() })); +vi.mock('./pax8SyncService', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createPax8ClientForIntegration: mocks.createClient }; +}); import { pax8OrderSubmitRepository } from './pax8OrderSubmitRepository'; @@ -53,15 +57,16 @@ const order = { function selectChain(rows: unknown[], terminal: 'limit' | 'for' | 'orderBy', event?: string) { const chain: Record = {}; + chain.then = Promise.resolve(rows).then.bind(Promise.resolve(rows)); 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 () => { + chain.for = vi.fn(() => { if (terminal === 'for' && event) mocks.events.push(event); - return rows; + return Object.assign(Promise.resolve(rows), { limit: chain.limit }); }); chain.orderBy = vi.fn(async () => { if (terminal === 'orderBy' && event) mocks.events.push(event); @@ -74,6 +79,7 @@ beforeEach(() => { mocks.db.select.mockReset(); mocks.db.update.mockReset(); mocks.validateLines.mockReset(); + mocks.createClient.mockReset(); mocks.events.length = 0; }); @@ -95,7 +101,8 @@ describe('pax8OrderSubmitRepository.claimOrder', () => { .mockReturnValueOnce(selectChain([{ pax8CompanyId: 'company-1', status: 'Active', metadata: READY_METADATA, }], 'for', 'company-locked')) - .mockReturnValueOnce(selectChain([patchedLine], 'orderBy', 'lines-read')); + .mockReturnValueOnce(selectChain([patchedLine], 'orderBy', 'lines-read')) + .mockReturnValueOnce(selectChain([integration], 'limit')); const returning = vi.fn(async () => { mocks.events.push('parent-claimed'); return [{ ...order, status: 'submitting', submittedAt: new Date() }]; @@ -196,3 +203,116 @@ describe('pax8OrderSubmitRepository.claimOrder', () => { expect(mocks.db.update).toHaveBeenCalledTimes(1); }); }); + + +const integration = { + id: order.integrationId, partnerId: order.partnerId, isActive: true, + apiBaseUrl: 'https://api.example.test', tokenUrl: 'https://auth.example.test/token', + clientIdEncrypted: 'synthetic-client-ciphertext', clientSecretEncrypted: 'synthetic-secret-ciphertext', +} as import('./pax8SyncService').Pax8IntegrationSnapshot; + +const line = { + id: 'line-1', orderId: order.id, partnerId: order.partnerId, orgId: order.orgId, + action: 'new_subscription', submitState: 'pending', contractLineId: 'contract-line-1', + quantity: '7.00', authorizedBaselineQuantity: null, sortOrder: 0, +} as import('./pax8OrderService').Pax8OrderLineRow; + +function mutation(rows: unknown[]) { + const returning = vi.fn().mockResolvedValue(rows); + const where = vi.fn((_condition: unknown) => ({ returning })); + const set = vi.fn(() => ({ where })); + return { set, where, returning }; +} + +function prepareClaim(action: string = 'new_subscription', status: string = 'ready') { + const original = { ...order, status }; + const claimed = { ...original, status: 'submitting', submittedAt: new Date('2026-10-31T12:00:00Z') }; + const staged = { ...line, action }; + mocks.db.select + .mockReturnValueOnce(selectChain([original], 'limit')) + .mockReturnValueOnce(selectChain([original], 'limit')) + .mockReturnValueOnce(selectChain([{ pax8CompanyId: 'company-1', status: 'Active', metadata: READY_METADATA }], 'for')) + .mockReturnValueOnce(selectChain([staged], 'orderBy')) + .mockReturnValueOnce(selectChain([integration], 'limit')) + .mockReturnValueOnce(selectChain([{ id: line.contractLineId, manualQuantity: '4.00', lineType: 'manual' }], 'limit')); + const parent = mutation([claimed]); + const baseline = mutation([{ id: line.id }]); + mocks.db.update.mockReturnValueOnce(parent).mockReturnValueOnce(baseline); + mocks.validateLines.mockImplementation(async (_order, lines) => { mocks.events.push('validated'); return lines; }); + return { parent, baseline, claimed }; +} + +it.each(['new_subscription', 'change_quantity', 'cancel'])('captures and durably stores the %s approved baseline before verifyClaim', async (action) => { + const { baseline } = prepareClaim(action); + const verifyClaim = vi.fn(async (bundle: import('./pax8OrderSubmit').SubmitBundle) => { + expect(mocks.events).toContain('validated'); + expect(bundle.lines[0].authorizedBaselineQuantity).toBe('4.00'); + expect(baseline.returning).toHaveBeenCalled(); + }); + const bundle = await pax8OrderSubmitRepository.claimOrder({ partnerId: order.partnerId, orderId: order.id, actorUserId: 'actor-1', verifyClaim }); + expect(verifyClaim).toHaveBeenCalledWith(bundle, mocks.db); + expect(bundle.preClaimStatus).toBe('ready'); + expect(bundle.integrationGeneration).toEqual(integration); + expect(baseline.set).toHaveBeenCalledWith(expect.objectContaining({ authorizedBaselineQuantity: '4.00' })); +}); + +it('propagates verifyClaim drift inside the claim context without constructing a client', async () => { + prepareClaim(); + await expect(pax8OrderSubmitRepository.claimOrder({ + partnerId: order.partnerId, orderId: order.id, actorUserId: 'actor-1', + verifyClaim: async () => { throw new Error('PREVIEW_MISMATCH'); }, + })).rejects.toThrow('PREVIEW_MISMATCH'); + expect(mocks.createClient).not.toHaveBeenCalled(); +}); + +it.each(['apiBaseUrl', 'tokenUrl', 'clientIdEncrypted', 'clientSecretEncrypted', 'isActive'] as const)('refuses a client when the claimed integration %s changed', async (field) => { + mocks.db.select.mockReturnValueOnce(selectChain([], 'limit')); + mocks.createClient.mockResolvedValue({ integration: { ...integration, [field]: field === 'isActive' ? false : 'changed' }, client: {} }); + await expect(pax8OrderSubmitRepository.createClient({ order, lines: [], integrationGeneration: integration })) + .rejects.toMatchObject({ name: 'Pax8IntegrationGenerationChangedError' }); +}); + +it('accepts access-token churn without changing the claimed integration generation', async () => { + const client = {}; + mocks.db.select.mockReturnValueOnce(selectChain([integration], 'limit')); + mocks.createClient.mockResolvedValue({ integration: { ...integration, accessTokenEncrypted: 'refreshed' }, client }); + await expect(pax8OrderSubmitRepository.createClient({ order, lines: [], integrationGeneration: integration })).resolves.toBe(client); +}); + +it.each(['draft', 'awaiting_details', 'ready'])('reopens a provably unsent claim to %s with pending lines', async (preClaimStatus) => { + const claimed = { ...order, status: 'submitting', submittedAt: new Date('2026-10-31T12:00:00Z') }; + mocks.db.select.mockReturnValueOnce(selectChain([claimed], 'limit')).mockReturnValueOnce(selectChain([line], 'limit')); + const reset = mutation([{ id: order.id }]); + mocks.db.update.mockReturnValueOnce(reset); + const result = await pax8OrderSubmitRepository.reopenUnsentOrder({ order: claimed, lines: [line], preClaimStatus: preClaimStatus as never }, 'preflight failed'); + expect(result.status).toBe(preClaimStatus); + expect(result.lines[0]?.submitState).toBe('pending'); + expect(reset.set).toHaveBeenCalledWith(expect.objectContaining({ status: preClaimStatus, submittedBy: null, submittedAt: null })); +}); + +it('refuses to reopen after a line has entered in_flight', async () => { + const claimed = { ...order, status: 'submitting', submittedAt: new Date() }; + mocks.db.select.mockReturnValueOnce(selectChain([claimed], 'limit')).mockReturnValueOnce(selectChain([{ ...line, submitState: 'in_flight' }], 'limit')); + await expect(pax8OrderSubmitRepository.reopenUnsentOrder({ order: claimed, lines: [line], preClaimStatus: 'draft' }, 'failed')).rejects.toMatchObject({ status: 409 }); + expect(mocks.db.update).not.toHaveBeenCalled(); +}); + +it.each(['persistSubmitResults', 'persistReconcileResults'] as const)('%s preserves a succeeded remote outcome on a billing CAS conflict', async (method) => { + const claimed = { ...order, status: 'submitting', submittedAt: new Date() }; + const sentLine = { ...line, authorizedBaselineQuantity: '4.00', submitState: 'in_flight' }; + const recorded = { ...sentLine, submitState: 'succeeded', error: 'billing_conflict' }; + mocks.db.select.mockReturnValueOnce(selectChain([claimed], 'limit')) + .mockReturnValueOnce(selectChain([recorded], 'limit')); + // The billing CAS sees a concurrent manual quantity edit and matches no row. + const billingWrite = mutation([]); + const lineWrite = mutation([{ id: line.id }]); + const parentWrite = mutation([{ id: order.id }]); + mocks.db.update.mockReturnValueOnce(billingWrite).mockReturnValueOnce(lineWrite).mockReturnValueOnce(parentWrite); + await pax8OrderSubmitRepository[method]({ order: claimed, lines: [sentLine] }, [{ lineId: line.id, submitState: 'succeeded', error: null, resultSubscriptionId: 'synthetic-subscription' }], 'synthetic-order'); + expect(billingWrite.set).toHaveBeenCalledWith({ manualQuantity: '7.00' }); + expect(lineWrite.set).toHaveBeenCalledWith(expect.objectContaining({ submitState: 'succeeded', resultSubscriptionId: 'synthetic-subscription', error: expect.stringContaining('billing_conflict') })); + const { PgDialect } = await import('drizzle-orm/pg-core'); + const query = new PgDialect().sqlToQuery(billingWrite.where.mock.calls[0]![0] as never); + expect(query.sql).toContain('"manual_quantity" ='); + expect(query.params).toContain('4.00'); +}); diff --git a/apps/api/src/services/pax8OrderSubmitRepository.ts b/apps/api/src/services/pax8OrderSubmitRepository.ts index b5469c24eed..5b75b186a7e 100644 --- a/apps/api/src/services/pax8OrderSubmitRepository.ts +++ b/apps/api/src/services/pax8OrderSubmitRepository.ts @@ -10,6 +10,7 @@ import { contractLines, pax8CompanyMappings, pax8OrderLines, + pax8Integrations, pax8Orders, } from '../db/schema'; import { @@ -26,7 +27,7 @@ import type { SubmitLineOutcome, SubmitResult, } from './pax8OrderSubmit'; -import { createPax8ClientForIntegration } from './pax8SyncService'; +import { createPax8ClientForIntegration, integrationGenerationPredicate, Pax8IntegrationGenerationChangedError } from './pax8SyncService'; import { pax8CompanyOrderReadiness } from './pax8CompanyReadiness'; const SUBMITTABLE_STATUSES = ['draft', 'awaiting_details', 'ready'] as const; @@ -186,24 +187,11 @@ async function lockExecutingOrder(bundle: SubmitBundle): Promise { 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; +/** The remote result must survive a billing edit while the request was in flight. */ +async function updateManualContractQuantity(line: Pax8OrderLineRow): Promise { + if (!line.contractLineId) return true; const quantity = line.action === 'cancel' ? '0' : line.quantity; - if (quantity === null) return; + if (quantity === null) return false; const updated = await db .update(contractLines) .set({ manualQuantity: quantity }) @@ -211,11 +199,12 @@ async function updateManualContractQuantity( eq(contractLines.id, line.contractLineId), eq(contractLines.orgId, line.orgId), eq(contractLines.lineType, 'manual' as never), + line.authorizedBaselineQuantity === null + ? isNull(contractLines.manualQuantity) + : eq(contractLines.manualQuantity, line.authorizedBaselineQuantity), )) .returning({ id: contractLines.id }); - if (updated.length !== 1) { - throw new Pax8OrderError('The linked manual contract line changed before billing quantity was recorded.', 409); - } + return updated.length === 1; } async function persistLineOutcomes( @@ -233,6 +222,9 @@ async function persistLineOutcomes( const stateCondition = typeof expectedState === 'string' ? eq(pax8OrderLines.submitState, expectedState) : inArray(pax8OrderLines.submitState, [...expectedState]); + if (outcome.submitState === 'succeeded' && !await updateManualContractQuantity(line)) { + outcome.error = 'billing_conflict: The linked billing quantity changed after approval; the Pax8 write succeeded and requires billing review.'; + } const updated = await db .update(pax8OrderLines) .set({ @@ -252,7 +244,6 @@ async function persistLineOutcomes( 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); } } @@ -376,7 +367,39 @@ export const pax8OrderSubmitRepository: Pax8OrderSubmitRepository = { 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; + const [integrationGeneration] = await db.select().from(pax8Integrations) + .where(and(eq(pax8Integrations.id, claimed.integrationId), eq(pax8Integrations.partnerId, claimed.partnerId))) + .limit(1); + if (!integrationGeneration || !integrationGeneration.isActive) { + throw new Pax8OrderError('The Pax8 integration is unavailable.', 409); + } + // Keep the approved billing baseline durable across a timeout/crash for + // every action, including cancels and quote-created subscriptions. + for (const line of lines) { + let baseline: string | null = null; + if (line.contractLineId) { + const [linked] = await db.select({ manualQuantity: contractLines.manualQuantity }) + .from(contractLines) + .where(and(eq(contractLines.id, line.contractLineId), eq(contractLines.orgId, claimed.orgId))) + .for('share').limit(1); + if (!linked) throw new Pax8OrderError('The linked contract line is unavailable.', 409); + baseline = linked.manualQuantity; + } + const captured = await db.update(pax8OrderLines) + .set({ authorizedBaselineQuantity: baseline, updatedAt: new Date() }) + .where(and(eq(pax8OrderLines.id, line.id), eq(pax8OrderLines.orderId, claimed.id), + eq(pax8OrderLines.partnerId, claimed.partnerId), eq(pax8OrderLines.orgId, claimed.orgId), + eq(pax8OrderLines.submitState, 'pending'))) + .returning({ id: pax8OrderLines.id }); + if (captured.length !== 1) throw new Pax8OrderError('The Pax8 billing baseline could not be captured.', 409); + line.authorizedBaselineQuantity = baseline; + } + const bundle: SubmitBundle = { + order: claimed, lines, integrationGeneration, + preClaimStatus: existing.status as SubmitBundle['preClaimStatus'], + }; + await input.verifyClaim?.(bundle, db); + return { bundle } as const; }); if ('restageError' in outcome) throw outcome.restageError; return outcome.bundle; @@ -384,10 +407,20 @@ export const pax8OrderSubmitRepository: Pax8OrderSubmitRepository = { createClient(bundle) { return withPartnerDbContext(bundle.order.partnerId, async () => { + if (bundle.integrationGeneration) { + const [current] = await db.select({ id: pax8Integrations.id }).from(pax8Integrations) + .where(integrationGenerationPredicate(bundle.integrationGeneration)).for('share').limit(1); + if (!current) throw new Pax8IntegrationGenerationChangedError(); + } 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); } + const expected = bundle.integrationGeneration; + if (expected && (['id', 'partnerId', 'apiBaseUrl', 'tokenUrl', 'clientIdEncrypted', 'clientSecretEncrypted', 'isActive'] as const) + .some((field) => created.integration[field] !== expected[field])) { + throw new Pax8IntegrationGenerationChangedError(); + } return created.client; }); }, @@ -414,6 +447,30 @@ export const pax8OrderSubmitRepository: Pax8OrderSubmitRepository = { }); }, + reopenUnsentOrder(bundle, error) { + return withOrderDbContext(bundle, async () => { + await lockExecutingOrder(bundle); + const lines = await reloadLines(bundle); + if (!bundle.preClaimStatus || lines.length === 0 || lines.length !== bundle.lines.length + || lines.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: bundle.preClaimStatus, submittedBy: null, submittedAt: null, + error: error.slice(0, 2048), 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'), eq(pax8Orders.submittedAt, bundle.order.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 (updated.length !== 1) throw new Pax8OrderError('The unsent Pax8 order could not be reopened.', 409); + return resultFromRows(bundle.order.id, bundle.preClaimStatus, lines); + }); + }, + persistPreflightFailure(bundle, errorBody) { return withOrderDbContext(bundle, async () => { await lockExecutingOrder(bundle); diff --git a/apps/api/src/services/pax8SyncService.ts b/apps/api/src/services/pax8SyncService.ts index 4566b668a5b..3b2267bd9d7 100644 --- a/apps/api/src/services/pax8SyncService.ts +++ b/apps/api/src/services/pax8SyncService.ts @@ -28,9 +28,9 @@ function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } -type Pax8IntegrationSnapshot = typeof pax8Integrations.$inferSelect; +export type Pax8IntegrationSnapshot = typeof pax8Integrations.$inferSelect; -class Pax8IntegrationGenerationChangedError extends Error { +export class Pax8IntegrationGenerationChangedError extends Error { constructor() { super('Pax8 integration configuration changed during sync'); this.name = 'Pax8IntegrationGenerationChangedError'; @@ -42,7 +42,7 @@ class Pax8IntegrationGenerationChangedError extends Error { * Re-entering the same plaintext creates fresh ciphertext, so this exact tuple * is ABA-safe without adding a separate schema revision. */ -function integrationGenerationPredicate(integration: Pax8IntegrationSnapshot) { +export function integrationGenerationPredicate(integration: Pax8IntegrationSnapshot) { return and( eq(pax8Integrations.id, integration.id), eq(pax8Integrations.partnerId, integration.partnerId), From 30531640d9c4bf7620d9370683d23605f1af9267 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Sat, 31 Oct 2026 11:40:41 -0600 Subject: [PATCH 02/12] feat(ai-tools): pinned four-eyes Pax8 submit and supervised reconcile (#6785) Pin local order, billing and destination inputs; re-check live price and commitment evidence and verify the preview under the claim lock before isMock. Expose truthful estimated-cost and terminal-cancel approval copy, reopen unsent failures, and retain reconciliation hints for unknown remote outcomes. Co-Authored-By: Claude Fable 5.1 --- .../__tests__/parkedFanout.contract.test.ts | 8 + .../src/services/actionIntents/actionLabel.ts | 13 ++ .../services/actionIntents/effectDigest.ts | 11 ++ .../actionIntents/effectDigest.w15b.test.ts | 1 + .../services/actionIntents/intentService.ts | 4 +- .../services/actionIntents/pax8OrderDigest.ts | 164 ++++++++++++++++++ .../actionIntents/pinnedEffectPolicy.ts | 1 + ...Guardrails.agentPrincipal.contract.test.ts | 2 + .../src/services/aiGuardrails.pax8.test.ts | 10 +- apps/api/src/services/aiGuardrails.ts | 13 +- apps/api/src/services/aiToolExposure.ts | 1 + apps/api/src/services/aiToolMfaGate.ts | 2 +- apps/api/src/services/aiToolSchemasPax8.ts | 17 +- .../services/aiToolsPax8OrderSubmit.test.ts | 128 ++++++++++++++ .../aiToolsPax8Orders.handlers.test.ts | 2 +- apps/api/src/services/aiToolsPax8Orders.ts | 74 +++++++- .../api/src/services/pax8OrderPreview.test.ts | 20 ++- apps/api/src/services/pax8OrderPreview.ts | 32 +++- apps/api/src/services/pax8OrderSubmit.test.ts | 10 ++ apps/api/src/services/pax8OrderSubmit.ts | 10 +- .../pax8OrderSubmitRepository.test.ts | 2 +- 21 files changed, 493 insertions(+), 32 deletions(-) create mode 100644 apps/api/src/services/actionIntents/pax8OrderDigest.ts create mode 100644 apps/api/src/services/aiToolsPax8OrderSubmit.test.ts diff --git a/apps/api/src/__tests__/parkedFanout.contract.test.ts b/apps/api/src/__tests__/parkedFanout.contract.test.ts index e55100acca6..ab36719b8db 100644 --- a/apps/api/src/__tests__/parkedFanout.contract.test.ts +++ b/apps/api/src/__tests__/parkedFanout.contract.test.ts @@ -106,6 +106,7 @@ function discovered(): string[] { 'services/partnerGroupPreview.ts', // W15 (#6785): explicitly classify this billing-only service as required by the wave contract. 'services/pax8OrderPreview.ts', + 'services/actionIntents/pax8OrderDigest.ts', ]); for (const file of [...JOB_FILES, ...SERVICE_FILES, ...EE_FILES]) { const code = read(file); @@ -212,6 +213,7 @@ function exemptByPattern(file: string): boolean { /** Cannot reach a parked device, or must (removal and retention). */ const EXEMPT: Record = { + 'services/actionIntents/pax8OrderDigest.ts': 'W15 (#6785): one Pax8 order approval digest; billing evidence with no device access or fan-out', 'services/pax8OrderPreview.ts': 'W15 (#6785): read-only Pax8 order billing evidence; no device access or fan-out', 'services/webhookPayload/alertEnrichment.ts': 'enriches the webhook payload of one already-raised alert by that alert\'s own device_id; selects no targets', 'services/manualAssetOps.ts': 'manual (non-networked) inventory CRUD (#4622, lifted from routes/devices/manual.ts): reads agent devices only to resolve the ONE device id a link request names (same-org + same-site check); selects no fan-out targets and runs nothing', @@ -493,6 +495,12 @@ describe('contract: background selectors leave parked devices out', () => { expect(DEVICE_READ.test(read('services/pax8OrderPreview.ts'))).toBe(false); }); + it('W15 classifies the Pax8 order digest as billing evidence with no device fan-out', () => { + expect(found).toContain('services/actionIntents/pax8OrderDigest.ts'); + expect(EXEMPT['services/actionIntents/pax8OrderDigest.ts']).toContain('no device'); + expect(DEVICE_READ.test(read('services/actionIntents/pax8OrderDigest.ts'))).toBe(false); + }); + it('discovers the expected shape of the tree (not vacuous)', () => { expect(found.length).toBeGreaterThan(60); expect(found).toContain('jobs/cisJobs.ts'); diff --git a/apps/api/src/services/actionIntents/actionLabel.ts b/apps/api/src/services/actionIntents/actionLabel.ts index 7505ffc86aa..b349cf92b2a 100644 --- a/apps/api/src/services/actionIntents/actionLabel.ts +++ b/apps/api/src/services/actionIntents/actionLabel.ts @@ -22,6 +22,17 @@ export function titleCaseWords(snake: string): string { .join(' '); } +/** Call-specific four-eyes copy: every cancellation is terminal and names its term. */ +export function describePax8Submit(input: Record): string { + const text = (value: unknown) => typeof value === 'string' ? value.replace(/[\r\n\t]/g, ' ').slice(0, 255) : '?'; + const company = input.expectedCompany && typeof input.expectedCompany === 'object' + ? input.expectedCompany as Record : {}; + const lines = Array.isArray(input.expectedLines) ? input.expectedLines as Array> : []; + const counts = ['new_subscription', 'change_quantity', 'cancel'].map(action => `${lines.filter(l => l.action === action).length} ${action}`).join(', '); + return `Place a real, billable Pax8 order that cannot be undone for ${text(company.name)} (${text(company.pax8CompanyId)}): ${counts}. Cost is estimated at Pax8's current rate, re-checked before sending. ` + + lines.map(line => `${line.action === 'cancel' ? 'CANCEL (terminal)' : text(line.action)} ${text(line.productName)} [${text(line.pax8ProductId)}]: ${text(line.fromQuantity)} → ${text(line.quantity)}, ${text(line.unitCost)} ${text(line.currency)} per seat / ${text(line.billingTerm)}, commitment ${text(line.commitmentTerm)}${line.cancellationFeeApplied === true ? ', cancellation fee applies' : ''}`).join('; '); +} + /** * Human headlines for approval-gated tools whose prettified identifier reads * poorly (#9657). Tools absent here fall back to `titleCaseWords(toolName)`. @@ -67,6 +78,8 @@ export function describePax8Action(toolName: string, action?: string | null): st case 'add_line': return 'Add a line to a Pax8 order draft'; case 'update_line': return 'Edit a Pax8 order draft line'; case 'remove_line': return 'Remove a Pax8 order draft line'; + case 'submit': return 'Place a real, billable Pax8 order that cannot be undone; cost is estimated at Pax8’s current rate'; + case 'reconcile': return 'Reconcile a previously approved Pax8 attempt from vendor evidence and record billing quantity; ambiguous writes remain unknown'; case 'preflight': return 'Run a dry run that sends the staged order content to Pax8 (isMock)'; } } diff --git a/apps/api/src/services/actionIntents/effectDigest.ts b/apps/api/src/services/actionIntents/effectDigest.ts index e657756b85e..30e7a344a1a 100644 --- a/apps/api/src/services/actionIntents/effectDigest.ts +++ b/apps/api/src/services/actionIntents/effectDigest.ts @@ -507,6 +507,17 @@ const EFFECT_DIGEST_RESOLVERS: Record< } }, + // W15: one real Pax8 order, with local destination / billing inputs pinned. Vendor rates are + // live assertions at release; this resolver deliberately loads the ordering graph only on demand. + 'manage_pax8_orders:submit': async (args, database) => { + const orderId = typeof args.orderId === 'string' ? args.orderId : null; + if (!orderId) return MISSING_ARG; + if (!UUID_RE.test(orderId)) return TARGET_ABSENT; + const { pax8OrderDigestMaterial, serializePax8OrderMaterial } = await import('./pax8OrderDigest'); + const pinned = await pax8OrderDigestMaterial(database, orderId); + return pinned ? material(serializePax8OrderMaterial(pinned)) : TARGET_ABSENT; + }, + // manage_accounting_sync push / push_bulk (full-control W14 PR B, #6784; four-eyes, QW1, PR B quorum QB4/QB5): // every input of what lands in the books — the invoice and its lines, the destination connection and payload // settings, the invoice / customer / item mapping rows, and the customer's and items' payload inputs diff --git a/apps/api/src/services/actionIntents/effectDigest.w15b.test.ts b/apps/api/src/services/actionIntents/effectDigest.w15b.test.ts index 4269272c81b..e5f5eda3302 100644 --- a/apps/api/src/services/actionIntents/effectDigest.w15b.test.ts +++ b/apps/api/src/services/actionIntents/effectDigest.w15b.test.ts @@ -57,6 +57,7 @@ async function digest() { describe('Pax8 submit local effect material (W15 Q2/Q4/Q5/Q7)', () => { it.each([ ['line quantity', 'pax8_order_lines', 'quantity', '9.00'], + ['staged change baseline', 'pax8_order_lines', 'authorizedBaselineQuantity', '6.00'], ['sortOrder / outbound lineItemNumber', 'pax8_order_lines', 'sortOrder', 1], ['provisioning details', 'pax8_order_lines', 'provisioningDetails', [{ key: 'domain', values: ['changed.example.test'] }]], ['commitmentTermId', 'pax8_order_lines', 'commitmentTermId', 'fixture-other-term'], diff --git a/apps/api/src/services/actionIntents/intentService.ts b/apps/api/src/services/actionIntents/intentService.ts index 542ea87cc50..66c80b50fe5 100644 --- a/apps/api/src/services/actionIntents/intentService.ts +++ b/apps/api/src/services/actionIntents/intentService.ts @@ -1,5 +1,5 @@ import { flowBuiltinTierFor, isFlowBuiltin, isTenantToolName, remediationTriggerSchema, type RemediationTrigger, AI_APPROVAL_TIMEOUT_MAX_MINUTES, AI_APPROVAL_TIMEOUT_MIN_MINUTES } from '@breeze/shared'; -import { buildActionLabel, describePax8Action, hasDeviceIdStub } from './actionLabel'; +import { buildActionLabel, describePax8Action, describePax8Submit, hasDeviceIdStub } from './actionLabel'; import { argumentDeviceId, resolveApprovalDeviceName } from './approvalDeviceName'; import { randomUUID, createHash } from 'crypto'; import { and, eq, inArray, sql } from 'drizzle-orm'; @@ -641,7 +641,7 @@ const IMPACT_SUMMARY_BUILDERS: Record) = manage_quotes: (input) => nonEmptyString(input.action) === 'send' ? 'Sends the quote to the customer.' : null, manage_pax8_subscriptions: (input) => describePax8Action('manage_pax8_subscriptions', nonEmptyString(input.action)), - manage_pax8_orders: (input) => describePax8Action('manage_pax8_orders', nonEmptyString(input.action)), + manage_pax8_orders: (input) => input.action === 'submit' ? describePax8Submit(input) : describePax8Action('manage_pax8_orders', nonEmptyString(input.action)), manage_contracts: (input) => { switch (nonEmptyString(input.action)) { case 'activate': return 'Activates the contract so billing starts.'; diff --git a/apps/api/src/services/actionIntents/pax8OrderDigest.ts b/apps/api/src/services/actionIntents/pax8OrderDigest.ts new file mode 100644 index 00000000000..2668bb15aec --- /dev/null +++ b/apps/api/src/services/actionIntents/pax8OrderDigest.ts @@ -0,0 +1,164 @@ +/** W15: local inputs of one Pax8 submit. Reads only through the supplied transaction. */ +import { createHash } from 'node:crypto'; +import { and, asc, eq } from 'drizzle-orm'; +import type { Database } from '../../db'; +import { + catalogItemOrgPricing, catalogItemPrices, catalogItems, contractLines, contracts, + pax8CompanyMappings, pax8ContractLineLinks, pax8Integrations, pax8OrderLines, + pax8Orders, pax8ProductMappings, pax8SubscriptionSnapshots, +} from '../../db/schema'; +import { snapshotActiveCommitmentEvidence } from '../pax8OrderService'; +import { pax8CompanyOrderReadiness } from '../pax8CompanyReadiness'; +import { canonicalizeArguments } from './canonicalize'; + +export const serializePax8OrderMaterial = (order: Record): string => + canonicalizeArguments({ v: 1, order }); +export const hashPax8OrderMaterial = (order: Record): string => + createHash('sha256').update(serializePax8OrderMaterial(order)).digest('hex'); + +export async function pax8OrderDigestMaterial( + database: Database, + orderId: string, +): Promise | null> { + const [order] = await database.select({ + id: pax8Orders.id, partnerId: pax8Orders.partnerId, orgId: pax8Orders.orgId, + integrationId: pax8Orders.integrationId, source: pax8Orders.source, + sourceQuoteId: pax8Orders.sourceQuoteId, status: pax8Orders.status, + }).from(pax8Orders).where(eq(pax8Orders.id, orderId)).limit(1); + if (!order) return null; + + const rows = await database.select({ + id: pax8OrderLines.id, sortOrder: pax8OrderLines.sortOrder, action: pax8OrderLines.action, + submitState: pax8OrderLines.submitState, pax8ProductId: pax8OrderLines.pax8ProductId, + catalogItemId: pax8OrderLines.catalogItemId, billingTerm: pax8OrderLines.billingTerm, + commitmentTermId: pax8OrderLines.commitmentTermId, quantity: pax8OrderLines.quantity, + authorizedBaselineQuantity: pax8OrderLines.authorizedBaselineQuantity, + targetSubscriptionId: pax8OrderLines.targetSubscriptionId, cancelDate: pax8OrderLines.cancelDate, + contractLineId: pax8OrderLines.contractLineId, provisioningDetails: pax8OrderLines.provisioningDetails, + }).from(pax8OrderLines).where(and( + eq(pax8OrderLines.orderId, order.id), eq(pax8OrderLines.partnerId, order.partnerId), + eq(pax8OrderLines.orgId, order.orgId), + )).orderBy(asc(pax8OrderLines.sortOrder), asc(pax8OrderLines.id)); + + const companies = await database.select({ + pax8CompanyId: pax8CompanyMappings.pax8CompanyId, name: pax8CompanyMappings.pax8CompanyName, + status: pax8CompanyMappings.status, ignored: pax8CompanyMappings.ignored, + orgId: pax8CompanyMappings.orgId, 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), + )).orderBy(asc(pax8CompanyMappings.pax8CompanyId)); + const mappings = companies.map(({ metadata, ...company }) => ({ + ...company, readiness: pax8CompanyOrderReadiness(company.status, metadata), + })); + const company = mappings.length === 1 && mappings[0]!.readiness.orderReady ? mappings[0]! : null; + + const [generation] = await database.select({ + id: pax8Integrations.id, partnerId: pax8Integrations.partnerId, isActive: pax8Integrations.isActive, + apiBaseUrl: pax8Integrations.apiBaseUrl, tokenUrl: pax8Integrations.tokenUrl, + clientIdEncrypted: pax8Integrations.clientIdEncrypted, clientSecretEncrypted: pax8Integrations.clientSecretEncrypted, + }).from(pax8Integrations).where(and( + eq(pax8Integrations.id, order.integrationId), eq(pax8Integrations.partnerId, order.partnerId), + )).limit(1); + const integration = generation ? { + id: generation.id, partnerId: generation.partnerId, isActive: generation.isActive, + apiBaseUrl: generation.apiBaseUrl, tokenUrl: generation.tokenUrl, + credentialGeneration: createHash('sha256').update(canonicalizeArguments({ + clientId: generation.clientIdEncrypted, clientSecret: generation.clientSecretEncrypted, + })).digest('hex'), + } : null; + + const lines = []; + for (const line of rows) { + const [contractLine] = line.contractLineId ? await database.select({ + id: contractLines.id, contractId: contractLines.contractId, manualQuantity: contractLines.manualQuantity, + unitPrice: contractLines.unitPrice, lineType: contractLines.lineType, catalogItemId: contractLines.catalogItemId, + }).from(contractLines).where(and( + eq(contractLines.id, line.contractLineId), eq(contractLines.orgId, order.orgId), + )).limit(1) : []; + const [contract] = contractLine ? await database.select({ + id: contracts.id, currencyCode: contracts.currencyCode, + }).from(contracts).where(and( + eq(contracts.id, contractLine.contractId), eq(contracts.orgId, order.orgId), + eq(contracts.partnerId, order.partnerId), + )).limit(1) : []; + const [snapshotRow] = line.targetSubscriptionId ? await database.select({ + id: pax8SubscriptionSnapshots.id, quantity: pax8SubscriptionSnapshots.quantity, + quantityKnown: pax8SubscriptionSnapshots.quantityKnown, unitCost: pax8SubscriptionSnapshots.unitCost, + unitPrice: pax8SubscriptionSnapshots.unitPrice, currencyCode: pax8SubscriptionSnapshots.currencyCode, + status: pax8SubscriptionSnapshots.status, orgId: pax8SubscriptionSnapshots.orgId, + productId: pax8SubscriptionSnapshots.productId, productName: pax8SubscriptionSnapshots.productName, + vendorSkuId: pax8SubscriptionSnapshots.vendorSkuId, billingTerm: pax8SubscriptionSnapshots.billingTerm, + raw: pax8SubscriptionSnapshots.raw, + }).from(pax8SubscriptionSnapshots).where(and( + eq(pax8SubscriptionSnapshots.integrationId, order.integrationId), + eq(pax8SubscriptionSnapshots.partnerId, order.partnerId), eq(pax8SubscriptionSnapshots.orgId, order.orgId), + eq(pax8SubscriptionSnapshots.pax8SubscriptionId, line.targetSubscriptionId), + )).limit(1) : []; + const snapshot = snapshotRow ? (() => { + const { raw, ...local } = snapshotRow; + return { ...local, activeCommitment: snapshotActiveCommitmentEvidence(raw) }; + })() : null; + const [link] = snapshot ? await database.select({ + contractLineId: pax8ContractLineLinks.contractLineId, syncEnabled: pax8ContractLineLinks.syncEnabled, + }).from(pax8ContractLineLinks).where(and( + eq(pax8ContractLineLinks.subscriptionSnapshotId, snapshot.id), + eq(pax8ContractLineLinks.integrationId, order.integrationId), + eq(pax8ContractLineLinks.partnerId, order.partnerId), eq(pax8ContractLineLinks.orgId, order.orgId), + )).limit(1) : []; + const productId = line.pax8ProductId ?? snapshot?.productId; + // All actions pin the display name too: the claim-time check also binds the approval card's assertions. + const [productMapping] = productId ? await database.select({ + pax8ProductId: pax8ProductMappings.pax8ProductId, productName: pax8ProductMappings.productName, + vendorSkuId: pax8ProductMappings.vendorSkuId, catalogItemId: pax8ProductMappings.catalogItemId, + }).from(pax8ProductMappings).where(and( + eq(pax8ProductMappings.integrationId, order.integrationId), eq(pax8ProductMappings.partnerId, order.partnerId), + eq(pax8ProductMappings.pax8ProductId, productId), + )).limit(1) : []; + const itemIds = [...new Set([line.catalogItemId, contractLine?.catalogItemId, productMapping?.catalogItemId] + .filter((id): id is string => typeof id === 'string'))].sort(); + const catalog = []; + for (const itemId of itemIds) { + const [item] = await database.select({ + id: catalogItems.id, name: catalogItems.name, sku: catalogItems.sku, description: catalogItems.description, + itemType: catalogItems.itemType, isActive: catalogItems.isActive, billingType: catalogItems.billingType, + billingFrequency: catalogItems.billingFrequency, commitmentTermMonths: catalogItems.commitmentTermMonths, + costBasis: catalogItems.costBasis, costCurrency: catalogItems.costCurrency, markupPercent: catalogItems.markupPercent, + unitOfMeasure: catalogItems.unitOfMeasure, taxable: catalogItems.taxable, taxCategory: catalogItems.taxCategory, + isBundle: catalogItems.isBundle, + }).from(catalogItems).where(and(eq(catalogItems.id, itemId), eq(catalogItems.partnerId, order.partnerId))).limit(1); + const [price] = contract ? await database.select({ + unitPrice: catalogItemPrices.unitPrice, currencyCode: catalogItemPrices.currencyCode, + }).from(catalogItemPrices).where(and( + eq(catalogItemPrices.itemId, itemId), eq(catalogItemPrices.partnerId, order.partnerId), + eq(catalogItemPrices.currencyCode, contract.currencyCode), + )).limit(1) : []; + const [orgPrice] = contract ? await database.select({ + unitPrice: catalogItemOrgPricing.unitPrice, currencyCode: catalogItemOrgPricing.currencyCode, + }).from(catalogItemOrgPricing).where(and( + eq(catalogItemOrgPricing.catalogItemId, itemId), eq(catalogItemOrgPricing.partnerId, order.partnerId), + eq(catalogItemOrgPricing.orgId, order.orgId), + )).limit(1) : []; + catalog.push({ itemId, item: item ?? null, price: price ?? null, orgPrice: orgPrice ?? null }); + } + lines.push({ + ...line, + // A staged quantity change already carries its authorization baseline, which validation must preserve. + // B1 newly captures new/cancel baselines at claim time: normalize only those pre-capture values. + authorizedBaselineQuantity: line.action === 'change_quantity' + ? line.authorizedBaselineQuantity : contractLine?.manualQuantity ?? null, + contractLine: contractLine ?? null, contract: contract ?? null, snapshot, link: link ?? null, + productMapping: productMapping ?? null, catalog, + }); + } + // The claim's own parent transition is not content drift. Only a provably unsent submitting bundle is + // equivalent here; submit's CAS still refuses every re-delivery before any HTTP can run. + const submittable = ['draft', 'awaiting_details', 'ready'].includes(order.status) + || (order.status === 'submitting' && rows.length > 0 && rows.every(line => line.submitState === 'pending')); + return { + id: order.id, partnerId: order.partnerId, orgId: order.orgId, integrationId: order.integrationId, + source: order.source, sourceQuoteId: order.sourceQuoteId, submittable, + pax8CompanyId: company?.pax8CompanyId ?? null, mappings, integration, lines, + }; +} diff --git a/apps/api/src/services/actionIntents/pinnedEffectPolicy.ts b/apps/api/src/services/actionIntents/pinnedEffectPolicy.ts index fe91a026d2a..68113ceb951 100644 --- a/apps/api/src/services/actionIntents/pinnedEffectPolicy.ts +++ b/apps/api/src/services/actionIntents/pinnedEffectPolicy.ts @@ -27,6 +27,7 @@ const PINNED_EFFECT_DIGEST_REQUIRED: ReadonlySet = new Set([ */ const PINNED_EFFECT_DIGEST_REQUIRED_ACTIONS: ReadonlySet = new Set([ 'manage_contracts:generate_invoice', + 'manage_pax8_orders:submit', 'manage_accounting_sync:push', 'manage_accounting_sync:push_bulk', ]); diff --git a/apps/api/src/services/aiGuardrails.agentPrincipal.contract.test.ts b/apps/api/src/services/aiGuardrails.agentPrincipal.contract.test.ts index 3965bf6a109..04521a8e16a 100644 --- a/apps/api/src/services/aiGuardrails.agentPrincipal.contract.test.ts +++ b/apps/api/src/services/aiGuardrails.agentPrincipal.contract.test.ts @@ -1029,6 +1029,8 @@ describe('AGENT_HUMAN_ONLY_ACTIONS (W02-D2, #6756)', () => { 'manage_device_software:uninstall', // W04-D8 (#6758): group membership is the same self-scope path (triggers.deviceGroupIds). 'manage_groups:add_devices', 'manage_groups:remove_devices', + // W15: billable orders and terminal cancellations require a person. + 'manage_pax8_orders:submit', 'manage_tags:add', 'manage_tags:remove', // W13 PR B (#6783): tenant-variable values are script inputs. 'manage_tenant_variables:create', 'manage_tenant_variables:delete', 'manage_tenant_variables:update', diff --git a/apps/api/src/services/aiGuardrails.pax8.test.ts b/apps/api/src/services/aiGuardrails.pax8.test.ts index 342c01743a7..bb1154575a4 100644 --- a/apps/api/src/services/aiGuardrails.pax8.test.ts +++ b/apps/api/src/services/aiGuardrails.pax8.test.ts @@ -15,15 +15,17 @@ const actions = [ ...['list', 'get', 'preview'].map((action) => ['manage_pax8_orders', action, 1]), ...['create_draft', 'add_line', 'update_line', 'remove_line'].map((action) => ['manage_pax8_orders', action, 2]), ['manage_pax8_orders', 'preflight', 3], + ['manage_pax8_orders', 'submit', 3], + ['manage_pax8_orders', 'reconcile', 3], ] as Array<[string, string | undefined, number]>; const tools = ['get_pax8_status', 'search_pax8_products', 'manage_pax8_subscriptions', 'manage_pax8_orders'] as const; -describe('W15 PR A Pax8 guardrail classes', () => { +describe('W15 Pax8 guardrail classes', () => { it.each(actions)('%s:%s resolves to W15 tier %s', (tool, action, tier) => { const result = checkGuardrails(tool, action ? { action } : {}); expect(result).toMatchObject({ tier, allowed: true, requiresApproval: tier === 3 }); expect(isReadOnlyResolution(tool, result)).toBe(tier === 1); - if (tier === 3) expect(result).toHaveProperty('approvalScope', 'supervised'); + if (tier === 3) expect(result).toHaveProperty('approvalScope', action === 'submit' ? 'four_eyes' : 'supervised'); else expect(result.approvalScope).toBeUndefined(); }); @@ -40,8 +42,8 @@ describe('W15 PR A Pax8 guardrail classes', () => { expect(TOOL_CAPABILITY[tool]).toBe('business'); }); - it.each(actions.filter(([, , tier]) => tier !== 1))('%s:%s uses explicit-grant exposure rather than agent-human-only', (tool, action) => { - expect(AGENT_HUMAN_ONLY_ACTIONS.has(`${tool}:${action}`)).toBe(false); + it.each(actions.filter(([, , tier]) => tier !== 1))('%s:%s uses its reviewed agent exposure', (tool, action) => { + expect(AGENT_HUMAN_ONLY_ACTIONS.has(`${tool}:${action}`)).toBe(tool === 'manage_pax8_orders' && action === 'submit'); }); it.each(tools)('%s fences vendor-authored term and SKU text', (tool) => { diff --git a/apps/api/src/services/aiGuardrails.ts b/apps/api/src/services/aiGuardrails.ts index ad73e9ca60e..f2a58a0f3dd 100644 --- a/apps/api/src/services/aiGuardrails.ts +++ b/apps/api/src/services/aiGuardrails.ts @@ -18,7 +18,7 @@ import { rateLimiter } from './rate-limit'; import { TOOL_RATE_LIMITS, resolveToolRateLimitMultiplier, scaleToolRateLimit, type ToolRateLimitScope } from './aiToolRateLimits'; export { listEffectiveToolRateLimits, type EffectiveToolRateLimit } from './aiToolRateLimits'; import { getRedis } from './redis'; -import { describePax8Action, humanizeToolAction } from './actionIntents/actionLabel'; +import { describePax8Action, describePax8Submit, humanizeToolAction } from './actionIntents/actionLabel'; import { isSecretBearingTool } from './actionIntents/secretBearingTools'; import { WORKSPACE_TOOL_NAMES } from './workspace/workspaceToolNames'; import { isFlowToolName } from './flows/toolNames'; @@ -679,7 +679,7 @@ export const TIER3_ACTIONS: Record = { manage_accounting_sync: ['link_mapping', 'unlink_mapping', 'sync_mapping', 'push', 'push_bulk'], // W15 PR A (#6785, Gate B): sync calls the vendor partner-wide; preflight sends staged content. manage_pax8_subscriptions: ['sync', 'link', 'unlink'], - manage_pax8_orders: ['preflight'], + manage_pax8_orders: ['preflight', 'submit', 'reconcile'], // Money-authoring drafts vs money-moving actions (#2551): this boundary // tracks reversibility/external-commitment, not dollar amount. Drafting — // create_draft, add_manual_line, add_catalog_line, update, etc. — only @@ -773,6 +773,7 @@ export const TIER3_ACTIONS: Record = { // See docs/superpowers/specs/ai-mcp/2026-08-05-tier3-supervised-four-eyes-split-design.md §3.2 // for the full classification rationale. export const TIER3_FOUR_EYES_ACTIONS: Record = { + manage_pax8_orders: ['submit'], // Financial / externally binding. `void` is not named in spec §3.2's // bullet list, but TOOL_PERMISSIONS maps it to the same `invoices:send` // RBAC action as issue/record_payment/void_payment — voiding an issued @@ -1072,7 +1073,7 @@ export const TIER3_SUPERVISED_ACTIONS: Record = { manage_accounting_sync: ['link_mapping', 'unlink_mapping', 'sync_mapping'], // W15 PR A (#6785): local billing links, partner-wide sync and vendor isMock preflight. manage_pax8_subscriptions: ['sync', 'link', 'unlink'], - manage_pax8_orders: ['preflight'], + manage_pax8_orders: ['preflight', 'reconcile'], manage_ticket_response_templates: ['create', 'update'], manage_quotes: ['decline', 'create_pay_link'], manage_billing_catalog: ['update_item', 'archive_item', 'set_price', 'remove_price', 'set_org_price', 'remove_org_price', 'set_bundle_components'], @@ -2856,6 +2857,8 @@ export const TOOL_PERMISSIONS: Record = new Map value.commitmentTermId !== undefined || value.provisioningDetails !== undefined, { message: 'At least one editable line field is required.' }, ); +/** Assertions copied verbatim from preview.submitWith; release re-checks every field. */ +export const pax8ExpectedLineSchema = z.object({ + lineId: uuid, action: z.enum(PAX8_ORDER_ACTIONS), pax8ProductId: productId.nullable(), + productName: z.string().max(255).nullable(), quantity: z.string().max(40).nullable(), + fromQuantity: z.string().max(40).nullable().optional(), unitCost: z.string().max(40).nullable(), + currency: z.string().max(3).nullable(), billingTerm: z.enum(PAX8_BILLING_TERMS).nullable(), + commitmentTerm: z.string().max(255).nullable(), cancellationFeeApplied: z.boolean().nullable().optional(), +}).strict(); +export const pax8SubmitAssertionsSchema = z.object({ + previewDigest: z.string().regex(/^[a-f0-9]{64}$/), + expectedCompany: z.object({ pax8CompanyId: productId, name: z.string().max(255) }).strict(), + expectedLines: z.array(pax8ExpectedLineSchema).min(1).max(20), +}).strict(); const partner = { partnerId: uuid.optional() }; export const pax8ToolSchemas = { get_pax8_status: z.object({ ...partner, view: z.enum(['summary', 'companies']).optional(), integrationId: uuid.optional(), ...pageZodShape(50, 100) }).strict(), search_pax8_products: z.object({ ...partner, action: z.enum(PAX8_PRODUCT_ACTIONS), productId: productId.optional(), q: z.string().max(255).optional(), ...pageZodShape(50, 100) }).strict(), manage_pax8_subscriptions: z.object({ ...partner, action: z.enum(PAX8_SUBSCRIPTION_ACTIONS), integrationId: uuid.optional(), orgId: uuid.optional(), unmappedOnly: z.boolean().optional(), subscriptionSnapshotId: uuid.optional(), contractLineId: uuid.optional(), syncEnabled: z.boolean().optional(), ...pageZodShape(50, 100) }).strict(), - manage_pax8_orders: z.object({ ...partner, action: z.enum(PAX8_DRAFT_ACTIONS), orgId: uuid.optional(), orderId: uuid.optional(), lineId: uuid.optional(), line: pax8AddLineSchema.optional(), patch: z.object(patchShape).strict().optional(), ...pageZodShape(50, 100) }).strict(), + manage_pax8_orders: z.object({ ...partner, action: z.enum(PAX8_DRAFT_ACTIONS), orgId: uuid.optional(), orderId: uuid.optional(), lineId: uuid.optional(), previewDigest: pax8SubmitAssertionsSchema.shape.previewDigest.optional(), expectedCompany: pax8SubmitAssertionsSchema.shape.expectedCompany.optional(), expectedLines: pax8SubmitAssertionsSchema.shape.expectedLines.optional(), line: pax8AddLineSchema.optional(), patch: z.object(patchShape).strict().optional(), ...pageZodShape(50, 100) }).strict(), }; diff --git a/apps/api/src/services/aiToolsPax8OrderSubmit.test.ts b/apps/api/src/services/aiToolsPax8OrderSubmit.test.ts new file mode 100644 index 00000000000..fbd3e3b6c78 --- /dev/null +++ b/apps/api/src/services/aiToolsPax8OrderSubmit.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AuthContext } from '../middleware/auth'; +import type { AiTool } from './aiTools'; +const h = vi.hoisted(() => ({ get: vi.fn(), preview: vi.fn(), submit: vi.fn(), reconcile: vi.fn(), material: vi.fn(), audit: vi.fn() })); +vi.mock('../db', () => ({ db: {}, runOutsideDbContext: (f: any) => f(), withDbAccessContext: (_c: any, f: any) => f() })); +vi.mock('./pax8OrderService', async original => ({ ...await original(), getOrderWithLines: h.get })); +vi.mock('./pax8OrderSubmit', () => ({ preflightOrder: vi.fn(), submitOrder: h.submit, reconcileOrder: h.reconcile })); +vi.mock('./pax8OrderPreview', () => ({ previewPax8Order: h.preview })); +vi.mock('./actionIntents/pax8OrderDigest', () => ({ pax8OrderDigestMaterial: h.material, hashPax8OrderMaterial: (value: unknown) => value === null ? null : (value as any).digest })); +vi.mock('./aiToolsSoftwareShared', async original => ({ ...await original(), auditToolWrite: h.audit })); +import { registerPax8OrderTools } from './aiToolsPax8Orders'; +import { pax8ToolSchemas } from './aiToolSchemasPax8'; +import { AGENT_HUMAN_ONLY_ACTIONS } from './aiToolExposure'; +import { buildApprovalDescription, checkGuardrails } from './aiGuardrails'; +import { isMfaGatedToolAction } from './aiToolMfaGate'; +import { Pax8OrderError } from './pax8OrderService'; +const ID = '00000000-0000-4000-8000-000000000001', ORG = '00000000-0000-4000-8000-000000000002', PARTNER = '00000000-0000-4000-8000-000000000003'; +const digest = 'a'.repeat(64); +const company = { pax8CompanyId: 'fixture-company', name: 'Fixture Company' }; +const line = { lineId: ID, action: 'new_subscription', pax8ProductId: 'fixture-product', productName: 'Fixture Product', quantity: '5', fromQuantity: null, unitCost: '8.25', currency: 'USD', billingTerm: 'Monthly', commitmentTerm: 'Annual', cancellationFeeApplied: true }; +const args = () => ({ action: 'submit', orderId: ID, previewDigest: digest, expectedCompany: company, expectedLines: [{ ...line }] }); +const auth = (over = {}) => ({ scope: 'partner', partnerId: PARTNER, partnerOrgAccess: 'all', user: { id: ID }, principal: { kind: 'user_session' }, accessibleOrgIds: [ORG], canAccessOrg: (id: string) => id === ORG, orgCondition: () => undefined, token: { mfa: true }, ...over }) as unknown as AuthContext; +const tools = new Map(); registerPax8OrderTools(tools); +const run = async (input: Record = args(), a = auth()) => JSON.parse(await tools.get('manage_pax8_orders')!.handler(input, a)); +let preview: any; +beforeEach(() => { + vi.clearAllMocks(); + preview = { order: { id: ID, orgId: ORG, source: 'quote', status: 'draft' }, company, lines: [{ ...line }], previewDigest: digest, blocked: [], totalsComplete: true }; + h.get.mockResolvedValue({ order: { id: ID, orgId: ORG, status: 'draft' }, lines: [{ id: ID }] }); + h.preview.mockImplementation(async () => preview); + h.material.mockResolvedValue({ digest }); + h.submit.mockImplementation(async input => { await input.verifyClaim({ order: { id: ID, orgId: ORG }, lines: [{ id: ID }] }, {}); return { orderId: ID, status: 'completed', lines: [{ lineId: ID, submitState: 'succeeded', error: null }] }; }); + h.reconcile.mockResolvedValue({ resolved: 1, stillUnknown: 0 }); +}); +describe('W15 PR B Pax8 order submit', () => { + it('appends exactly submit and reconcile to the action schema', () => expect(pax8ToolSchemas.manage_pax8_orders.shape.action.options).toEqual(['list','get','create_draft','add_line','update_line','remove_line','preview','preflight','submit','reconcile'])); + it('submit is four-eyes and agent-human-only; reconcile is supervised and MFA-gated', () => { + expect(checkGuardrails('manage_pax8_orders', { action: 'submit' })).toMatchObject({ tier: 3, approvalScope: 'four_eyes' }); + expect(AGENT_HUMAN_ONLY_ACTIONS.has('manage_pax8_orders:submit')).toBe(true); + expect(checkGuardrails('manage_pax8_orders', { action: 'reconcile' })).toMatchObject({ tier: 3, approvalScope: 'supervised' }); + expect(isMfaGatedToolAction('manage_pax8_orders', 'submit')).toBe(true); + expect(isMfaGatedToolAction('manage_pax8_orders', 'reconcile')).toBe(true); + }); + it('verifies inside the claim before any isMock/write, without a pre-claim preflight', async () => { + expect((await run()).status).toBe('completed'); + expect(h.submit).toHaveBeenCalledWith(expect.objectContaining({ actorUserId: ID, onPreflightFailure: 'reopen', verifyClaim: expect.any(Function) })); + expect(h.material).toHaveBeenCalledWith({}, ID); + expect(h.audit).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: 'pax8.order.submit' })); + }); + it.each(['unitCost', 'currency'])('PRICE_CHANGED on %s refuses before claim or writes', async field => { + preview.lines[0][field] = 'changed'; + expect((await run()).code).toBe('PRICE_CHANGED'); expect(h.submit).not.toHaveBeenCalled(); + }); + it.each(['commitmentTerm', 'cancellationFeeApplied'])('COMMITMENT_CHANGED on %s refuses before claim or writes', async field => { + preview.lines[0][field] = field === 'cancellationFeeApplied' ? false : 'Monthly'; + expect((await run()).code).toBe('COMMITMENT_CHANGED'); expect(h.submit).not.toHaveBeenCalled(); + }); + it.each(['change_quantity', 'cancel'])('%s checks freshly fetched commitment assertions too', async action => { + const input = args(); input.expectedLines[0]!.action = action; preview.lines[0].action = action; + preview.lines[0].commitmentTerm = 'Monthly'; + expect((await run(input)).code).toBe('COMMITMENT_CHANGED'); expect(h.submit).not.toHaveBeenCalled(); + }); + it.each([ + ['cancel', '0', '5', 'allowForEarlyCancellation'], + ['change_quantity', '8', '5', 'allowForQuantityIncrease'], + ['change_quantity', '2', '5', 'allowForQuantityDecrease'], + ])('refuses newly forbidden %s even when its term and fee are unchanged', async (action, quantity, fromQuantity, flag) => { + const input = args(); Object.assign(input.expectedLines[0]!, { action, quantity, fromQuantity }); + Object.assign(preview.lines[0], { action, quantity, fromQuantity, [flag]: false }); + expect((await run(input)).code).toBe('COMMITMENT_CHANGED'); expect(h.submit).not.toHaveBeenCalled(); + }); + it.each(['quantity','productName','pax8ProductId','billingTerm','fromQuantity'])('PREVIEW_MISMATCH for a wrong asserted %s', async field => { + const input = args(); (input.expectedLines[0] as any)[field] = field === 'billingTerm' ? 'Annual' : 'wrong'; + expect((await run(input)).code).toBe('PREVIEW_MISMATCH'); expect(h.submit).not.toHaveBeenCalled(); + }); + it('refuses mismatched company, changed digest and changed locked material', async () => { + expect((await run({ ...args(), expectedCompany: { ...company, name: 'Wrong' } })).code).toBe('PREVIEW_MISMATCH'); + preview.previewDigest = 'b'.repeat(64); expect((await run()).code).toBe('PREVIEW_MISMATCH'); + preview.previewDigest = digest; h.material.mockResolvedValue({ digest: 'c'.repeat(64) }); + expect((await run()).code).toBe('PREVIEW_MISMATCH'); + }); + it.each(['COST_UNKNOWN','COMMITMENT_AMBIGUOUS','TOO_MANY_LINES','NOT_PENDING','COMPANY_UNRESOLVED'])('%s refuses without submit', async code => { + preview.blocked = [{ code }]; expect((await run()).code).toBe(code); expect(h.submit).not.toHaveBeenCalled(); + }); + it('refuses more than 20 lines before vendor preview', async () => { + h.get.mockResolvedValue({ order: { id: ID, orgId: ORG }, lines: Array(21).fill({ id: ID }) }); + expect((await run()).code).toBe('TOO_MANY_LINES'); expect(h.preview).not.toHaveBeenCalled(); expect(h.submit).not.toHaveBeenCalled(); + }); + it('PREFLIGHT_FAILED reports the reopened order, never claims a terminal failure', async () => { + h.submit.mockResolvedValue({ orderId: ID, status: 'draft', lines: [{ lineId: ID, submitState: 'pending', error: null }], refusalCode: 'PREFLIGHT_FAILED', error: 'bad provisioning' }); + const out = await run(); expect(out.code).toBe('PREFLIGHT_FAILED'); expect(out.result.status).toBe('draft'); + }); + it('clips all nested vendor errors to 2 KB on an unsent refusal', async () => { + h.submit.mockResolvedValue({ orderId: ID, status: 'draft', lines: [{ lineId: ID, submitState: 'pending', error: '界'.repeat(4000) }], refusalCode: 'PREFLIGHT_FAILED', error: '界'.repeat(4000) }); + const out = await run(); + expect(out.code).toBe('PREFLIGHT_FAILED'); + for (const error of [out.error, out.result.error, out.result.lines[0].error]) expect(Buffer.byteLength(error)).toBeLessThanOrEqual(2048); + }); + it('a re-delivered release loses the claim and is never re-sent', async () => { + h.submit.mockRejectedValue(new Pax8OrderError('Another submit won the order claim; reconcile it.', 409)); + expect((await run()).code).toBe('CONFLICT'); expect(h.submit).toHaveBeenCalledTimes(1); + }); + it('names ambiguous writes with a reconcile hint', async () => { + h.submit.mockResolvedValue({ orderId: ID, status: 'partially_failed', lines: [{ lineId: ID, submitState: 'needs_reconcile', error: 'timeout' }] }); + expect((await run()).nextStep).toContain('reconcile'); + }); + it.each(['submit','reconcile'])('%s repeats MFA, site, device and org gates before services', async action => { + for (const [over, code] of [[{ token: {} },'MFA_REQUIRED'],[{ allowedSiteIds: [ID] },'ACCESS_DENIED'],[{ allowedDeviceIds: [ID] },'ACCESS_DENIED']] as const) expect((await run({ ...args(), action }, auth(over))).code).toBe(code); + h.get.mockResolvedValue({ order: { id: ID, orgId: ID }, lines: [] }); expect((await run({ ...args(), action })).code).toBe('FORBIDDEN'); + expect(h.submit).not.toHaveBeenCalled(); expect(h.reconcile).not.toHaveBeenCalled(); expect(h.preview).not.toHaveBeenCalled(); + }); + it('site-ceilinged preview cannot read whole-contract billing evidence', async () => { + expect((await run({ action: 'preview', orderId: ID }, auth({ allowedSiteIds: [ID] }))).code).toBe('ACCESS_DENIED'); + expect(h.preview).not.toHaveBeenCalled(); + }); + it('refuses agent submit even when directly invoked', async () => { + expect((await run(args(), auth({ principal: { kind: 'ai_agent' } }))).code).toBe('AGENT_HUMAN_ONLY'); expect(h.submit).not.toHaveBeenCalled(); + }); + it('reconcile calls the existing service and audits the read-backed billing result', async () => { + expect(await run({ action: 'reconcile', orderId: ID })).toEqual({ resolved: 1, stillUnknown: 0 }); + expect(h.reconcile).toHaveBeenCalledWith({ partnerId: PARTNER, orderId: ID }); + expect(h.audit).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: 'pax8.order.reconcile' })); + }); + it('the approval card says estimated and identifies every CANCEL as terminal with its term', () => { + const copy = buildApprovalDescription('manage_pax8_orders', 'submit', { ...args(), expectedLines: [{ ...line, action: 'cancel' }, { ...line, lineId: ORG, productName: 'Second Product', action: 'cancel', commitmentTerm: 'Monthly' }] }); + expect(copy).toContain('estimated'); expect(copy).toContain('Fixture Company'); expect(copy).toContain('Annual'); expect(copy).toContain('Monthly'); expect(copy.match(/terminal/gi)).toHaveLength(2); + }); +}); diff --git a/apps/api/src/services/aiToolsPax8Orders.handlers.test.ts b/apps/api/src/services/aiToolsPax8Orders.handlers.test.ts index 0138ff2d07b..5d980903cf0 100644 --- a/apps/api/src/services/aiToolsPax8Orders.handlers.test.ts +++ b/apps/api/src/services/aiToolsPax8Orders.handlers.test.ts @@ -24,7 +24,7 @@ describe('Pax8 orders', () => { auditToolWrite(auth(), { orgId: ORG, action: 'pax8.order.preflight', resourceType: 'pax8_order', result }); expect(h.auditLog).toHaveBeenCalledWith(expect.objectContaining({ result })); }); - it('action enum is exactly PR A', () => expect(pax8ToolSchemas.manage_pax8_orders.shape.action.options).toEqual(['list','get','create_draft','add_line','update_line','remove_line','preview','preflight'])); + it('action enum is exactly PR B', () => expect(pax8ToolSchemas.manage_pax8_orders.shape.action.options).toEqual(['list','get','create_draft','add_line','update_line','remove_line','preview','preflight','submit','reconcile'])); it('refuses organization scope and device-bound sessions', async () => { expect((await run({ action: 'list' }, auth({ scope: 'organization' }))).code).toBe('PARTNER_SCOPE_REQUIRED'); expect((await run({ action: 'get', orderId: ID }, auth({ allowedDeviceIds: [ID] }))).code).toBe('ACCESS_DENIED'); diff --git a/apps/api/src/services/aiToolsPax8Orders.ts b/apps/api/src/services/aiToolsPax8Orders.ts index 5626675b7aa..6f4ac8388f3 100644 --- a/apps/api/src/services/aiToolsPax8Orders.ts +++ b/apps/api/src/services/aiToolsPax8Orders.ts @@ -1,13 +1,16 @@ -/** W15 PR A: editable drafts and read-only previews; preflight sends only isMock requests. */ +/** Pax8 drafts, verified four-eyes submit and evidence-based reconciliation. */ import { runOutsideDbContext, withDbAccessContext } from '../db'; import type { AuthContext } from '../middleware/auth'; import type { AiTool } from './aiTools'; import { mfaGatedToolError } from './aiToolMfaGate'; -import { PAX8_DRAFT_ACTIONS, pax8AddLineSchema, pax8UpdateLineSchema, pax8ToolSchemas } from './aiToolSchemasPax8'; +import { PAX8_DRAFT_ACTIONS, pax8SubmitAssertionsSchema, pax8AddLineSchema, pax8UpdateLineSchema, pax8ToolSchemas } from './aiToolSchemasPax8'; import { pax8Clip, pax8DbContext, pax8Error, pax8InputSchema, pax8Page, pax8Partner } from './aiToolsPax8'; import { addOrderLine, getOrderWithLines, getOrCreateDraftOrder, listPax8Orders, removeOrderLine, updateOrderLine, type Pax8OrderLineRow, type Pax8OrderRow } from './pax8OrderService'; import { previewPax8Order } from './pax8OrderPreview'; -import { preflightOrder } from './pax8OrderSubmit'; +import { preflightOrder, submitOrder, reconcileOrder } from './pax8OrderSubmit'; +import { pax8OrderDigestMaterial, hashPax8OrderMaterial } from './actionIntents/pax8OrderDigest'; +import { isAutomationPrincipal } from '../middleware/principalKind'; +import { Pax8OrderError } from './pax8OrderService'; import { Pax8ApiError } from './pax8Client'; import { auditToolWrite, bounded, jsonError, orgWideWriteDenied, verifiedUserIdOrNull } from './aiToolsSoftwareShared'; const TOOL = 'manage_pax8_orders'; @@ -31,9 +34,9 @@ function audit(auth: AuthContext, partnerId: string, orgId: string, action: stri } export function registerPax8OrderTools(aiTools: Map): void { aiTools.set(TOOL, { - tier: 1, deviceArgs: [], domain: 'billing', searchHint: 'Pax8 order drafts, seat changes, staged cancellations, cost preview and mock preflight', - selfManagedDbContext: ['add_line', 'preview', 'preflight'], - definition: { name: TOOL, description: 'Pax8 orders: list, get, create_draft, add_line, update_line, remove_line, preview and preflight. Draft edits send no order; preflight is a supervised vendor dry run.', input_schema: pax8InputSchema(pax8ToolSchemas.manage_pax8_orders) }, + tier: 1, deviceArgs: [], domain: 'billing', searchHint: 'Pax8 drafts, seat changes, cancellations, preview, submit and reconcile', + selfManagedDbContext: ['add_line', 'preview', 'preflight', 'submit', 'reconcile'], + definition: { name: TOOL, description: 'Pax8 orders: drafts, preview, preflight, submit and reconcile. Submit copies preview.submitWith: four-eyes, estimated cost, terminal cancellations. Never retry unknown writes; reconcile them.', input_schema: pax8InputSchema(pax8ToolSchemas.manage_pax8_orders) }, handler: async (input, auth) => { const partner = pax8Partner(auth, input.partnerId); if ('refusal' in partner) return partner.refusal; const partnerId = partner.partnerId, action = String(input.action); @@ -43,6 +46,8 @@ export function registerPax8OrderTools(aiTools: Map): void { const denied = orgWideWriteDenied(auth, 'Pax8 orders'); if (denied) return denied; if (auth.allowedSiteIds !== undefined) return jsonError('Pax8 orders affect the whole organization', 'ACCESS_DENIED'); } + // Preview material includes contract_lines and catalog prices for the whole org. + if (action === 'preview' && auth.allowedSiteIds !== undefined) return jsonError('Pax8 billing previews require unrestricted organization visibility', 'ACCESS_DENIED'); const ctx = pax8DbContext(auth, partnerId); try { if (action === 'list') { @@ -63,6 +68,57 @@ export function registerPax8OrderTools(aiTools: Map): void { if (!auth.canAccessOrg(bundle.order.orgId)) return jsonError('Access to organization denied', 'FORBIDDEN'); if (action === 'get') return bounded({ data: { order: presentOrder(bundle.order), lines: bundle.lines.map(presentLine) } }); if (action === 'preview') return bounded(await runOutsideDbContext(() => previewPax8Order({ partnerId, orderId }))); + if (action === 'reconcile') { + const result = await runOutsideDbContext(() => reconcileOrder({ partnerId, orderId })); + audit(auth, partnerId, bundle.order.orgId, 'pax8.order.reconcile', orderId, result); + return bounded(result); + } + if (action === 'submit') { + if (isAutomationPrincipal(auth)) return jsonError('A person must request Pax8 order submission', 'AGENT_HUMAN_ONLY'); + if (bundle.lines.length > 20) return jsonError('AI submission supports at most 20 lines', 'TOO_MANY_LINES'); + const parsed = pax8SubmitAssertionsSchema.safeParse({ previewDigest: input.previewDigest, expectedCompany: input.expectedCompany, expectedLines: input.expectedLines }); + if (!parsed.success) return jsonError('Copy previewDigest, expectedCompany and expectedLines from preview.submitWith', 'VALIDATION_ERROR'); + const expected = parsed.data; + // Fresh product-id-only reads for every product (including change and cancel + // dependencies). Preview never preflights or writes through loadResolvedOrder. + const live = await runOutsideDbContext(() => previewPax8Order({ partnerId, orderId })); + if (live.blocked.length) return bounded({ error: 'The order cannot be submitted by AI; review its preview', code: live.blocked[0]!.code, blocked: live.blocked }); + const refuse = (code: string, message: string) => jsonError(message, code); + if (expected.expectedLines.length !== live.lines.length) return refuse('PREVIEW_MISMATCH', 'The approved line set changed'); + const actualById = new Map(live.lines.map(line => [line.lineId, line])); + if (new Set(expected.expectedLines.map(line => line.lineId)).size !== live.lines.length) return refuse('PREVIEW_MISMATCH', 'The approved line set is not unique'); + for (const line of expected.expectedLines) { + const actual = actualById.get(line.lineId); + if (!actual) return refuse('PREVIEW_MISMATCH', 'An approved order line is unavailable'); + if (line.unitCost !== actual.unitCost || line.currency !== actual.currency) return refuse('PRICE_CHANGED', 'The estimated Pax8 rate changed; preview and approve again'); + if (line.commitmentTerm !== actual.commitmentTerm || (line.cancellationFeeApplied ?? null) !== actual.cancellationFeeApplied) return refuse('COMMITMENT_CHANGED', 'Pax8 commitment rules changed; preview and approve again'); + if ((actual.action === 'cancel' && actual.allowForEarlyCancellation !== true) + || (actual.action === 'change_quantity' && Number(actual.quantity) > Number(actual.fromQuantity) && actual.allowForQuantityIncrease !== true) + || (actual.action === 'change_quantity' && Number(actual.quantity) < Number(actual.fromQuantity) && actual.allowForQuantityDecrease !== true)) { + return refuse('COMMITMENT_CHANGED', 'The current Pax8 commitment no longer permits this action'); + } + for (const key of ['action', 'pax8ProductId', 'productName', 'quantity', 'billingTerm'] as const) { + if (line[key] !== actual[key]) return refuse('PREVIEW_MISMATCH', `The approved ${key} differs from the order`); + } + if ((line.fromQuantity ?? null) !== actual.fromQuantity) return refuse('PREVIEW_MISMATCH', 'The approved baseline quantity differs from the order'); + } + if (expected.expectedCompany.pax8CompanyId !== live.company?.pax8CompanyId || expected.expectedCompany.name !== live.company?.name + || expected.previewDigest !== live.previewDigest) return refuse('PREVIEW_MISMATCH', 'The approved company or local order inputs changed'); + const result = await runOutsideDbContext(() => submitOrder({ + partnerId, orderId, actorUserId: auth.user.id, onPreflightFailure: 'reopen', + verifyClaim: async (_claimed, database) => { + const material = await pax8OrderDigestMaterial(database, orderId); + if (!material || hashPax8OrderMaterial(material) !== expected.previewDigest) { + throw new Pax8OrderError('PREVIEW_MISMATCH: The order changed before its claim; nothing was sent.', 409); + } + }, + })); + const failure = result.refusalCode !== undefined; + audit(auth, partnerId, bundle.order.orgId, 'pax8.order.submit', orderId, { status: result.status, refusalCode: result.refusalCode }, failure ? 'failure' : 'success'); + if (failure) return bounded({ error: pax8Clip(result.error) ?? 'The unsent order was reopened', code: result.refusalCode!, result: { ...result, error: pax8Clip(result.error), lines: result.lines.map(line => ({ ...line, error: pax8Clip(line.error) })) } }); + return bounded({ ...result, lines: result.lines.map(line => ({ ...line, error: pax8Clip(line.error) })), + ...(result.lines.some(line => line.submitState === 'needs_reconcile') ? { nextStep: 'Use reconcile to inspect vendor evidence; never retry an unknown write.' } : {}) }); + } if (action === 'preflight') { try { const result = await runOutsideDbContext(() => preflightOrder({ partnerId, orderId })); @@ -104,7 +160,11 @@ export function registerPax8OrderTools(aiTools: Map): void { return bounded(result); } return jsonError('Unknown Pax8 order action', 'VALIDATION_ERROR'); - } catch (error) { return pax8Error(TOOL, error); } + } catch (error) { + if (error instanceof Pax8OrderError && error.message.startsWith('PREVIEW_MISMATCH:')) return jsonError(error.message, 'PREVIEW_MISMATCH'); + if (error instanceof Pax8OrderError && error.status === 409) return bounded({ error: pax8Clip(error.message), code: 'CONFLICT', nextStep: 'Inspect the order and reconcile any unknown write; never retry blindly.' }); + return pax8Error(TOOL, error); + } }, }); } diff --git a/apps/api/src/services/pax8OrderPreview.test.ts b/apps/api/src/services/pax8OrderPreview.test.ts index 1e95c3df617..d83e6d10cfe 100644 --- a/apps/api/src/services/pax8OrderPreview.test.ts +++ b/apps/api/src/services/pax8OrderPreview.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getTableName } from 'drizzle-orm'; const m = vi.hoisted(() => ({ - select: vi.fn(), insert: vi.fn(), update: vi.fn(), delete: vi.fn(), + material: vi.fn(), select: vi.fn(), insert: vi.fn(), update: vi.fn(), delete: vi.fn(), getOrderWithLines: vi.fn(), createClient: vi.fn(), resolvePrice: vi.fn(), dependencies: vi.fn(), pricing: vi.fn(), contextDepth: 0, outsideDepth: 0, withContext: vi.fn(), outside: vi.fn(), @@ -19,6 +19,7 @@ vi.mock('./catalogService', () => ({ resolvePrice: m.resolvePrice, CatalogServiceError: class CatalogServiceError extends Error { constructor(message: string, public status = 400, public code?: string) { super(message); } }, })); +vi.mock('./actionIntents/pax8OrderDigest', () => ({ pax8OrderDigestMaterial: m.material, hashPax8OrderMaterial: () => 'a'.repeat(64) })); import { previewPax8Order } from './pax8OrderPreview'; import { Pax8ApiError } from './pax8Client'; import { CatalogServiceError } from './catalogService'; @@ -40,6 +41,7 @@ const run = () => previewPax8Order({ partnerId, orderId, client: client as never beforeEach(() => { vi.clearAllMocks(); + m.material.mockImplementation(async () => { expect(m.contextDepth).toBeGreaterThan(0); return { local: true }; }); m.contextDepth = 0; m.outsideDepth = 0; m.outside.mockImplementation(async (fn) => { const saved = m.contextDepth; m.contextDepth = 0; m.outsideDepth++; @@ -87,6 +89,22 @@ beforeEach(() => { }); describe('previewPax8Order', () => { + it('returns a digest and exact submit assertions from the same local read context', async () => { + const result = await run(); + expect(result.previewDigest).toBe('a'.repeat(64)); + expect(result.submitWith).toMatchObject({ orderId, previewDigest: result.previewDigest, expectedCompany: result.company, + expectedLines: [expect.objectContaining({ lineId, quantity: '5', unitCost: '8.25', commitmentTerm: 'Annual' })] }); + expect(m.material).toHaveBeenCalledTimes(1); + }); + it('blocked previews never offer submitWith', async () => { + m.pricing.mockResolvedValue([]); + expect((await run()).submitWith).toBeUndefined(); + }); + it('cancel preview uses the current linked manual quantity for its approved baseline', async () => { + Object.assign(lines[0]!, { action: 'cancel', targetSubscriptionId: 'fixture-subscription', contractLineId: 'fixture-contract-line', authorizedBaselineQuantity: null }); + Object.assign(rows.contract_lines![0]!, { manualQuantity: '3' }); + expect((await run()).lines[0]?.fromQuantity).toBe('3'); + }); it('new line priced from the live pricing row matching billing and commitment term', async () => { m.pricing.mockResolvedValue([ { commitmentTerm: 'Annual', billingTerm: 'Annual', partnerBuyRate: '99', currencyCode: 'USD' }, diff --git a/apps/api/src/services/pax8OrderPreview.ts b/apps/api/src/services/pax8OrderPreview.ts index 7190b67426e..58bdcab85e2 100644 --- a/apps/api/src/services/pax8OrderPreview.ts +++ b/apps/api/src/services/pax8OrderPreview.ts @@ -3,6 +3,9 @@ import { and, eq } from 'drizzle-orm'; import { PAX8_BILLING_TERMS, type Pax8BillingTerm, type Pax8OrderAction, type Pax8OrderStatus } from '@breeze/shared'; import { db, runOutsideDbContext, withDbAccessContext } from '../db'; import { contracts, contractLines, pax8CompanyMappings, pax8ProductMappings, pax8SubscriptionSnapshots } from '../db/schema'; +import { pax8OrderDigestMaterial, hashPax8OrderMaterial } from './actionIntents/pax8OrderDigest'; +import type { z } from 'zod'; +import type { pax8SubmitAssertionsSchema } from './aiToolSchemasPax8'; import { getOrderWithLines, snapshotActiveCommitmentEvidence } from './pax8OrderService'; import { createPax8ClientForIntegration } from './pax8SyncService'; import { pax8CompanyOrderReadiness } from './pax8CompanyReadiness'; @@ -49,6 +52,8 @@ export interface Pax8OrderPreview { totals: Array<{ currency: string; billingTerm: Pax8BillingTerm; partnerCost: string }>; totalsComplete: boolean; blocked: Pax8PreviewBlocker[]; + previewDigest: string | null; + submitWith?: z.infer & { orderId: string }; } export const MAX_PAX8_PREVIEW_LINES = 20; @@ -107,16 +112,18 @@ export async function previewPax8Order(input: { eq(pax8ProductMappings.integrationId, order.integrationId), eq(pax8ProductMappings.pax8ProductId, productId), )).limit(1) : []; + let baseline = line.authorizedBaselineQuantity; let sellUnitPrice: string | null = null; let sellCurrency: string | null = null; if (line.contractLineId) { const [contractLine] = await db.select({ contractId: contractLines.contractId, catalogItemId: contractLines.catalogItemId, - unitPrice: contractLines.unitPrice, + unitPrice: contractLines.unitPrice, manualQuantity: contractLines.manualQuantity, }).from(contractLines).where(and( eq(contractLines.id, line.contractLineId), eq(contractLines.orgId, order.orgId), )).limit(1); if (contractLine) { + baseline = contractLine.manualQuantity; const [contract] = await db.select({ currencyCode: contracts.currencyCode, createdBy: contracts.createdBy, }).from(contracts).where(and( @@ -140,12 +147,13 @@ export async function previewPax8Order(input: { } } } - evidence.push({ line, snapshot, mapping, productId, sellUnitPrice, sellCurrency }); + evidence.push({ line, snapshot, mapping, productId, sellUnitPrice, sellCurrency, baseline }); } - return { ...bundle, companies, evidence }; - })); + const material = await pax8OrderDigestMaterial(db, order.id); + return { ...bundle, companies, evidence, previewDigest: material ? hashPax8OrderMaterial(material) : null }; + }, { isolationLevel: 'repeatable read' })); - const { order } = local; + const { order, previewDigest } = local; const blocked: Pax8OrderPreview['blocked'] = []; const mapping = local.companies.length === 1 ? local.companies[0]! : null; const company = mapping && pax8CompanyOrderReadiness(mapping.status, mapping.metadata).orderReady @@ -157,7 +165,7 @@ export async function previewPax8Order(input: { if (!SUBMITTABLE.has(status)) blocked.push({ code: 'NOT_PENDING' }); const projectedOrder = { id: order.id, orgId: order.orgId, source: order.source, status }; if (blocked.some(item => item.code === 'TOO_MANY_LINES' || item.code === 'NOT_PENDING')) { - return { order: projectedOrder, company, lines: [], totals: [], totalsComplete: local.lines.length === 0, blocked }; + return { order: projectedOrder, company, lines: [], totals: [], totalsComplete: local.lines.length === 0, blocked, previewDigest }; } // Client construction reads encrypted credentials; keep that DB read scoped, @@ -170,7 +178,7 @@ export async function previewPax8Order(input: { // Cache live evidence only for this preview: repeated product lines must share one observation. const dependencies = new Map>>(); const pricing = new Map>>(); - for (const { line, snapshot, mapping: product, productId, sellUnitPrice, sellCurrency } of local.evidence) { + for (const { line, snapshot, mapping: product, productId, sellUnitPrice, sellCurrency, baseline } of local.evidence) { if (line.submitState !== 'pending') blocked.push({ lineId: line.id, code: 'NOT_PENDING' }); const missingSnapshot = line.action !== 'new_subscription' && !snapshot; if (missingSnapshot) blocked.push({ lineId: line.id, code: 'SUBSCRIPTION_NOT_FOUND' }); @@ -217,7 +225,7 @@ export async function previewPax8Order(input: { productName: product?.productName ?? snapshot?.productName ?? null, vendorSkuId: product?.vendorSkuId ?? snapshot?.vendorSkuId ?? null, quantity, - fromQuantity: line.action === 'new_subscription' ? null : line.authorizedBaselineQuantity, + fromQuantity: line.action === 'new_subscription' ? null : baseline, billingTerm, commitmentTerm: commitment?.term ?? null, unitCost, currency, sellUnitPrice, sellCurrency, contractLineId: line.contractLineId, allowForQuantityIncrease: commitment?.allowForQuantityIncrease ?? null, @@ -242,5 +250,11 @@ export async function previewPax8Order(input: { lineTotal, taxable: false, customerVisible: true, })), null, currency).total, })); - return { order: projectedOrder, company, lines, totals, totalsComplete, blocked }; + const submitWith = blocked.length === 0 && lines.length > 0 && company && previewDigest ? { + orderId: order.id, previewDigest, expectedCompany: company, + expectedLines: lines.map(({ lineId, action, pax8ProductId, productName, quantity, fromQuantity, unitCost, currency, billingTerm, commitmentTerm, cancellationFeeApplied }) => ({ + lineId, action, pax8ProductId, productName, quantity, fromQuantity, unitCost, currency, billingTerm, commitmentTerm, cancellationFeeApplied, + })), + } : undefined; + return { order: projectedOrder, company, lines, totals, totalsComplete, blocked, previewDigest, ...(submitWith ? { submitWith } : {}) }; } diff --git a/apps/api/src/services/pax8OrderSubmit.test.ts b/apps/api/src/services/pax8OrderSubmit.test.ts index 9bd996ef831..a1237cc87ea 100644 --- a/apps/api/src/services/pax8OrderSubmit.test.ts +++ b/apps/api/src/services/pax8OrderSubmit.test.ts @@ -646,3 +646,13 @@ describe('approved submit options', () => { expect(client.createOrder).not.toHaveBeenCalled(); }); }); + +it('reopens a provably unsent quote when preflight payload construction throws', async () => { + const { service, repository, client } = setup([{ ...newLine, provisioningDetails: {} }], { source: 'quote' }); + const result = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1', onPreflightFailure: 'reopen' }); + expect(result).toMatchObject({ status: 'draft', refusalCode: 'PREFLIGHT_FAILED' }); + expect(repository.reopenUnsentOrder).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('invalid provisioning details')); + expect(repository.persistPreflightFailure).not.toHaveBeenCalled(); + expect(repository.claimLines).not.toHaveBeenCalled(); + expect(client.createOrder).not.toHaveBeenCalled(); +}); diff --git a/apps/api/src/services/pax8OrderSubmit.ts b/apps/api/src/services/pax8OrderSubmit.ts index 1933623e891..e115c4f7585 100644 --- a/apps/api/src/services/pax8OrderSubmit.ts +++ b/apps/api/src/services/pax8OrderSubmit.ts @@ -387,7 +387,15 @@ export function createPax8OrderSubmitService(deps: ServiceDeps) { return preflightFailure(errorText(error), error instanceof Pax8IntegrationGenerationChangedError ? 'INTEGRATION_CHANGED' : 'PREFLIGHT_FAILED'); } - const preflight = await preflightBundle(bundle, client, deps.runOutsideDbContext); + let preflight: PreflightResult; + try { + preflight = await preflightBundle(bundle, client, deps.runOutsideDbContext); + } catch (error) { + // Payload construction may fail locally before isMock. It is still + // provably unsent and must follow the AI reopen policy. + if (input.onPreflightFailure === 'reopen') return preflightFailure(errorText(error)); + throw error; + } if (!preflight.ok) { return preflightFailure(preflight.errorBody); } diff --git a/apps/api/src/services/pax8OrderSubmitRepository.test.ts b/apps/api/src/services/pax8OrderSubmitRepository.test.ts index 7824a186dbc..33cecc5a816 100644 --- a/apps/api/src/services/pax8OrderSubmitRepository.test.ts +++ b/apps/api/src/services/pax8OrderSubmitRepository.test.ts @@ -246,7 +246,7 @@ it.each(['new_subscription', 'change_quantity', 'cancel'])('captures and durably const { baseline } = prepareClaim(action); const verifyClaim = vi.fn(async (bundle: import('./pax8OrderSubmit').SubmitBundle) => { expect(mocks.events).toContain('validated'); - expect(bundle.lines[0].authorizedBaselineQuantity).toBe('4.00'); + expect(bundle.lines[0]!.authorizedBaselineQuantity).toBe('4.00'); expect(baseline.returning).toHaveBeenCalled(); }); const bundle = await pax8OrderSubmitRepository.claimOrder({ partnerId: order.partnerId, orderId: order.id, actorUserId: 'actor-1', verifyClaim }); From b19fa0cdc995660322780c55576a8295f7440c14 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Sat, 31 Oct 2026 11:40:41 -0600 Subject: [PATCH 03/12] test(pax8): integration proof for approved order submits (#6785) Cover partner isolation, real intent content drift, claim-time rollback, approved billing quantity persistence and timeout reconciliation. Mock Pax8 only at client construction. Integration execution is deferred to CI; static guard-flip reading and the API test-project typecheck completed locally. Co-Authored-By: Claude Fable 5.1 --- ...lsPax8OrderSubmitScope.integration.test.ts | 258 ++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 apps/api/src/__tests__/integration/aiToolsPax8OrderSubmitScope.integration.test.ts diff --git a/apps/api/src/__tests__/integration/aiToolsPax8OrderSubmitScope.integration.test.ts b/apps/api/src/__tests__/integration/aiToolsPax8OrderSubmitScope.integration.test.ts new file mode 100644 index 00000000000..124ca07bcf4 --- /dev/null +++ b/apps/api/src/__tests__/integration/aiToolsPax8OrderSubmitScope.integration.test.ts @@ -0,0 +1,258 @@ +/** W15 PR B: real claim/RLS/digest/billing persistence; only Pax8 HTTP is mocked. */ +import './setup'; +import { randomUUID } from 'node:crypto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { and, eq } from 'drizzle-orm'; +import { withDbAccessContext } from '../../db'; +import { + actionIntents, approvalRequests, catalogItems, contractLines, contracts, + pax8CompanyMappings, pax8Integrations, pax8OrderLines, pax8Orders, pax8ProductMappings, +} from '../../db/schema'; +import { buildDbAccessContext, buildOrgAccessClosures, type AuthContext } from '../../middleware/auth'; +import { aiTools, executeTool, toolManagesDbContext } from '../../services/aiTools'; +import { createActionIntent } from '../../services/actionIntents/intentService'; +import { decideApprovalRequest } from '../../services/approvals/decideApprovalRequest'; +import { releaseApprovedIntent } from '../../jobs/intentReleaseWorker'; +import { pax8OrderSubmitRepository } from '../../services/pax8OrderSubmitRepository'; +import type { Pax8Client } from '../../services/pax8Client'; +import { + assignUserToPartner, createOrganization, createPartner, createRole, createUser, grantRolePermissions, +} from './db-utils'; +import { getTestDb } from './setup'; + +const vendor = vi.hoisted(() => ({ + factory: vi.fn(), dependencies: vi.fn(), pricing: vi.fn(), createOrder: vi.fn(), + listOrders: vi.fn(), listSubscriptions: vi.fn(), +})); +vi.mock('../../services/pax8SyncService', async original => ({ + ...await original(), + createPax8ClientForIntegration: vendor.factory, +})); +const TOOL = 'manage_pax8_orders'; +const COMPANY = 'fixture-company'; +const PRODUCT = 'fixture-product'; +const TERM = 'fixture-term'; +const REMOTE_ORDER = 'fixture-remote-order'; +const REMOTE_SUBSCRIPTION = 'fixture-remote-subscription'; +const READY = { contacts: [{ types: ['Admin', 'Billing', 'Technical'].map(type => ({ type, primary: true })) }] }; + +function authFor(user: { id: string; email: string }, partnerId: string, orgId: string, roleId: string): AuthContext { + return { + principal: { kind: 'user_session' }, + user: { id: user.id, email: user.email, name: 'Fixture Operator', isPlatformAdmin: false }, + token: { sub: user.id, email: user.email, roleId, orgId, partnerId, scope: 'partner', type: 'access', mfa: true }, + scope: 'partner', orgId, partnerId, partnerOrgAccess: 'all', accessibleOrgIds: [orgId], + ...buildOrgAccessClosures([orgId]), canAccessSite: () => true, + } as AuthContext; +} +function context(auth: AuthContext) { + return buildDbAccessContext({ scope: auth.scope, orgId: auth.orgId, accessibleOrgIds: auth.accessibleOrgIds ?? [], + partnerId: auth.partnerId, userId: auth.user.id }); +} +async function run(input: Record, auth: AuthContext) { + const call = () => executeTool(TOOL, input, auth); + const raw = toolManagesDbContext(aiTools.get(TOOL), input) ? await call() : await withDbAccessContext(context(auth), call); + return { raw, body: JSON.parse(raw) as Record }; +} + +async function seed() { + const database = getTestDb(); + const partner = await createPartner(); + const otherPartner = await createPartner(); + const org = await createOrganization({ partnerId: partner.id }); + const otherOrg = await createOrganization({ partnerId: otherPartner.id }); + const role = await createRole({ scope: 'partner', partnerId: partner.id }); + await grantRolePermissions(role.id, [{ resource: '*', action: '*' }]); + const requester = await createUser({ partnerId: partner.id, email: `fixture-requester-${randomUUID()}@example.com`, mfaEnabled: true }); + const approver = await createUser({ partnerId: partner.id, email: `fixture-approver-${randomUUID()}@example.com`, mfaEnabled: true }); + await assignUserToPartner(requester.id, partner.id, role.id, 'all'); + await assignUserToPartner(approver.id, partner.id, role.id, 'all'); + const otherRole = await createRole({ scope: 'partner', partnerId: otherPartner.id }); + await grantRolePermissions(otherRole.id, [{ resource: '*', action: '*' }]); + const otherUser = await createUser({ partnerId: otherPartner.id, email: `fixture-other-${randomUUID()}@example.com` }); + await assignUserToPartner(otherUser.id, otherPartner.id, otherRole.id, 'all'); + const [integration] = await database.insert(pax8Integrations).values({ + partnerId: partner.id, name: 'Fixture Integration', clientIdEncrypted: 'fixture-encrypted-client-id', + clientSecretEncrypted: 'fixture-encrypted-client-secret', + apiBaseUrl: 'https://vendor.example.com/v1', tokenUrl: 'https://vendor.example.com/token', + }).returning(); + if (!integration) throw new Error('integration fixture missing'); + await database.insert(pax8CompanyMappings).values({ + integrationId: integration.id, partnerId: partner.id, orgId: org.id, + pax8CompanyId: COMPANY, pax8CompanyName: 'Fixture Company', status: 'Active', metadata: READY, + }); + const [catalog] = await database.insert(catalogItems).values({ + partnerId: partner.id, itemType: 'software', name: 'Fixture Seats', billingType: 'recurring', + billingFrequency: 'monthly', costCurrency: 'USD', taxable: false, + }).returning(); + if (!catalog) throw new Error('catalog fixture missing'); + await database.insert(pax8ProductMappings).values({ integrationId: integration.id, partnerId: partner.id, + pax8ProductId: PRODUCT, productName: 'Fixture Seats', vendorSkuId: 'fixture-sku', catalogItemId: catalog.id }); + const [contract] = await database.insert(contracts).values({ partnerId: partner.id, orgId: org.id, + name: 'Fixture Contract', intervalMonths: 1, startDate: '2026-10-01', currencyCode: 'USD' }).returning(); + if (!contract) throw new Error('contract fixture missing'); + const [billingLine] = await database.insert(contractLines).values({ contractId: contract.id, orgId: org.id, + lineType: 'manual', description: 'Fixture Seats', manualQuantity: '0.00', unitPrice: '12.00' }).returning(); + if (!billingLine) throw new Error('billing fixture missing'); + const [order] = await database.insert(pax8Orders).values({ integrationId: integration.id, + partnerId: partner.id, orgId: org.id, source: 'quote', status: 'draft', + dedupeKey: `fixture:${randomUUID()}`, createdBy: requester.id }).returning(); + if (!order) throw new Error('order fixture missing'); + const [line] = await database.insert(pax8OrderLines).values({ orderId: order.id, partnerId: partner.id, + orgId: org.id, action: 'new_subscription', pax8ProductId: PRODUCT, catalogItemId: catalog.id, + billingTerm: 'Monthly', commitmentTermId: TERM, quantity: '7.00', contractLineId: billingLine.id }).returning(); + if (!line) throw new Error('line fixture missing'); + vendor.factory.mockResolvedValue({ integration, client: { + getProductDependencies: vendor.dependencies, getProductPricing: vendor.pricing, createOrder: vendor.createOrder, + listOrders: vendor.listOrders, listSubscriptions: vendor.listSubscriptions, + } }); + return { partner, org, integration, billingLine, order, line, requester, approver, + auth: authFor(requester, partner.id, org.id, role.id), + approverAuth: authFor(approver, partner.id, org.id, role.id), + otherAuth: authFor(otherUser, otherPartner.id, otherOrg.id, otherRole.id) }; +} +let f: Awaited>; +beforeEach(async () => { + vi.clearAllMocks(); + vendor.dependencies.mockResolvedValue({ commitments: [{ id: TERM, term: 'Annual', + allowForQuantityIncrease: true, allowForQuantityDecrease: true, allowForEarlyCancellation: true, cancellationFeeApplied: false }] }); + vendor.pricing.mockResolvedValue([{ billingTerm: 'Monthly', commitmentTerm: 'Annual', partnerBuyRate: '8.00', currencyCode: 'USD', raw: {} }]); + vendor.createOrder.mockImplementation(async (_input, options) => options?.isMock + ? { pax8OrderId: null, lineItems: [] } + : { pax8OrderId: REMOTE_ORDER, lineItems: [{ lineItemNumber: 1, productId: PRODUCT, subscriptionId: REMOTE_SUBSCRIPTION }] }); + vendor.listOrders.mockResolvedValue([]); + vendor.listSubscriptions.mockResolvedValue([]); + f = await seed(); +}); +afterEach(() => { vi.restoreAllMocks(); }); + +async function approvedArguments() { + const preview = await run({ action: 'preview', orderId: f.order.id }, f.auth); + expect(preview.body.blocked, preview.raw).toEqual([]); + expect(preview.body.previewDigest).toMatch(/^[a-f0-9]{64}$/); + expect(preview.body.submitWith).toBeTruthy(); + return { action: 'submit', ...preview.body.submitWith }; +} +async function state() { + const database = getTestDb(); + const [order] = await database.select().from(pax8Orders).where(eq(pax8Orders.id, f.order.id)); + const [line] = await database.select().from(pax8OrderLines).where(eq(pax8OrderLines.id, f.line.id)); + const [billing] = await database.select().from(contractLines).where(eq(contractLines.id, f.billingLine.id)); + return { order: order!, line: line!, billing: billing! }; +} + +describe('Pax8 AI approved submit scope and execution binding (real PostgreSQL)', () => { + it('partner B cannot submit partner A order: no row changes and no client construction', async () => { + const input = await approvedArguments(); + const before = await state(); + vendor.factory.mockClear(); vendor.createOrder.mockClear(); + const result = await run(input, f.otherAuth); + expect(result.body.code, result.raw).toBe('NOT_FOUND'); + expect(await state()).toEqual(before); + expect(vendor.factory).not.toHaveBeenCalled(); + expect(vendor.createOrder).not.toHaveBeenCalled(); + }); + + it('an edit after intent creation fails real release with content_changed before any Pax8 HTTP', async () => { + const input = await approvedArguments(); + const intent = await createActionIntent(f.auth, { toolName: TOOL, input, source: 'chat', orgId: f.org.id }); + expect(intent.status).toBe('pending_approval'); + const [approval] = await getTestDb().select().from(approvalRequests).where(and( + eq(approvalRequests.intentId, intent.id), eq(approvalRequests.userId, f.approver.id), + )); + expect(approval).toBeTruthy(); + const decision = await withDbAccessContext(context(f.approverAuth), () => decideApprovalRequest({ + auth: f.approverAuth, id: approval!.id, status: 'approved', + })); + expect(decision.httpStatus, JSON.stringify(decision.body)).toBe(200); + const readIntent = async () => (await getTestDb().select().from(actionIntents).where(eq(actionIntents.id, intent.id)))[0]!; + const approved = await readIntent(); + expect(approved.status).toBe('approved'); + expect(approved.approvalScope).toBe('four_eyes'); + expect(approved.effectDigest).toMatch(/^[a-f0-9]{64}$/); + await getTestDb().update(pax8OrderLines).set({ quantity: '8.00' }).where(eq(pax8OrderLines.id, f.line.id)); + vendor.factory.mockClear(); vendor.createOrder.mockClear(); + await releaseApprovedIntent(intent.id); + const released = await readIntent(); + expect(released.status).toBe('failed'); + expect(released.errorCode).toBe('content_changed'); + expect(released.executedAt).toBeNull(); + expect(released.effectDigest).toBe(approved.effectDigest); + expect((await state()).order.status).toBe('draft'); + expect(vendor.factory).not.toHaveBeenCalled(); + expect(vendor.createOrder).not.toHaveBeenCalled(); + }); + + it('an edit inside verifyClaim after the release read refuses and rolls the claim back to draft', async () => { + const input = await approvedArguments(); + const claimOrder = pax8OrderSubmitRepository.claimOrder; + const verification = vi.fn(); + vi.spyOn(pax8OrderSubmitRepository, 'claimOrder').mockImplementationOnce(args => claimOrder({ + ...args, + verifyClaim: async (bundle, database) => { + // Deliberately use the claim transaction: a separate connection would + // wait for its parent lock and could not model this exact check/use gap. + await database.update(pax8OrderLines).set({ quantity: '8.00' }).where(eq(pax8OrderLines.id, f.line.id)); + const [edited] = await database.select({ quantity: pax8OrderLines.quantity }).from(pax8OrderLines) + .where(eq(pax8OrderLines.id, f.line.id)); + expect(edited?.quantity).toBe('8.00'); + verification(); + expect(args.verifyClaim).toBeTypeOf('function'); + await args.verifyClaim!(bundle, database); + }, + })); + vendor.createOrder.mockClear(); + const result = await run(input, f.auth); + expect(verification).toHaveBeenCalledTimes(1); + expect(result.body.code, result.raw).toBe('PREVIEW_MISMATCH'); + const current = await state(); + expect(current.order.status).toBe('draft'); + expect(current.order.submittedAt).toBeNull(); + expect(current.line.submitState).toBe('pending'); + expect(current.line.quantity).toBe('7.00'); + expect(current.line.authorizedBaselineQuantity).toBeNull(); + expect(current.billing.manualQuantity).toBe('0.00'); + expect(vendor.createOrder).not.toHaveBeenCalled(); + }); + + it('an approved successful submit persists the captured baseline and linked manual billing quantity', async () => { + const result = await run(await approvedArguments(), f.auth); + expect(result.body.status, result.raw).toBe('completed'); + expect(result.body.lines).toEqual([{ lineId: f.line.id, submitState: 'succeeded', error: null }]); + const current = await state(); + expect(current.line.authorizedBaselineQuantity).toBe('0.00'); + expect(current.line.resultSubscriptionId).toBe(REMOTE_SUBSCRIPTION); + expect(current.billing.manualQuantity).toBe('7.00'); + expect(vendor.createOrder).toHaveBeenCalledTimes(2); + expect(vendor.createOrder.mock.calls[0]?.[1]).toEqual({ isMock: true }); + expect(vendor.createOrder.mock.calls[1]?.[1]).toBeUndefined(); + }); + + it('a mocked timeout remains needs_reconcile until a unique listOrders match resolves billing without resending', async () => { + const input = await approvedArguments(); + vendor.createOrder.mockImplementation(async (_input, options) => { + if (options?.isMock) return { pax8OrderId: null, lineItems: [] }; + throw new Error('Fixture timeout after request was sent'); + }); + const submitted = await run(input, f.auth); + expect(submitted.body.lines, submitted.raw).toEqual([expect.objectContaining({ lineId: f.line.id, submitState: 'needs_reconcile' })]); + const unknown = await state(); + expect(unknown.line.authorizedBaselineQuantity).toBe('0.00'); + expect(unknown.billing.manualQuantity).toBe('0.00'); + expect(unknown.order.submittedAt).toBeInstanceOf(Date); + vendor.listOrders.mockResolvedValue([{ + pax8OrderId: REMOTE_ORDER, pax8CompanyId: COMPANY, + createdDate: unknown.order.submittedAt!.toISOString().slice(0, 10), + lineItems: [{ lineItemNumber: 1, productId: PRODUCT, quantity: '7.00', quantityKnown: true, subscriptionId: REMOTE_SUBSCRIPTION }], raw: {}, + } satisfies Awaited>[number]]); + const reconciled = await run({ action: 'reconcile', orderId: f.order.id }, f.auth); + expect(reconciled.body, reconciled.raw).toEqual({ resolved: 1, stillUnknown: 0 }); + const current = await state(); + expect(current.order.status).toBe('completed'); + expect(current.line.submitState).toBe('succeeded'); + expect(current.line.resultSubscriptionId).toBe(REMOTE_SUBSCRIPTION); + expect(current.billing.manualQuantity).toBe('7.00'); + expect(vendor.listOrders).toHaveBeenCalledWith({ companyId: COMPANY }); + expect(vendor.createOrder).toHaveBeenCalledTimes(2); + }); +}); From 062c19196ece8b0c4bbfcad3f948774585919351 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Sat, 31 Oct 2026 11:40:41 -0600 Subject: [PATCH 04/12] feat(ai-tools): W15 Pax8 approval copy, docs and submit golden prompt (#6785) Document four-eyes submit and supervised reconcile, add g116 and tier rows, and report the measured lazy-index delta against PR A. Keep every declared action in the bounded tool description and verify API/integration area gates. Co-Authored-By: Claude Fable 5.1 --- .github/scripts/ci-area-gating.test.mjs | 14 ++++++++++++++ apps/api/src/services/actionIntents/actionLabel.ts | 5 +++-- .../src/services/aiGuardrailsAiDocs.parity.test.ts | 4 ++++ .../services/aiGuardrailsTierConfig.parity.test.ts | 4 ++++ .../src/services/aiToolIndex.w15-budget.test.ts | 6 ++++-- apps/api/src/services/aiToolsPax8Orders.ts | 2 +- .../services/llm/toolEval/goldenPrompts.test.ts | 7 ++++--- .../api/src/services/llm/toolEval/goldenPrompts.ts | 2 ++ apps/docs/src/content/docs/features/ai.mdx | 2 ++ apps/docs/src/content/docs/features/mcp-server.mdx | 4 ++-- apps/web/src/components/ai-risk/tierConfig.ts | 2 ++ docs/release-notes/next-release-draft.md | 1 + 12 files changed, 43 insertions(+), 10 deletions(-) diff --git a/.github/scripts/ci-area-gating.test.mjs b/.github/scripts/ci-area-gating.test.mjs index 5a8438cca0c..4ef1d52a4d6 100644 --- a/.github/scripts/ci-area-gating.test.mjs +++ b/.github/scripts/ci-area-gating.test.mjs @@ -752,3 +752,17 @@ for (const file of ['pax8OrderPreview', 'aiToolSchemasPax8', 'aiToolsPax8', 'aiT assert.equal(out.app, 'true'); }); } + + +// W15 PR B (#6785): the pinned money-surface digest and its real-DB proof. +for (const file of [ + 'apps/api/src/services/actionIntents/pax8OrderDigest.ts', + 'apps/api/src/__tests__/integration/aiToolsPax8OrderSubmitScope.integration.test.ts', +]) { + test(`W15 PR B ${file} runs API and integration checks`, () => { + const out = classifyPrGate([file]); + assert.deepEqual(areasOf(out), expectAreas(['api'])); + assert.equal(out.integration, 'true'); + assert.equal(out.app, 'true'); + }); +} diff --git a/apps/api/src/services/actionIntents/actionLabel.ts b/apps/api/src/services/actionIntents/actionLabel.ts index b349cf92b2a..1461f78574e 100644 --- a/apps/api/src/services/actionIntents/actionLabel.ts +++ b/apps/api/src/services/actionIntents/actionLabel.ts @@ -28,9 +28,10 @@ export function describePax8Submit(input: Record): string { const company = input.expectedCompany && typeof input.expectedCompany === 'object' ? input.expectedCompany as Record : {}; const lines = Array.isArray(input.expectedLines) ? input.expectedLines as Array> : []; - const counts = ['new_subscription', 'change_quantity', 'cancel'].map(action => `${lines.filter(l => l.action === action).length} ${action}`).join(', '); + const labels: Record = { new_subscription: 'new subscriptions', change_quantity: 'quantity changes', cancel: 'cancellations' }; + const counts = Object.entries(labels).map(([action, label]) => `${lines.filter(l => l.action === action).length} ${label}`).join(', '); return `Place a real, billable Pax8 order that cannot be undone for ${text(company.name)} (${text(company.pax8CompanyId)}): ${counts}. Cost is estimated at Pax8's current rate, re-checked before sending. ` - + lines.map(line => `${line.action === 'cancel' ? 'CANCEL (terminal)' : text(line.action)} ${text(line.productName)} [${text(line.pax8ProductId)}]: ${text(line.fromQuantity)} → ${text(line.quantity)}, ${text(line.unitCost)} ${text(line.currency)} per seat / ${text(line.billingTerm)}, commitment ${text(line.commitmentTerm)}${line.cancellationFeeApplied === true ? ', cancellation fee applies' : ''}`).join('; '); + + lines.map(line => `${line.action === 'cancel' ? 'CANCEL (terminal)' : titleCaseWords(text(line.action))} ${text(line.productName)} [${text(line.pax8ProductId)}]: ${text(line.fromQuantity)} → ${text(line.quantity)}, ${text(line.unitCost)} ${text(line.currency)} per seat / ${text(line.billingTerm)}, commitment ${text(line.commitmentTerm)}${line.cancellationFeeApplied === true ? ', cancellation fee applies' : ''}`).join('; '); } /** diff --git a/apps/api/src/services/aiGuardrailsAiDocs.parity.test.ts b/apps/api/src/services/aiGuardrailsAiDocs.parity.test.ts index e620cb50357..2007e4a6167 100644 --- a/apps/api/src/services/aiGuardrailsAiDocs.parity.test.ts +++ b/apps/api/src/services/aiGuardrailsAiDocs.parity.test.ts @@ -193,6 +193,10 @@ const W03_DOCUMENTED_TOOLS = [ ] as const; describe('features/ai.mdx ↔ aiGuardrails tier tables parity (#2686)', () => { + it.each(['submit', 'reconcile'])('W15: documents manage_pax8_orders:%s at Tier 3', (action) => { + expect(claims.filter((entry) => entry.tool === 'manage_pax8_orders' && entry.actions.includes(action)) + .map((entry) => entry.claimedTier)).toEqual([3]); + }); it.each(W04_DOCUMENTED)('W04 (#6758): %s is documented in a tool table with every action', (tool) => { const rows = claims.filter((c) => c.tool === tool); expect(rows.length, `${tool} needs a \`| Tool | Tier | Description |\` row in ${DOC_REL}`).toBeGreaterThan(0); diff --git a/apps/api/src/services/aiGuardrailsTierConfig.parity.test.ts b/apps/api/src/services/aiGuardrailsTierConfig.parity.test.ts index cfc8cc48692..385cbe84bd7 100644 --- a/apps/api/src/services/aiGuardrailsTierConfig.parity.test.ts +++ b/apps/api/src/services/aiGuardrailsTierConfig.parity.test.ts @@ -58,6 +58,10 @@ for (const tier of TIER_DEFINITIONS) { } describe('tierConfig.ts ↔ aiGuardrails tier tables parity (#2686)', () => { + it.each(['submit', 'reconcile'])('W15: documents manage_pax8_orders:%s at Tier 3', (action) => { + expect(parsed.filter((entry) => entry.tool === 'manage_pax8_orders' && entry.actions.includes(action)) + .map((entry) => entry.claimedTier)).toEqual([3]); + }); it.each(['get', 'create', 'update', 'delete'])('lists manage_monitors:%s at Tier 1 with read permission only', (action) => { const entries = parsed.filter((entry) => entry.tool === 'manage_monitors' && entry.actions.includes(action)); expect(entries.map((entry) => entry.claimedTier)).toEqual([1]); diff --git a/apps/api/src/services/aiToolIndex.w15-budget.test.ts b/apps/api/src/services/aiToolIndex.w15-budget.test.ts index 79be91a4db1..d9f7c393268 100644 --- a/apps/api/src/services/aiToolIndex.w15-budget.test.ts +++ b/apps/api/src/services/aiToolIndex.w15-budget.test.ts @@ -2,7 +2,7 @@ import { expect, it } from 'vitest'; import { listChatSurfaceToolNames } from './aiAgentSdkTools'; import { renderAlwaysOnIndex, renderDomainBlock, renderPageContextToolBlocks } from './aiToolIndex'; -it('W15 Pax8 vocabulary fits the +128 B lazy-index wave allowance and 7,040 B total cap', () => { +it('W15 PR B stays within +128 B of PR A and the existing 7,040 B total cap', () => { const names = listChatSurfaceToolNames(); const bytes = (value: string) => Buffer.byteLength(value, 'utf8'); const alwaysOn = renderAlwaysOnIndex(names); @@ -11,9 +11,11 @@ it('W15 Pax8 vocabulary fits the +128 B lazy-index wave allowance and 7,040 B to const largestBlock = Math.max(...(['core', 'devices', 'scripts', 'patching', 'monitoring', 'network', 'security', 'backup', 'tickets', 'billing', 'accounts', 'integrations', 'admin', 'ai'] as const).map((d) => bytes(renderDomainBlock(d, names)))); const B3 = B1 + bytes(renderPageContextToolBlocks({ type: 'alert', id: 'alert-fixture', title: 'Fixture' } as never, names)); expect(alwaysOn).toContain('Pax8 orders'); - expect(B1 - 2494).toBeLessThanOrEqual(128); + expect(B1 - 2507).toBeLessThanOrEqual(128); expect(B1).toBeLessThan(2560); expect(B2).toBeLessThan(2048); expect(largestBlock).toBeLessThan(2048); + expect(B3 - 5270).toBeLessThanOrEqual(128); expect(B3).toBeLessThanOrEqual(7040); + process.stdout.write(`W15 PR B index bytes ${JSON.stringify({ B1, B2, largestBlock, B3, deltaB1: B1 - 2507, deltaB2: B2 - 737, deltaB3: B3 - 5270 })}\n`); }); diff --git a/apps/api/src/services/aiToolsPax8Orders.ts b/apps/api/src/services/aiToolsPax8Orders.ts index 6f4ac8388f3..347e1739916 100644 --- a/apps/api/src/services/aiToolsPax8Orders.ts +++ b/apps/api/src/services/aiToolsPax8Orders.ts @@ -36,7 +36,7 @@ export function registerPax8OrderTools(aiTools: Map): void { aiTools.set(TOOL, { tier: 1, deviceArgs: [], domain: 'billing', searchHint: 'Pax8 drafts, seat changes, cancellations, preview, submit and reconcile', selfManagedDbContext: ['add_line', 'preview', 'preflight', 'submit', 'reconcile'], - definition: { name: TOOL, description: 'Pax8 orders: drafts, preview, preflight, submit and reconcile. Submit copies preview.submitWith: four-eyes, estimated cost, terminal cancellations. Never retry unknown writes; reconcile them.', input_schema: pax8InputSchema(pax8ToolSchemas.manage_pax8_orders) }, + definition: { name: TOOL, description: 'Pax8 orders: list, get, create_draft, add_line, update_line, remove_line, preview, preflight, submit, reconcile. Submit uses preview.submitWith: four-eyes, estimated cost, terminal cancellations. Reconcile unknown writes; never retry them.', input_schema: pax8InputSchema(pax8ToolSchemas.manage_pax8_orders) }, handler: async (input, auth) => { const partner = pax8Partner(auth, input.partnerId); if ('refusal' in partner) return partner.refusal; const partnerId = partner.partnerId, action = String(input.action); diff --git a/apps/api/src/services/llm/toolEval/goldenPrompts.test.ts b/apps/api/src/services/llm/toolEval/goldenPrompts.test.ts index 5a93f8f6c19..d3217cf314e 100644 --- a/apps/api/src/services/llm/toolEval/goldenPrompts.test.ts +++ b/apps/api/src/services/llm/toolEval/goldenPrompts.test.ts @@ -22,7 +22,7 @@ const PENDING_DECLARATION = new Set([]); // search_documentation was dec const BASELINE_UNDECLARED_TOOLS = new Set([]); describe('GOLDEN_CASES', () => { - it('has 115 unique ids', () => { + it('has 116 unique ids', () => { // g68 (#8167 W03): EDR-framework containment. g69 (#8167 W03 PR-B): EDR "sync now". // g70-g75 (full-control W02, #6756; the plan's g73-g78): PSA/billing writes. // g76-g78 (full-control W03, #6757): backup restore, DR rehearsal, Hyper-V power. @@ -38,8 +38,9 @@ describe('GOLDEN_CASES', () => { // g106-g108 (full-control W13, #6783 PR B): live event-log query (supervised), tenant variables, end a support session (supervised). // g109-g111 (full-control W14, #6784 PR A): an org's rate card, publish a template version (supervised), device billing. // g112-g113 (full-control W14, #6784 PR B): generate a contract invoice (preview, then four-eyes), push to Xero (four-eyes). - expect(GOLDEN_CASES).toHaveLength(115); - expect(new Set(GOLDEN_CASES.map((c) => c.id)).size).toBe(115); + expect(GOLDEN_CASES).toHaveLength(116); + expect(new Set(GOLDEN_CASES.map((c) => c.id)).size).toBe(116); + expect(GOLDEN_CASES.find((c) => c.id === 'g116')?.expect).toEqual([{ tool: 'manage_pax8_orders', action: 'preview' }, { tool: 'manage_pax8_orders', action: 'submit' }]); expect(GOLDEN_CASES.find((c) => c.id === 'g114')?.expect[0]).toEqual({ tool: 'manage_pax8_subscriptions', action: 'drift' }); expect(GOLDEN_CASES.find((c) => c.id === 'g115')?.expect).toEqual([{ tool: 'search_pax8_products', action: 'search' }, { tool: 'manage_pax8_orders', action: 'create_draft' }, { tool: 'manage_pax8_orders', action: 'add_line' }]); expect(GOLDEN_CASES.find((c) => c.id === 'g112')?.expect.map((e) => e.action)).toContain('generate_invoice'); diff --git a/apps/api/src/services/llm/toolEval/goldenPrompts.ts b/apps/api/src/services/llm/toolEval/goldenPrompts.ts index e3c2d1d4541..2035beabf36 100644 --- a/apps/api/src/services/llm/toolEval/goldenPrompts.ts +++ b/apps/api/src/services/llm/toolEval/goldenPrompts.ts @@ -150,4 +150,6 @@ export const GOLDEN_CASES: readonly GoldenCase[] = [ // Full-control W15 PR A (#6785): seat drift and local order staging. { id: 'g114', prompt: "Which Pax8 seat counts don't match what we bill?", expect: [{ tool: 'manage_pax8_subscriptions', action: 'drift' }] }, { id: 'g115', prompt: 'Stage 5 more Microsoft 365 Business Premium seats for Contoso in Pax8.', expect: [{ tool: 'search_pax8_products', action: 'search' }, { tool: 'manage_pax8_orders', action: 'create_draft' }, { tool: 'manage_pax8_orders', action: 'add_line' }] }, + // Full-control W15 PR B (#6785): preview, then a four-eyes submit. + { id: 'g116', prompt: "Submit Contoso's Pax8 order.", expect: [{ tool: 'manage_pax8_orders', action: 'preview' }, { tool: 'manage_pax8_orders', action: 'submit' }] }, ]; diff --git a/apps/docs/src/content/docs/features/ai.mdx b/apps/docs/src/content/docs/features/ai.mdx index d376c7685e9..3fb39fe03fd 100644 --- a/apps/docs/src/content/docs/features/ai.mdx +++ b/apps/docs/src/content/docs/features/ai.mdx @@ -752,6 +752,8 @@ These tools read and change how your organizations are billed: rate cards and wo | `manage_pax8_orders` (list/get/preview) | 1 | Orders and read-only previews with live cost evidence, customer price and active commitment rules | | `manage_pax8_orders` (create_draft/add_line/update_line/remove_line) | 2 | Stage and edit order drafts; nothing is purchased | | `manage_pax8_orders` (preflight) | 3 | Supervised: send staged content to Pax8 for an isMock dry run. Nothing is purchased | +| `manage_pax8_orders` (submit) | 3 | Four-eyes: two people approve a billable order estimated at Pax8's current rate. Changed inputs, prices or commitments refuse submission. Every CANCEL is terminal; its commitment term appears on the approval card. Not offered to AI agents | +| `manage_pax8_orders` (reconcile) | 3 | Supervised: record an approved attempt from vendor evidence without sending another write. Ambiguous outcomes stay unknown; a concurrent billing edit is preserved and flagged as `billing_conflict` | | `manage_billing_profiles` (list/get/list_work_types/get_org_profile) | 1 | Rate cards with their hourly rates per work type, work types, and the card an organization is billed by | | `manage_billing_profiles` (create/update/archive/set_rows/clone/create_work_type/update_work_type) | 3 | Change rate cards or work types. They price every organization of the partner, so every change is approved and needs full partner access. The previous values come back with the result. Archiving a work type, and AI price-list edits after a card is created, are done in the web app | | `manage_billing_profiles` (set_org_profile/clear_org_profile) | 3 | Choose, or clear, the rate card one organization is billed by | diff --git a/apps/docs/src/content/docs/features/mcp-server.mdx b/apps/docs/src/content/docs/features/mcp-server.mdx index 9c71ddaae07..670714a458e 100644 --- a/apps/docs/src/content/docs/features/mcp-server.mdx +++ b/apps/docs/src/content/docs/features/mcp-server.mdx @@ -526,7 +526,7 @@ To make sure `run_script` executes exactly the content you reviewed, take `conte | `get_pax8_status` | 1 | Integration health and companies; full partner access. | | `search_pax8_products` | 1 | Mapped products and product-only provisioning, commitment and live pricing reads. | | `manage_pax8_subscriptions` | 1/3 | `list` and `drift` are reads; `sync`, `link`, `unlink` are supervised and MFA-gated. Full partner access on every action. | -| `manage_pax8_orders` | 1/2/3 | `list`, `get`, `preview` are reads; draft and line edits are Tier 2; `preflight` is supervised and sends staged content for an isMock dry run. Writes need MFA and whole-org access. | +| `manage_pax8_orders` | 1/2/3 | `list`, `get`, `preview` are reads; draft and line edits are Tier 2; `preflight` and `reconcile` are supervised. `submit` requires two-person approval bound to a preview; the estimated live rate and commitment terms are rechecked before submission. Cancellations are terminal. Writes need MFA and whole-org access. | | `manage_billing_profiles` | 1/3 | Rate cards, work types and an organization's card. `list`, `get`, `list_work_types`, `get_org_profile` are Tier 1 (`billing_profiles:read`); every write is Tier 3, supervised (`billing_profiles:write`, MFA; rate-card and work-type writes also need full partner access). Partner scope only. | | `manage_billing_settings` | 1/3 | `get_partner` and `get_org` are Tier 1 (`organizations:read`), `currency_impact` Tier 1 (`invoices:write`), `reporting_totals` Tier 1 (`invoices:read`); `update_partner` and `update_org` Tier 3, supervised (`invoices:write`, MFA). Partner scope only. | | `manage_contract_templates` | 1/2/3 | `list`, `get`, `get_version`, `usage` are Tier 1 (`agreements:read`); `create` and `update` Tier 2 for an organization's template, supervised with `ownerScope: partner` (`agreements:write`); `create_version`, `publish`, `archive` Tier 3, supervised, organization templates only. Not offered to AI agents for writes. | @@ -1279,4 +1279,4 @@ The tool name in `tools/call` does not match any registered tool. Use `tools/lis SSE sessions expire after 30 minutes. If you see errors about missing sessions, the client should reconnect to `/api/v1/mcp/sse` to obtain a new session. -Pax8 tools require `billing:manage` and partner or system scope (system callers pass `partnerId`). Device-bound sessions are refused. Supervised actions return `MCP_APPROVAL_REQUIRED` by default over MCP. Credential save/test and company mapping remain in the web app. +Pax8 tools require `billing:manage` and partner or system scope (system callers pass `partnerId`). Device-bound sessions are refused. Supervised actions return `MCP_APPROVAL_REQUIRED` by default over MCP. `manage_pax8_orders:submit` creates a four-eyes intent with a mandatory pinned digest; it is never offered to headless agents. Preview first and copy `submitWith` unchanged. A timeout requires `reconcile`, never a blind retry. Reconciliation records vendor evidence, preserves conflicting billing edits as `billing_conflict`, and leaves ambiguous outcomes unknown. Credential save/test and company mapping remain in the web app. diff --git a/apps/web/src/components/ai-risk/tierConfig.ts b/apps/web/src/components/ai-risk/tierConfig.ts index a1ea86d4e57..6cf6b2ded1b 100644 --- a/apps/web/src/components/ai-risk/tierConfig.ts +++ b/apps/web/src/components/ai-risk/tierConfig.ts @@ -551,6 +551,8 @@ export const TIER_DEFINITIONS: TierDefinition[] = [ // Full-control W14 (#6784): what every organization (or one) is billed, and the legal documents customers sign. { name: 'manage_pax8_subscriptions (sync/link/unlink)', description: 'Supervised: sync every organization or change which contract line later orders bill (full partner access)', category: 'Billing' }, { name: 'manage_pax8_orders (preflight)', description: 'Supervised: send staged order content to Pax8 for an isMock dry run', category: 'Billing' }, + { name: 'manage_pax8_orders (submit)', description: 'Submit a billable Pax8 order at an estimated current rate; two people approve, changed inputs refuse, and cancellations are terminal (not offered to AI agents)', category: 'Billing' }, + { name: 'manage_pax8_orders (reconcile)', description: 'Supervised: record Pax8 outcomes from vendor evidence and update billing quantities; conflicting billing edits are flagged for review', category: 'Billing' }, { name: 'manage_billing_profiles (create/update/archive/set_rows/clone/create_work_type/update_work_type/set_org_profile/clear_org_profile)', description: 'Change rate cards and work types (every organization of the partner) or which card bills an organization', category: 'Billing' }, { name: 'manage_billing_settings (update_partner/update_org)', description: 'Change invoice defaults, customer-facing invoice text, or an organization\'s tax, terms and billing contact', category: 'Billing' }, { name: 'manage_contract_templates (create_version/publish/archive)', description: 'Add a draft version, publish it for signature, or archive an organization\'s agreement template (not offered to AI agents)', category: 'Billing' }, diff --git a/docs/release-notes/next-release-draft.md b/docs/release-notes/next-release-draft.md index 2dee96be8ef..ef5b636ca02 100644 --- a/docs/release-notes/next-release-draft.md +++ b/docs/release-notes/next-release-draft.md @@ -221,6 +221,7 @@ Only the winning policy for each device applies; newly added providers start wit Update agents to a release containing this change before relying on the provider list. Older agents ignore it. ### AI tools — Pax8 (#6754 W15) +- Submit Pax8 orders with two-person approval of estimated costs and terminal cancellations; reconcile unknown outcomes while preserving concurrent billing quantity edits. - `get_pax8_status`: inspect integration health and company readiness with full partner access. - `search_pax8_products`: find mapped products and read provisioning requirements, commitments and live vendor pricing. - `manage_pax8_subscriptions`: inspect seat drift, request a supervised sync, or link and unlink billing contract lines. From 1ffe12c91408ee376bffbbb3271c089cfea514bb Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Sat, 31 Oct 2026 12:08:35 -0600 Subject: [PATCH 05/12] =?UTF-8?q?fix(ai-tools):=20W15=20PR=20B=20review=20?= =?UTF-8?q?follow-ups=20=E2=80=94=20preserve=20Pax8=20evidence=20and=20bil?= =?UTF-8?q?ling=20conflicts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record vendor outcomes before persistence, distinguish unsent claim refusals, and audit failures and billing conflicts accurately. Tighten submit modes, claim bundles and digest contracts; cover repeat release and billing drift. Reopen unsent REST integration drift. Keep legacy missing baselines fail-closed and document the production inventory required before deployment. Co-Authored-By: Claude Fable 5.1 --- ...lsPax8OrderSubmitScope.integration.test.ts | 59 ++++- .../pax8OrderSubmit.integration.test.ts | 26 +- apps/api/src/routes/pax8Orders.test.ts | 36 ++- apps/api/src/routes/pax8Orders.ts | 17 +- .../actionIntents/actionLabel.test.ts | 8 +- .../src/services/actionIntents/actionLabel.ts | 2 +- .../actionIntents/effectDigest.w15b.test.ts | 16 +- .../actionIntents/intentService.test.ts | 10 + .../services/actionIntents/pax8OrderDigest.ts | 100 ++++++-- .../aiGuardrailsAiDocs.parity.test.ts | 9 + apps/api/src/services/aiToolSchemasPax8.ts | 4 +- .../services/aiToolsPax8OrderSubmit.test.ts | 58 ++++- apps/api/src/services/aiToolsPax8Orders.ts | 78 ++++-- .../services/pax8OrderObservability.test.ts | 24 ++ .../src/services/pax8OrderObservability.ts | 22 ++ .../api/src/services/pax8OrderPreview.test.ts | 14 +- apps/api/src/services/pax8OrderPreview.ts | 20 +- apps/api/src/services/pax8OrderService.ts | 13 +- .../pax8OrderSubmit.releaseTwice.test.ts | 34 +++ apps/api/src/services/pax8OrderSubmit.test.ts | 98 ++++--- apps/api/src/services/pax8OrderSubmit.ts | 132 ++++++---- .../pax8OrderSubmitRepository.test.ts | 128 +++++++-- .../src/services/pax8OrderSubmitRepository.ts | 242 +++++++++++------- apps/api/src/services/pax8OrderTypes.ts | 6 + apps/api/src/services/pax8SyncService.ts | 4 +- apps/api/src/services/sentry.test.ts | 4 + apps/api/src/services/sentryEventCodes.ts | 1 + apps/docs/src/content/docs/features/ai.mdx | 2 + .../src/content/docs/features/mcp-server.mdx | 2 + 29 files changed, 873 insertions(+), 296 deletions(-) create mode 100644 apps/api/src/services/pax8OrderObservability.test.ts create mode 100644 apps/api/src/services/pax8OrderObservability.ts create mode 100644 apps/api/src/services/pax8OrderSubmit.releaseTwice.test.ts create mode 100644 apps/api/src/services/pax8OrderTypes.ts diff --git a/apps/api/src/__tests__/integration/aiToolsPax8OrderSubmitScope.integration.test.ts b/apps/api/src/__tests__/integration/aiToolsPax8OrderSubmitScope.integration.test.ts index 124ca07bcf4..e4cd1c5c104 100644 --- a/apps/api/src/__tests__/integration/aiToolsPax8OrderSubmitScope.integration.test.ts +++ b/apps/api/src/__tests__/integration/aiToolsPax8OrderSubmitScope.integration.test.ts @@ -3,7 +3,7 @@ import './setup'; import { randomUUID } from 'node:crypto'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { and, eq } from 'drizzle-orm'; -import { withDbAccessContext } from '../../db'; +import { db, withDbAccessContext } from '../../db'; import { actionIntents, approvalRequests, catalogItems, contractLines, contracts, pax8CompanyMappings, pax8Integrations, pax8OrderLines, pax8Orders, pax8ProductMappings, @@ -189,17 +189,22 @@ describe('Pax8 AI approved submit scope and execution binding (real PostgreSQL)' const verification = vi.fn(); vi.spyOn(pax8OrderSubmitRepository, 'claimOrder').mockImplementationOnce(args => claimOrder({ ...args, - verifyClaim: async (bundle, database) => { - // Deliberately use the claim transaction: a separate connection would - // wait for its parent lock and could not model this exact check/use gap. - await database.update(pax8OrderLines).set({ quantity: '8.00' }).where(eq(pax8OrderLines.id, f.line.id)); + mode: { kind: 'ai', verifyClaim: async (bundle, database) => { + expect(Object.keys(database)).toEqual(['select']); + // Test-only fault injection uses the ambient claim transaction through + // the DB proxy; the verifier's own runtime view remains read-only. + // A separate connection would wait on the parent lock, so it cannot + // model this gap or prove that the injected edit rolls back with claim. + await db.update(pax8OrderLines).set({ quantity: '8.00' }) + .where(eq(pax8OrderLines.id, f.line.id)); const [edited] = await database.select({ quantity: pax8OrderLines.quantity }).from(pax8OrderLines) .where(eq(pax8OrderLines.id, f.line.id)); expect(edited?.quantity).toBe('8.00'); verification(); - expect(args.verifyClaim).toBeTypeOf('function'); - await args.verifyClaim!(bundle, database); - }, + expect(args.mode.kind).toBe('ai'); + if (args.mode.kind !== 'ai') throw new Error('AI verification hook was not supplied'); + return args.mode.verifyClaim(bundle, database); + } }, })); vendor.createOrder.mockClear(); const result = await run(input, f.auth); @@ -216,7 +221,8 @@ describe('Pax8 AI approved submit scope and execution binding (real PostgreSQL)' }); it('an approved successful submit persists the captured baseline and linked manual billing quantity', async () => { - const result = await run(await approvedArguments(), f.auth); + const input = await approvedArguments(); + const result = await run(input, f.auth); expect(result.body.status, result.raw).toBe('completed'); expect(result.body.lines).toEqual([{ lineId: f.line.id, submitState: 'succeeded', error: null }]); const current = await state(); @@ -226,6 +232,39 @@ describe('Pax8 AI approved submit scope and execution binding (real PostgreSQL)' expect(vendor.createOrder).toHaveBeenCalledTimes(2); expect(vendor.createOrder.mock.calls[0]?.[1]).toEqual({ isMock: true }); expect(vendor.createOrder.mock.calls[1]?.[1]).toBeUndefined(); + + const repeated = await run(input, f.auth); + expect(['CONFLICT', 'NOT_PENDING'], repeated.raw).toContain(repeated.body.code); + expect(vendor.createOrder).toHaveBeenCalledTimes(2); + expect(await state()).toEqual(current); + }); + + it('a successful vendor write preserves concurrent billing edits and surfaces the conflict', async () => { + const input = await approvedArguments(); + vendor.createOrder.mockImplementation(async (_input, options) => { + if (options?.isMock) return { pax8OrderId: null, lineItems: [] }; + await getTestDb().update(contractLines).set({ manualQuantity: '3.00' }) + .where(eq(contractLines.id, f.billingLine.id)); + return { pax8OrderId: REMOTE_ORDER, lineItems: [ + { lineItemNumber: 1, productId: PRODUCT, subscriptionId: REMOTE_SUBSCRIPTION }, + ] }; + }); + + const result = await run(input, f.auth); + + expect(result.body.billingConflicts, result.raw).toEqual([{ lineId: f.line.id, reason: 'baseline_changed' }]); + expect(result.body.nextStep).toMatch(/billing/i); + expect(result.body.status).toBe('partially_failed'); + expect(result.body.lines).toEqual([expect.objectContaining({ + lineId: f.line.id, submitState: 'succeeded', error: expect.stringContaining('billing_conflict:baseline_changed:'), + })]); + const current = await state(); + expect(current.order.status).toBe('partially_failed'); + expect(current.line.submitState).toBe('succeeded'); + expect(current.line.error).toContain('billing_conflict:baseline_changed:'); + expect(current.line.resultSubscriptionId).toBe(REMOTE_SUBSCRIPTION); + expect(current.billing.manualQuantity).toBe('3.00'); + expect(vendor.createOrder).toHaveBeenCalledTimes(2); }); it('a mocked timeout remains needs_reconcile until a unique listOrders match resolves billing without resending', async () => { @@ -246,7 +285,7 @@ describe('Pax8 AI approved submit scope and execution binding (real PostgreSQL)' lineItems: [{ lineItemNumber: 1, productId: PRODUCT, quantity: '7.00', quantityKnown: true, subscriptionId: REMOTE_SUBSCRIPTION }], raw: {}, } satisfies Awaited>[number]]); const reconciled = await run({ action: 'reconcile', orderId: f.order.id }, f.auth); - expect(reconciled.body, reconciled.raw).toEqual({ resolved: 1, stillUnknown: 0 }); + expect(reconciled.body, reconciled.raw).toMatchObject({ resolved: 1, stillUnknown: 0, status: 'completed', billingConflicts: [] }); const current = await state(); expect(current.order.status).toBe('completed'); expect(current.line.submitState).toBe('succeeded'); diff --git a/apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts b/apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts index 816fbfaedba..06e5cf0aed6 100644 --- a/apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts +++ b/apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts @@ -207,6 +207,7 @@ describe('Pax8 submit pipeline (real Postgres)', () => { partnerId: fixture.partner.id, orderId: fixture.order.id, actorUserId: fixture.user.id, + mode: { kind: 'rest' }, })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('ready') }); expect(createClient).not.toHaveBeenCalled(); @@ -236,6 +237,7 @@ describe('Pax8 submit pipeline (real Postgres)', () => { partnerId: fixture.partner.id, orderId: fixture.order.id, actorUserId: fixture.user.id, + mode: { kind: 'rest' }, })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('future') }); expect(createClient).not.toHaveBeenCalled(); @@ -272,6 +274,7 @@ describe('Pax8 submit pipeline (real Postgres)', () => { partnerId: fixture.partner.id, orderId: fixture.order.id, actorUserId: fixture.user.id, + mode: { kind: 'rest' }, })).rejects.toMatchObject({ status: 409, message: expect.stringContaining('no longer matches') }); expect(client.cancelSubscription).not.toHaveBeenCalled(); @@ -291,6 +294,7 @@ describe('Pax8 submit pipeline (real Postgres)', () => { partnerId: fixture.partner.id, orderId: fixture.order.id, actorUserId: fixture.user.id, + mode: { kind: 'rest' }, })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('active') }); expect(client.createOrder).not.toHaveBeenCalled(); @@ -305,6 +309,7 @@ describe('Pax8 submit pipeline (real Postgres)', () => { partnerId: fixture.partner.id, orderId: fixture.order.id, actorUserId: fixture.user.id, + mode: { kind: 'rest' }, })).rejects.toMatchObject({ status: 409, message: expect.stringContaining('changed') }); expect(createClient).not.toHaveBeenCalled(); @@ -338,6 +343,7 @@ describe('Pax8 submit pipeline (real Postgres)', () => { partnerId: fixture.partner.id, orderId: fixture.order.id, actorUserId: fixture.user.id, + mode: { kind: 'rest' }, }); expect(restagedClient.updateSubscriptionQuantity) .toHaveBeenCalledWith('subscription-cancel', 25); @@ -354,6 +360,7 @@ describe('Pax8 submit pipeline (real Postgres)', () => { partnerId: fixture.partner.id, orderId: fixture.order.id, actorUserId: fixture.user.id, + mode: { kind: 'rest' }, })).rejects.toMatchObject({ status: 409, message: expect.stringContaining('baseline') }); expect(client.updateSubscriptionQuantity).not.toHaveBeenCalled(); const [orderAfter] = await withSystemDbAccessContext(() => db.select() @@ -374,6 +381,7 @@ describe('Pax8 submit pipeline (real Postgres)', () => { partnerId: fixture.partner.id, orderId: fixture.order.id, actorUserId: fixture.user.id, + mode: { kind: 'rest' }, }); expect(client.updateSubscriptionQuantity).toHaveBeenCalledWith('subscription-cancel', 15); @@ -406,6 +414,7 @@ describe('Pax8 submit pipeline (real Postgres)', () => { partnerId: patchWins.partner.id, orderId: patchWins.order.id, actorUserId: patchWins.user.id, + mode: { kind: 'rest' }, }); await new Promise((resolve) => setTimeout(resolve, 25)); expect(patchWinsClient.createOrder).not.toHaveBeenCalled(); @@ -428,6 +437,7 @@ describe('Pax8 submit pipeline (real Postgres)', () => { partnerId: claimWins.partner.id, orderId: claimWins.order.id, actorUserId: claimWins.user.id, + mode: { kind: 'rest' }, }); await expect(updateOrderLine({ partnerId: claimWins.partner.id, @@ -441,7 +451,7 @@ describe('Pax8 submit pipeline (real Postgres)', () => { 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 input = { partnerId: fixture.partner.id, orderId: fixture.order.id, actorUserId: fixture.user.id, mode: { kind: 'rest' as const } }; const results = await Promise.allSettled([service.submitOrder(input), service.submitOrder(input)]); const failureMessages = results.flatMap((result) => result.status === 'rejected' @@ -471,6 +481,7 @@ describe('Pax8 submit pipeline (real Postgres)', () => { partnerId: rejectedFixture.partner.id, orderId: rejectedFixture.order.id, actorUserId: rejectedFixture.user.id, + mode: { kind: 'rest' }, }); const [contractLine] = await withSystemDbAccessContext(() => @@ -488,6 +499,7 @@ describe('Pax8 submit pipeline (real Postgres)', () => { partnerId: cancelFixture.partner.id, orderId: cancelFixture.order.id, actorUserId: cancelFixture.user.id, + mode: { kind: 'rest' }, }); const [cancelContractLine] = await withSystemDbAccessContext(() => db.select().from(contractLines).where(eq(contractLines.id, cancelFixture.contractLine.id))); @@ -505,6 +517,7 @@ describe('Pax8 submit pipeline (real Postgres)', () => { partnerId: timeoutFixture.partner.id, orderId: timeoutFixture.order.id, actorUserId: timeoutFixture.user.id, + mode: { kind: 'rest' }, }); expect(timeoutResult.lines[0]?.submitState).toBe('needs_reconcile'); await withSystemDbAccessContext(() => db.update(pax8CompanyMappings) @@ -535,7 +548,7 @@ describe('Pax8 submit pipeline (real Postgres)', () => { pax8OrderId: 'captured-parent', submittedAt: capturedAt, }).where(eq(pax8Orders.id, parentGuardFixture.order.id)); - await db.update(pax8OrderLines).set({ submitState: 'needs_reconcile' }) + await db.update(pax8OrderLines).set({ submitState: 'needs_reconcile', authorizedBaselineQuantity: '0.00' }) .where(eq(pax8OrderLines.id, parentGuardFixture.line.id)); }); const parentGuardBundle = await pax8OrderSubmitRepository.loadReconcileOrder({ @@ -574,7 +587,7 @@ describe('Pax8 submit pipeline (real Postgres)', () => { resultSubscriptionId: 'captured-subscription', }], 'captured-parent', - )).resolves.toEqual({ resolved: 1, stillUnknown: 0 }); + )).resolves.toEqual(expect.objectContaining({ resolved: 1, stillUnknown: 0, billingConflicts: [] })); const nullParentFixture = await seedOrder(); await withSystemDbAccessContext(async () => { @@ -583,7 +596,7 @@ describe('Pax8 submit pipeline (real Postgres)', () => { pax8CompanyId: 'company-1', submittedAt: capturedAt, }).where(eq(pax8Orders.id, nullParentFixture.order.id)); - await db.update(pax8OrderLines).set({ submitState: 'needs_reconcile' }) + await db.update(pax8OrderLines).set({ submitState: 'needs_reconcile', authorizedBaselineQuantity: '0.00' }) .where(eq(pax8OrderLines.id, nullParentFixture.line.id)); }); const nullParentBundle = await pax8OrderSubmitRepository.loadReconcileOrder({ @@ -599,7 +612,7 @@ describe('Pax8 submit pipeline (real Postgres)', () => { resultSubscriptionId: 'matched-subscription', }], 'matched-parent', - )).resolves.toEqual({ resolved: 1, stillUnknown: 0 }); + )).resolves.toEqual(expect.objectContaining({ resolved: 1, stillUnknown: 0, billingConflicts: [] })); const [nullParentOrder] = await withSystemDbAccessContext(() => db.select() .from(pax8Orders).where(eq(pax8Orders.id, nullParentFixture.order.id))); expect(nullParentOrder?.pax8OrderId).toBe('matched-parent'); @@ -621,6 +634,7 @@ describe('Pax8 submit pipeline (real Postgres)', () => { partnerId: rollbackFixture.partner.id, orderId: rollbackFixture.order.id, actorUserId: rollbackFixture.user.id, + mode: { kind: 'rest' }, })).rejects.toMatchObject({ status: 409 }); const rollbackState = await withSystemDbAccessContext(async () => { const [order] = await db.select().from(pax8Orders).where(eq(pax8Orders.id, rollbackFixture.order.id)); @@ -646,6 +660,7 @@ describe('Pax8 submit pipeline (real Postgres)', () => { partnerId: duplicateFixture.partner.id, orderId: duplicateFixture.order.id, actorUserId: duplicateFixture.user.id, + mode: { kind: 'rest' }, })).rejects.toMatchObject({ status: 422 }); expect(duplicateClient.createOrder).not.toHaveBeenCalled(); expect(duplicateClient.cancelSubscription).not.toHaveBeenCalled(); @@ -656,6 +671,7 @@ describe('Pax8 submit pipeline (real Postgres)', () => { partnerId: wrongPartner.id, orderId: duplicateFixture.order.id, actorUserId: duplicateFixture.user.id, + mode: { kind: 'rest' }, })).rejects.toMatchObject({ status: 404 }); expect(isolatedClient.createOrder).not.toHaveBeenCalled(); diff --git a/apps/api/src/routes/pax8Orders.test.ts b/apps/api/src/routes/pax8Orders.test.ts index 96ad20d8535..628a8ce4032 100644 --- a/apps/api/src/routes/pax8Orders.test.ts +++ b/apps/api/src/routes/pax8Orders.test.ts @@ -38,8 +38,10 @@ const mocks = vi.hoisted(() => ({ runOutsideDbContext: vi.fn((fn: () => unknown) => fn()), withDbAccessContext: vi.fn((_context: unknown, fn: () => unknown) => fn()), dbSelect: vi.fn(), + captureException: vi.fn(), })); +vi.mock('../services/sentry', () => ({captureException:mocks.captureException})); vi.mock('../middleware/auth', () => ({ authMiddleware: vi.fn((c: any, next: any) => { if (state.unauthenticated) return c.json({ error: 'Unauthorized' }, 401); @@ -179,8 +181,8 @@ beforeEach(() => { 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.submitOrder.mockResolvedValue({ orderId: ORDER_ID, status: 'completed', lines: [], billingConflicts: [] }); + mocks.reconcileOrder.mockResolvedValue({ resolved: 1, stillUnknown: 0, billingConflicts: [] }); mocks.detectPax8Drift.mockResolvedValue([]); }); @@ -542,14 +544,14 @@ describe('Pax8 order route handlers', () => { 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(mocks.submitOrder).toHaveBeenCalledWith({ partnerId: PARTNER_A, orderId: ORDER_ID, actorUserId: USER_ID, mode: {kind:'rest'} }); 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', + partnerId: PARTNER_A, orderStatus: 'completed', billingConflicts: [], succeededCount: 0, failedCount: 0, needsReconcileCount: 0, }, })); @@ -558,14 +560,29 @@ describe('Pax8 order route handlers', () => { })); }); + it('reports post-send Pax8OrderError conflicts to Sentry', async () => { + const error = new Pax8OrderError('post-send persist conflict',409,{phase:'persist'}); + mocks.submitOrder.mockRejectedValueOnce(error); + expect((await request(`/orders/${ORDER_ID}/submit`,{method:'POST'})).status).toBe(409); + expect(mocks.captureException).toHaveBeenCalledWith(error,undefined,{errorId:'PAX8_PERSIST_FAILED'}); + }); + it.each(['submit', 'reconcile'])('audits %s billing conflicts as failure', async action => { + const billingConflicts = [{ lineId: LINE_ID, reason: 'baseline_changed' }]; + (action === 'submit' ? mocks.submitOrder : mocks.reconcileOrder).mockResolvedValueOnce({ + kind: 'executed', orderId: ORDER_ID, status: 'completed', lines: [], resolved: 1, stillUnknown: 0, billingConflicts, + }); + const response = await request(`/orders/${ORDER_ID}/${action}`, { method: 'POST' }); + expect(response.status).toBe(200); + expect(mocks.writeRouteAudit).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ result: 'failure', details: expect.objectContaining({ billingConflicts }) })); + }); it('audits a terminally failed submit outcome as failure', async () => { - mocks.submitOrder.mockResolvedValueOnce({ orderId: ORDER_ID, status: 'failed', lines: [] }); + mocks.submitOrder.mockResolvedValueOnce({ orderId: ORDER_ID, status: 'failed', lines: [], billingConflicts: [] }); 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', + partnerId: PARTNER_A, orderStatus: 'failed', billingConflicts: [], succeededCount: 0, failedCount: 0, needsReconcileCount: 0, status: 200, errorClass: 'OrderResultNotCompleted', }, @@ -575,7 +592,7 @@ describe('Pax8 order route handlers', () => { it('audits non-completed submit state counts without line payloads', async () => { mocks.submitOrder.mockResolvedValueOnce({ orderId: ORDER_ID, - status: 'partially_failed', + status: 'partially_failed', billingConflicts: [], lines: [ { lineId: 'line-success', submitState: 'succeeded', error: null }, { lineId: 'line-failed', submitState: 'failed', error: 'sensitive vendor detail' }, @@ -603,16 +620,17 @@ describe('Pax8 order route handlers', () => { }); it('audits incomplete reconciliation totals without changing its response', async () => { - mocks.reconcileOrder.mockResolvedValueOnce({ resolved: 2, stillUnknown: 1 }); + mocks.reconcileOrder.mockResolvedValueOnce({ resolved: 2, stillUnknown: 1, billingConflicts: [] }); 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 }); + await expect(res.json()).resolves.toEqual({ resolved: 2, stillUnknown: 1, billingConflicts: [] }); expect(mocks.writeRouteAudit).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: 'pax8.order.reconcile', result: 'failure', details: { partnerId: PARTNER_A, resolved: 2, stillUnknown: 1, + billingConflicts: [], status: 200, errorClass: 'ReconciliationIncomplete', }, diff --git a/apps/api/src/routes/pax8Orders.ts b/apps/api/src/routes/pax8Orders.ts index 28c889c6a1c..24ca682aec9 100644 --- a/apps/api/src/routes/pax8Orders.ts +++ b/apps/api/src/routes/pax8Orders.ts @@ -28,6 +28,7 @@ import { preflightOrder, reconcileOrder, submitOrder } from '../services/pax8Ord import { createPax8ClientForIntegration } from '../services/pax8SyncService'; import { detectPax8Drift } from '../services/pax8Drift'; import { PERMISSIONS } from '../services/permissions'; +import { errorIds } from '../services/pax8OrderObservability'; import { captureException } from '../services/sentry'; export const pax8OrderRoutes = new Hono(); @@ -160,7 +161,10 @@ function rawBody(c: Context, body: string, status: 422 | 502): Response { } function routeError(c: Context, error: unknown): Response { - if (error instanceof Pax8OrderError) return c.json({ error: error.message }, error.status); + if (error instanceof Pax8OrderError) { + if (error.phase !== 'claim') captureException(error, undefined, { errorId: errorIds.PAX8_PERSIST_FAILED }); + 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); @@ -242,6 +246,7 @@ function submitAuditDetails(result: Awaited>) { } return { orderStatus: result.status, + billingConflicts: result.billingConflicts, succeededCount, failedCount, needsReconcileCount, @@ -540,11 +545,11 @@ pax8OrderRoutes.post( orgId: bundle.order.orgId, orderId: id, }; const mutation = await runAuditedMutation(c, audit, () => - submitOrder({ partnerId: partner.partnerId, orderId: id, actorUserId: auth.user.id })); + submitOrder({ partnerId: partner.partnerId, orderId: id, actorUserId: auth.user.id, mode: { kind: 'rest' } })); if ('response' in mutation) return mutation.response; const result = mutation.value; const details = submitAuditDetails(result); - if (result.status === 'completed') { + if (result.status === 'completed' && result.billingConflicts.length === 0) { auditOrderAction(c, { ...audit, result: 'success', @@ -586,14 +591,14 @@ pax8OrderRoutes.post( reconcileOrder({ partnerId: partner.partnerId, orderId: id })); if ('response' in mutation) return mutation.response; const result = mutation.value; - if (result.stillUnknown === 0) { + if (result.stillUnknown === 0 && result.billingConflicts.length === 0) { auditOrderAction(c, { ...audit, result: 'success', - details: { resolved: result.resolved, stillUnknown: result.stillUnknown }, + details: { resolved: result.resolved, stillUnknown: result.stillUnknown, billingConflicts: result.billingConflicts }, }); } else { auditOrderFailure(c, { - ...audit, details: { resolved: result.resolved, stillUnknown: result.stillUnknown }, + ...audit, details: { resolved: result.resolved, stillUnknown: result.stillUnknown, billingConflicts: result.billingConflicts }, }, { status: 200, errorClass: 'ReconciliationIncomplete' }); } return c.json(result); diff --git a/apps/api/src/services/actionIntents/actionLabel.test.ts b/apps/api/src/services/actionIntents/actionLabel.test.ts index 8bed1637b7b..8a2e2801e29 100644 --- a/apps/api/src/services/actionIntents/actionLabel.test.ts +++ b/apps/api/src/services/actionIntents/actionLabel.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { canonicalizeArguments, computeArgumentDigest } from './canonicalize'; -import { buildActionLabel } from './actionLabel'; +import { buildActionLabel, describePax8Submit } from './actionLabel'; import { checkGuardrails } from '../aiGuardrails'; describe('buildActionLabel', () => { @@ -188,3 +188,9 @@ describe('buildActionLabel', () => { }); }); + +it('renders new subscriptions as new quantity on the Pax8 approval card', () => { + const copy = describePax8Submit({ expectedCompany: { name: 'Fixture', pax8CompanyId: 'company' }, expectedLines: [{ action: 'new_subscription', productName: 'Seats', pax8ProductId: 'product', fromQuantity: null, quantity: '5', unitCost: '8.25', currency: 'USD', billingTerm: 'Monthly', commitmentTerm: 'Annual' }] }); + expect(copy).toContain('new → 5'); + expect(copy).not.toContain('? →'); +}); diff --git a/apps/api/src/services/actionIntents/actionLabel.ts b/apps/api/src/services/actionIntents/actionLabel.ts index 1461f78574e..2740f133c1b 100644 --- a/apps/api/src/services/actionIntents/actionLabel.ts +++ b/apps/api/src/services/actionIntents/actionLabel.ts @@ -31,7 +31,7 @@ export function describePax8Submit(input: Record): string { const labels: Record = { new_subscription: 'new subscriptions', change_quantity: 'quantity changes', cancel: 'cancellations' }; const counts = Object.entries(labels).map(([action, label]) => `${lines.filter(l => l.action === action).length} ${label}`).join(', '); return `Place a real, billable Pax8 order that cannot be undone for ${text(company.name)} (${text(company.pax8CompanyId)}): ${counts}. Cost is estimated at Pax8's current rate, re-checked before sending. ` - + lines.map(line => `${line.action === 'cancel' ? 'CANCEL (terminal)' : titleCaseWords(text(line.action))} ${text(line.productName)} [${text(line.pax8ProductId)}]: ${text(line.fromQuantity)} → ${text(line.quantity)}, ${text(line.unitCost)} ${text(line.currency)} per seat / ${text(line.billingTerm)}, commitment ${text(line.commitmentTerm)}${line.cancellationFeeApplied === true ? ', cancellation fee applies' : ''}`).join('; '); + + lines.map(line => `${line.action === 'cancel' ? 'CANCEL (terminal)' : titleCaseWords(text(line.action))} ${text(line.productName)} [${text(line.pax8ProductId)}]: ${line.action === 'new_subscription' ? 'new' : text(line.fromQuantity)} → ${text(line.quantity)}, ${text(line.unitCost)} ${text(line.currency)} per seat / ${text(line.billingTerm)}, commitment ${text(line.commitmentTerm)}${line.cancellationFeeApplied === true ? ', cancellation fee applies' : ''}`).join('; '); } /** diff --git a/apps/api/src/services/actionIntents/effectDigest.w15b.test.ts b/apps/api/src/services/actionIntents/effectDigest.w15b.test.ts index e5f5eda3302..2bcad3944a5 100644 --- a/apps/api/src/services/actionIntents/effectDigest.w15b.test.ts +++ b/apps/api/src/services/actionIntents/effectDigest.w15b.test.ts @@ -2,7 +2,7 @@ import { getTableColumns, getTableName } from 'drizzle-orm'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Database } from '../../db'; import { computeEffectDigestOutcome, effectDigestResolverKey, requiresPinnedEffectDigest } from './effectDigest'; -import { hashPax8OrderMaterial, pax8OrderDigestMaterial } from './pax8OrderDigest'; +import { hashPax8OrderMaterial, pax8OrderDigestMaterial, PAX8_ORDER_MATERIAL_VERSION, serializePax8OrderMaterial } from './pax8OrderDigest'; const ORDER = '10000000-0000-4000-8000-000000000001'; const PARTNER = '10000000-0000-4000-8000-000000000002'; @@ -130,6 +130,20 @@ describe('Pax8 submit local effect material (W15 Q2/Q4/Q5/Q7)', () => { expect(await digest()).not.toBe(before); }); + it('uses the explicit material version in the canonical envelope', async () => { + const material = await pax8OrderDigestMaterial(database, ORDER); + if (!material) throw new Error('missing material'); + expect(JSON.parse(serializePax8OrderMaterial(material))).toEqual({ v: PAX8_ORDER_MATERIAL_VERSION, order: material }); + }); + + it('omits catalog fields unused by the payload, approval card or price resolution', async () => { + const before = await digest(); + Object.assign(first('catalog_items'), { description: 'Changed description', sku: 'Changed SKU', billingFrequency: 'annual', commitmentTermMonths: 36 }); + expect(await digest()).toBe(before); + const material = await pax8OrderDigestMaterial(database, ORDER); + expect(Object.keys(material!.lines[0]!.catalog[0]!.item!).sort()).toEqual(['id', 'isActive', 'costBasis', 'costCurrency', 'taxable', 'taxCategory'].sort()); + }); + it('canonicalizes provisioning object-key order', async () => { const before = await digest(); first('pax8_order_lines').provisioningDetails = [{ values: ['example.test'], key: 'domain' }]; diff --git a/apps/api/src/services/actionIntents/intentService.test.ts b/apps/api/src/services/actionIntents/intentService.test.ts index d9b375aaa56..1660483f8b4 100644 --- a/apps/api/src/services/actionIntents/intentService.test.ts +++ b/apps/api/src/services/actionIntents/intentService.test.ts @@ -3005,6 +3005,16 @@ describe('buildImpactSummary (#5106)', () => { guardrailDescription: 'Execute a system command on a device.', expected: 'Execute a system command on a device.', }, + { + name: 'Pax8 submit impact names the company, line outcomes, price, and terminal cancellation', + toolName: 'manage_pax8_orders', + input: { action: 'submit', expectedCompany: { name: 'Fixture Company', pax8CompanyId: 'company-1' }, + expectedLines: [{ action: 'cancel', productName: 'Fixture Seats', pax8ProductId: 'product-1', + fromQuantity: '7.00', quantity: '0', unitCost: '8.00', currency: 'USD', billingTerm: 'Monthly', + commitmentTerm: 'Annual', cancellationFeeApplied: true }] }, + guardrailDescription: 'Generic Pax8 management description.', + expected: "Place a real, billable Pax8 order that cannot be undone for Fixture Company (company-1): 0 new subscriptions, 0 quantity changes, 1 cancellations. Cost is estimated at Pax8's current rate, re-checked before sending. CANCEL (terminal) Fixture Seats [product-1]: 7.00 → 0, 8.00 USD per seat / Monthly, commitment Annual, cancellation fee applies", + }, { name: 'manage_invoices void gets plain-language impact (#9657)', toolName: 'manage_invoices', diff --git a/apps/api/src/services/actionIntents/pax8OrderDigest.ts b/apps/api/src/services/actionIntents/pax8OrderDigest.ts index 2668bb15aec..f0ff40451fc 100644 --- a/apps/api/src/services/actionIntents/pax8OrderDigest.ts +++ b/apps/api/src/services/actionIntents/pax8OrderDigest.ts @@ -1,6 +1,6 @@ /** W15: local inputs of one Pax8 submit. Reads only through the supplied transaction. */ import { createHash } from 'node:crypto'; -import { and, asc, eq } from 'drizzle-orm'; +import { and, asc, eq, type AnyColumn } from 'drizzle-orm'; import type { Database } from '../../db'; import { catalogItemOrgPricing, catalogItemPrices, catalogItems, contractLines, contracts, @@ -9,22 +9,69 @@ import { } from '../../db/schema'; import { snapshotActiveCommitmentEvidence } from '../pax8OrderService'; import { pax8CompanyOrderReadiness } from '../pax8CompanyReadiness'; +import { isSubmittableStatus } from '../pax8OrderTypes'; import { canonicalizeArguments } from './canonicalize'; -export const serializePax8OrderMaterial = (order: Record): string => - canonicalizeArguments({ v: 1, order }); -export const hashPax8OrderMaterial = (order: Record): string => +export const PAX8_ORDER_MATERIAL_VERSION = 1 as const; +type ContractLineMaterial = Pick; +type ContractMaterial = Pick; +type OrderLineMaterial = Pick; +type SnapshotProjection = Pick; +type SnapshotMaterial = Omit & { + activeCommitment: ReturnType; +}; +type ProductMaterial = Pick; +type CatalogMaterial = Pick; +type PriceMaterial = Pick; +type LinkMaterial = Pick; +type GenerationProjection = Pick; +export type Pax8IntegrationGeneration = Omit & { credentialGeneration: string }; +export const pax8CredentialGeneration = (generation: Pick): string => + createHash('sha256').update(canonicalizeArguments({ + clientId: generation.clientIdEncrypted, clientSecret: generation.clientSecretEncrypted, + })).digest('hex'); +type CompanyProjection = Pick & { name: string }; +type CompanyMaterial = Omit & { readiness: ReturnType }; +export interface Pax8OrderMaterial { + id: string; + partnerId: string; + orgId: string; + integrationId: string; + source: string; + sourceQuoteId: string | null; + submittable: boolean; + pax8CompanyId: string | null; + mappings: CompanyMaterial[]; + integration: Pax8IntegrationGeneration | null; + lines: Array; + }>; +} +export const serializePax8OrderMaterial = (order: Pax8OrderMaterial): string => + canonicalizeArguments({ v: PAX8_ORDER_MATERIAL_VERSION, order }); +export const hashPax8OrderMaterial = (order: Pax8OrderMaterial): string => createHash('sha256').update(serializePax8OrderMaterial(order)).digest('hex'); export async function pax8OrderDigestMaterial( - database: Database, + database: Pick, orderId: string, -): Promise | null> { +): Promise { const [order] = await database.select({ id: pax8Orders.id, partnerId: pax8Orders.partnerId, orgId: pax8Orders.orgId, integrationId: pax8Orders.integrationId, source: pax8Orders.source, sourceQuoteId: pax8Orders.sourceQuoteId, status: pax8Orders.status, - }).from(pax8Orders).where(eq(pax8Orders.id, orderId)).limit(1); + } satisfies Record, AnyColumn>).from(pax8Orders).where(eq(pax8Orders.id, orderId)).limit(1); if (!order) return null; const rows = await database.select({ @@ -35,7 +82,7 @@ export async function pax8OrderDigestMaterial( authorizedBaselineQuantity: pax8OrderLines.authorizedBaselineQuantity, targetSubscriptionId: pax8OrderLines.targetSubscriptionId, cancelDate: pax8OrderLines.cancelDate, contractLineId: pax8OrderLines.contractLineId, provisioningDetails: pax8OrderLines.provisioningDetails, - }).from(pax8OrderLines).where(and( + } satisfies Record).from(pax8OrderLines).where(and( eq(pax8OrderLines.orderId, order.id), eq(pax8OrderLines.partnerId, order.partnerId), eq(pax8OrderLines.orgId, order.orgId), )).orderBy(asc(pax8OrderLines.sortOrder), asc(pax8OrderLines.id)); @@ -44,7 +91,7 @@ export async function pax8OrderDigestMaterial( pax8CompanyId: pax8CompanyMappings.pax8CompanyId, name: pax8CompanyMappings.pax8CompanyName, status: pax8CompanyMappings.status, ignored: pax8CompanyMappings.ignored, orgId: pax8CompanyMappings.orgId, metadata: pax8CompanyMappings.metadata, - }).from(pax8CompanyMappings).where(and( + } satisfies Record).from(pax8CompanyMappings).where(and( eq(pax8CompanyMappings.integrationId, order.integrationId), eq(pax8CompanyMappings.partnerId, order.partnerId), eq(pax8CompanyMappings.orgId, order.orgId), eq(pax8CompanyMappings.ignored, false), @@ -58,15 +105,13 @@ export async function pax8OrderDigestMaterial( id: pax8Integrations.id, partnerId: pax8Integrations.partnerId, isActive: pax8Integrations.isActive, apiBaseUrl: pax8Integrations.apiBaseUrl, tokenUrl: pax8Integrations.tokenUrl, clientIdEncrypted: pax8Integrations.clientIdEncrypted, clientSecretEncrypted: pax8Integrations.clientSecretEncrypted, - }).from(pax8Integrations).where(and( + } satisfies Record).from(pax8Integrations).where(and( eq(pax8Integrations.id, order.integrationId), eq(pax8Integrations.partnerId, order.partnerId), )).limit(1); const integration = generation ? { id: generation.id, partnerId: generation.partnerId, isActive: generation.isActive, apiBaseUrl: generation.apiBaseUrl, tokenUrl: generation.tokenUrl, - credentialGeneration: createHash('sha256').update(canonicalizeArguments({ - clientId: generation.clientIdEncrypted, clientSecret: generation.clientSecretEncrypted, - })).digest('hex'), + credentialGeneration: pax8CredentialGeneration(generation), } : null; const lines = []; @@ -74,12 +119,12 @@ export async function pax8OrderDigestMaterial( const [contractLine] = line.contractLineId ? await database.select({ id: contractLines.id, contractId: contractLines.contractId, manualQuantity: contractLines.manualQuantity, unitPrice: contractLines.unitPrice, lineType: contractLines.lineType, catalogItemId: contractLines.catalogItemId, - }).from(contractLines).where(and( + } satisfies Record).from(contractLines).where(and( eq(contractLines.id, line.contractLineId), eq(contractLines.orgId, order.orgId), )).limit(1) : []; const [contract] = contractLine ? await database.select({ id: contracts.id, currencyCode: contracts.currencyCode, - }).from(contracts).where(and( + } satisfies Record).from(contracts).where(and( eq(contracts.id, contractLine.contractId), eq(contracts.orgId, order.orgId), eq(contracts.partnerId, order.partnerId), )).limit(1) : []; @@ -91,7 +136,7 @@ export async function pax8OrderDigestMaterial( productId: pax8SubscriptionSnapshots.productId, productName: pax8SubscriptionSnapshots.productName, vendorSkuId: pax8SubscriptionSnapshots.vendorSkuId, billingTerm: pax8SubscriptionSnapshots.billingTerm, raw: pax8SubscriptionSnapshots.raw, - }).from(pax8SubscriptionSnapshots).where(and( + } satisfies Record).from(pax8SubscriptionSnapshots).where(and( eq(pax8SubscriptionSnapshots.integrationId, order.integrationId), eq(pax8SubscriptionSnapshots.partnerId, order.partnerId), eq(pax8SubscriptionSnapshots.orgId, order.orgId), eq(pax8SubscriptionSnapshots.pax8SubscriptionId, line.targetSubscriptionId), @@ -102,7 +147,7 @@ export async function pax8OrderDigestMaterial( })() : null; const [link] = snapshot ? await database.select({ contractLineId: pax8ContractLineLinks.contractLineId, syncEnabled: pax8ContractLineLinks.syncEnabled, - }).from(pax8ContractLineLinks).where(and( + } satisfies Record).from(pax8ContractLineLinks).where(and( eq(pax8ContractLineLinks.subscriptionSnapshotId, snapshot.id), eq(pax8ContractLineLinks.integrationId, order.integrationId), eq(pax8ContractLineLinks.partnerId, order.partnerId), eq(pax8ContractLineLinks.orgId, order.orgId), @@ -112,7 +157,7 @@ export async function pax8OrderDigestMaterial( const [productMapping] = productId ? await database.select({ pax8ProductId: pax8ProductMappings.pax8ProductId, productName: pax8ProductMappings.productName, vendorSkuId: pax8ProductMappings.vendorSkuId, catalogItemId: pax8ProductMappings.catalogItemId, - }).from(pax8ProductMappings).where(and( + } satisfies Record).from(pax8ProductMappings).where(and( eq(pax8ProductMappings.integrationId, order.integrationId), eq(pax8ProductMappings.partnerId, order.partnerId), eq(pax8ProductMappings.pax8ProductId, productId), )).limit(1) : []; @@ -121,22 +166,21 @@ export async function pax8OrderDigestMaterial( const catalog = []; for (const itemId of itemIds) { const [item] = await database.select({ - id: catalogItems.id, name: catalogItems.name, sku: catalogItems.sku, description: catalogItems.description, - itemType: catalogItems.itemType, isActive: catalogItems.isActive, billingType: catalogItems.billingType, - billingFrequency: catalogItems.billingFrequency, commitmentTermMonths: catalogItems.commitmentTermMonths, - costBasis: catalogItems.costBasis, costCurrency: catalogItems.costCurrency, markupPercent: catalogItems.markupPercent, - unitOfMeasure: catalogItems.unitOfMeasure, taxable: catalogItems.taxable, taxCategory: catalogItems.taxCategory, - isBundle: catalogItems.isBundle, - }).from(catalogItems).where(and(eq(catalogItems.id, itemId), eq(catalogItems.partnerId, order.partnerId))).limit(1); + // Price resolution uses cost/currency/tax fields; pin identity and availability too. + // Catalog description, SKU and billing metadata are not shown on the approval or sent to Pax8. + id: catalogItems.id, isActive: catalogItems.isActive, + costBasis: catalogItems.costBasis, costCurrency: catalogItems.costCurrency, + taxable: catalogItems.taxable, taxCategory: catalogItems.taxCategory, + } satisfies Record).from(catalogItems).where(and(eq(catalogItems.id, itemId), eq(catalogItems.partnerId, order.partnerId))).limit(1); const [price] = contract ? await database.select({ unitPrice: catalogItemPrices.unitPrice, currencyCode: catalogItemPrices.currencyCode, - }).from(catalogItemPrices).where(and( + } satisfies Record).from(catalogItemPrices).where(and( eq(catalogItemPrices.itemId, itemId), eq(catalogItemPrices.partnerId, order.partnerId), eq(catalogItemPrices.currencyCode, contract.currencyCode), )).limit(1) : []; const [orgPrice] = contract ? await database.select({ unitPrice: catalogItemOrgPricing.unitPrice, currencyCode: catalogItemOrgPricing.currencyCode, - }).from(catalogItemOrgPricing).where(and( + } satisfies Record).from(catalogItemOrgPricing).where(and( eq(catalogItemOrgPricing.catalogItemId, itemId), eq(catalogItemOrgPricing.partnerId, order.partnerId), eq(catalogItemOrgPricing.orgId, order.orgId), )).limit(1) : []; @@ -154,7 +198,7 @@ export async function pax8OrderDigestMaterial( } // The claim's own parent transition is not content drift. Only a provably unsent submitting bundle is // equivalent here; submit's CAS still refuses every re-delivery before any HTTP can run. - const submittable = ['draft', 'awaiting_details', 'ready'].includes(order.status) + const submittable = isSubmittableStatus(order.status) || (order.status === 'submitting' && rows.length > 0 && rows.every(line => line.submitState === 'pending')); return { id: order.id, partnerId: order.partnerId, orgId: order.orgId, integrationId: order.integrationId, diff --git a/apps/api/src/services/aiGuardrailsAiDocs.parity.test.ts b/apps/api/src/services/aiGuardrailsAiDocs.parity.test.ts index 2007e4a6167..099f8160f13 100644 --- a/apps/api/src/services/aiGuardrailsAiDocs.parity.test.ts +++ b/apps/api/src/services/aiGuardrailsAiDocs.parity.test.ts @@ -1,3 +1,4 @@ +import { PAX8_SUBMIT_REFUSAL_CODES } from './pax8OrderSubmit'; /** * Contract test: the published AI docs page * (apps/docs/src/content/docs/features/ai.mdx) must agree with the real @@ -279,3 +280,11 @@ describe('W15 Pax8 documentation', () => { expect(actions.filter((a) => !documented.includes(a))).toEqual([]); }); }); + +it('Pax8 submit refusal codes stay documented in both client guides', () => { + for (const file of ['ai.mdx', 'mcp-server.mdx']) { + const guide = readFileSync(path.resolve(process.cwd(), `../docs/src/content/docs/features/${file}`), 'utf8'); + expect(guide).toContain('PAX8_SUBMIT_REFUSAL_CODES'); + for (const code of PAX8_SUBMIT_REFUSAL_CODES) expect(guide, `${file}: ${code}`).toContain(`\`${code}\``); + } +}); diff --git a/apps/api/src/services/aiToolSchemasPax8.ts b/apps/api/src/services/aiToolSchemasPax8.ts index 4729fbc3e51..eac9379519d 100644 --- a/apps/api/src/services/aiToolSchemasPax8.ts +++ b/apps/api/src/services/aiToolSchemasPax8.ts @@ -27,9 +27,9 @@ export const pax8UpdateLineSchema = z.object(patchShape).strict().refine( export const pax8ExpectedLineSchema = z.object({ lineId: uuid, action: z.enum(PAX8_ORDER_ACTIONS), pax8ProductId: productId.nullable(), productName: z.string().max(255).nullable(), quantity: z.string().max(40).nullable(), - fromQuantity: z.string().max(40).nullable().optional(), unitCost: z.string().max(40).nullable(), + fromQuantity: z.string().max(40).nullable(), unitCost: z.string().max(40).nullable(), currency: z.string().max(3).nullable(), billingTerm: z.enum(PAX8_BILLING_TERMS).nullable(), - commitmentTerm: z.string().max(255).nullable(), cancellationFeeApplied: z.boolean().nullable().optional(), + commitmentTerm: z.string().max(255).nullable(), cancellationFeeApplied: z.boolean().nullable(), }).strict(); export const pax8SubmitAssertionsSchema = z.object({ previewDigest: z.string().regex(/^[a-f0-9]{64}$/), diff --git a/apps/api/src/services/aiToolsPax8OrderSubmit.test.ts b/apps/api/src/services/aiToolsPax8OrderSubmit.test.ts index fbd3e3b6c78..28dcfbdf586 100644 --- a/apps/api/src/services/aiToolsPax8OrderSubmit.test.ts +++ b/apps/api/src/services/aiToolsPax8OrderSubmit.test.ts @@ -1,10 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { AuthContext } from '../middleware/auth'; import type { AiTool } from './aiTools'; -const h = vi.hoisted(() => ({ get: vi.fn(), preview: vi.fn(), submit: vi.fn(), reconcile: vi.fn(), material: vi.fn(), audit: vi.fn() })); +const h = vi.hoisted(() => ({ get: vi.fn(), preview: vi.fn(), submit: vi.fn(), reconcile: vi.fn(), material: vi.fn(), audit: vi.fn(), preflight: vi.fn(), capture: vi.fn() })); +vi.mock('./sentry', () => ({captureException:h.capture})); vi.mock('../db', () => ({ db: {}, runOutsideDbContext: (f: any) => f(), withDbAccessContext: (_c: any, f: any) => f() })); vi.mock('./pax8OrderService', async original => ({ ...await original(), getOrderWithLines: h.get })); -vi.mock('./pax8OrderSubmit', () => ({ preflightOrder: vi.fn(), submitOrder: h.submit, reconcileOrder: h.reconcile })); +vi.mock('./pax8OrderSubmit', () => ({ preflightOrder: h.preflight, submitOrder: h.submit, reconcileOrder: h.reconcile })); vi.mock('./pax8OrderPreview', () => ({ previewPax8Order: h.preview })); vi.mock('./actionIntents/pax8OrderDigest', () => ({ pax8OrderDigestMaterial: h.material, hashPax8OrderMaterial: (value: unknown) => value === null ? null : (value as any).digest })); vi.mock('./aiToolsSoftwareShared', async original => ({ ...await original(), auditToolWrite: h.audit })); @@ -13,7 +14,7 @@ import { pax8ToolSchemas } from './aiToolSchemasPax8'; import { AGENT_HUMAN_ONLY_ACTIONS } from './aiToolExposure'; import { buildApprovalDescription, checkGuardrails } from './aiGuardrails'; import { isMfaGatedToolAction } from './aiToolMfaGate'; -import { Pax8OrderError } from './pax8OrderService'; +import { Pax8ClaimDriftError, Pax8OrderError } from './pax8OrderService'; const ID = '00000000-0000-4000-8000-000000000001', ORG = '00000000-0000-4000-8000-000000000002', PARTNER = '00000000-0000-4000-8000-000000000003'; const digest = 'a'.repeat(64); const company = { pax8CompanyId: 'fixture-company', name: 'Fixture Company' }; @@ -29,8 +30,8 @@ beforeEach(() => { h.get.mockResolvedValue({ order: { id: ID, orgId: ORG, status: 'draft' }, lines: [{ id: ID }] }); h.preview.mockImplementation(async () => preview); h.material.mockResolvedValue({ digest }); - h.submit.mockImplementation(async input => { await input.verifyClaim({ order: { id: ID, orgId: ORG }, lines: [{ id: ID }] }, {}); return { orderId: ID, status: 'completed', lines: [{ lineId: ID, submitState: 'succeeded', error: null }] }; }); - h.reconcile.mockResolvedValue({ resolved: 1, stillUnknown: 0 }); + h.submit.mockImplementation(async input => { const verification = await input.mode.verifyClaim({ order: { id: ID, orgId: ORG }, lines: [{ id: ID }] }, {}); if (!verification.ok) throw new Pax8ClaimDriftError(verification.message); return { kind:'executed',billingConflicts:[], orderId: ID, status: 'completed', lines: [{ lineId: ID, submitState: 'succeeded', error: null }] }; }); + h.reconcile.mockResolvedValue({ status:'completed',lines:[],billingConflicts:[], resolved: 1, stillUnknown: 0 }); }); describe('W15 PR B Pax8 order submit', () => { it('appends exactly submit and reconcile to the action schema', () => expect(pax8ToolSchemas.manage_pax8_orders.shape.action.options).toEqual(['list','get','create_draft','add_line','update_line','remove_line','preview','preflight','submit','reconcile'])); @@ -43,10 +44,47 @@ describe('W15 PR B Pax8 order submit', () => { }); it('verifies inside the claim before any isMock/write, without a pre-claim preflight', async () => { expect((await run()).status).toBe('completed'); - expect(h.submit).toHaveBeenCalledWith(expect.objectContaining({ actorUserId: ID, onPreflightFailure: 'reopen', verifyClaim: expect.any(Function) })); + expect(h.submit).toHaveBeenCalledWith(expect.objectContaining({ actorUserId: ID, mode: {kind:'ai',verifyClaim: expect.any(Function)} })); expect(h.material).toHaveBeenCalledWith({}, ID); + expect(h.preflight).not.toHaveBeenCalled(); expect(h.audit).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: 'pax8.order.submit' })); }); + it.each(['submit', 'reconcile'])('audits %s service throws as failure with the error class', async action => { + const service = action === 'submit' ? h.submit : h.reconcile; + const createOrder = vi.fn().mockResolvedValue({pax8OrderId:'vendor-order'}); + service.mockImplementationOnce(async () => { await createOrder(); throw new Pax8OrderError('result persistence conflict after send', 409, { phase: 'persist' }); }); + await run({ ...args(), action }); + expect(h.capture).toHaveBeenCalledWith(expect.any(Pax8OrderError), undefined, expect.objectContaining({errorId:'PAX8_PERSIST_FAILED'})); + expect(h.audit).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: `pax8.order.${action}`, result: 'failure', details: expect.objectContaining({ error: 'Pax8OrderError' }), + })); + }); + it.each(['partially_failed', 'failed'])('audits %s executed outcomes as failure', async status => { + h.submit.mockResolvedValueOnce({ kind: 'executed', orderId: ID, status, billingConflicts: [], lines: [{ lineId: ID, submitState: 'failed', error: null }] }); + await run(); + expect(h.audit).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ result: 'failure', details: expect.objectContaining({ lines: [{ lineId: ID, submitState: 'failed' }] }) })); + }); + it.each(['submit', 'reconcile'])('surfaces %s billing conflicts with billing guidance and a failure audit', async action => { + const billingConflicts = [{ lineId: ID, reason: 'baseline_changed' }]; + const result = action === 'submit' + ? { kind: 'executed', orderId: ID, status: 'completed', lines: [], billingConflicts } + : { status: 'completed', lines: [], resolved: 1, stillUnknown: 0, billingConflicts }; + (action === 'submit' ? h.submit : h.reconcile).mockResolvedValueOnce(result); + const out = await run({ ...args(), action }); + expect(out.billingConflicts).toEqual(billingConflicts); expect(out.nextStep).toContain('billing'); + expect(h.audit).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ result: 'failure' })); + }); + it.each(['CLAIM_LOST', 'INTEGRATION_UNAVAILABLE', 'BASELINE_NOT_CAPTURED', 'CONTRACT_LINE_MISSING'])('gives unsent guidance for %s claim refusals', async code => { + h.submit.mockRejectedValueOnce(new Pax8OrderError('Claim refused', 409, { phase: 'claim', code })); + const out = await run(); expect(out.code).toBe(code); expect(out.nextStep).toContain('nothing was sent'); expect(out.nextStep).not.toContain('unknown write'); + }); + it.each(['count', 'duplicate', 'unknown'])('rejects structural %s line mismatch', async shape => { + const input = args(); + if (shape === 'count') preview.lines.push({ ...line, lineId: ORG }); + if (shape === 'duplicate') { input.expectedLines.push({ ...line }); preview.lines.push({ ...line, lineId: ORG }); } + if (shape === 'unknown') input.expectedLines[0]!.lineId = ORG; + expect((await run(input)).code).toBe('PREVIEW_MISMATCH'); expect(h.submit).not.toHaveBeenCalled(); + }); it.each(['unitCost', 'currency'])('PRICE_CHANGED on %s refuses before claim or writes', async field => { preview.lines[0][field] = 'changed'; expect((await run()).code).toBe('PRICE_CHANGED'); expect(h.submit).not.toHaveBeenCalled(); @@ -87,11 +125,11 @@ describe('W15 PR B Pax8 order submit', () => { expect((await run()).code).toBe('TOO_MANY_LINES'); expect(h.preview).not.toHaveBeenCalled(); expect(h.submit).not.toHaveBeenCalled(); }); it('PREFLIGHT_FAILED reports the reopened order, never claims a terminal failure', async () => { - h.submit.mockResolvedValue({ orderId: ID, status: 'draft', lines: [{ lineId: ID, submitState: 'pending', error: null }], refusalCode: 'PREFLIGHT_FAILED', error: 'bad provisioning' }); + h.submit.mockResolvedValue({ kind:'refused', billingConflicts:[], orderId: ID, status: 'draft', lines: [{ lineId: ID, submitState: 'pending', error: null }], refusalCode: 'PREFLIGHT_FAILED', error: 'bad provisioning' }); const out = await run(); expect(out.code).toBe('PREFLIGHT_FAILED'); expect(out.result.status).toBe('draft'); }); it('clips all nested vendor errors to 2 KB on an unsent refusal', async () => { - h.submit.mockResolvedValue({ orderId: ID, status: 'draft', lines: [{ lineId: ID, submitState: 'pending', error: '界'.repeat(4000) }], refusalCode: 'PREFLIGHT_FAILED', error: '界'.repeat(4000) }); + h.submit.mockResolvedValue({ kind:'refused', billingConflicts:[], orderId: ID, status: 'draft', lines: [{ lineId: ID, submitState: 'pending', error: '界'.repeat(4000) }], refusalCode: 'PREFLIGHT_FAILED', error: '界'.repeat(4000) }); const out = await run(); expect(out.code).toBe('PREFLIGHT_FAILED'); for (const error of [out.error, out.result.error, out.result.lines[0].error]) expect(Buffer.byteLength(error)).toBeLessThanOrEqual(2048); @@ -101,7 +139,7 @@ describe('W15 PR B Pax8 order submit', () => { expect((await run()).code).toBe('CONFLICT'); expect(h.submit).toHaveBeenCalledTimes(1); }); it('names ambiguous writes with a reconcile hint', async () => { - h.submit.mockResolvedValue({ orderId: ID, status: 'partially_failed', lines: [{ lineId: ID, submitState: 'needs_reconcile', error: 'timeout' }] }); + h.submit.mockResolvedValue({ kind:'executed', billingConflicts:[], orderId: ID, status: 'partially_failed', lines: [{ lineId: ID, submitState: 'needs_reconcile', error: 'timeout' }] }); expect((await run()).nextStep).toContain('reconcile'); }); it.each(['submit','reconcile'])('%s repeats MFA, site, device and org gates before services', async action => { @@ -117,7 +155,7 @@ describe('W15 PR B Pax8 order submit', () => { expect((await run(args(), auth({ principal: { kind: 'ai_agent' } }))).code).toBe('AGENT_HUMAN_ONLY'); expect(h.submit).not.toHaveBeenCalled(); }); it('reconcile calls the existing service and audits the read-backed billing result', async () => { - expect(await run({ action: 'reconcile', orderId: ID })).toEqual({ resolved: 1, stillUnknown: 0 }); + expect(await run({ action: 'reconcile', orderId: ID })).toMatchObject({ resolved: 1, stillUnknown: 0, billingConflicts:[] }); expect(h.reconcile).toHaveBeenCalledWith({ partnerId: PARTNER, orderId: ID }); expect(h.audit).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: 'pax8.order.reconcile' })); }); diff --git a/apps/api/src/services/aiToolsPax8Orders.ts b/apps/api/src/services/aiToolsPax8Orders.ts index 347e1739916..2dc3a821c56 100644 --- a/apps/api/src/services/aiToolsPax8Orders.ts +++ b/apps/api/src/services/aiToolsPax8Orders.ts @@ -1,18 +1,28 @@ /** Pax8 drafts, verified four-eyes submit and evidence-based reconciliation. */ +import type { z } from 'zod'; +import { captureException } from './sentry'; +import { errorIds } from './pax8OrderObservability'; import { runOutsideDbContext, withDbAccessContext } from '../db'; import type { AuthContext } from '../middleware/auth'; import type { AiTool } from './aiTools'; import { mfaGatedToolError } from './aiToolMfaGate'; -import { PAX8_DRAFT_ACTIONS, pax8SubmitAssertionsSchema, pax8AddLineSchema, pax8UpdateLineSchema, pax8ToolSchemas } from './aiToolSchemasPax8'; +import { PAX8_DRAFT_ACTIONS, pax8ExpectedLineSchema, pax8SubmitAssertionsSchema, pax8AddLineSchema, pax8UpdateLineSchema, pax8ToolSchemas } from './aiToolSchemasPax8'; import { pax8Clip, pax8DbContext, pax8Error, pax8InputSchema, pax8Page, pax8Partner } from './aiToolsPax8'; import { addOrderLine, getOrderWithLines, getOrCreateDraftOrder, listPax8Orders, removeOrderLine, updateOrderLine, type Pax8OrderLineRow, type Pax8OrderRow } from './pax8OrderService'; import { previewPax8Order } from './pax8OrderPreview'; -import { preflightOrder, submitOrder, reconcileOrder } from './pax8OrderSubmit'; +import { preflightOrder, submitOrder, reconcileOrder, type Pax8SubmitRefusalCode, type SubmitResult } from './pax8OrderSubmit'; import { pax8OrderDigestMaterial, hashPax8OrderMaterial } from './actionIntents/pax8OrderDigest'; import { isAutomationPrincipal } from '../middleware/principalKind'; -import { Pax8OrderError } from './pax8OrderService'; +import { Pax8ClaimDriftError, Pax8OrderError } from './pax8OrderService'; import { Pax8ApiError } from './pax8Client'; import { auditToolWrite, bounded, jsonError, orgWideWriteDenied, verifiedUserIdOrNull } from './aiToolsSoftwareShared'; +const LINE_ASSERTION_CODE = { + lineId: 'PREVIEW_MISMATCH', action: 'PREVIEW_MISMATCH', pax8ProductId: 'PREVIEW_MISMATCH', + productName: 'PREVIEW_MISMATCH', quantity: 'PREVIEW_MISMATCH', fromQuantity: 'PREVIEW_MISMATCH', + billingTerm: 'PREVIEW_MISMATCH', unitCost: 'PRICE_CHANGED', currency: 'PRICE_CHANGED', + commitmentTerm: 'COMMITMENT_CHANGED', cancellationFeeApplied: 'COMMITMENT_CHANGED', +} satisfies Record, Pax8SubmitRefusalCode>; +const BILLING_NEXT_STEP = 'Review billingConflicts and resolve the manual billing quantity with a person; do not re-submit successful vendor writes.'; const TOOL = 'manage_pax8_orders'; const WRITES: ReadonlySet<(typeof PAX8_DRAFT_ACTIONS)[number]> = new Set( PAX8_DRAFT_ACTIONS.filter(action => action !== 'list' && action !== 'get' && action !== 'preview'), @@ -69,9 +79,17 @@ export function registerPax8OrderTools(aiTools: Map): void { if (action === 'get') return bounded({ data: { order: presentOrder(bundle.order), lines: bundle.lines.map(presentLine) } }); if (action === 'preview') return bounded(await runOutsideDbContext(() => previewPax8Order({ partnerId, orderId }))); if (action === 'reconcile') { - const result = await runOutsideDbContext(() => reconcileOrder({ partnerId, orderId })); - audit(auth, partnerId, bundle.order.orgId, 'pax8.order.reconcile', orderId, result); - return bounded(result); + try { + const result = await runOutsideDbContext(() => reconcileOrder({ partnerId, orderId })); + audit(auth, partnerId, bundle.order.orgId, 'pax8.order.reconcile', orderId, + { ...result, lines: result.lines?.map(line => ({ lineId: line.lineId, submitState: line.submitState })) }, + result.status === 'completed' && result.billingConflicts.length === 0 ? 'success' : 'failure'); + return bounded({ ...result, ...(result.billingConflicts.length ? { nextStep: BILLING_NEXT_STEP } : {}) }); + } catch (error) { + audit(auth, partnerId, bundle.order.orgId, 'pax8.order.reconcile', orderId, + { error: error instanceof Error ? error.constructor.name : typeof error }, 'failure'); + throw error; + } } if (action === 'submit') { if (isAutomationPrincipal(auth)) return jsonError('A person must request Pax8 order submission', 'AGENT_HUMAN_ONLY'); @@ -83,41 +101,51 @@ export function registerPax8OrderTools(aiTools: Map): void { // dependencies). Preview never preflights or writes through loadResolvedOrder. const live = await runOutsideDbContext(() => previewPax8Order({ partnerId, orderId })); if (live.blocked.length) return bounded({ error: 'The order cannot be submitted by AI; review its preview', code: live.blocked[0]!.code, blocked: live.blocked }); - const refuse = (code: string, message: string) => jsonError(message, code); + const refuse = (code: Pax8SubmitRefusalCode, message: string) => jsonError(message, code); if (expected.expectedLines.length !== live.lines.length) return refuse('PREVIEW_MISMATCH', 'The approved line set changed'); const actualById = new Map(live.lines.map(line => [line.lineId, line])); if (new Set(expected.expectedLines.map(line => line.lineId)).size !== live.lines.length) return refuse('PREVIEW_MISMATCH', 'The approved line set is not unique'); for (const line of expected.expectedLines) { const actual = actualById.get(line.lineId); if (!actual) return refuse('PREVIEW_MISMATCH', 'An approved order line is unavailable'); - if (line.unitCost !== actual.unitCost || line.currency !== actual.currency) return refuse('PRICE_CHANGED', 'The estimated Pax8 rate changed; preview and approve again'); - if (line.commitmentTerm !== actual.commitmentTerm || (line.cancellationFeeApplied ?? null) !== actual.cancellationFeeApplied) return refuse('COMMITMENT_CHANGED', 'Pax8 commitment rules changed; preview and approve again'); + for (const key of Object.keys(LINE_ASSERTION_CODE) as Array) { + if (line[key] !== actual[key]) return refuse(LINE_ASSERTION_CODE[key], `The approved ${key} changed; nothing was sent; preview and approve again`); + } if ((actual.action === 'cancel' && actual.allowForEarlyCancellation !== true) || (actual.action === 'change_quantity' && Number(actual.quantity) > Number(actual.fromQuantity) && actual.allowForQuantityIncrease !== true) || (actual.action === 'change_quantity' && Number(actual.quantity) < Number(actual.fromQuantity) && actual.allowForQuantityDecrease !== true)) { return refuse('COMMITMENT_CHANGED', 'The current Pax8 commitment no longer permits this action'); } - for (const key of ['action', 'pax8ProductId', 'productName', 'quantity', 'billingTerm'] as const) { - if (line[key] !== actual[key]) return refuse('PREVIEW_MISMATCH', `The approved ${key} differs from the order`); - } - if ((line.fromQuantity ?? null) !== actual.fromQuantity) return refuse('PREVIEW_MISMATCH', 'The approved baseline quantity differs from the order'); } if (expected.expectedCompany.pax8CompanyId !== live.company?.pax8CompanyId || expected.expectedCompany.name !== live.company?.name || expected.previewDigest !== live.previewDigest) return refuse('PREVIEW_MISMATCH', 'The approved company or local order inputs changed'); - const result = await runOutsideDbContext(() => submitOrder({ - partnerId, orderId, actorUserId: auth.user.id, onPreflightFailure: 'reopen', + let result: SubmitResult; + try { + result = await runOutsideDbContext(() => submitOrder({ + partnerId, orderId, actorUserId: auth.user.id, mode: { kind: 'ai', verifyClaim: async (_claimed, database) => { const material = await pax8OrderDigestMaterial(database, orderId); if (!material || hashPax8OrderMaterial(material) !== expected.previewDigest) { - throw new Pax8OrderError('PREVIEW_MISMATCH: The order changed before its claim; nothing was sent.', 409); + return { ok: false, code: 'PREVIEW_MISMATCH', message: 'The order changed before its claim; nothing was sent; preview and approve again.' }; } - }, + return { ok: true }; + }, }, })); - const failure = result.refusalCode !== undefined; - audit(auth, partnerId, bundle.order.orgId, 'pax8.order.submit', orderId, { status: result.status, refusalCode: result.refusalCode }, failure ? 'failure' : 'success'); - if (failure) return bounded({ error: pax8Clip(result.error) ?? 'The unsent order was reopened', code: result.refusalCode!, result: { ...result, error: pax8Clip(result.error), lines: result.lines.map(line => ({ ...line, error: pax8Clip(line.error) })) } }); + } catch (error) { + audit(auth, partnerId, bundle.order.orgId, 'pax8.order.submit', orderId, + { error: error instanceof Error ? error.constructor.name : typeof error }, 'failure'); + throw error; + } + audit(auth, partnerId, bundle.order.orgId, 'pax8.order.submit', orderId, + { status: result.status, ...(result.kind === 'refused' ? { refusalCode: result.refusalCode } : {}), billingConflicts: result.billingConflicts, + lines: result.lines.map(line => ({ lineId: line.lineId, submitState: line.submitState })) }, + result.status === 'completed' && result.billingConflicts.length === 0 ? 'success' : 'failure'); + if (result.kind === 'refused') return bounded({ error: pax8Clip(result.error), code: result.refusalCode, + nextStep: 'nothing was sent; preview and approve again.', + result: { ...result, error: pax8Clip(result.error), lines: result.lines.map(line => ({ ...line, error: pax8Clip(line.error) })) } }); return bounded({ ...result, lines: result.lines.map(line => ({ ...line, error: pax8Clip(line.error) })), - ...(result.lines.some(line => line.submitState === 'needs_reconcile') ? { nextStep: 'Use reconcile to inspect vendor evidence; never retry an unknown write.' } : {}) }); + ...(result.billingConflicts.length ? { nextStep: BILLING_NEXT_STEP } + : result.lines.some(line => line.submitState === 'needs_reconcile') ? { nextStep: 'Use reconcile to inspect vendor evidence; never retry an unknown write.' } : {}) }); } if (action === 'preflight') { try { @@ -161,8 +189,12 @@ export function registerPax8OrderTools(aiTools: Map): void { } return jsonError('Unknown Pax8 order action', 'VALIDATION_ERROR'); } catch (error) { - if (error instanceof Pax8OrderError && error.message.startsWith('PREVIEW_MISMATCH:')) return jsonError(error.message, 'PREVIEW_MISMATCH'); - if (error instanceof Pax8OrderError && error.status === 409) return bounded({ error: pax8Clip(error.message), code: 'CONFLICT', nextStep: 'Inspect the order and reconcile any unknown write; never retry blindly.' }); + if (error instanceof Pax8ClaimDriftError) return bounded({ error: pax8Clip(error.message), code: error.code, nextStep: 'nothing was sent; preview and approve again.' }); + if (error instanceof Pax8OrderError) { + if (error.phase !== 'claim') captureException(error, undefined, { errorId: errorIds.PAX8_PERSIST_FAILED }); + if (error.status === 409) return bounded({ error: pax8Clip(error.message), code: error.code ?? 'CONFLICT', + nextStep: error.phase === 'claim' ? 'nothing was sent; preview and approve again.' : 'Inspect the order and reconcile any unknown write; never retry blindly.' }); + } return pax8Error(TOOL, error); } }, diff --git a/apps/api/src/services/pax8OrderObservability.test.ts b/apps/api/src/services/pax8OrderObservability.test.ts new file mode 100644 index 00000000000..02823785138 --- /dev/null +++ b/apps/api/src/services/pax8OrderObservability.test.ts @@ -0,0 +1,24 @@ +import { beforeEach, expect, it, vi } from 'vitest'; +const sentry = vi.hoisted(() => ({ captureException: vi.fn(), captureMessage: vi.fn() })); +vi.mock('./sentry', () => sentry); +import { errorIds, recordExecution } from './pax8OrderObservability'; +import { isRegisteredSentryEventCode } from './sentryEventCodes'; +beforeEach(() => vi.clearAllMocks()); +it('registers bounded unique error IDs and an execution event code', () => { + const ids = Object.values(errorIds); + expect(new Set(ids).size).toBe(ids.length); + for (const id of ids) expect(id).toMatch(/^[A-Z0-9_]{1,128}$/); + expect(isRegisteredSentryEventCode('pax8_order_execution_result')).toBe(true); +}); +it('retains response evidence in structured logs and tags both Sentry events', () => { + const info = vi.spyOn(console, 'info').mockImplementation(() => {}); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const evidence = {partnerId:'p',orderId:'o',pax8OrderId:'vendor',outcomes:[{lineId:'l',resultSubscriptionId:'sub',submitState:'succeeded'}]}; + recordExecution(evidence); + const failure = new Error('persist conflict'); recordExecution(evidence, failure); + expect(info).toHaveBeenCalledWith(expect.any(String),evidence); + expect(error).toHaveBeenCalledWith(expect.any(String),{...evidence,error:failure}); + expect(sentry.captureMessage).toHaveBeenCalledWith(expect.any(String),expect.objectContaining({eventCode:'pax8_order_execution_result',tags:{errorId:errorIds.PAX8_EXECUTION_RESULT}})); + expect(sentry.captureException).toHaveBeenCalledWith(failure,undefined,{errorId:errorIds.PAX8_PERSIST_FAILED}); + info.mockRestore(); error.mockRestore(); +}); diff --git a/apps/api/src/services/pax8OrderObservability.ts b/apps/api/src/services/pax8OrderObservability.ts new file mode 100644 index 00000000000..fc77d528847 --- /dev/null +++ b/apps/api/src/services/pax8OrderObservability.ts @@ -0,0 +1,22 @@ +import { captureException, captureMessage } from './sentry'; + +/** Stable errorId tags survive the Sentry scrubber; full IDs/outcomes stay in structured server logs. */ +export const errorIds = { + PAX8_EXECUTION_RESULT: 'PAX8_EXECUTION_RESULT', + PAX8_PERSIST_FAILED: 'PAX8_PERSIST_FAILED', + PAX8_BILLING_CONFLICT: 'PAX8_BILLING_CONFLICT', + PAX8_BILLING_INTEGRITY_DRIFT: 'PAX8_BILLING_INTEGRITY_DRIFT', + PAX8_REOPEN_FAILED: 'PAX8_REOPEN_FAILED', +} as const; +export function logError(errorId: typeof errorIds[keyof typeof errorIds], message: string, context: Record): void { + console.error(`[pax8OrderSubmit] ${errorId} ${message}`, context); +} +export function recordExecution(context: Record, error?: unknown): void { + if (error !== undefined) { + logError(errorIds.PAX8_PERSIST_FAILED, 'Vendor execution result could not be persisted', { ...context, error }); + captureException(error, undefined, { errorId: errorIds.PAX8_PERSIST_FAILED }); + } else { + console.info(`[pax8OrderSubmit] ${errorIds.PAX8_EXECUTION_RESULT}`, context); + captureMessage('Pax8 execution result received', { eventCode: 'pax8_order_execution_result', level: 'info', tags: { errorId: errorIds.PAX8_EXECUTION_RESULT } }); + } +} diff --git a/apps/api/src/services/pax8OrderPreview.test.ts b/apps/api/src/services/pax8OrderPreview.test.ts index d83e6d10cfe..5c0d399f5e6 100644 --- a/apps/api/src/services/pax8OrderPreview.test.ts +++ b/apps/api/src/services/pax8OrderPreview.test.ts @@ -20,7 +20,7 @@ vi.mock('./catalogService', () => ({ CatalogServiceError: class CatalogServiceError extends Error { constructor(message: string, public status = 400, public code?: string) { super(message); } }, })); vi.mock('./actionIntents/pax8OrderDigest', () => ({ pax8OrderDigestMaterial: m.material, hashPax8OrderMaterial: () => 'a'.repeat(64) })); -import { previewPax8Order } from './pax8OrderPreview'; +import { PREVIEW_FIELD_SOURCE, previewPax8Order } from './pax8OrderPreview'; import { Pax8ApiError } from './pax8Client'; import { CatalogServiceError } from './catalogService'; @@ -96,6 +96,18 @@ describe('previewPax8Order', () => { expectedLines: [expect.objectContaining({ lineId, quantity: '5', unitCost: '8.25', commitmentTerm: 'Annual' })] }); expect(m.material).toHaveBeenCalledTimes(1); }); + it('classifies every emitted preview field by evidence source', async () => { + const result = await run(); + expect(Object.keys(PREVIEW_FIELD_SOURCE).sort()).toEqual(Object.keys(result.lines[0]!).sort()); + expect(new Set(Object.values(PREVIEW_FIELD_SOURCE))).toEqual(new Set(['material', 'vendor-live', 'derived'])); + }); + it('blocks a missing linked contract line before offering submit assertions', async () => { + lines[0]!.contractLineId = 'missing-contract-line'; + rows.contract_lines = []; + const result = await run(); + expect(result.blocked).toContainEqual({ lineId, code: 'CONTRACT_LINE_MISSING' }); + expect(result.submitWith).toBeUndefined(); + }); it('blocked previews never offer submitWith', async () => { m.pricing.mockResolvedValue([]); expect((await run()).submitWith).toBeUndefined(); diff --git a/apps/api/src/services/pax8OrderPreview.ts b/apps/api/src/services/pax8OrderPreview.ts index 58bdcab85e2..f995ac1bb83 100644 --- a/apps/api/src/services/pax8OrderPreview.ts +++ b/apps/api/src/services/pax8OrderPreview.ts @@ -6,6 +6,7 @@ import { contracts, contractLines, pax8CompanyMappings, pax8ProductMappings, pax import { pax8OrderDigestMaterial, hashPax8OrderMaterial } from './actionIntents/pax8OrderDigest'; import type { z } from 'zod'; import type { pax8SubmitAssertionsSchema } from './aiToolSchemasPax8'; +import { isSubmittableStatus } from './pax8OrderTypes'; import { getOrderWithLines, snapshotActiveCommitmentEvidence } from './pax8OrderService'; import { createPax8ClientForIntegration } from './pax8SyncService'; import { pax8CompanyOrderReadiness } from './pax8CompanyReadiness'; @@ -34,7 +35,16 @@ export interface Pax8PreviewLine { cancellationFeeApplied: boolean | null; } +export const PREVIEW_FIELD_SOURCE = { + lineId: 'material', action: 'material', pax8ProductId: 'material', productName: 'material', + vendorSkuId: 'material', quantity: 'derived', fromQuantity: 'material', billingTerm: 'material', + commitmentTerm: 'vendor-live', unitCost: 'derived', currency: 'derived', sellUnitPrice: 'material', + sellCurrency: 'material', contractLineId: 'material', allowForQuantityIncrease: 'vendor-live', + allowForQuantityDecrease: 'vendor-live', allowForEarlyCancellation: 'vendor-live', cancellationFeeApplied: 'vendor-live', +} as const satisfies Record; + export type Pax8PreviewBlocker = + | { code: 'CONTRACT_LINE_MISSING'; lineId: string } | { code: 'COST_UNKNOWN'; lineId: string } | { code: 'TERM_UNKNOWN'; lineId: string } | { code: 'QUANTITY_UNKNOWN'; lineId: string } @@ -57,7 +67,6 @@ export interface Pax8OrderPreview { } export const MAX_PAX8_PREVIEW_LINES = 20; -const SUBMITTABLE: ReadonlySet = new Set(['draft', 'awaiting_details', 'ready']); const billingTerms: ReadonlySet = new Set(PAX8_BILLING_TERMS); const asBillingTerm = (value: string | null): Pax8BillingTerm | null => value !== null && billingTerms.has(value) ? value as Pax8BillingTerm : null; @@ -112,6 +121,7 @@ export async function previewPax8Order(input: { eq(pax8ProductMappings.integrationId, order.integrationId), eq(pax8ProductMappings.pax8ProductId, productId), )).limit(1) : []; + let contractLineMissing = false; let baseline = line.authorizedBaselineQuantity; let sellUnitPrice: string | null = null; let sellCurrency: string | null = null; @@ -122,6 +132,7 @@ export async function previewPax8Order(input: { }).from(contractLines).where(and( eq(contractLines.id, line.contractLineId), eq(contractLines.orgId, order.orgId), )).limit(1); + contractLineMissing = !contractLine; if (contractLine) { baseline = contractLine.manualQuantity; const [contract] = await db.select({ @@ -147,7 +158,7 @@ export async function previewPax8Order(input: { } } } - evidence.push({ line, snapshot, mapping, productId, sellUnitPrice, sellCurrency, baseline }); + evidence.push({ line, snapshot, mapping, productId, sellUnitPrice, sellCurrency, baseline, contractLineMissing }); } const material = await pax8OrderDigestMaterial(db, order.id); return { ...bundle, companies, evidence, previewDigest: material ? hashPax8OrderMaterial(material) : null }; @@ -162,7 +173,7 @@ export async function previewPax8Order(input: { if (local.lines.length > MAX_PAX8_PREVIEW_LINES) blocked.push({ code: 'TOO_MANY_LINES' }); // The persisted action/status vocabularies are constrained by DB CHECKs. const status = order.status as Pax8OrderStatus; - if (!SUBMITTABLE.has(status)) blocked.push({ code: 'NOT_PENDING' }); + if (!isSubmittableStatus(status)) blocked.push({ code: 'NOT_PENDING' }); const projectedOrder = { id: order.id, orgId: order.orgId, source: order.source, status }; if (blocked.some(item => item.code === 'TOO_MANY_LINES' || item.code === 'NOT_PENDING')) { return { order: projectedOrder, company, lines: [], totals: [], totalsComplete: local.lines.length === 0, blocked, previewDigest }; @@ -178,7 +189,8 @@ export async function previewPax8Order(input: { // Cache live evidence only for this preview: repeated product lines must share one observation. const dependencies = new Map>>(); const pricing = new Map>>(); - for (const { line, snapshot, mapping: product, productId, sellUnitPrice, sellCurrency, baseline } of local.evidence) { + for (const { line, snapshot, mapping: product, productId, sellUnitPrice, sellCurrency, baseline, contractLineMissing } of local.evidence) { + if (contractLineMissing) blocked.push({ lineId: line.id, code: 'CONTRACT_LINE_MISSING' }); if (line.submitState !== 'pending') blocked.push({ lineId: line.id, code: 'NOT_PENDING' }); const missingSnapshot = line.action !== 'new_subscription' && !snapshot; if (missingSnapshot) blocked.push({ lineId: line.id, code: 'SUBSCRIPTION_NOT_FOUND' }); diff --git a/apps/api/src/services/pax8OrderService.ts b/apps/api/src/services/pax8OrderService.ts index 384ae3adad2..98cc4417c62 100644 --- a/apps/api/src/services/pax8OrderService.ts +++ b/apps/api/src/services/pax8OrderService.ts @@ -26,10 +26,21 @@ export class Pax8OrderError extends Error { constructor( message: string, public readonly status: 400 | 403 | 404 | 409 | 422, + public readonly context: { phase?: 'claim' | 'execute' | 'persist'; code?: string; cause?: unknown } = {}, ) { - super(message); + super(message, { cause: context.cause }); this.name = 'Pax8OrderError'; } + get phase(): 'claim' | 'execute' | 'persist' { return this.context.phase ?? 'claim'; } + get code(): string | undefined { return this.context.code; } +} + +export class Pax8ClaimDriftError extends Pax8OrderError { + override get code(): 'PREVIEW_MISMATCH' { return 'PREVIEW_MISMATCH'; } + constructor(message: string) { + super(message, 409, { phase: 'claim', code: 'PREVIEW_MISMATCH' }); + this.name = 'Pax8ClaimDriftError'; + } } export class Pax8OrderRestageRequiredError extends Pax8OrderError { diff --git a/apps/api/src/services/pax8OrderSubmit.releaseTwice.test.ts b/apps/api/src/services/pax8OrderSubmit.releaseTwice.test.ts new file mode 100644 index 00000000000..4511ce13e6b --- /dev/null +++ b/apps/api/src/services/pax8OrderSubmit.releaseTwice.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ select: vi.fn(), update: vi.fn() })); +vi.mock('../db', () => ({ + db: { select: mocks.select, update: mocks.update }, + runOutsideDbContext: (fn: () => unknown) => fn(), + withDbAccessContext: (_context: unknown, fn: () => unknown) => fn(), +})); + +import { createPax8OrderSubmitService } from './pax8OrderSubmit'; +import { pax8OrderSubmitRepository } from './pax8OrderSubmitRepository'; + +describe('Pax8 repeated release with the real claim repository', () => { + it('refuses a completed order before constructing a client or attempting another write', async () => { + const completed = { id: 'order-1', partnerId: 'partner-1', orgId: 'org-1', status: 'completed' }; + const chain = { + from: vi.fn(() => chain), where: vi.fn(() => chain), limit: vi.fn(async () => [completed]), + }; + mocks.select.mockReturnValue(chain); + const createClient = vi.fn(); + const service = createPax8OrderSubmitService({ + repository: { ...pax8OrderSubmitRepository, createClient }, + runOutsideDbContext: fn => fn(), + }); + + await expect(service.submitOrder({ + partnerId: completed.partnerId, orderId: completed.id, actorUserId: 'actor-1', mode: { kind: 'rest' }, + })).rejects.toMatchObject({ status: 409, phase: 'claim' }); + + expect(mocks.select).toHaveBeenCalledTimes(2); // scoped discovery, then authoritative claim read + expect(createClient).not.toHaveBeenCalled(); + expect(mocks.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/services/pax8OrderSubmit.test.ts b/apps/api/src/services/pax8OrderSubmit.test.ts index a1237cc87ea..0298bd30a2e 100644 --- a/apps/api/src/services/pax8OrderSubmit.test.ts +++ b/apps/api/src/services/pax8OrderSubmit.test.ts @@ -5,7 +5,7 @@ import { Pax8ApiError } from './pax8Client'; import { createPax8OrderSubmitService, type Pax8OrderSubmitRepository, - type SubmitBundle, + type ClaimedBundle, } from './pax8OrderSubmit'; const newLine = { @@ -55,7 +55,7 @@ const cancelLine = { sortOrder: 2, } as const; -function bundle(lines: readonly unknown[] = [newLine]): SubmitBundle { +function bundle(lines: readonly unknown[] = [newLine]): ClaimedBundle { return { order: { id: 'ord-1', @@ -66,8 +66,10 @@ function bundle(lines: readonly unknown[] = [newLine]): SubmitBundle { 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'], + } as ClaimedBundle['order'], + lines: lines as ClaimedBundle['lines'], + preClaimStatus: 'draft', + integrationGeneration: {id:'integration-1',partnerId:'p1',isActive:true,apiBaseUrl:'https://example.test',tokenUrl:'https://example.test/token',credentialGeneration:'hash'}, }; } @@ -78,12 +80,12 @@ function resultFor( const status = states.every((state) => state === 'succeeded') ? 'completed' : states.every((state) => state === 'failed') ? 'failed' : 'partially_failed'; - return { orderId: 'ord-1', status, lines } as const; + return { kind: 'executed', orderId: 'ord-1', status, lines, billingConflicts: [] } as const; } function setup( lines: readonly unknown[] = [newLine], - orderOverrides: Partial = {}, + orderOverrides: Partial = {}, ) { const client = { createOrder: vi.fn(), @@ -99,7 +101,7 @@ function setup( claimOrder: vi.fn().mockResolvedValue(claimed), createClient: vi.fn().mockResolvedValue(client), claimLines: vi.fn().mockResolvedValue(undefined), - reopenUnsentOrder: vi.fn().mockResolvedValue({ orderId: 'ord-1', status: 'draft', lines: [] }), + reopenUnsentOrder: vi.fn().mockImplementation(async (_bundle,error,refusalCode) => ({ kind: 'refused', orderId: 'ord-1', status: 'draft', lines: [], billingConflicts: [], error, refusalCode })), persistPreflightFailure: vi.fn().mockImplementation(async (_bundle, errorBody) => resultFor(claimed.lines.map((line) => ({ lineId: line.id, @@ -168,7 +170,7 @@ describe('submitOrder', () => { 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' }); + const res = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1', mode: {kind: 'rest'} }); expect(client.createOrder).toHaveBeenCalledTimes(1); expect(client.createOrder.mock.calls[0]![1]).toEqual({ isMock: true }); @@ -182,7 +184,7 @@ describe('submitOrder', () => { 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' }); + const res = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1', mode: {kind: 'rest'} }); expect(res.status).toBe('failed'); expect(res.lines.every((line) => line.submitState === 'failed')).toBe(true); @@ -196,7 +198,7 @@ describe('submitOrder', () => { .mockResolvedValueOnce({ pax8OrderId: null, lineItems: [] }) .mockResolvedValueOnce({ pax8OrderId: 'pax-order-1', lineItems: [] }); - await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1' }); + await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1', mode: {kind: 'rest'} }); expect(repository.claimOrder).toHaveBeenCalledBefore(repository.claimLines as any); expect(client.createOrder).toHaveBeenCalledTimes(2); @@ -210,7 +212,7 @@ describe('submitOrder', () => { .mockResolvedValueOnce({ pax8OrderId: null, lineItems: [] }) .mockRejectedValueOnce(Object.assign(new Error('aborted'), { name: 'AbortError' })); - const res = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1' }); + const res = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1', mode: {kind: 'rest'} }); expect(res.lines[0]!.submitState).toBe('needs_reconcile'); expect(client.createOrder).toHaveBeenCalledTimes(2); @@ -220,7 +222,7 @@ describe('submitOrder', () => { 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' })) + await expect(service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1', mode: {kind: 'rest'} })) .rejects.toMatchObject({ status: 409 }); expect(client.createOrder).not.toHaveBeenCalled(); }); @@ -237,7 +239,7 @@ describe('submitOrder', () => { new Pax8ApiError('Pax8 API returned 422', 422, '{"message":"seat decrease not allowed"}'), ); - const res = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1' }); + const res = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1', mode: {kind: 'rest'} }); expect(res.status).toBe('partially_failed'); expect(res.lines.find((line) => line.lineId === 'line-new')!.submitState).toBe('succeeded'); @@ -250,7 +252,7 @@ describe('submitOrder', () => { 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' })) + await expect(service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1', mode: {kind: 'rest'} })) .rejects.toMatchObject({ status: 409 }); expect(client.createOrder).toHaveBeenCalledTimes(1); // isMock only }); @@ -264,7 +266,7 @@ describe('submitOrder', () => { { lineItemNumber: 2, productId: 'prod-1', subscriptionId: 'sub-2' }, ] }); - const res = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1' }); + const res = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1', mode: {kind: 'rest'} }); expect(res.lines.every((line) => line.submitState === 'succeeded')).toBe(true); expect(repository.persistSubmitResults).toHaveBeenCalledWith( @@ -295,7 +297,7 @@ describe('submitOrder', () => { .mockResolvedValueOnce({ pax8OrderId: null, lineItems: [] }) .mockResolvedValueOnce({ pax8OrderId: 'pax-order-1', lineItems }); - const res = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1' }); + const res = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1', mode: {kind: 'rest'} }); expect(res.lines.every((line) => line.submitState === 'needs_reconcile')).toBe(true); }); @@ -309,7 +311,7 @@ describe('submitOrder', () => { lineItems: [{ lineItemNumber: 1, productId: 'prod-1', subscriptionId: 'sub-1' }], }); - const res = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1' }); + const res = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1', mode: {kind: 'rest'} }); expect(res.lines[0]!.submitState).toBe('needs_reconcile'); }); @@ -321,7 +323,7 @@ describe('submitOrder', () => { const { service, client } = setup([line]); client[method].mockRejectedValueOnce(error); - const res = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1' }); + const res = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1', mode: {kind: 'rest'} }); expect(client[method]).toHaveBeenCalledTimes(1); expect(res.lines[0]!.submitState).toBe('needs_reconcile'); @@ -332,7 +334,7 @@ describe('submitOrder', () => { 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' })) + await expect(service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1', mode: {kind: 'rest'} })) .rejects.toMatchObject({ status: 422 }); expect(client.updateSubscriptionQuantity).not.toHaveBeenCalled(); }); @@ -601,25 +603,26 @@ describe('approved submit options', () => { const { service, repository, client } = setup(); const verifyClaim = vi.fn().mockRejectedValue(new Error('PREVIEW_MISMATCH')); vi.mocked(repository.claimOrder).mockImplementation(async (input) => { - await input.verifyClaim?.(bundle(), {} as never); + if (input.mode.kind === 'ai') await input.mode.verifyClaim(bundle(), {} as never); return bundle(); }); - await expect(service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1', verifyClaim })) + await expect(service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1', mode: {kind: 'ai',verifyClaim} })) .rejects.toThrow('PREVIEW_MISMATCH'); expect(repository.createClient).not.toHaveBeenCalled(); expect(repository.claimLines).not.toHaveBeenCalled(); expect(client.createOrder).not.toHaveBeenCalled(); }); - it.each(['draft', 'awaiting_details', 'ready'] as const)('verifies an unchanged %s order before isMock', async (status) => { + it('verifies an unchanged order before isMock', async () => { + const status = 'draft' as const; const { service, repository, client } = setup(); - const verifyClaim = vi.fn().mockResolvedValue(undefined); + const verifyClaim = vi.fn().mockResolvedValue({ok:true}); vi.mocked(repository.claimOrder).mockImplementation(async (input) => { const claimed = { ...bundle(), preClaimStatus: status }; - await input.verifyClaim?.(claimed, {} as never); + if (input.mode.kind === 'ai') await input.mode.verifyClaim(claimed, {} as never); return claimed; }); - await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1', verifyClaim }); + await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1', mode: {kind: 'ai',verifyClaim} }); expect(verifyClaim).toHaveBeenCalledOnce(); expect(verifyClaim).toHaveBeenCalledBefore(client.createOrder); }); @@ -627,10 +630,10 @@ describe('approved submit options', () => { it('reopens a failed post-claim isMock without terminally failing or sending real writes', async () => { const { service, repository, client } = setup(); client.createOrder.mockRejectedValueOnce(new Pax8ApiError('validation', 422, 'invalid provisioning')); - const result = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1', onPreflightFailure: 'reopen' }); + const result = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1', mode: {kind: 'ai',verifyClaim: async () => ({ok:true})} }); expect(result.status).toBe('draft'); - expect(result.refusalCode).toBe('PREFLIGHT_FAILED'); - expect(repository.reopenUnsentOrder).toHaveBeenCalledWith(expect.anything(), 'invalid provisioning'); + expect(result).toMatchObject({kind:'refused',refusalCode:'PREFLIGHT_FAILED'}); + expect(repository.reopenUnsentOrder).toHaveBeenCalledWith(expect.anything(), 'invalid provisioning', 'PREFLIGHT_FAILED'); expect(repository.persistPreflightFailure).not.toHaveBeenCalled(); expect(repository.claimLines).not.toHaveBeenCalled(); expect(client.createOrder).toHaveBeenCalledTimes(1); @@ -639,9 +642,9 @@ describe('approved submit options', () => { it('reopens an integration-generation refusal before any HTTP', async () => { const { service, repository, client } = setup(); vi.mocked(repository.createClient).mockRejectedValue(new Pax8IntegrationGenerationChangedError()); - const result = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1', onPreflightFailure: 'reopen' }); - expect(result.refusalCode).toBe('INTEGRATION_CHANGED'); - expect(repository.reopenUnsentOrder).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('integration configuration changed')); + const result = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1', mode: {kind: 'ai',verifyClaim: async () => ({ok:true})} }); + expect(result).toMatchObject({kind:'refused',refusalCode:'INTEGRATION_CHANGED'}); + expect(repository.reopenUnsentOrder).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('integration configuration changed'), 'INTEGRATION_CHANGED'); expect(repository.persistPreflightFailure).not.toHaveBeenCalled(); expect(client.createOrder).not.toHaveBeenCalled(); }); @@ -649,10 +652,39 @@ describe('approved submit options', () => { it('reopens a provably unsent quote when preflight payload construction throws', async () => { const { service, repository, client } = setup([{ ...newLine, provisioningDetails: {} }], { source: 'quote' }); - const result = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1', onPreflightFailure: 'reopen' }); + const result = await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1', mode: {kind: 'ai',verifyClaim: async () => ({ok:true})} }); expect(result).toMatchObject({ status: 'draft', refusalCode: 'PREFLIGHT_FAILED' }); - expect(repository.reopenUnsentOrder).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('invalid provisioning details')); + expect(repository.reopenUnsentOrder).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('invalid provisioning details'), 'PREFLIGHT_FAILED'); expect(repository.persistPreflightFailure).not.toHaveBeenCalled(); expect(repository.claimLines).not.toHaveBeenCalled(); expect(client.createOrder).not.toHaveBeenCalled(); }); + +it('reopens an integration change on REST before any vendor request', async () => { + const {service,repository,client} = setup(); + vi.mocked(repository.createClient).mockRejectedValueOnce(new Pax8IntegrationGenerationChangedError('before order submission; nothing was sent')); + const out = await service.submitOrder({partnerId:'p1',orderId:'ord-1',actorUserId:'u1',mode:{kind:'rest'}}); + expect(out).toMatchObject({kind:'refused',refusalCode:'INTEGRATION_CHANGED'}); + expect(repository.persistPreflightFailure).not.toHaveBeenCalled(); expect(client.createOrder).not.toHaveBeenCalled(); +}); +it('records successful vendor evidence before persist and again on persist failure', async () => { + const {service,repository,client} = setup(); + const info = vi.spyOn(console,'info').mockImplementation(() => {}); + const error = vi.spyOn(console,'error').mockImplementation(() => {}); + client.createOrder.mockResolvedValue({pax8OrderId:'remote-order',lineItems:[{lineItemNumber:1,productId:'prod-1',subscriptionId:'remote-sub'}]}); + vi.mocked(repository.persistSubmitResults).mockRejectedValueOnce(new Error('persist failed')); + await expect(service.submitOrder({partnerId:'p1',orderId:'ord-1',actorUserId:'u1',mode:{kind:'rest'}})).rejects.toThrow('persist failed'); + expect(info).toHaveBeenCalledWith(expect.stringContaining('PAX8_EXECUTION_RESULT'),expect.objectContaining({partnerId:'p1',orderId:'ord-1',pax8OrderId:'remote-order',outcomes:[expect.objectContaining({lineId:'line-new',resultSubscriptionId:'remote-sub',submitState:'succeeded'})]})); + expect(info).toHaveBeenCalledBefore(repository.persistSubmitResults as any); + expect(error).toHaveBeenCalledWith(expect.stringContaining('PAX8_PERSIST_FAILED'),expect.objectContaining({pax8OrderId:'remote-order',error:expect.any(Error)})); + info.mockRestore(); error.mockRestore(); +}); + +it('tags errors after line claim as execute-phase conflicts', async () => { + const {service,repository} = setup([{...newLine}]); + vi.mocked(repository.claimLines).mockImplementationOnce(async claimed => { + claimed.lines[0]!.provisioningDetails = {}; + }); + await expect(service.submitOrder({partnerId:'p1',orderId:'ord-1',actorUserId:'u1',mode:{kind:'rest'}})) + .rejects.toMatchObject({phase:'execute'}); +}); diff --git a/apps/api/src/services/pax8OrderSubmit.ts b/apps/api/src/services/pax8OrderSubmit.ts index e115c4f7585..0d29997902c 100644 --- a/apps/api/src/services/pax8OrderSubmit.ts +++ b/apps/api/src/services/pax8OrderSubmit.ts @@ -1,6 +1,6 @@ import type { Pax8OrderStatus, Pax8SubmitState } from '@breeze/shared'; import { runOutsideDbContext, type Database } from '../db'; -import { Pax8IntegrationGenerationChangedError, type Pax8IntegrationSnapshot } from './pax8SyncService'; +import { Pax8IntegrationGenerationChangedError } from './pax8SyncService'; import type { Pax8OrderLineRow, Pax8OrderRow } from './pax8OrderService'; import { Pax8OrderError } from './pax8OrderService'; import { @@ -11,24 +11,32 @@ import { type Pax8OrderResult, type Pax8SubscriptionRecord, } from './pax8Client'; +import type { SubmittableStatus } from './pax8OrderTypes'; +import type { Pax8IntegrationGeneration } from './actionIntents/pax8OrderDigest'; +import { recordExecution } from './pax8OrderObservability'; import { pax8OrderSubmitRepository } from './pax8OrderSubmitRepository'; -export interface SubmitBundle { - order: Pax8OrderRow; - lines: Pax8OrderLineRow[]; - /** Captured inside the claim transaction; never exposed in tool output. */ - integrationGeneration?: Pax8IntegrationSnapshot; - preClaimStatus?: 'draft' | 'awaiting_details' | 'ready'; +export const PAX8_SUBMIT_REFUSAL_CODES = [ + 'PREVIEW_MISMATCH', 'PRICE_CHANGED', 'COMMITMENT_CHANGED', 'COST_UNKNOWN', + 'COMMITMENT_AMBIGUOUS', 'TOO_MANY_LINES', 'NOT_PENDING', 'COMPANY_UNRESOLVED', + 'TERM_UNKNOWN', 'QUANTITY_UNKNOWN', 'SUBSCRIPTION_NOT_FOUND', 'PRODUCT_UNKNOWN', + 'CONTRACT_LINE_MISSING', 'CLAIM_LOST', 'INTEGRATION_UNAVAILABLE', 'BASELINE_NOT_CAPTURED', + 'INTEGRATION_CHANGED', 'PREFLIGHT_FAILED', 'CONFLICT', +] as const; +export type Pax8SubmitRefusalCode = (typeof PAX8_SUBMIT_REFUSAL_CODES)[number]; +export type UnsentRefusalCode = Extract; +export interface LoadedBundle { order: Pax8OrderRow; lines: Pax8OrderLineRow[] } +export interface ClaimedBundle extends LoadedBundle { + /** Opaque credential generation, without encrypted credentials. */ + integrationGeneration: Pax8IntegrationGeneration; + preClaimStatus: SubmittableStatus; } - -export type VerifyClaim = (bundle: SubmitBundle, database: Database) => Promise; - +export type VerifyClaim = (bundle: ClaimedBundle, database: Pick) => Promise< + { ok: true } | { ok: false; code: 'PREVIEW_MISMATCH'; message: string } +>; +export type SubmitMode = { kind: 'rest' } | { kind: 'ai'; verifyClaim: VerifyClaim }; export interface SubmitOrderInput { - partnerId: string; - orderId: string; - actorUserId: string; - verifyClaim?: VerifyClaim; - onPreflightFailure?: 'terminal' | 'reopen'; + partnerId: string; orderId: string; actorUserId: string; mode: SubmitMode; } export interface SubmitLineOutcome { @@ -38,33 +46,30 @@ export interface SubmitLineOutcome { resultSubscriptionId: string | null; } -export interface SubmitResult { - orderId: string; - status: Pax8OrderStatus; - refusalCode?: 'INTEGRATION_CHANGED' | 'PREFLIGHT_FAILED'; - error?: string; +export interface BillingConflict { lineId: string; reason: string } +interface OrderResult { + orderId: string; status: Pax8OrderStatus; lines: Array<{ lineId: string; submitState: Pax8SubmitState; error: string | null }>; + billingConflicts: BillingConflict[]; +} +export type SubmitResult = + | (OrderResult & { kind: 'refused'; refusalCode: UnsentRefusalCode; error: string }) + | (OrderResult & { kind: 'executed' }); +export interface ReconcileResult { + status: Pax8OrderStatus; lines: OrderResult['lines']; billingConflicts: BillingConflict[]; + resolved: number; stillUnknown: number; } - export interface Pax8OrderSubmitRepository { - loadResolvedOrder(input: { partnerId: string; orderId: string }): Promise; - claimOrder(input: SubmitOrderInput): Promise; - createClient(bundle: SubmitBundle): Promise; - claimLines(bundle: SubmitBundle): Promise; - reopenUnsentOrder(bundle: SubmitBundle, error: string): 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 }>; + loadResolvedOrder(input: { partnerId: string; orderId: string }): Promise; + claimOrder(input: SubmitOrderInput): Promise; + createClient(bundle: LoadedBundle | ClaimedBundle): Promise; + claimLines(bundle: ClaimedBundle): Promise; + reopenUnsentOrder(bundle: ClaimedBundle, error: string, refusalCode: UnsentRefusalCode): Promise>; + persistPreflightFailure(bundle: ClaimedBundle, errorBody: string): Promise; + persistSubmitResults(bundle: ClaimedBundle, outcomes: SubmitLineOutcome[], pax8OrderId: string | null): Promise; + loadReconcileOrder(input: { partnerId: string; orderId: string }): Promise; + resetUnsentOrder(bundle: LoadedBundle): Promise; + persistReconcileResults(bundle: LoadedBundle, outcomes: SubmitLineOutcome[], pax8OrderId: string | null): Promise; } /** Optional evidence lets adapters distinguish a vendor dry run from a local no-op. */ @@ -85,11 +90,11 @@ function numberQuantity(value: string | null): number { return quantity; } -function newSubscriptionLines(bundle: SubmitBundle): Pax8OrderLineRow[] { +function newSubscriptionLines(bundle: LoadedBundle): Pax8OrderLineRow[] { return bundle.lines.filter((line) => line.action === 'new_subscription'); } -function buildCreateOrderInput(bundle: SubmitBundle): Pax8CreateOrderInput | null { +function buildCreateOrderInput(bundle: LoadedBundle): Pax8CreateOrderInput | null { const lines = newSubscriptionLines(bundle); if (lines.length === 0) return null; if (!bundle.order.pax8CompanyId) { @@ -174,7 +179,7 @@ function createResponseAssignment(lines: Pax8OrderLineRow[], result: Pax8OrderRe } async function preflightBundle( - bundle: SubmitBundle, + bundle: LoadedBundle, client: Pax8Client, outside: typeof runOutsideDbContext, ): Promise { @@ -189,7 +194,7 @@ async function preflightBundle( } async function executeWrites( - bundle: SubmitBundle, + bundle: LoadedBundle, client: Pax8Client, ): Promise<{ outcomes: SubmitLineOutcome[]; pax8OrderId: string | null }> { const outcomes: SubmitLineOutcome[] = []; @@ -267,7 +272,7 @@ function sameQuantity(left: string | null, right: string): boolean { } function reconcileNewBatch( - bundle: SubmitBundle, + bundle: LoadedBundle, unknownNewLines: Pax8OrderLineRow[], orders: Pax8OrderRecord[], ): { outcomes: SubmitLineOutcome[]; pax8OrderId: string | null } { @@ -375,10 +380,10 @@ export function createPax8OrderSubmitService(deps: ServiceDeps) { error: string, refusalCode: 'INTEGRATION_CHANGED' | 'PREFLIGHT_FAILED' = 'PREFLIGHT_FAILED', ): Promise => { - if (input.onPreflightFailure !== 'reopen') return deps.repository.persistPreflightFailure(bundle, error); + // Generation drift is provably unsent for both REST and AI: reopen it. + if (input.mode.kind === 'rest' && refusalCode !== 'INTEGRATION_CHANGED') return deps.repository.persistPreflightFailure(bundle, error); const clipped = error.slice(0, 2048); - const result = await deps.repository.reopenUnsentOrder(bundle, clipped); - return { ...result, refusalCode, error: clipped }; + return deps.repository.reopenUnsentOrder(bundle, clipped, refusalCode); }; let client: Pax8Client; try { @@ -393,18 +398,36 @@ export function createPax8OrderSubmitService(deps: ServiceDeps) { } catch (error) { // Payload construction may fail locally before isMock. It is still // provably unsent and must follow the AI reopen policy. - if (input.onPreflightFailure === 'reopen') return preflightFailure(errorText(error)); + if (input.mode.kind === 'ai') return preflightFailure(errorText(error)); throw error; } if (!preflight.ok) { return preflightFailure(preflight.errorBody); } await deps.repository.claimLines(bundle); - const execution = await deps.runOutsideDbContext(() => executeWrites(bundle, client)); - return deps.repository.persistSubmitResults(bundle, execution.outcomes, execution.pax8OrderId); + let execution: Awaited>; + try { + execution = await deps.runOutsideDbContext(() => executeWrites(bundle, client)); + } catch (error) { + if (error instanceof Pax8OrderError) { + throw new Pax8OrderError(error.message, error.status, { phase: 'execute', cause: error }); + } + throw error; + } + const evidence = { partnerId: bundle.order.partnerId, orderId: bundle.order.id, ...execution }; + recordExecution(evidence); + try { + return await deps.repository.persistSubmitResults(bundle, execution.outcomes, execution.pax8OrderId); + } catch (error) { + recordExecution(evidence, error); + if (error instanceof Pax8OrderError) { + throw new Pax8OrderError(error.message, error.status, { phase: 'persist', cause: error }); + } + throw error; + } }, - async reconcileOrder(input: { partnerId: string; orderId: string }): Promise<{ resolved: number; stillUnknown: number }> { + async reconcileOrder(input: { partnerId: string; orderId: string }): Promise { 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 @@ -417,7 +440,12 @@ export function createPax8OrderSubmitService(deps: ServiceDeps) { && bundle.lines.every((line) => line.submitState === 'pending')) { return deps.repository.resetUnsentOrder(bundle); } - return { resolved: 0, stillUnknown: 0 }; + return { + resolved: 0, stillUnknown: 0, status: bundle.order.status as Pax8OrderStatus, + lines: bundle.lines.map(line => ({ lineId: line.id, submitState: line.submitState as Pax8SubmitState, error: line.error })), + billingConflicts: bundle.lines.flatMap(line => line.error?.startsWith('billing_conflict:') + ? [{ lineId: line.id, reason: line.error.split(':')[1] ?? 'unknown' }] : []), + }; } if (!bundle.order.pax8CompanyId) { throw new Pax8OrderError('Map this organization to a Pax8 company before reconciliation.', 422); diff --git a/apps/api/src/services/pax8OrderSubmitRepository.test.ts b/apps/api/src/services/pax8OrderSubmitRepository.test.ts index 33cecc5a816..0f3a1e413e8 100644 --- a/apps/api/src/services/pax8OrderSubmitRepository.test.ts +++ b/apps/api/src/services/pax8OrderSubmitRepository.test.ts @@ -5,8 +5,13 @@ const mocks = vi.hoisted(() => ({ validateLines: vi.fn(), events: [] as string[], createClient: vi.fn(), + logError: vi.fn(), + captureException: vi.fn(), })); +vi.mock('./pax8OrderObservability', () => ({ errorIds: { PAX8_BILLING_CONFLICT: 'PAX8_BILLING_CONFLICT', PAX8_BILLING_INTEGRITY_DRIFT: 'PAX8_BILLING_INTEGRITY_DRIFT', PAX8_REOPEN_FAILED: 'PAX8_REOPEN_FAILED' }, logError: mocks.logError })); +vi.mock('./sentry', () => ({ captureException: mocks.captureException })); + vi.mock('../db', () => ({ db: mocks.db, runOutsideDbContext: (fn: () => unknown) => fn(), @@ -15,8 +20,9 @@ vi.mock('../db', () => ({ vi.mock('./pax8OrderService', () => { class Pax8OrderError extends Error { - constructor(message: string, public readonly status: number) { - super(message); + constructor(message: string, public readonly status: number, options: { phase?: string; code?: string; cause?: unknown } = {}) { + super(message, { cause: options.cause }); + Object.assign(this, { phase: options.phase ?? 'claim', code: options.code }); } } class Pax8OrderRestageRequiredError extends Pax8OrderError { @@ -27,6 +33,7 @@ vi.mock('./pax8OrderService', () => { return { Pax8OrderError, Pax8OrderRestageRequiredError, + Pax8ClaimDriftError: class extends Pax8OrderError { constructor(message: string) { super(message, 409, { phase: 'claim', code: 'PREVIEW_MISMATCH' }); } }, requireImmediateCancelDate: vi.fn(), validateDirectOrderLinesForSubmit: mocks.validateLines, }; @@ -37,6 +44,7 @@ vi.mock('./pax8SyncService', async (importOriginal) => { return { ...actual, createPax8ClientForIntegration: mocks.createClient }; }); +import { pax8CredentialGeneration } from './actionIntents/pax8OrderDigest'; import { pax8OrderSubmitRepository } from './pax8OrderSubmitRepository'; const READY_METADATA = { @@ -81,6 +89,8 @@ beforeEach(() => { mocks.validateLines.mockReset(); mocks.createClient.mockReset(); mocks.events.length = 0; + mocks.logError.mockReset(); + mocks.captureException.mockReset(); }); describe('pax8OrderSubmitRepository.claimOrder', () => { @@ -118,7 +128,7 @@ describe('pax8OrderSubmitRepository.claimOrder', () => { const bundle = await pax8OrderSubmitRepository.claimOrder({ partnerId: order.partnerId, orderId: order.id, - actorUserId: 'actor-1', + actorUserId: 'actor-1', mode: { kind: 'rest' }, }); expect(mocks.events.indexOf('parent-claimed')).toBeLessThan(mocks.events.indexOf('lines-read')); @@ -161,7 +171,7 @@ describe('pax8OrderSubmitRepository.claimOrder', () => { await expect(pax8OrderSubmitRepository.claimOrder({ partnerId: order.partnerId, orderId: order.id, - actorUserId: 'actor-1', + actorUserId: 'actor-1', mode: { kind: 'rest' }, })).rejects.toMatchObject({ status: 409, message: expect.stringContaining('stage it again') }); expect(resetSet).toHaveBeenCalledWith(expect.objectContaining({ @@ -197,7 +207,7 @@ describe('pax8OrderSubmitRepository.claimOrder', () => { await expect(pax8OrderSubmitRepository.claimOrder({ partnerId: order.partnerId, orderId: order.id, - actorUserId: 'actor-1', + actorUserId: 'actor-1', mode: { kind: 'rest' }, })).rejects.toMatchObject({ status: 409 }); expect(mocks.db.update).toHaveBeenCalledTimes(1); @@ -211,6 +221,8 @@ const integration = { clientIdEncrypted: 'synthetic-client-ciphertext', clientSecretEncrypted: 'synthetic-secret-ciphertext', } as import('./pax8SyncService').Pax8IntegrationSnapshot; +const generation = { id: integration.id, partnerId: integration.partnerId, apiBaseUrl: integration.apiBaseUrl, tokenUrl: integration.tokenUrl, isActive: integration.isActive, credentialGeneration: pax8CredentialGeneration(integration) }; + const line = { id: 'line-1', orderId: order.id, partnerId: order.partnerId, orgId: order.orgId, action: 'new_subscription', submitState: 'pending', contractLineId: 'contract-line-1', @@ -244,15 +256,17 @@ function prepareClaim(action: string = 'new_subscription', status: string = 'rea it.each(['new_subscription', 'change_quantity', 'cancel'])('captures and durably stores the %s approved baseline before verifyClaim', async (action) => { const { baseline } = prepareClaim(action); - const verifyClaim = vi.fn(async (bundle: import('./pax8OrderSubmit').SubmitBundle) => { + const verifyClaim = vi.fn(async (bundle: import('./pax8OrderSubmit').ClaimedBundle) => { expect(mocks.events).toContain('validated'); expect(bundle.lines[0]!.authorizedBaselineQuantity).toBe('4.00'); expect(baseline.returning).toHaveBeenCalled(); + return { ok: true as const }; }); - const bundle = await pax8OrderSubmitRepository.claimOrder({ partnerId: order.partnerId, orderId: order.id, actorUserId: 'actor-1', verifyClaim }); - expect(verifyClaim).toHaveBeenCalledWith(bundle, mocks.db); + const bundle = await pax8OrderSubmitRepository.claimOrder({ partnerId: order.partnerId, orderId: order.id, actorUserId: 'actor-1', mode: { kind: 'ai', verifyClaim } }); + expect(verifyClaim).toHaveBeenCalledWith(bundle, { select: expect.any(Function) }); expect(bundle.preClaimStatus).toBe('ready'); - expect(bundle.integrationGeneration).toEqual(integration); + expect(bundle.integrationGeneration).toEqual(generation); + expect(bundle.integrationGeneration).not.toHaveProperty('clientSecretEncrypted'); expect(baseline.set).toHaveBeenCalledWith(expect.objectContaining({ authorizedBaselineQuantity: '4.00' })); }); @@ -260,23 +274,24 @@ it('propagates verifyClaim drift inside the claim context without constructing a prepareClaim(); await expect(pax8OrderSubmitRepository.claimOrder({ partnerId: order.partnerId, orderId: order.id, actorUserId: 'actor-1', - verifyClaim: async () => { throw new Error('PREVIEW_MISMATCH'); }, + mode: { kind: 'ai', verifyClaim: async () => ({ ok: false, code: 'PREVIEW_MISMATCH', message: 'PREVIEW_MISMATCH' }) }, })).rejects.toThrow('PREVIEW_MISMATCH'); - expect(mocks.createClient).not.toHaveBeenCalled(); }); it.each(['apiBaseUrl', 'tokenUrl', 'clientIdEncrypted', 'clientSecretEncrypted', 'isActive'] as const)('refuses a client when the claimed integration %s changed', async (field) => { - mocks.db.select.mockReturnValueOnce(selectChain([], 'limit')); + const generationSelect = selectChain([integration], 'limit'); + mocks.db.select.mockReturnValueOnce(generationSelect).mockReturnValueOnce(selectChain([integration], 'limit')); mocks.createClient.mockResolvedValue({ integration: { ...integration, [field]: field === 'isActive' ? false : 'changed' }, client: {} }); - await expect(pax8OrderSubmitRepository.createClient({ order, lines: [], integrationGeneration: integration })) + await expect(pax8OrderSubmitRepository.createClient({ order, lines: [], integrationGeneration: generation, preClaimStatus: 'ready' })) .rejects.toMatchObject({ name: 'Pax8IntegrationGenerationChangedError' }); + expect(mocks.createClient).toHaveBeenCalled(); }); it('accepts access-token churn without changing the claimed integration generation', async () => { const client = {}; - mocks.db.select.mockReturnValueOnce(selectChain([integration], 'limit')); + mocks.db.select.mockReturnValueOnce(selectChain([integration], 'limit')).mockReturnValueOnce(selectChain([integration], 'limit')); mocks.createClient.mockResolvedValue({ integration: { ...integration, accessTokenEncrypted: 'refreshed' }, client }); - await expect(pax8OrderSubmitRepository.createClient({ order, lines: [], integrationGeneration: integration })).resolves.toBe(client); + await expect(pax8OrderSubmitRepository.createClient({ order, lines: [], integrationGeneration: generation, preClaimStatus: 'ready' })).resolves.toBe(client); }); it.each(['draft', 'awaiting_details', 'ready'])('reopens a provably unsent claim to %s with pending lines', async (preClaimStatus) => { @@ -284,16 +299,21 @@ it.each(['draft', 'awaiting_details', 'ready'])('reopens a provably unsent claim mocks.db.select.mockReturnValueOnce(selectChain([claimed], 'limit')).mockReturnValueOnce(selectChain([line], 'limit')); const reset = mutation([{ id: order.id }]); mocks.db.update.mockReturnValueOnce(reset); - const result = await pax8OrderSubmitRepository.reopenUnsentOrder({ order: claimed, lines: [line], preClaimStatus: preClaimStatus as never }, 'preflight failed'); + const result = await pax8OrderSubmitRepository.reopenUnsentOrder({ order: claimed, lines: [line], preClaimStatus: preClaimStatus as never, integrationGeneration: generation }, 'preflight failed', 'PREFLIGHT_FAILED'); expect(result.status).toBe(preClaimStatus); expect(result.lines[0]?.submitState).toBe('pending'); expect(reset.set).toHaveBeenCalledWith(expect.objectContaining({ status: preClaimStatus, submittedBy: null, submittedAt: null })); + const { PgDialect } = await import('drizzle-orm/pg-core'); + const query = new PgDialect().sqlToQuery(reset.where.mock.calls[0]![0] as never); + expect(query.sql).toContain('submitted_at'); + expect(query.sql).toContain("submit_state <> 'pending'"); + expect(result).toMatchObject({ kind: 'refused', refusalCode: 'PREFLIGHT_FAILED' }); }); it('refuses to reopen after a line has entered in_flight', async () => { const claimed = { ...order, status: 'submitting', submittedAt: new Date() }; mocks.db.select.mockReturnValueOnce(selectChain([claimed], 'limit')).mockReturnValueOnce(selectChain([{ ...line, submitState: 'in_flight' }], 'limit')); - await expect(pax8OrderSubmitRepository.reopenUnsentOrder({ order: claimed, lines: [line], preClaimStatus: 'draft' }, 'failed')).rejects.toMatchObject({ status: 409 }); + await expect(pax8OrderSubmitRepository.reopenUnsentOrder({ order: claimed, lines: [line], preClaimStatus: 'draft', integrationGeneration: generation }, 'failed', 'PREFLIGHT_FAILED')).rejects.toMatchObject({ status: 409 }); expect(mocks.db.update).not.toHaveBeenCalled(); }); @@ -302,13 +322,15 @@ it.each(['persistSubmitResults', 'persistReconcileResults'] as const)('%s preser const sentLine = { ...line, authorizedBaselineQuantity: '4.00', submitState: 'in_flight' }; const recorded = { ...sentLine, submitState: 'succeeded', error: 'billing_conflict' }; mocks.db.select.mockReturnValueOnce(selectChain([claimed], 'limit')) + .mockReturnValueOnce(selectChain([{ id: line.contractLineId, lineType: 'manual', manualQuantity: '3.00' }], 'limit')) .mockReturnValueOnce(selectChain([recorded], 'limit')); // The billing CAS sees a concurrent manual quantity edit and matches no row. const billingWrite = mutation([]); const lineWrite = mutation([{ id: line.id }]); const parentWrite = mutation([{ id: order.id }]); mocks.db.update.mockReturnValueOnce(billingWrite).mockReturnValueOnce(lineWrite).mockReturnValueOnce(parentWrite); - await pax8OrderSubmitRepository[method]({ order: claimed, lines: [sentLine] }, [{ lineId: line.id, submitState: 'succeeded', error: null, resultSubscriptionId: 'synthetic-subscription' }], 'synthetic-order'); + const result = await pax8OrderSubmitRepository[method]({ order: claimed, lines: [sentLine] } as never, [{ lineId: line.id, submitState: 'succeeded', error: null, resultSubscriptionId: 'synthetic-subscription' }], 'synthetic-order'); + expect(result.billingConflicts).toEqual([{ lineId: line.id, reason: 'baseline_changed' }]); expect(billingWrite.set).toHaveBeenCalledWith({ manualQuantity: '7.00' }); expect(lineWrite.set).toHaveBeenCalledWith(expect.objectContaining({ submitState: 'succeeded', resultSubscriptionId: 'synthetic-subscription', error: expect.stringContaining('billing_conflict') })); const { PgDialect } = await import('drizzle-orm/pg-core'); @@ -316,3 +338,73 @@ it.each(['persistSubmitResults', 'persistReconcileResults'] as const)('%s preser expect(query.sql).toContain('"manual_quantity" ='); expect(query.params).toContain('4.00'); }); + + +describe('billing result fidelity', () => { + it.each([ + ['baseline_changed', [{ id: line.contractLineId, lineType: 'manual', manualQuantity: '3.00' }], '4.00', '7.00'], + ['line_missing', [], '4.00', '7.00'], + ['not_manual', [{ id: line.contractLineId, lineType: 'fixed', manualQuantity: '4.00' }], '4.00', '7.00'], + ['no_quantity', [], '4.00', null], + ['baseline_missing', [{ id: line.contractLineId, lineType: 'manual', manualQuantity: '3.00' }], null, '7.00'], + ] as const)('surfaces %s without rewriting a successful vendor result', async (reason, linked, baseline, quantity) => { + const claimed = { ...order, status: 'submitting', submittedAt: new Date() }; + const sent = { ...line, quantity, authorizedBaselineQuantity: baseline, submitState: 'in_flight' }; + const error = `billing_conflict:${reason}: review billing`; + mocks.db.select.mockReturnValueOnce(selectChain([claimed], 'limit')); + if (reason !== 'no_quantity') mocks.db.select.mockReturnValueOnce(selectChain([...linked], 'limit')); + mocks.db.select.mockReturnValueOnce(selectChain([{ ...sent, submitState: 'succeeded', error }], 'limit')); + if (reason !== 'no_quantity') mocks.db.update.mockReturnValueOnce(mutation([])); + const lineWrite = mutation([{ id: line.id }]); + const parentWrite = mutation([{ id: order.id }]); + mocks.db.update.mockReturnValueOnce(lineWrite).mockReturnValueOnce(parentWrite); + const result = await pax8OrderSubmitRepository.persistSubmitResults({ order: claimed, lines: [sent] } as never, + [{ lineId: line.id, submitState: 'succeeded', error: null, resultSubscriptionId: 'remote-sub' }], 'remote-order'); + expect(result.billingConflicts).toEqual([{ lineId: line.id, reason }]); + expect(result.status).toBe('partially_failed'); + expect(lineWrite.set).toHaveBeenCalledWith(expect.objectContaining({ submitState: 'succeeded', error: expect.stringContaining(`billing_conflict:${reason}:`) })); + expect(mocks.logError).toHaveBeenCalledWith(expect.any(String), expect.any(String), expect.objectContaining({ partnerId: order.partnerId, orderId: order.id, lineId: line.id })); + if (reason === 'line_missing' || reason === 'not_manual') expect(mocks.captureException).toHaveBeenCalled(); + }); + + it.each(['new_subscription', 'cancel'] as const)('uses the null-baseline SQL CAS and correct %s quantity', async (action) => { + const claimed = { ...order, status: 'submitting', submittedAt: new Date() }; + const sent = { ...line, action, authorizedBaselineQuantity: null, submitState: 'in_flight' }; + mocks.db.select.mockReturnValueOnce(selectChain([claimed], 'limit')).mockReturnValueOnce(selectChain([{ ...sent, submitState: 'succeeded' }], 'limit')); + const billing = mutation([{ id: line.contractLineId }]); + mocks.db.update.mockReturnValueOnce(billing).mockReturnValueOnce(mutation([{ id: line.id }])).mockReturnValueOnce(mutation([{ id: order.id }])); + const result = await pax8OrderSubmitRepository.persistSubmitResults({ order: claimed, lines: [sent] } as never, + [{ lineId: line.id, submitState: 'succeeded', error: null, resultSubscriptionId: 'remote-sub' }], 'remote-order'); + expect(result.billingConflicts).toEqual([]); + expect(billing.set).toHaveBeenCalledWith({ manualQuantity: action === 'cancel' ? '0' : '7.00' }); + const { PgDialect } = await import('drizzle-orm/pg-core'); + expect(new PgDialect().sqlToQuery(billing.where.mock.calls[0]![0] as never).sql).toContain('"manual_quantity" is null'); + }); +}); + + +it('keeps the original refusal as cause when reopening loses its CAS', async () => { + const claimed = { ...order, status: 'submitting', submittedAt: new Date() }; + mocks.db.select.mockReturnValueOnce(selectChain([claimed], 'limit')).mockReturnValueOnce(selectChain([line], 'limit')); + mocks.db.update.mockReturnValueOnce(mutation([])); + await expect(pax8OrderSubmitRepository.reopenUnsentOrder({ order: claimed, lines: [line], preClaimStatus: 'ready', integrationGeneration: generation }, + 'Vendor dry run rejected input', 'PREFLIGHT_FAILED')).rejects.toMatchObject({ + code: 'CLAIM_LOST', cause: { code: 'PREFLIGHT_FAILED', message: 'Vendor dry run rejected input' }, + }); + expect(mocks.logError).toHaveBeenCalledWith(expect.any(String), expect.any(String), expect.objectContaining({ + orderId: order.id, partnerId: order.partnerId, refusalCode: 'PREFLIGHT_FAILED', originalError: 'Vendor dry run rejected input', + })); +}); + +it('checks exact API URL and ciphertext generation predicates under the integration lock', async () => { + const locked = selectChain([integration], 'limit'); + const exact = selectChain([integration], 'limit'); + mocks.db.select.mockReturnValueOnce(locked).mockReturnValueOnce(exact); + mocks.createClient.mockResolvedValue({ integration, client: {} }); + await pax8OrderSubmitRepository.createClient({ order, lines: [], integrationGeneration: generation, preClaimStatus: 'ready' }); + const { PgDialect } = await import('drizzle-orm/pg-core'); + const query = new PgDialect().sqlToQuery((exact.where as ReturnType).mock.calls[0]![0] as never); + expect(query.sql).toContain('api_base_url'); + expect(query.sql).toContain('client_secret_encrypted'); + expect(query.params).toContain(integration.clientSecretEncrypted); +}); diff --git a/apps/api/src/services/pax8OrderSubmitRepository.ts b/apps/api/src/services/pax8OrderSubmitRepository.ts index 5b75b186a7e..03b151d5f06 100644 --- a/apps/api/src/services/pax8OrderSubmitRepository.ts +++ b/apps/api/src/services/pax8OrderSubmitRepository.ts @@ -15,6 +15,7 @@ import { } from '../db/schema'; import { Pax8OrderError, + Pax8ClaimDriftError, Pax8OrderRestageRequiredError, requireImmediateCancelDate, validateDirectOrderLinesForSubmit, @@ -23,14 +24,19 @@ import { } from './pax8OrderService'; import type { Pax8OrderSubmitRepository, - SubmitBundle, + LoadedBundle, + ClaimedBundle, + BillingConflict, SubmitLineOutcome, SubmitResult, } from './pax8OrderSubmit'; import { createPax8ClientForIntegration, integrationGenerationPredicate, Pax8IntegrationGenerationChangedError } from './pax8SyncService'; import { pax8CompanyOrderReadiness } from './pax8CompanyReadiness'; +import { SUBMITTABLE_STATUSES, isSubmittableStatus } from './pax8OrderTypes'; +import { pax8CredentialGeneration } from './actionIntents/pax8OrderDigest'; +import { errorIds, logError } from './pax8OrderObservability'; +import { captureException } from './sentry'; -const SUBMITTABLE_STATUSES = ['draft', 'awaiting_details', 'ready'] as const; function partnerDbContext(partnerId: string): DbAccessContext { return { @@ -55,7 +61,7 @@ function withPartnerDbContext(partnerId: string, fn: () => Promise): Promi return runOutsideDbContext(() => withDbAccessContext(partnerDbContext(partnerId), fn)); } -function withOrderDbContext(bundle: SubmitBundle, fn: () => Promise): Promise { +function withOrderDbContext(bundle: LoadedBundle, fn: () => Promise): Promise { return runOutsideDbContext(() => withDbAccessContext( orderDbContext(bundle.order.partnerId, bundle.order.orgId), fn, @@ -141,9 +147,9 @@ async function persistResolvedCompany(order: VersionedOrder, pax8CompanyId: stri 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 assertSubmittable(order: Pax8OrderRow): asserts order is Pax8OrderRow & { status: typeof SUBMITTABLE_STATUSES[number] } { + if (!isSubmittableStatus(order.status)) { + throw new Pax8OrderError('This Pax8 order cannot be submitted in its current state.', 409, { phase: 'claim', code: 'NOT_PENDING' }); } } @@ -153,10 +159,12 @@ function deriveOrderStatus(states: Pax8SubmitState[]): Pax8OrderStatus { return 'partially_failed'; } -function resultFromRows(orderId: string, status: Pax8OrderStatus, lines: Pax8OrderLineRow[]): SubmitResult { +function resultFromRows(orderId: string, status: Pax8OrderStatus, lines: Pax8OrderLineRow[], billingConflicts: BillingConflict[] = []): Extract { return { + kind: 'executed', orderId, status, + billingConflicts, lines: lines.map((line) => ({ lineId: line.id, submitState: line.submitState as Pax8SubmitState, @@ -165,9 +173,9 @@ function resultFromRows(orderId: string, status: Pax8OrderStatus, lines: Pax8Ord }; } -async function lockExecutingOrder(bundle: SubmitBundle): Promise { +async function lockExecutingOrder(bundle: LoadedBundle, phase: 'claim' | 'persist' = 'persist'): Promise { if (!bundle.order.pax8CompanyId || !bundle.order.submittedAt) { - throw new Pax8OrderError('The Pax8 order is missing its immutable submission snapshot.', 409); + throw new Pax8OrderError('The Pax8 order is missing its immutable submission snapshot.', 409, { phase, ...(phase === 'claim' ? { code: 'CLAIM_LOST' } : {}) }); } const [order] = await db .select() @@ -183,47 +191,67 @@ async function lockExecutingOrder(bundle: SubmitBundle): Promise { )) .for('update') .limit(1); - if (!order) throw new Pax8OrderError('The Pax8 order execution state changed.', 409); + if (!order) throw new Pax8OrderError('The Pax8 order execution state changed.', 409, { phase, ...(phase === 'claim' ? { code: 'CLAIM_LOST' } : {}) }); return order; } -/** The remote result must survive a billing edit while the request was in flight. */ -async function updateManualContractQuantity(line: Pax8OrderLineRow): Promise { - if (!line.contractLineId) return true; +/** A CAS miss never overwrites a billing edit, even when the vendor succeeded. */ +type BillingUpdateReason = 'ok' | 'baseline_changed' | 'line_missing' | 'not_manual' | 'no_quantity' | 'baseline_missing'; +async function updateManualContractQuantity(line: Pax8OrderLineRow): Promise { + if (!line.contractLineId) return 'ok'; const quantity = line.action === 'cancel' ? '0' : line.quantity; - if (quantity === null) return false; - 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), - line.authorizedBaselineQuantity === null - ? isNull(contractLines.manualQuantity) - : eq(contractLines.manualQuantity, line.authorizedBaselineQuantity), - )) - .returning({ id: contractLines.id }); - return updated.length === 1; + if (quantity === null) return 'no_quantity'; + 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), + line.authorizedBaselineQuantity === null ? isNull(contractLines.manualQuantity) + : eq(contractLines.manualQuantity, line.authorizedBaselineQuantity), + )).returning({ id: contractLines.id }); + if (updated.length === 1) return 'ok'; + const [linked] = await db.select({ id: contractLines.id, lineType: contractLines.lineType, manualQuantity: contractLines.manualQuantity }) + .from(contractLines).where(and(eq(contractLines.id, line.contractLineId), eq(contractLines.orgId, line.orgId))).limit(1); + if (!linked) return 'line_missing'; + if (linked.lineType !== 'manual') return 'not_manual'; + // NULL can mean a captured SQL NULL or an older in-flight line that never + // captured a baseline. The migration filename cannot establish deployment + // time. A failed NULL CAS therefore cannot authorize any overwrite. + return line.authorizedBaselineQuantity === null && line.action !== 'change_quantity' + ? 'baseline_missing' : 'baseline_changed'; } +const BILLING_CONFLICT_MESSAGES = { + baseline_changed: 'The linked billing quantity changed after approval.', + line_missing: 'The linked contract line no longer exists or is unavailable.', + not_manual: 'The linked contract line is no longer a manual billing line.', + no_quantity: 'The successful Pax8 line has no quantity to apply to billing.', + baseline_missing: 'A trustworthy billing baseline is unavailable for this older or NULL-baseline line; compare billing with Pax8 manually.', +} as const; + async function persistLineOutcomes( - bundle: SubmitBundle, + bundle: LoadedBundle, outcomes: SubmitLineOutcome[], expectedState: 'in_flight' | readonly ['in_flight', 'needs_reconcile'], -): Promise { +): Promise { + const billingConflicts: BillingConflict[] = []; 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); + throw new Pax8OrderError('Duplicate Pax8 line outcomes were produced.', 409, { phase: 'persist' }); } 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); + if (!line) throw new Pax8OrderError('A Pax8 line result does not belong to this order.', 409, { phase: 'persist' }); const stateCondition = typeof expectedState === 'string' ? eq(pax8OrderLines.submitState, expectedState) : inArray(pax8OrderLines.submitState, [...expectedState]); - if (outcome.submitState === 'succeeded' && !await updateManualContractQuantity(line)) { - outcome.error = 'billing_conflict: The linked billing quantity changed after approval; the Pax8 write succeeded and requires billing review.'; + if (outcome.submitState === 'succeeded') { + const reason = await updateManualContractQuantity(line); + if (reason !== 'ok') { + outcome.error = `billing_conflict:${reason}: ${BILLING_CONFLICT_MESSAGES[reason]} The Pax8 write succeeded; review billing without resending it.`; + billingConflicts.push({ lineId: line.id, reason }); + const context = { partnerId: bundle.order.partnerId, orderId: bundle.order.id, lineId: line.id, contractLineId: line.contractLineId, reason }; + logError(errorIds.PAX8_BILLING_CONFLICT, outcome.error, context); + if (reason === 'line_missing' || reason === 'not_manual') captureException(new Error(outcome.error), undefined, { errorId: errorIds.PAX8_BILLING_INTEGRITY_DRIFT }); + } } const updated = await db .update(pax8OrderLines) @@ -242,12 +270,13 @@ async function persistLineOutcomes( )) .returning({ id: pax8OrderLines.id }); if (updated.length !== 1) { - throw new Pax8OrderError('A Pax8 line execution state changed before its result could be recorded.', 409); + throw new Pax8OrderError('A Pax8 line execution state changed before its result could be recorded.', 409, { phase: 'persist' }); } } + return billingConflicts; } -async function reloadLines(bundle: SubmitBundle): Promise { +async function reloadLines(bundle: LoadedBundle): Promise { return db .select() .from(pax8OrderLines) @@ -310,7 +339,7 @@ export const pax8OrderSubmitRepository: Pax8OrderSubmitRepository = { )) .returning(); if (!claimed) { - throw new Pax8OrderError('Another submit won the order claim, or an earlier write requires reconciliation.', 409); + throw new Pax8OrderError('Another submit won the order claim, or an earlier write requires reconciliation.', 409, { phase: 'claim', code: 'CLAIM_LOST' }); } // The parent transition owns the row lock that every authoring mutation // must acquire. Read only after that lock: if PATCH committed first we @@ -371,7 +400,7 @@ export const pax8OrderSubmitRepository: Pax8OrderSubmitRepository = { .where(and(eq(pax8Integrations.id, claimed.integrationId), eq(pax8Integrations.partnerId, claimed.partnerId))) .limit(1); if (!integrationGeneration || !integrationGeneration.isActive) { - throw new Pax8OrderError('The Pax8 integration is unavailable.', 409); + throw new Pax8OrderError('The Pax8 integration is unavailable.', 409, { phase: 'claim', code: 'INTEGRATION_UNAVAILABLE' }); } // Keep the approved billing baseline durable across a timeout/crash for // every action, including cancels and quote-created subscriptions. @@ -382,7 +411,7 @@ export const pax8OrderSubmitRepository: Pax8OrderSubmitRepository = { .from(contractLines) .where(and(eq(contractLines.id, line.contractLineId), eq(contractLines.orgId, claimed.orgId))) .for('share').limit(1); - if (!linked) throw new Pax8OrderError('The linked contract line is unavailable.', 409); + if (!linked) throw new Pax8OrderError('The linked contract line is unavailable.', 409, { phase: 'claim', code: 'CONTRACT_LINE_MISSING' }); baseline = linked.manualQuantity; } const captured = await db.update(pax8OrderLines) @@ -391,14 +420,22 @@ export const pax8OrderSubmitRepository: Pax8OrderSubmitRepository = { eq(pax8OrderLines.partnerId, claimed.partnerId), eq(pax8OrderLines.orgId, claimed.orgId), eq(pax8OrderLines.submitState, 'pending'))) .returning({ id: pax8OrderLines.id }); - if (captured.length !== 1) throw new Pax8OrderError('The Pax8 billing baseline could not be captured.', 409); + if (captured.length !== 1) throw new Pax8OrderError('The Pax8 billing baseline could not be captured.', 409, { phase: 'claim', code: 'BASELINE_NOT_CAPTURED' }); line.authorizedBaselineQuantity = baseline; } - const bundle: SubmitBundle = { - order: claimed, lines, integrationGeneration, - preClaimStatus: existing.status as SubmitBundle['preClaimStatus'], + const bundle: ClaimedBundle = { + order: claimed, lines, + integrationGeneration: { + id: integrationGeneration.id, partnerId: integrationGeneration.partnerId, + apiBaseUrl: integrationGeneration.apiBaseUrl, tokenUrl: integrationGeneration.tokenUrl, + isActive: integrationGeneration.isActive, credentialGeneration: pax8CredentialGeneration(integrationGeneration), + }, + preClaimStatus: existing.status, }; - await input.verifyClaim?.(bundle, db); + if (input.mode.kind === 'ai') { + const verified = await input.mode.verifyClaim(bundle, { select: db.select.bind(db) }); + if (!verified.ok) throw new Pax8ClaimDriftError(verified.message); + } return { bundle } as const; }); if ('restageError' in outcome) throw outcome.restageError; @@ -407,19 +444,29 @@ export const pax8OrderSubmitRepository: Pax8OrderSubmitRepository = { createClient(bundle) { return withPartnerDbContext(bundle.order.partnerId, async () => { - if (bundle.integrationGeneration) { - const [current] = await db.select({ id: pax8Integrations.id }).from(pax8Integrations) - .where(integrationGenerationPredicate(bundle.integrationGeneration)).for('share').limit(1); - if (!current) throw new Pax8IntegrationGenerationChangedError(); + const expected = 'integrationGeneration' in bundle ? bundle.integrationGeneration : null; + if (expected) { + const [current] = await db.select().from(pax8Integrations) + .where(and(eq(pax8Integrations.id, expected.id), eq(pax8Integrations.partnerId, expected.partnerId))) + .for('share').limit(1); + if (!current || current.apiBaseUrl !== expected.apiBaseUrl || current.tokenUrl !== expected.tokenUrl + || current.isActive !== expected.isActive || pax8CredentialGeneration(current) !== expected.credentialGeneration) { + throw new Pax8IntegrationGenerationChangedError('before order submission; nothing was sent'); + } + // Preserve exact generation predicates while the current row is locked; + // ciphertext is used only inside this context and never carried in a bundle. + const [matched] = await db.select({ id: pax8Integrations.id }).from(pax8Integrations) + .where(integrationGenerationPredicate(current)).for('share').limit(1); + if (!matched) throw new Pax8IntegrationGenerationChangedError('before order submission; nothing was sent'); } 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); } - const expected = bundle.integrationGeneration; - if (expected && (['id', 'partnerId', 'apiBaseUrl', 'tokenUrl', 'clientIdEncrypted', 'clientSecretEncrypted', 'isActive'] as const) - .some((field) => created.integration[field] !== expected[field])) { - throw new Pax8IntegrationGenerationChangedError(); + if (expected && (created.integration.id !== expected.id || created.integration.partnerId !== expected.partnerId + || created.integration.apiBaseUrl !== expected.apiBaseUrl || created.integration.tokenUrl !== expected.tokenUrl + || created.integration.isActive !== expected.isActive || pax8CredentialGeneration(created.integration) !== expected.credentialGeneration)) { + throw new Pax8IntegrationGenerationChangedError('before order submission; nothing was sent'); } return created.client; }); @@ -427,7 +474,7 @@ export const pax8OrderSubmitRepository: Pax8OrderSubmitRepository = { claimLines(bundle) { return withPartnerDbContext(bundle.order.partnerId, async () => { - await lockExecutingOrder(bundle); + await lockExecutingOrder(bundle, 'claim'); const claimed = await db .update(pax8OrderLines) .set({ submitState: 'in_flight', error: null, updatedAt: new Date() }) @@ -442,38 +489,48 @@ export const pax8OrderSubmitRepository: Pax8OrderSubmitRepository = { 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); + throw new Pax8OrderError('The Pax8 order line claim was incomplete; no external write was attempted.', 409, { phase: 'claim', code: 'CLAIM_LOST' }); } }); }, - reopenUnsentOrder(bundle, error) { - return withOrderDbContext(bundle, async () => { - await lockExecutingOrder(bundle); - const lines = await reloadLines(bundle); - if (!bundle.preClaimStatus || lines.length === 0 || lines.length !== bundle.lines.length - || lines.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: bundle.preClaimStatus, submittedBy: null, submittedAt: null, - error: error.slice(0, 2048), 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'), eq(pax8Orders.submittedAt, bundle.order.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 (updated.length !== 1) throw new Pax8OrderError('The unsent Pax8 order could not be reopened.', 409); - return resultFromRows(bundle.order.id, bundle.preClaimStatus, lines); - }); + async reopenUnsentOrder(bundle, error, refusalCode) { + try { + return await withOrderDbContext(bundle, async () => { + await lockExecutingOrder(bundle, 'claim'); + const lines = await reloadLines(bundle); + if (lines.length === 0 || lines.length !== bundle.lines.length + || lines.some((line) => line.submitState !== 'pending')) { + throw new Pax8OrderError('The Pax8 order no longer matches this unsent claim.', 409); + } + const updated = await db.update(pax8Orders).set({ + status: bundle.preClaimStatus, submittedBy: null, submittedAt: null, + error: error.slice(0, 2048), 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'), eq(pax8Orders.submittedAt, bundle.order.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 (updated.length !== 1) throw new Pax8OrderError('The unsent Pax8 order could not be reopened.', 409); + return { ...resultFromRows(bundle.order.id, bundle.preClaimStatus, lines), kind: 'refused' as const, refusalCode, error }; + }); + } catch (reopenError) { + const cause = new Pax8OrderError(error, 409, { phase: 'claim', code: refusalCode }); + const wrapped = new Pax8OrderError(reopenError instanceof Error ? reopenError.message : String(reopenError), 409, + { phase: 'claim', code: 'CLAIM_LOST', cause }); + logError(errorIds.PAX8_REOPEN_FAILED, 'Could not reopen a provably unsent Pax8 order', { + partnerId: bundle.order.partnerId, orderId: bundle.order.id, lineIds: bundle.lines.map(line => line.id), refusalCode, originalError: error, reopenError, + }); + throw wrapped; + } }, persistPreflightFailure(bundle, errorBody) { return withOrderDbContext(bundle, async () => { - await lockExecutingOrder(bundle); + await lockExecutingOrder(bundle, 'claim'); const lineIds = bundle.lines.map((line) => line.id); const updatedLines = await db .update(pax8OrderLines) @@ -506,10 +563,10 @@ export const pax8OrderSubmitRepository: Pax8OrderSubmitRepository = { 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); + throw new Pax8OrderError('Pax8 did not produce one result for every claimed order line.', 409, { phase: 'persist' }); } - await persistLineOutcomes(bundle, outcomes, 'in_flight'); - const status = deriveOrderStatus(outcomes.map((outcome) => outcome.submitState)); + const billingConflicts = await persistLineOutcomes(bundle, outcomes, 'in_flight'); + const status = billingConflicts.length > 0 ? 'partially_failed' : deriveOrderStatus(outcomes.map((outcome) => outcome.submitState)); const errors = outcomes.flatMap((outcome) => outcome.error ? [outcome.error] : []); const updated = await db .update(pax8Orders) @@ -526,8 +583,8 @@ export const pax8OrderSubmitRepository: Pax8OrderSubmitRepository = { 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)); + if (updated.length !== 1) throw new Pax8OrderError('The Pax8 order result could not be recorded.', 409, { phase: 'persist' }); + return resultFromRows(bundle.order.id, status, await reloadLines(bundle), billingConflicts); }); }, @@ -545,7 +602,7 @@ export const pax8OrderSubmitRepository: Pax8OrderSubmitRepository = { 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); + throw new Pax8OrderError('The Pax8 order is no longer provably unsent; reconcile its unknown writes.', 409, { phase: 'persist' }); } const updated = await db .update(pax8Orders) @@ -564,8 +621,8 @@ export const pax8OrderSubmitRepository: Pax8OrderSubmitRepository = { 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 }; + if (updated.length !== 1) throw new Pax8OrderError('The unsent Pax8 order could not be recovered.', 409, { phase: 'persist' }); + return { ...resultFromRows(bundle.order.id, 'ready', currentLines), resolved: 0, stillUnknown: 0 }; }); }, @@ -584,13 +641,19 @@ export const pax8OrderSubmitRepository: Pax8OrderSubmitRepository = { )) .for('update') .limit(1); - if (!locked) throw new Pax8OrderError('Pax8 order not found.', 404); + if (!locked) throw new Pax8OrderError('Pax8 order not found.', 404, { phase: 'persist' }); if (pax8OrderId && locked.pax8OrderId && locked.pax8OrderId !== pax8OrderId) { - throw new Pax8OrderError('The reconciled Pax8 parent conflicts with the captured order id.', 409); + throw new Pax8OrderError('The reconciled Pax8 parent conflicts with the captured order id.', 409, { phase: 'persist' }); } - await persistLineOutcomes(bundle, outcomes, ['in_flight', 'needs_reconcile']); + const freshConflicts = await persistLineOutcomes(bundle, outcomes, ['in_flight', 'needs_reconcile']); const lines = await reloadLines(bundle); - const status = deriveOrderStatus(lines.map((line) => line.submitState as Pax8SubmitState)); + const conflictsByLine = new Map(freshConflicts.map(conflict => [conflict.lineId, conflict])); + for (const line of lines) { + const reason = /^billing_conflict:([^:]+):/.exec(line.error ?? '')?.[1]; + if (reason) conflictsByLine.set(line.id, { lineId: line.id, reason }); + } + const billingConflicts = [...conflictsByLine.values()]; + const status = billingConflicts.length > 0 ? 'partially_failed' : deriveOrderStatus(lines.map((line) => line.submitState as Pax8SubmitState)); const errors = lines.flatMap((line) => line.error ? [line.error] : []); const updated = await db .update(pax8Orders) @@ -613,9 +676,10 @@ export const pax8OrderSubmitRepository: Pax8OrderSubmitRepository = { )) .returning({ id: pax8Orders.id }); if (updated.length !== 1) { - throw new Pax8OrderError('The reconciled Pax8 parent conflicts with the captured order id.', 409); + throw new Pax8OrderError('The reconciled Pax8 parent conflicts with the captured order id.', 409, { phase: 'persist' }); } return { + ...resultFromRows(bundle.order.id, status, lines, billingConflicts), 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/pax8OrderTypes.ts b/apps/api/src/services/pax8OrderTypes.ts new file mode 100644 index 00000000000..696f96c04ed --- /dev/null +++ b/apps/api/src/services/pax8OrderTypes.ts @@ -0,0 +1,6 @@ +import type { Pax8OrderStatus } from '@breeze/shared'; + +export const SUBMITTABLE_STATUSES = ['draft', 'awaiting_details', 'ready'] as const satisfies readonly Pax8OrderStatus[]; +export type SubmittableStatus = typeof SUBMITTABLE_STATUSES[number]; +export const isSubmittableStatus = (status: string): status is SubmittableStatus => + SUBMITTABLE_STATUSES.some(candidate => candidate === status); diff --git a/apps/api/src/services/pax8SyncService.ts b/apps/api/src/services/pax8SyncService.ts index 3b2267bd9d7..0ba545742bf 100644 --- a/apps/api/src/services/pax8SyncService.ts +++ b/apps/api/src/services/pax8SyncService.ts @@ -31,8 +31,8 @@ function errorMessage(err: unknown): string { export type Pax8IntegrationSnapshot = typeof pax8Integrations.$inferSelect; export class Pax8IntegrationGenerationChangedError extends Error { - constructor() { - super('Pax8 integration configuration changed during sync'); + constructor(context = 'during sync') { + super(`Pax8 integration configuration changed ${context}`); this.name = 'Pax8IntegrationGenerationChangedError'; } } diff --git a/apps/api/src/services/sentry.test.ts b/apps/api/src/services/sentry.test.ts index 3192eefb632..4c5f8353889 100644 --- a/apps/api/src/services/sentry.test.ts +++ b/apps/api/src/services/sentry.test.ts @@ -849,6 +849,10 @@ describe('accounting captureException tags stay allowlisted (#4828)', () => { // 10 -> 12: Xero W04 refinement 22 void-recovery persist added its own // zero-row and catch captureException calls (same tag shape as the // existing SyncToken persist). + ['../routes/pax8Orders.ts', 1], + ['pax8OrderObservability.ts', 1], + ['pax8OrderSubmitRepository.ts', 1], + ['aiToolsPax8Orders.ts', 1], ['accounting/accountingInvoicePush.ts', 12], ['accounting/accountingMappingService.ts', 5], // Phase D2. Paths are resolved against THIS file's directory (services/), diff --git a/apps/api/src/services/sentryEventCodes.ts b/apps/api/src/services/sentryEventCodes.ts index 2d0d1c1c85e..dc515247860 100644 --- a/apps/api/src/services/sentryEventCodes.ts +++ b/apps/api/src/services/sentryEventCodes.ts @@ -29,6 +29,7 @@ * ceiling `isBoundedTagValue` enforces. */ export const SENTRY_EVENT_CODES = [ + 'pax8_order_execution_result', // --- middleware ------------------------------------------------------- /** A request was refused by a body-size limit (global gate or route rule). */ 'body_limit_rejected', diff --git a/apps/docs/src/content/docs/features/ai.mdx b/apps/docs/src/content/docs/features/ai.mdx index 3fb39fe03fd..51ae845b14e 100644 --- a/apps/docs/src/content/docs/features/ai.mdx +++ b/apps/docs/src/content/docs/features/ai.mdx @@ -772,6 +772,8 @@ These tools read and change how your organizations are billed: rate cards and wo | `manage_accounting_sync` (link_mapping/unlink_mapping/sync_mapping) | 3 | Link a Breeze organization or catalog item to a QuickBooks or Xero record, unlink it, or re-send a linked one. Linking and syncing overwrite the provider's record with Breeze's details. Creating a new record in the provider is done in the web app | | `manage_accounting_sync` (push/push_bulk) | 3 | Push one invoice, or up to 10, into QuickBooks or Xero. It needs a second approver (four-eyes); the push also syncs the invoice's customer and items if they are not synced yet. If the invoice, its customer or items, or the connection change before release, the release is refused | +Pax8 submission refusal codes (the `PAX8_SUBMIT_REFUSAL_CODES` contract): `PREVIEW_MISMATCH`, `PRICE_CHANGED`, `COMMITMENT_CHANGED`, `COST_UNKNOWN`, `COMMITMENT_AMBIGUOUS`, `TOO_MANY_LINES`, `NOT_PENDING`, `COMPANY_UNRESOLVED`, `TERM_UNKNOWN`, `QUANTITY_UNKNOWN`, `SUBSCRIPTION_NOT_FOUND`, `PRODUCT_UNKNOWN`, `CONTRACT_LINE_MISSING`, `CLAIM_LOST`, `INTEGRATION_UNAVAILABLE`, `BASELINE_NOT_CAPTURED`, `INTEGRATION_CHANGED`, `PREFLIGHT_FAILED`, `CONFLICT`. Claim refusals mean nothing was sent by that attempt; preview and approve again. Post-send persistence conflicts require inspection and reconciliation of unknown writes. A successful vendor write with `billingConflicts` requires a person to resolve billing without resubmitting it. + The existing contract and invoice reads gained one view each: `get_contract` with `periodId` returns that billing period's outcome (devices billed, uncovered and flagged, overages), `list_contracts` with `view: currency_mismatches` lists contracts whose currency differs from their organization's, and `get_invoice` with `lineId` pages the devices one invoice line billed. Rate-card, work-type and settings writes need a session that has completed MFA. A session limited to some sites or devices cannot change an organization's rate card, billing settings or agreement templates, or file a signed agreement. diff --git a/apps/docs/src/content/docs/features/mcp-server.mdx b/apps/docs/src/content/docs/features/mcp-server.mdx index 670714a458e..c0a77c42068 100644 --- a/apps/docs/src/content/docs/features/mcp-server.mdx +++ b/apps/docs/src/content/docs/features/mcp-server.mdx @@ -1280,3 +1280,5 @@ The tool name in `tools/call` does not match any registered tool. Use `tools/lis SSE sessions expire after 30 minutes. If you see errors about missing sessions, the client should reconnect to `/api/v1/mcp/sse` to obtain a new session. Pax8 tools require `billing:manage` and partner or system scope (system callers pass `partnerId`). Device-bound sessions are refused. Supervised actions return `MCP_APPROVAL_REQUIRED` by default over MCP. `manage_pax8_orders:submit` creates a four-eyes intent with a mandatory pinned digest; it is never offered to headless agents. Preview first and copy `submitWith` unchanged. A timeout requires `reconcile`, never a blind retry. Reconciliation records vendor evidence, preserves conflicting billing edits as `billing_conflict`, and leaves ambiguous outcomes unknown. Credential save/test and company mapping remain in the web app. + +Pax8 submission refusal codes (the `PAX8_SUBMIT_REFUSAL_CODES` contract): `PREVIEW_MISMATCH`, `PRICE_CHANGED`, `COMMITMENT_CHANGED`, `COST_UNKNOWN`, `COMMITMENT_AMBIGUOUS`, `TOO_MANY_LINES`, `NOT_PENDING`, `COMPANY_UNRESOLVED`, `TERM_UNKNOWN`, `QUANTITY_UNKNOWN`, `SUBSCRIPTION_NOT_FOUND`, `PRODUCT_UNKNOWN`, `CONTRACT_LINE_MISSING`, `CLAIM_LOST`, `INTEGRATION_UNAVAILABLE`, `BASELINE_NOT_CAPTURED`, `INTEGRATION_CHANGED`, `PREFLIGHT_FAILED`, `CONFLICT`. Claim refusals mean nothing was sent by that attempt; preview and approve again. Post-send persistence conflicts require inspection and reconciliation of unknown writes. A successful vendor write with `billingConflicts` requires a person to resolve billing without resubmitting it. From ba49d453584c49a0b416e954b435c92896772b47 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Sat, 31 Oct 2026 13:09:19 -0600 Subject: [PATCH 06/12] =?UTF-8?q?fix(ai-tools):=20W15=20PR=20B=20CI=20foll?= =?UTF-8?q?ow-ups=20=E2=80=94=20restore=20linked-line=20preview=20visibili?= =?UTF-8?q?ty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discover the order organization under partner scope and authorize that single organization for the coherent preview evidence transaction. Preserve the missing-line blocker and reject organization drift before collecting evidence. Align the REST integration assertion with preserved vendor success and explicit line_missing billing conflicts, and classify the human-only submit refusal as an automation principal site. Co-Authored-By: Claude Fable 5.1 --- .../pax8OrderSubmit.integration.test.ts | 22 +++++++-- .../automationPrincipal.contract.test.ts | 3 ++ .../api/src/services/pax8OrderPreview.test.ts | 46 +++++++++++++++++-- apps/api/src/services/pax8OrderPreview.ts | 18 ++++++-- 4 files changed, 77 insertions(+), 12 deletions(-) diff --git a/apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts b/apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts index 06e5cf0aed6..ded62374d00 100644 --- a/apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts +++ b/apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts @@ -630,19 +630,31 @@ describe('Pax8 submit pipeline (real Postgres)', () => { }; }); const rollbackService = serviceWithClient(rollbackClient); - await expect(rollbackService.submitOrder({ + const missingLineResult = await rollbackService.submitOrder({ partnerId: rollbackFixture.partner.id, orderId: rollbackFixture.order.id, actorUserId: rollbackFixture.user.id, mode: { kind: 'rest' }, - })).rejects.toMatchObject({ status: 409 }); + }); + expect(missingLineResult).toMatchObject({ + kind: 'executed', + status: 'partially_failed', + lines: [{ lineId: rollbackFixture.line.id, submitState: 'succeeded', error: expect.stringContaining('billing_conflict:line_missing:') }], + billingConflicts: [{ lineId: rollbackFixture.line.id, reason: 'line_missing' }], + }); 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 }; + const [contractLine] = await db.select().from(contractLines).where(eq(contractLines.id, rollbackFixture.contractLine.id)); + return { order, line, contractLine }; }); - expect(rollbackState.order?.status).toBe('submitting'); - expect(rollbackState.line?.submitState).toBe('in_flight'); + expect(rollbackState.order?.status).toBe('partially_failed'); + expect(rollbackState.order?.pax8OrderId).toBe('pax-order-rollback'); + expect(rollbackState.line?.submitState).toBe('succeeded'); + expect(rollbackState.line?.resultSubscriptionId).toBe('subscription-rollback'); + expect(rollbackState.line?.error).toContain('billing_conflict:line_missing:'); + expect(rollbackState.contractLine).toBeUndefined(); // billing must not be recreated after deletion + expect(rollbackClient.createOrder).toHaveBeenCalledTimes(2); // one dry run and one vendor send const duplicateFixture = await seedOrder({ action: 'cancel' }); await withSystemDbAccessContext(() => db.insert(pax8OrderLines).values({ diff --git a/apps/api/src/middleware/automationPrincipal.contract.test.ts b/apps/api/src/middleware/automationPrincipal.contract.test.ts index 09ec0fb8a18..1aced3eca4c 100644 --- a/apps/api/src/middleware/automationPrincipal.contract.test.ts +++ b/apps/api/src/middleware/automationPrincipal.contract.test.ts @@ -59,6 +59,9 @@ export const REVIEWED_AI_AGENT_SITES: Record = { // human-only; neither an agent nor a flow is a person who can own the row. 'services/aiToolsAuditBaselines.ts:if (isAutomationPrincipal(auth)) return automationRefusal(action);': 'automation', "services/aiToolsPamRules.ts:if (isAutomationPrincipal(auth)) return jsonError('PAM policy is changed by a person, not an automated run', 'AGENT_REFUSED');": 'automation', + // Pax8 submit needs a human requester and approval surface; agents and flows + // are both refused before preparing a submit intent. + "services/aiToolsPax8Orders.ts:if (isAutomationPrincipal(auth)) return jsonError('A person must request Pax8 order submission', 'AGENT_HUMAN_ONLY');": 'automation', 'services/aiToolsTicketOpsShared.ts:return isAutomationPrincipal(auth);': 'automation', // Same upstream-approval-instead-of-MFA rule for delivery-rail writes. "services/aiToolsDelivery.ts:if (!isAutomationPrincipal(auth) && !hasSatisfiedMfa(auth)) fail(403, 'MFA required');": 'automation', diff --git a/apps/api/src/services/pax8OrderPreview.test.ts b/apps/api/src/services/pax8OrderPreview.test.ts index 5c0d399f5e6..4bb7dc3d0b9 100644 --- a/apps/api/src/services/pax8OrderPreview.test.ts +++ b/apps/api/src/services/pax8OrderPreview.test.ts @@ -6,6 +6,7 @@ const m = vi.hoisted(() => ({ getOrderWithLines: vi.fn(), createClient: vi.fn(), resolvePrice: vi.fn(), dependencies: vi.fn(), pricing: vi.fn(), contextDepth: 0, outsideDepth: 0, withContext: vi.fn(), outside: vi.fn(), + accessibleOrgIds: null as string[] | null, })); vi.mock('../db', () => ({ db: { select: m.select, insert: m.insert, update: m.update, delete: m.delete }, @@ -42,20 +43,23 @@ const run = () => previewPax8Order({ partnerId, orderId, client: client as never beforeEach(() => { vi.clearAllMocks(); m.material.mockImplementation(async () => { expect(m.contextDepth).toBeGreaterThan(0); return { local: true }; }); - m.contextDepth = 0; m.outsideDepth = 0; + m.contextDepth = 0; m.outsideDepth = 0; m.accessibleOrgIds = null; m.outside.mockImplementation(async (fn) => { const saved = m.contextDepth; m.contextDepth = 0; m.outsideDepth++; try { return await fn(); } finally { m.outsideDepth--; m.contextDepth = saved; } }); - m.withContext.mockImplementation(async (_ctx, fn) => { + m.withContext.mockImplementation(async (ctx, fn) => { + const previousOrgIds = m.accessibleOrgIds; + m.accessibleOrgIds = ctx.accessibleOrgIds; m.contextDepth++; - try { return await fn(); } finally { m.contextDepth--; } + try { return await fn(); } finally { m.contextDepth--; m.accessibleOrgIds = previousOrgIds; } }); order = { id: orderId, partnerId, orgId, integrationId, pax8CompanyId: null, source: 'direct', status: 'draft' }; lines = [{ id: lineId, action: 'new_subscription', submitState: 'pending', pax8ProductId: 'fixture-product', quantity: '5', billingTerm: 'Monthly', commitmentTermId: 'fixture-term', contractLineId: null, authorizedBaselineQuantity: null, targetSubscriptionId: null }]; rows = { + pax8_orders: [{ orgId }], pax8_company_mappings: [{ pax8CompanyId: 'fixture-company', pax8CompanyName: 'Fixture Company', status: 'Active', metadata: { contacts: [{ types: ['Admin', 'Billing', 'Technical'].map(type => ({ type, primary: true })) }] } }], pax8_product_mappings: [{ productName: 'Fixture Product', vendorSkuId: 'fixture-sku' }], @@ -69,7 +73,11 @@ beforeEach(() => { let table = ''; chain.from = vi.fn((value) => { table = getTableName(value); return chain; }); for (const method of ['where', 'limit', 'orderBy']) chain[method] = vi.fn(() => chain); - chain.then = (resolve: (v: unknown[]) => unknown) => resolve(rows[table] ?? []); + chain.then = (resolve: (v: unknown[]) => unknown) => { + // These tables use org-axis forced RLS, even for a partner-scoped caller. + const hidden = ['contract_lines', 'contracts'].includes(table) && !m.accessibleOrgIds?.includes(orgId); + return resolve(hidden ? [] : rows[table] ?? []); + }; return chain; }); m.getOrderWithLines.mockImplementation(async () => { @@ -108,6 +116,36 @@ describe('previewPax8Order', () => { expect(result.blocked).toContainEqual({ lineId, code: 'CONTRACT_LINE_MISSING' }); expect(result.submitWith).toBeUndefined(); }); + it('reads an existing linked contract line and its price with order-org RLS visibility', async () => { + lines[0]!.contractLineId = 'fixture-contract-line'; + const result = await run(); + expect(result.blocked).toEqual([]); + expect(result.lines[0]).toMatchObject({ sellUnitPrice: '15.00', sellCurrency: 'CAD' }); + expect(result.submitWith).toBeDefined(); + expect(m.withContext).toHaveBeenCalledWith(expect.objectContaining({ + scope: 'partner', orgId, accessibleOrgIds: [orgId], accessiblePartnerIds: [partnerId], + }), expect.any(Function), { isolationLevel: 'repeatable read' }); + }); + it('refuses an order whose org changed after discovery before collecting preview evidence', async () => { + order.orgId = '10000000-0000-4000-8000-000000000099'; + await expect(run()).rejects.toMatchObject({ status: 409 }); + expect(m.material).not.toHaveBeenCalled(); + expect(m.dependencies).not.toHaveBeenCalled(); + expect(m.pricing).not.toHaveBeenCalled(); + }); + it('pins org discovery to the requested partner and refuses an unavailable order', async () => { + rows.pax8_orders = []; + await expect(run()).rejects.toMatchObject({ status: 404 }); + const { PgDialect } = await import('drizzle-orm/pg-core'); + const discovery = m.select.mock.results[0]!.value; + const query = new PgDialect().sqlToQuery(discovery.where.mock.calls[0]![0]); + expect(query.sql).toContain('"pax8_orders"."partner_id" ='); + expect(query.sql).toContain('"pax8_orders"."id" ='); + expect(query.params).toEqual([orderId, partnerId]); + expect(m.withContext).toHaveBeenCalledTimes(1); + expect(m.getOrderWithLines).not.toHaveBeenCalled(); + expect(m.dependencies).not.toHaveBeenCalled(); + }); it('blocked previews never offer submitWith', async () => { m.pricing.mockResolvedValue([]); expect((await run()).submitWith).toBeUndefined(); diff --git a/apps/api/src/services/pax8OrderPreview.ts b/apps/api/src/services/pax8OrderPreview.ts index f995ac1bb83..47c388d4f10 100644 --- a/apps/api/src/services/pax8OrderPreview.ts +++ b/apps/api/src/services/pax8OrderPreview.ts @@ -2,12 +2,12 @@ import { and, eq } from 'drizzle-orm'; import { PAX8_BILLING_TERMS, type Pax8BillingTerm, type Pax8OrderAction, type Pax8OrderStatus } from '@breeze/shared'; import { db, runOutsideDbContext, withDbAccessContext } from '../db'; -import { contracts, contractLines, pax8CompanyMappings, pax8ProductMappings, pax8SubscriptionSnapshots } from '../db/schema'; +import { contracts, contractLines, pax8CompanyMappings, pax8Orders, pax8ProductMappings, pax8SubscriptionSnapshots } from '../db/schema'; import { pax8OrderDigestMaterial, hashPax8OrderMaterial } from './actionIntents/pax8OrderDigest'; import type { z } from 'zod'; import type { pax8SubmitAssertionsSchema } from './aiToolSchemasPax8'; import { isSubmittableStatus } from './pax8OrderTypes'; -import { getOrderWithLines, snapshotActiveCommitmentEvidence } from './pax8OrderService'; +import { getOrderWithLines, Pax8OrderError, snapshotActiveCommitmentEvidence } from './pax8OrderService'; import { createPax8ClientForIntegration } from './pax8SyncService'; import { pax8CompanyOrderReadiness } from './pax8CompanyReadiness'; import { CatalogServiceError, resolvePrice } from './catalogService'; @@ -81,13 +81,25 @@ export async function previewPax8Order(input: { orderId: string; client?: Pax8Client; }): Promise { + // Discover the org through the partner-axis parent before authorizing any + // org-axis reads. A partner grant alone cannot see contracts/contract_lines. + const [discovered] = await runOutsideDbContext(() => withDbAccessContext({ + scope: 'partner', orgId: null, accessibleOrgIds: null, + accessiblePartnerIds: [input.partnerId], userId: null, currentPartnerId: input.partnerId, + }, () => db.select({ orgId: pax8Orders.orgId }).from(pax8Orders).where(and( + eq(pax8Orders.id, input.orderId), eq(pax8Orders.partnerId, input.partnerId), + )).limit(1))); + if (!discovered) throw new Pax8OrderError('Pax8 order not found.', 404); // Defensively escape an accidental ambient caller before opening the short read transaction. const local = await runOutsideDbContext(() => withDbAccessContext({ - scope: 'partner', orgId: null, accessibleOrgIds: null, + scope: 'partner', orgId: discovered.orgId, accessibleOrgIds: [discovered.orgId], accessiblePartnerIds: [input.partnerId], userId: null, currentPartnerId: input.partnerId, }, async () => { const bundle = await getOrderWithLines(input); const { order } = bundle; + if (order.orgId !== discovered.orgId) { + throw new Pax8OrderError('The Pax8 order organization changed while previewing it.', 409); + } const companies = await db.select({ pax8CompanyId: pax8CompanyMappings.pax8CompanyId, pax8CompanyName: pax8CompanyMappings.pax8CompanyName, From cbff742e1a4dee88d54b7880b472c12495041c0a Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Sat, 31 Oct 2026 17:23:08 -0600 Subject: [PATCH 07/12] fix(migrations): rename pax8 order-line baseline migration to sort after main (#6785 W15) main gained 2027-01-18-130000-alerts-owner-kind-device-topology-chk.sql (#10835) after this branch was cut, so Check Migrations rejected the merge group. Rename to 2027-01-18-140000 and update the schema test's path. Co-Authored-By: Claude Fable 5.1 --- ...> 2027-01-18-140000-pax8-order-line-baseline-any-action.sql} | 0 apps/api/src/db/schema/pax8.test.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename apps/api/migrations/{2027-01-18-120000-pax8-order-line-baseline-any-action.sql => 2027-01-18-140000-pax8-order-line-baseline-any-action.sql} (100%) diff --git a/apps/api/migrations/2027-01-18-120000-pax8-order-line-baseline-any-action.sql b/apps/api/migrations/2027-01-18-140000-pax8-order-line-baseline-any-action.sql similarity index 100% rename from apps/api/migrations/2027-01-18-120000-pax8-order-line-baseline-any-action.sql rename to apps/api/migrations/2027-01-18-140000-pax8-order-line-baseline-any-action.sql diff --git a/apps/api/src/db/schema/pax8.test.ts b/apps/api/src/db/schema/pax8.test.ts index fb9b8abee67..8272e62dbcc 100644 --- a/apps/api/src/db/schema/pax8.test.ts +++ b/apps/api/src/db/schema/pax8.test.ts @@ -37,7 +37,7 @@ describe('Pax8 quantity authorization baseline', () => { it('permits a durable approved billing baseline for every Pax8 line action', () => { const migration = readFileSync(fileURLToPath(new URL( - '../../../migrations/2027-01-18-120000-pax8-order-line-baseline-any-action.sql', import.meta.url, + '../../../migrations/2027-01-18-140000-pax8-order-line-baseline-any-action.sql', import.meta.url, )), 'utf8'); expect(migration).toMatch(/DROP CONSTRAINT IF EXISTS pax8_order_lines_authorized_baseline_chk/i); expect(migration).toMatch(/CHECK \(authorized_baseline_quantity IS NULL OR authorized_baseline_quantity >= 0\)/i); From 30fdf15792f67fdacf2966727bb9ddc1a9d86300 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Sat, 31 Oct 2026 17:25:21 -0600 Subject: [PATCH 08/12] chore(migrations): move pax8 baseline migration to slot 141000 (avoid 140000 collision) Co-Authored-By: Claude Fable 5.1 --- ...> 2027-01-18-141000-pax8-order-line-baseline-any-action.sql} | 0 apps/api/src/db/schema/pax8.test.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename apps/api/migrations/{2027-01-18-140000-pax8-order-line-baseline-any-action.sql => 2027-01-18-141000-pax8-order-line-baseline-any-action.sql} (100%) diff --git a/apps/api/migrations/2027-01-18-140000-pax8-order-line-baseline-any-action.sql b/apps/api/migrations/2027-01-18-141000-pax8-order-line-baseline-any-action.sql similarity index 100% rename from apps/api/migrations/2027-01-18-140000-pax8-order-line-baseline-any-action.sql rename to apps/api/migrations/2027-01-18-141000-pax8-order-line-baseline-any-action.sql diff --git a/apps/api/src/db/schema/pax8.test.ts b/apps/api/src/db/schema/pax8.test.ts index 8272e62dbcc..c913b408bb8 100644 --- a/apps/api/src/db/schema/pax8.test.ts +++ b/apps/api/src/db/schema/pax8.test.ts @@ -37,7 +37,7 @@ describe('Pax8 quantity authorization baseline', () => { it('permits a durable approved billing baseline for every Pax8 line action', () => { const migration = readFileSync(fileURLToPath(new URL( - '../../../migrations/2027-01-18-140000-pax8-order-line-baseline-any-action.sql', import.meta.url, + '../../../migrations/2027-01-18-141000-pax8-order-line-baseline-any-action.sql', import.meta.url, )), 'utf8'); expect(migration).toMatch(/DROP CONSTRAINT IF EXISTS pax8_order_lines_authorized_baseline_chk/i); expect(migration).toMatch(/CHECK \(authorized_baseline_quantity IS NULL OR authorized_baseline_quantity >= 0\)/i); From bb1b7cdc2772dc45fced83d5f55a43228be857ef Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Sun, 1 Nov 2026 00:03:39 -0600 Subject: [PATCH 09/12] chore(release-notes): move entry to the consolidated notes PR (#10832) Co-Authored-By: Claude Fable 5.1 --- docs/release-notes/next-release-draft.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/next-release-draft.md b/docs/release-notes/next-release-draft.md index ef5b636ca02..31edf6ba283 100644 --- a/docs/release-notes/next-release-draft.md +++ b/docs/release-notes/next-release-draft.md @@ -22,6 +22,10 @@ Last release: **v0.125.0** (2026-10-27). ## Features / API +### Wisp working set (#10364) + +- Wisp now adds records to its thread working set after successful focal tool calls, keeps pins ahead of recent auto links, and names the authorized record or device being viewed in the turn seed. + ## Migrations ### Flows W05 (#5215 / #5220, PR D — web, limits and egress audit) @@ -113,7 +117,12 @@ A UAC elevation the end user declines on the device (Deny, the PAM dialog times ### Webhooks -- **Alert webhook payload v2.** Each webhook has a `payloadVersion`: existing webhooks stay on v1 byte for byte, and new webhooks default to v2, including webhooks created through the API without `payloadVersion`. V2 alert data adds status, hostname, site, organization name, an alert link, and more. Manual retries carry `X-Breeze-Replay-Of`. New `alert.escalated` subscriptions are rejected; `alert.acknowledged` and `alert.suppressed` are now offered in the form (`alert.suppressed` is sent only when Breeze AI suppresses an alert). Migration `2027-01-12-100000-webhook-payload-version.sql` adds three columns on `webhook_deliveries` and one on `webhooks`, with version CHECKs on both tables. The delivery CHECK is `NOT VALID`, enforces new or updated rows, and requires no table scan. DDL only, no backfill, and no new environment variables. +- **Alert webhook payload v2.** Each webhook has a `payloadVersion`: existing webhooks stay on v1 byte for byte, and new webhooks default to v2, including webhooks created through the API without `payloadVersion`. V2 alert data adds status, hostname, site, organization name, an alert link, and more. Manual retries carry `X-Breeze-Replay-Of`. `alert.acknowledged`, `alert.suppressed` and `alert.escalated` are now offered in the form (see Alert webhook events). Migration `2027-01-12-100000-webhook-payload-version.sql` adds three columns on `webhook_deliveries` and one on `webhooks`, with version CHECKs on both tables. The delivery CHECK is `NOT VALID`, enforces new or updated rows, and requires no table scan. DDL only, no backfill, and no new environment variables. +### Alert webhook events (#10573, #10565) + +- **`alert.suppressed` now fires for every suppress (#10573).** Suppressing from the dashboard, `POST /alerts/:id/suppress` and `POST /alerts/bulk` now publish `alert.suppressed`, with the same data Breeze AI's suppress already sent; before, only Breeze AI did. Webhooks and flows subscribed to `alert.suppressed` (or `*`) start receiving these events. For an end-time suppression `durationHours` is the time from the request to `suppressedUntil`. +- **`alert.escalated` is published (#10565).** One event per alert per escalation step occurrence, when the step fires for an alert that is still active, with `escalationPolicyId`, `escalationStep`, `policyStep` and `repeat`; v2 webhooks get the enriched alert data. The API now accepts `alert.escalated` subscriptions and the form offers it; a stored subscription is no longer shown as "Never delivered" or dropped on save. Escalation policies scheduled before the upgrade publish no `alert.escalated` for their already-queued steps. No migration, no new environment variables. + ### UniFi - **UniFi site mapping lists every site (#10491, #10492).** The cloud (Site Manager) mapping card now reads every page of hosts and sites instead of the first page only, lists sites on self-hosted UniFi Network Application servers (`network-server` hosts) that were previously hidden, and shows sites whose host is missing from Site Manager under "Unknown console". `GET /unifi/hosts` gains the additive fields `type`, `unknownConsole` and `omitted`. Each sync now reads `/v1/devices` and the ISP metrics once per run rather than once per mapping. @@ -221,8 +230,10 @@ Only the winning policy for each device applies; newly added providers start wit Update agents to a release containing this change before relying on the provider list. Older agents ignore it. ### AI tools — Pax8 (#6754 W15) -- Submit Pax8 orders with two-person approval of estimated costs and terminal cancellations; reconcile unknown outcomes while preserving concurrent billing quantity edits. - `get_pax8_status`: inspect integration health and company readiness with full partner access. - `search_pax8_products`: find mapped products and read provisioning requirements, commitments and live vendor pricing. - `manage_pax8_subscriptions`: inspect seat drift, request a supervised sync, or link and unlink billing contract lines. - `manage_pax8_orders`: stage and edit drafts, preview live costs and commitments, and request a supervised dry run. +### Partner settings copy matches the inheritance badges (#10372) + +The default-expiry and maximum-lifetime selects and the partner agent-pin caption no longer say organizations "configure individually" or are "pinned for all"; each now agrees with its Org can override / Partner-wide badge. From 067623cc844f55252639fb38dfb3899a009792e6 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Sun, 1 Nov 2026 17:40:40 -0700 Subject: [PATCH 10/12] chore(migrations): move unshipped migration after main's newest (2027-01-18-170000) Rename 2027-01-18-141000-pax8-order-line-baseline-any-action.sql to 2027-01-18-189000 (after 170000 and 171000 on main) and update the pax8.test.ts reference. Co-Authored-By: Claude Fable 5.1 --- ...> 2027-01-18-189000-pax8-order-line-baseline-any-action.sql} | 0 apps/api/src/db/schema/pax8.test.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename apps/api/migrations/{2027-01-18-141000-pax8-order-line-baseline-any-action.sql => 2027-01-18-189000-pax8-order-line-baseline-any-action.sql} (100%) diff --git a/apps/api/migrations/2027-01-18-141000-pax8-order-line-baseline-any-action.sql b/apps/api/migrations/2027-01-18-189000-pax8-order-line-baseline-any-action.sql similarity index 100% rename from apps/api/migrations/2027-01-18-141000-pax8-order-line-baseline-any-action.sql rename to apps/api/migrations/2027-01-18-189000-pax8-order-line-baseline-any-action.sql diff --git a/apps/api/src/db/schema/pax8.test.ts b/apps/api/src/db/schema/pax8.test.ts index c913b408bb8..9171f460a1c 100644 --- a/apps/api/src/db/schema/pax8.test.ts +++ b/apps/api/src/db/schema/pax8.test.ts @@ -37,7 +37,7 @@ describe('Pax8 quantity authorization baseline', () => { it('permits a durable approved billing baseline for every Pax8 line action', () => { const migration = readFileSync(fileURLToPath(new URL( - '../../../migrations/2027-01-18-141000-pax8-order-line-baseline-any-action.sql', import.meta.url, + '../../../migrations/2027-01-18-189000-pax8-order-line-baseline-any-action.sql', import.meta.url, )), 'utf8'); expect(migration).toMatch(/DROP CONSTRAINT IF EXISTS pax8_order_lines_authorized_baseline_chk/i); expect(migration).toMatch(/CHECK \(authorized_baseline_quantity IS NULL OR authorized_baseline_quantity >= 0\)/i); From 131c2c13a6a9a089b4762643f1bf0305aa39a22c Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Sun, 1 Nov 2026 21:52:59 -0700 Subject: [PATCH 11/12] fix(web): picker labels for manage_pax8_orders submit (catalog label contract) Adds aiAgentsPage.catalog.actions.manage_pax8_orders.reconcile in all 8 locales (the only key the contract reported missing). Co-Authored-By: Claude Fable 5.1 --- apps/web/src/locales/de-DE/settings.json | 3 ++- apps/web/src/locales/en/settings.json | 3 ++- apps/web/src/locales/es-419/settings.json | 3 ++- apps/web/src/locales/fr-CA/settings.json | 3 ++- apps/web/src/locales/fr-FR/settings.json | 3 ++- apps/web/src/locales/it-IT/settings.json | 3 ++- apps/web/src/locales/pt-BR/settings.json | 3 ++- apps/web/src/locales/tr-TR/settings.json | 3 ++- 8 files changed, 16 insertions(+), 8 deletions(-) diff --git a/apps/web/src/locales/de-DE/settings.json b/apps/web/src/locales/de-DE/settings.json index 3e367c60fe9..5ee993d7cef 100644 --- a/apps/web/src/locales/de-DE/settings.json +++ b/apps/web/src/locales/de-DE/settings.json @@ -4735,7 +4735,8 @@ "update_line": "Pax8-Bestellposition aktualisieren", "remove_line": "Pax8-Bestellposition entfernen", "preview": "Pax8-Bestellung in der Vorschau anzeigen", - "preflight": "Pax8-Bestell-Preflight ausführen" + "preflight": "Pax8-Bestell-Preflight ausführen", + "reconcile": "Pax8-Bestellung abgleichen" }, "manage_pax8_subscriptions": { "list": "Pax8-Abonnements auflisten", diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json index 6810ecedc6e..e35ef436518 100644 --- a/apps/web/src/locales/en/settings.json +++ b/apps/web/src/locales/en/settings.json @@ -4735,7 +4735,8 @@ "update_line": "Update Pax8 order line", "remove_line": "Remove Pax8 order line", "preview": "Preview Pax8 order", - "preflight": "Run Pax8 order preflight" + "preflight": "Run Pax8 order preflight", + "reconcile": "Reconcile Pax8 order" }, "manage_pax8_subscriptions": { "list": "List Pax8 subscriptions", diff --git a/apps/web/src/locales/es-419/settings.json b/apps/web/src/locales/es-419/settings.json index 0cebfc2c70f..1a510dcbeeb 100644 --- a/apps/web/src/locales/es-419/settings.json +++ b/apps/web/src/locales/es-419/settings.json @@ -4735,7 +4735,8 @@ "update_line": "Actualizar línea del pedido de Pax8", "remove_line": "Quitar línea del pedido de Pax8", "preview": "Previsualizar pedido de Pax8", - "preflight": "Ejecutar verificación previa del pedido de Pax8" + "preflight": "Ejecutar verificación previa del pedido de Pax8", + "reconcile": "Conciliar pedido de Pax8" }, "manage_pax8_subscriptions": { "list": "Listar suscripciones de Pax8", diff --git a/apps/web/src/locales/fr-CA/settings.json b/apps/web/src/locales/fr-CA/settings.json index c3677ee1dee..c4c763fdd76 100644 --- a/apps/web/src/locales/fr-CA/settings.json +++ b/apps/web/src/locales/fr-CA/settings.json @@ -4735,7 +4735,8 @@ "update_line": "Mettre à jour une ligne de commande Pax8", "remove_line": "Retirer une ligne de commande Pax8", "preview": "Prévisualiser la commande Pax8", - "preflight": "Lancer la vérification préalable de la commande Pax8" + "preflight": "Lancer la vérification préalable de la commande Pax8", + "reconcile": "Réconcilier la commande Pax8" }, "manage_pax8_subscriptions": { "list": "Lister les abonnements Pax8", diff --git a/apps/web/src/locales/fr-FR/settings.json b/apps/web/src/locales/fr-FR/settings.json index 9c44958d94f..970f90b0bad 100644 --- a/apps/web/src/locales/fr-FR/settings.json +++ b/apps/web/src/locales/fr-FR/settings.json @@ -4735,7 +4735,8 @@ "update_line": "Mettre à jour une ligne de commande Pax8", "remove_line": "Retirer une ligne de commande Pax8", "preview": "Prévisualiser la commande Pax8", - "preflight": "Lancer la vérification préalable de la commande Pax8" + "preflight": "Lancer la vérification préalable de la commande Pax8", + "reconcile": "Réconcilier la commande Pax8" }, "manage_pax8_subscriptions": { "list": "Lister les abonnements Pax8", diff --git a/apps/web/src/locales/it-IT/settings.json b/apps/web/src/locales/it-IT/settings.json index 28fe8265e48..e2adef6df49 100644 --- a/apps/web/src/locales/it-IT/settings.json +++ b/apps/web/src/locales/it-IT/settings.json @@ -4735,7 +4735,8 @@ "update_line": "Aggiorna riga dell'ordine Pax8", "remove_line": "Rimuovi riga dall'ordine Pax8", "preview": "Anteprima ordine Pax8", - "preflight": "Esegui verifica preliminare dell'ordine Pax8" + "preflight": "Esegui verifica preliminare dell'ordine Pax8", + "reconcile": "Riconcilia ordine Pax8" }, "manage_pax8_subscriptions": { "list": "Elenca abbonamenti Pax8", diff --git a/apps/web/src/locales/pt-BR/settings.json b/apps/web/src/locales/pt-BR/settings.json index 5deb30a609e..09864cd269e 100644 --- a/apps/web/src/locales/pt-BR/settings.json +++ b/apps/web/src/locales/pt-BR/settings.json @@ -4735,7 +4735,8 @@ "update_line": "Atualizar linha do pedido do Pax8", "remove_line": "Remover linha do pedido do Pax8", "preview": "Visualizar pedido do Pax8", - "preflight": "Executar verificação prévia do pedido do Pax8" + "preflight": "Executar verificação prévia do pedido do Pax8", + "reconcile": "Reconciliar pedido do Pax8" }, "manage_pax8_subscriptions": { "list": "Listar assinaturas do Pax8", diff --git a/apps/web/src/locales/tr-TR/settings.json b/apps/web/src/locales/tr-TR/settings.json index d28a7f1560c..0dc268242a3 100644 --- a/apps/web/src/locales/tr-TR/settings.json +++ b/apps/web/src/locales/tr-TR/settings.json @@ -4735,7 +4735,8 @@ "update_line": "Pax8 sipariş satırını güncelle", "remove_line": "Pax8 sipariş satırını kaldır", "preview": "Pax8 siparişini önizle", - "preflight": "Pax8 sipariş ön kontrolünü çalıştır" + "preflight": "Pax8 sipariş ön kontrolünü çalıştır", + "reconcile": "Pax8 siparişini mutabık kıl" }, "manage_pax8_subscriptions": { "list": "Pax8 aboneliklerini listele", From 97f3b0cb50774b66a1bdcceeb09fe202d62db5ed Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Sun, 1 Nov 2026 22:59:20 -0700 Subject: [PATCH 12/12] chore(migrations): move unshipped migration after main's newest (2027-01-18-191000) Co-Authored-By: Claude Fable 5.1 --- ...> 2027-01-18-192000-pax8-order-line-baseline-any-action.sql} | 0 apps/api/src/db/schema/pax8.test.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename apps/api/migrations/{2027-01-18-189000-pax8-order-line-baseline-any-action.sql => 2027-01-18-192000-pax8-order-line-baseline-any-action.sql} (100%) diff --git a/apps/api/migrations/2027-01-18-189000-pax8-order-line-baseline-any-action.sql b/apps/api/migrations/2027-01-18-192000-pax8-order-line-baseline-any-action.sql similarity index 100% rename from apps/api/migrations/2027-01-18-189000-pax8-order-line-baseline-any-action.sql rename to apps/api/migrations/2027-01-18-192000-pax8-order-line-baseline-any-action.sql diff --git a/apps/api/src/db/schema/pax8.test.ts b/apps/api/src/db/schema/pax8.test.ts index 9171f460a1c..cef372c0c22 100644 --- a/apps/api/src/db/schema/pax8.test.ts +++ b/apps/api/src/db/schema/pax8.test.ts @@ -37,7 +37,7 @@ describe('Pax8 quantity authorization baseline', () => { it('permits a durable approved billing baseline for every Pax8 line action', () => { const migration = readFileSync(fileURLToPath(new URL( - '../../../migrations/2027-01-18-189000-pax8-order-line-baseline-any-action.sql', import.meta.url, + '../../../migrations/2027-01-18-192000-pax8-order-line-baseline-any-action.sql', import.meta.url, )), 'utf8'); expect(migration).toMatch(/DROP CONSTRAINT IF EXISTS pax8_order_lines_authorized_baseline_chk/i); expect(migration).toMatch(/CHECK \(authorized_baseline_quantity IS NULL OR authorized_baseline_quantity >= 0\)/i);