diff --git a/.github/scripts/ci-area-gating.test.mjs b/.github/scripts/ci-area-gating.test.mjs index 4f2de2c66dc..a41cb1e703f 100644 --- a/.github/scripts/ci-area-gating.test.mjs +++ b/.github/scripts/ci-area-gating.test.mjs @@ -793,3 +793,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/migrations/2027-01-18-192000-pax8-order-line-baseline-any-action.sql b/apps/api/migrations/2027-01-18-192000-pax8-order-line-baseline-any-action.sql new file mode 100644 index 00000000000..4b90516f235 --- /dev/null +++ b/apps/api/migrations/2027-01-18-192000-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/__tests__/integration/aiToolsPax8OrderSubmitScope.integration.test.ts b/apps/api/src/__tests__/integration/aiToolsPax8OrderSubmitScope.integration.test.ts new file mode 100644 index 00000000000..e4cd1c5c104 --- /dev/null +++ b/apps/api/src/__tests__/integration/aiToolsPax8OrderSubmitScope.integration.test.ts @@ -0,0 +1,297 @@ +/** 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 { db, 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, + 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.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); + 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 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(); + 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(); + + 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 () => { + 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).toMatchObject({ resolved: 1, stillUnknown: 0, status: 'completed', billingConflicts: [] }); + 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); + }); +}); diff --git a/apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts b/apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts index 816fbfaedba..ded62374d00 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'); @@ -617,18 +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, - })).rejects.toMatchObject({ status: 409 }); + mode: { kind: 'rest' }, + }); + 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({ @@ -646,6 +672,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 +683,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/__tests__/parkedFanout.contract.test.ts b/apps/api/src/__tests__/parkedFanout.contract.test.ts index 2fa49434788..620830e3e93 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', @@ -495,6 +497,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/db/schema/pax8.test.ts b/apps/api/src/db/schema/pax8.test.ts index adc7039a4d8..cef372c0c22 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-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); + 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/middleware/automationPrincipal.contract.test.ts b/apps/api/src/middleware/automationPrincipal.contract.test.ts index 21e7e185a04..7d0ba9972b2 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/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 7505ffc86aa..2740f133c1b 100644 --- a/apps/api/src/services/actionIntents/actionLabel.ts +++ b/apps/api/src/services/actionIntents/actionLabel.ts @@ -22,6 +22,18 @@ 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 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)}]: ${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('; '); +} + /** * Human headlines for approval-gated tools whose prettified identifier reads * poorly (#9657). Tools absent here fall back to `titleCaseWords(toolName)`. @@ -67,6 +79,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 new file mode 100644 index 00000000000..2bcad3944a5 --- /dev/null +++ b/apps/api/src/services/actionIntents/effectDigest.w15b.test.ts @@ -0,0 +1,194 @@ +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, PAX8_ORDER_MATERIAL_VERSION, serializePax8OrderMaterial } 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'], + ['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'], + ['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('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' }]; + 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/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/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..f0ff40451fc --- /dev/null +++ b/apps/api/src/services/actionIntents/pax8OrderDigest.ts @@ -0,0 +1,208 @@ +/** W15: local inputs of one Pax8 submit. Reads only through the supplied transaction. */ +import { createHash } from 'node:crypto'; +import { and, asc, eq, type AnyColumn } 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 { isSubmittableStatus } from '../pax8OrderTypes'; +import { canonicalizeArguments } from './canonicalize'; + +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: Pick, + orderId: string, +): 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, + } satisfies Record, AnyColumn>).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, + } 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)); + + const companies = await database.select({ + pax8CompanyId: pax8CompanyMappings.pax8CompanyId, name: pax8CompanyMappings.pax8CompanyName, + status: pax8CompanyMappings.status, ignored: pax8CompanyMappings.ignored, + orgId: pax8CompanyMappings.orgId, metadata: pax8CompanyMappings.metadata, + } 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), + )).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, + } 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: pax8CredentialGeneration(generation), + } : 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, + } 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, + } satisfies Record).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, + } 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), + )).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, + } 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), + )).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, + } satisfies Record).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({ + // 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, + } 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, + } satisfies Record).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 = 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, + 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 4092287d9cc..48efedb97e9 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_function:set', // W12 (#6782, quorum QW6): irreversible removal on a customer machine (conservative D4 exception). 'manage_device_software:uninstall', + // 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 fb3e5d7f242..ebeacbb260c 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'; @@ -685,7 +685,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 @@ -779,6 +779,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 @@ -1083,7 +1084,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'], @@ -2871,6 +2872,8 @@ export const TOOL_PERMISSIONS: Record { + 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); @@ -275,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/aiGuardrailsTierConfig.parity.test.ts b/apps/api/src/services/aiGuardrailsTierConfig.parity.test.ts index 676ec523a52..7eb6ea1f158 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/aiToolExposure.ts b/apps/api/src/services/aiToolExposure.ts index 97d8b58a384..90deb5ca260 100644 --- a/apps/api/src/services/aiToolExposure.ts +++ b/apps/api/src/services/aiToolExposure.ts @@ -225,6 +225,7 @@ export const AGENT_HUMAN_ONLY_ACTIONS: ReadonlyMap = new Map { +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/aiToolMfaGate.ts b/apps/api/src/services/aiToolMfaGate.ts index 48188f86a96..204b34b599f 100644 --- a/apps/api/src/services/aiToolMfaGate.ts +++ b/apps/api/src/services/aiToolMfaGate.ts @@ -63,7 +63,7 @@ export const MFA_GATED_TOOL_ACTIONS: Readonly 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(), 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(), +}).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..28dcfbdf586 --- /dev/null +++ b/apps/api/src/services/aiToolsPax8OrderSubmit.test.ts @@ -0,0 +1,166 @@ +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(), 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: 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 })); +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 { 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' }; +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 => { 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'])); + 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, 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(); + }); + 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({ 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({ 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); + }); + 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({ 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 => { + 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 })).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' })); + }); + 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..2dc3a821c56 100644 --- a/apps/api/src/services/aiToolsPax8Orders.ts +++ b/apps/api/src/services/aiToolsPax8Orders.ts @@ -1,15 +1,28 @@ -/** 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 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, 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 } from './pax8OrderSubmit'; +import { preflightOrder, submitOrder, reconcileOrder, type Pax8SubmitRefusalCode, type SubmitResult } from './pax8OrderSubmit'; +import { pax8OrderDigestMaterial, hashPax8OrderMaterial } from './actionIntents/pax8OrderDigest'; +import { isAutomationPrincipal } from '../middleware/principalKind'; +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'), @@ -31,9 +44,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: 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); @@ -43,6 +56,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 +78,75 @@ 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') { + 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'); + 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: 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'); + 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'); + } + } + 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'); + 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) { + return { ok: false, code: 'PREVIEW_MISMATCH', message: 'The order changed before its claim; nothing was sent; preview and approve again.' }; + } + return { ok: true }; + }, }, + })); + } 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.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 { const result = await runOutsideDbContext(() => preflightOrder({ partnerId, orderId })); @@ -104,7 +188,15 @@ 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 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/llm/toolEval/goldenPrompts.test.ts b/apps/api/src/services/llm/toolEval/goldenPrompts.test.ts index 2a78457d223..f533e7f1e52 100644 --- a/apps/api/src/services/llm/toolEval/goldenPrompts.test.ts +++ b/apps/api/src/services/llm/toolEval/goldenPrompts.test.ts @@ -23,7 +23,7 @@ const PENDING_DECLARATION = new Set([]); // search_documentation was dec const BASELINE_UNDECLARED_TOOLS = new Set([]); describe('GOLDEN_CASES', () => { - it('has 116 unique ids', () => { + it('has 117 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. @@ -39,11 +39,12 @@ 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(116); - expect(new Set(GOLDEN_CASES.map((c) => c.id)).size).toBe(116); + expect(GOLDEN_CASES).toHaveLength(117); + expect(new Set(GOLDEN_CASES.map((c) => c.id)).size).toBe(117); + 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' }]); - // g117 (#10161): ticket merge (preview, then the supervised merge) through the chat SDK shape; g116 is reserved by W15 PR B. + // g117 (#10161): ticket merge (preview, then the supervised merge) through the chat SDK shape; g116 is W15 PR B's Pax8 submit. expect(GOLDEN_CASES.find((c) => c.id === 'g117')?.expect[0]).toEqual({ tool: 'manage_tickets', action: 'merge_preview' }); expect(GOLDEN_CASES.find((c) => c.id === 'g112')?.expect.map((e) => e.action)).toContain('generate_invoice'); expect(GOLDEN_CASES.find((c) => c.id === 'g113')?.expect[0]).toEqual({ tool: 'manage_accounting_sync', action: 'push' }); diff --git a/apps/api/src/services/llm/toolEval/goldenPrompts.ts b/apps/api/src/services/llm/toolEval/goldenPrompts.ts index f3214ddd401..0a6a65ddd9b 100644 --- a/apps/api/src/services/llm/toolEval/goldenPrompts.ts +++ b/apps/api/src/services/llm/toolEval/goldenPrompts.ts @@ -150,6 +150,8 @@ 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' }] }, // #10161: ticket merge reaches the chat SDK shape (preview first, then the supervised merge). { id: 'g117', prompt: 'Ticket 1043 is a duplicate of 1042, merge it in.', expect: [{ tool: 'manage_tickets', action: 'merge_preview' }, { tool: 'manage_tickets', action: 'merge' }] }, ]; 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 1e95c3df617..4bb7dc3d0b9 100644 --- a/apps/api/src/services/pax8OrderPreview.test.ts +++ b/apps/api/src/services/pax8OrderPreview.test.ts @@ -2,10 +2,11 @@ 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(), + accessibleOrgIds: null as string[] | null, })); vi.mock('../db', () => ({ db: { select: m.select, insert: m.insert, update: m.update, delete: m.delete }, @@ -19,7 +20,8 @@ vi.mock('./catalogService', () => ({ resolvePrice: m.resolvePrice, CatalogServiceError: class CatalogServiceError extends Error { constructor(message: string, public status = 400, public code?: string) { super(message); } }, })); -import { previewPax8Order } from './pax8OrderPreview'; +vi.mock('./actionIntents/pax8OrderDigest', () => ({ pax8OrderDigestMaterial: m.material, hashPax8OrderMaterial: () => 'a'.repeat(64) })); +import { PREVIEW_FIELD_SOURCE, previewPax8Order } from './pax8OrderPreview'; import { Pax8ApiError } from './pax8Client'; import { CatalogServiceError } from './catalogService'; @@ -40,20 +42,24 @@ const run = () => previewPax8Order({ partnerId, orderId, client: client as never beforeEach(() => { vi.clearAllMocks(); - m.contextDepth = 0; m.outsideDepth = 0; + m.material.mockImplementation(async () => { expect(m.contextDepth).toBeGreaterThan(0); return { local: true }; }); + 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' }], @@ -67,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 () => { @@ -87,6 +97,64 @@ 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('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('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(); + }); + 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..47c388d4f10 100644 --- a/apps/api/src/services/pax8OrderPreview.ts +++ b/apps/api/src/services/pax8OrderPreview.ts @@ -2,8 +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 { getOrderWithLines, snapshotActiveCommitmentEvidence } from './pax8OrderService'; +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, Pax8OrderError, snapshotActiveCommitmentEvidence } from './pax8OrderService'; import { createPax8ClientForIntegration } from './pax8SyncService'; import { pax8CompanyOrderReadiness } from './pax8CompanyReadiness'; import { CatalogServiceError, resolvePrice } from './catalogService'; @@ -31,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 } @@ -49,10 +62,11 @@ 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; -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; @@ -67,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, @@ -107,16 +133,20 @@ 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; 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); + contractLineMissing = !contractLine; if (contractLine) { + baseline = contractLine.manualQuantity; const [contract] = await db.select({ currencyCode: contracts.currencyCode, createdBy: contracts.createdBy, }).from(contracts).where(and( @@ -140,12 +170,13 @@ export async function previewPax8Order(input: { } } } - evidence.push({ line, snapshot, mapping, productId, sellUnitPrice, sellCurrency }); + evidence.push({ line, snapshot, mapping, productId, sellUnitPrice, sellCurrency, baseline, contractLineMissing }); } - 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 @@ -154,10 +185,10 @@ 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 }; + 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 +201,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 } 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' }); @@ -217,7 +249,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 +274,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/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 c60905488c7..0298bd30a2e 100644 --- a/apps/api/src/services/pax8OrderSubmit.test.ts +++ b/apps/api/src/services/pax8OrderSubmit.test.ts @@ -1,10 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { RunOutsideDbContextFn } from '../db'; +import { Pax8IntegrationGenerationChangedError } from './pax8SyncService'; import { Pax8ApiError } from './pax8Client'; import { createPax8OrderSubmitService, type Pax8OrderSubmitRepository, - type SubmitBundle, + type ClaimedBundle, } from './pax8OrderSubmit'; const newLine = { @@ -54,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', @@ -65,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'}, }; } @@ -77,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(), @@ -98,6 +101,7 @@ function setup( claimOrder: vi.fn().mockResolvedValue(claimed), createClient: vi.fn().mockResolvedValue(client), claimLines: vi.fn().mockResolvedValue(undefined), + 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, @@ -166,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 }); @@ -180,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); @@ -194,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); @@ -208,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); @@ -218,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(); }); @@ -235,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'); @@ -248,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 }); @@ -262,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( @@ -293,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); }); @@ -307,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'); }); @@ -319,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'); @@ -330,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(); }); @@ -592,3 +596,95 @@ 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) => { + 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', mode: {kind: 'ai',verifyClaim} })) + .rejects.toThrow('PREVIEW_MISMATCH'); + expect(repository.createClient).not.toHaveBeenCalled(); + expect(repository.claimLines).not.toHaveBeenCalled(); + expect(client.createOrder).not.toHaveBeenCalled(); + }); + + it('verifies an unchanged order before isMock', async () => { + const status = 'draft' as const; + const { service, repository, client } = setup(); + const verifyClaim = vi.fn().mockResolvedValue({ok:true}); + vi.mocked(repository.claimOrder).mockImplementation(async (input) => { + const claimed = { ...bundle(), preClaimStatus: status }; + if (input.mode.kind === 'ai') await input.mode.verifyClaim(claimed, {} as never); + return claimed; + }); + await service.submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1', mode: {kind: 'ai',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', mode: {kind: 'ai',verifyClaim: async () => ({ok:true})} }); + expect(result.status).toBe('draft'); + 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); + }); + + 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', 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(); + }); +}); + +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', 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'), '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 992bf409a65..0d29997902c 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 } from './pax8SyncService'; import type { Pax8OrderLineRow, Pax8OrderRow } from './pax8OrderService'; import { Pax8OrderError } from './pax8OrderService'; import { @@ -10,11 +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[]; +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: 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; mode: SubmitMode; } export interface SubmitLineOutcome { @@ -24,30 +46,30 @@ export interface SubmitLineOutcome { resultSubscriptionId: string | null; } -export interface SubmitResult { - orderId: string; - status: Pax8OrderStatus; +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: { partnerId: string; orderId: string; actorUserId: string }): Promise; - createClient(bundle: SubmitBundle): Promise; - claimLines(bundle: SubmitBundle): Promise; - persistPreflightFailure(bundle: SubmitBundle, errorBody: string): Promise; - persistSubmitResults( - bundle: SubmitBundle, - outcomes: SubmitLineOutcome[], - pax8OrderId: string | null, - ): Promise; - loadReconcileOrder(input: { partnerId: string; orderId: string }): Promise; - resetUnsentOrder(bundle: SubmitBundle): Promise<{ resolved: number; stillUnknown: number }>; - persistReconcileResults( - bundle: SubmitBundle, - outcomes: SubmitLineOutcome[], - pax8OrderId: string | null, - ): Promise<{ resolved: number; stillUnknown: number }>; + 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. */ @@ -68,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) { @@ -157,7 +179,7 @@ function createResponseAssignment(lines: Pax8OrderLineRow[], result: Pax8OrderRe } async function preflightBundle( - bundle: SubmitBundle, + bundle: LoadedBundle, client: Pax8Client, outside: typeof runOutsideDbContext, ): Promise { @@ -172,7 +194,7 @@ async function preflightBundle( } async function executeWrites( - bundle: SubmitBundle, + bundle: LoadedBundle, client: Pax8Client, ): Promise<{ outcomes: SubmitLineOutcome[]; pax8OrderId: string | null }> { const outcomes: SubmitLineOutcome[] = []; @@ -250,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 } { @@ -352,24 +374,60 @@ 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 => { + // 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); + return deps.repository.reopenUnsentOrder(bundle, clipped, refusalCode); + }; 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'); + } + 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.mode.kind === 'ai') return preflightFailure(errorText(error)); + throw error; } - 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)); - 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 @@ -382,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 298a6052c77..0f3a1e413e8 100644 --- a/apps/api/src/services/pax8OrderSubmitRepository.test.ts +++ b/apps/api/src/services/pax8OrderSubmitRepository.test.ts @@ -4,8 +4,14 @@ const mocks = vi.hoisted(() => ({ db: { select: vi.fn(), update: vi.fn() }, 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(), @@ -14,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 { @@ -26,13 +33,18 @@ 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, }; }); -vi.mock('./pax8SyncService', () => ({ createPax8ClientForIntegration: vi.fn() })); +vi.mock('./pax8SyncService', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createPax8ClientForIntegration: mocks.createClient }; +}); +import { pax8CredentialGeneration } from './actionIntents/pax8OrderDigest'; import { pax8OrderSubmitRepository } from './pax8OrderSubmitRepository'; const READY_METADATA = { @@ -53,15 +65,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,7 +87,10 @@ beforeEach(() => { mocks.db.select.mockReset(); mocks.db.update.mockReset(); mocks.validateLines.mockReset(); + mocks.createClient.mockReset(); mocks.events.length = 0; + mocks.logError.mockReset(); + mocks.captureException.mockReset(); }); describe('pax8OrderSubmitRepository.claimOrder', () => { @@ -95,7 +111,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() }]; @@ -111,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')); @@ -154,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({ @@ -190,9 +207,204 @@ 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); }); }); + + +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 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', + 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').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', mode: { kind: 'ai', verifyClaim } }); + expect(verifyClaim).toHaveBeenCalledWith(bundle, { select: expect.any(Function) }); + expect(bundle.preClaimStatus).toBe('ready'); + expect(bundle.integrationGeneration).toEqual(generation); + expect(bundle.integrationGeneration).not.toHaveProperty('clientSecretEncrypted'); + 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', + mode: { kind: 'ai', verifyClaim: async () => ({ ok: false, code: 'PREVIEW_MISMATCH', message: 'PREVIEW_MISMATCH' }) }, + })).rejects.toThrow('PREVIEW_MISMATCH'); +}); + +it.each(['apiBaseUrl', 'tokenUrl', 'clientIdEncrypted', 'clientSecretEncrypted', 'isActive'] as const)('refuses a client when the claimed integration %s changed', async (field) => { + 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: 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')).mockReturnValueOnce(selectChain([integration], 'limit')); + mocks.createClient.mockResolvedValue({ integration: { ...integration, accessTokenEncrypted: 'refreshed' }, 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) => { + 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, 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', integrationGeneration: generation }, 'failed', 'PREFLIGHT_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([{ 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); + 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'); + 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'); +}); + + +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 b5469c24eed..03b151d5f06 100644 --- a/apps/api/src/services/pax8OrderSubmitRepository.ts +++ b/apps/api/src/services/pax8OrderSubmitRepository.ts @@ -10,10 +10,12 @@ import { contractLines, pax8CompanyMappings, pax8OrderLines, + pax8Integrations, pax8Orders, } from '../db/schema'; import { Pax8OrderError, + Pax8ClaimDriftError, Pax8OrderRestageRequiredError, requireImmediateCancelDate, validateDirectOrderLinesForSubmit, @@ -22,14 +24,19 @@ import { } from './pax8OrderService'; import type { Pax8OrderSubmitRepository, - SubmitBundle, + LoadedBundle, + ClaimedBundle, + BillingConflict, SubmitLineOutcome, SubmitResult, } from './pax8OrderSubmit'; -import { createPax8ClientForIntegration } from './pax8SyncService'; +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 { @@ -54,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, @@ -140,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' }); } } @@ -152,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, @@ -164,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() @@ -182,57 +191,68 @@ 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; } -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; +/** 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; - const updated = await db - .update(contractLines) - .set({ manualQuantity: quantity }) - .where(and( - eq(contractLines.id, line.contractLineId), - eq(contractLines.orgId, line.orgId), - eq(contractLines.lineType, 'manual' as never), - )) - .returning({ id: contractLines.id }); - if (updated.length !== 1) { - throw new Pax8OrderError('The linked manual contract line changed before billing quantity was recorded.', 409); - } + 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') { + 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) .set({ @@ -250,13 +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' }); } - if (outcome.submitState === 'succeeded') await updateManualContractQuantity(line); } + return billingConflicts; } -async function reloadLines(bundle: SubmitBundle): Promise { +async function reloadLines(bundle: LoadedBundle): Promise { return db .select() .from(pax8OrderLines) @@ -319,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 @@ -376,7 +396,47 @@ 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, { phase: 'claim', code: 'INTEGRATION_UNAVAILABLE' }); + } + // 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, { phase: 'claim', code: 'CONTRACT_LINE_MISSING' }); + 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, { phase: 'claim', code: 'BASELINE_NOT_CAPTURED' }); + line.authorizedBaselineQuantity = baseline; + } + 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, + }; + 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; return outcome.bundle; @@ -384,17 +444,37 @@ export const pax8OrderSubmitRepository: Pax8OrderSubmitRepository = { createClient(bundle) { return withPartnerDbContext(bundle.order.partnerId, async () => { + 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); } + 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; }); }, 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() }) @@ -409,14 +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' }); } }); }, + 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) @@ -449,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) @@ -469,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); }); }, @@ -488,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) @@ -507,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 }; }); }, @@ -527,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) @@ -556,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 4566b668a5b..0ba545742bf 100644 --- a/apps/api/src/services/pax8SyncService.ts +++ b/apps/api/src/services/pax8SyncService.ts @@ -28,11 +28,11 @@ 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 { - constructor() { - super('Pax8 integration configuration changed during sync'); +export class Pax8IntegrationGenerationChangedError extends Error { + constructor(context = 'during sync') { + super(`Pax8 integration configuration changed ${context}`); 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), diff --git a/apps/api/src/services/sentry.test.ts b/apps/api/src/services/sentry.test.ts index f5e24333217..c8743c80b7b 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 b351222429b..e8bfe1ddc19 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 | @@ -770,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 8a2edc35385..756be264711 100644 --- a/apps/docs/src/content/docs/features/mcp-server.mdx +++ b/apps/docs/src/content/docs/features/mcp-server.mdx @@ -527,7 +527,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. | @@ -1280,4 +1280,6 @@ 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. + +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. diff --git a/apps/web/src/components/ai-risk/tierConfig.ts b/apps/web/src/components/ai-risk/tierConfig.ts index ca4e3acf32b..5ac635bd04e 100644 --- a/apps/web/src/components/ai-risk/tierConfig.ts +++ b/apps/web/src/components/ai-risk/tierConfig.ts @@ -554,6 +554,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/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",