From 9e0d20c79730d8a4ef40a217177484ba19e975fc Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Mon, 13 Jul 2026 22:21:19 -0600 Subject: [PATCH 01/40] docs(pax8): design spec for Pax8 ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the write path to the Pax8 integration: place, change, and cancel subscriptions from a customer's org page or as the fulfillment step of an approved quote. Establishes Breeze as the source of truth for licensing and billing — Pax8's Subscription.quantity is stale and does not match what Pax8 invoices, so the existing nightly quantity push into contract_lines is demoted to drift detection. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-07-13-pax8-ordering-design.md | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-13-pax8-ordering-design.md diff --git a/docs/superpowers/specs/2026-07-13-pax8-ordering-design.md b/docs/superpowers/specs/2026-07-13-pax8-ordering-design.md new file mode 100644 index 0000000000..1cf8542b8c --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-pax8-ordering-design.md @@ -0,0 +1,211 @@ +# Pax8 Ordering — Design + +**Date:** 2026-07-13 +**Status:** Approved, pending implementation plan +**Epic:** Pax8 + invoicing, next phase + +## Summary + +Breeze can read Pax8 but cannot buy from it. This phase adds the write path: a technician can place, change, and cancel Pax8 subscriptions from inside Breeze, either directly on a customer record or as the fulfillment step of a quote the customer approved. + +The central design commitment is that **Breeze is the source of truth for licensing and billing, and Pax8 is not.** We never read a seat count back from Pax8 to bill from. We know what the customer has because every change went through us. + +## Background + +### What exists today + +`pax8Client.ts` is structurally read-only — `requestJson()` never sets a method, so every call is a GET. The integration authenticates per-partner via OAuth client-credentials, runs a nightly sync at 04:15, and persists: + +| Table | Purpose | +|---|---| +| `pax8_integrations` | Per-partner credentials (encrypted), one active row per partner | +| `pax8_company_mappings` | Pax8 company ↔ Breeze org | +| `pax8_subscription_snapshots` | Nightly snapshot of Pax8 subscriptions | +| `pax8_product_mappings` | Pax8 product ↔ `catalog_items` | +| `pax8_contract_line_links` | Subscription ↔ `contract_lines`, with a `sync_enabled` quantity push | + +All five are partner-axis (RLS shape 3), with `org_id` present on three as a linkage column explicitly excluded from org auto-discovery. + +Quotes are fully built: block-composed documents whose lines snapshot `catalog_item_id`, `sku`, `unit_cost`, and a `recurrence` of `one_time`/`monthly`/`annual`. A customer approves via a single-use JWT link or the portal; `acceptQuote()` then, in one transaction, issues an invoice from the one-time lines, creates draft contracts from the recurring lines, and marks the quote `converted`. + +Nothing anywhere calls Pax8 to buy anything. + +### The billing-truth problem (discovered during design) + +Pax8's API `Subscription.quantity` **does not reliably match the seat counts Pax8 actually invoices the partner for.** The API value lags or goes stale; the invoice is correct. + +This is not a hypothetical. `applyEnabledPax8ContractLineLinks()` (`pax8SyncService.ts:232-265`) currently pushes `pax8_subscription_snapshots.quantity` into `contract_lines.manual_quantity` every night for every `sync_enabled` link, and the daily contract billing sweep invoices the MSP's customer from that column. **Shipped code is billing customers off a number Pax8 itself does not bill from.** Under-count and the MSP absorbs the margin; over-count and they overbill their customer. + +This design fixes the cause rather than chasing the symptom: Breeze stops deriving billable quantities from Pax8 entirely. + +## Pax8 API contract + +Confirmed against Pax8's OpenAPI (`devx.pax8.com`; appending `.md` to any reference page returns raw OpenAPI JSON). + +| Action | Call | +|---|---| +| Create subscriptions | `POST /v1/orders` — one order, many `lineItems` | +| Change quantity | `PUT /v1/subscriptions/{id}` | +| Cancel | `DELETE /v1/subscriptions/{id}` (optional `cancelDate` query param) | +| Discover provisioning fields | `GET /v1/products/{id}/provision-details` | +| Discover commitment rules | `GET /v1/products/{id}/dependencies` | +| Dry-run validate | `POST /v1/orders?isMock=true` | + +Load-bearing facts, each of which shapes the design: + +**There is no suspend.** The complete set of subscription writes is `PUT` and `DELETE`. There is no documented endpoint to reactivate a cancelled subscription — `Resurrect` exists only as a webhook event type. Treat cancel as terminal and one-way. + +**Provisioning-detail requiredness is not machine-discoverable.** `provision-details` returns each field's `key`, `label`, `valueType` (`Input` | `Single-Value` | `Multi-Value`), and `possibleValues` — enough to generate a form and validate enum values. It does **not** return which fields are required, nor the conditional logic between them. Pax8's own docs prove the gap: discovery for Microsoft 365 E3 returns ten fields, while their official order example sends nine, because answering "no existing Microsoft account" makes the tenant-ID field moot. That branch logic exists nowhere in the API. `isMock=true` is therefore the only machine-checkable oracle for payload completeness. + +**There is no idempotency key, and orders have no status field.** `POST /v1/orders` accepts exactly one parameter (`isMock`) and zero headers. The `Order` schema is `{id, companyId, createdDate, orderedBy, orderedByUserId, orderedByUserEmail, isScheduled, lineItems}` — no state, no reason, no stuck-detection. `createdDate` is a **date, not a timestamp**, so two orders placed the same day cannot be distinguished after the fact. A retried POST creates a second real, billable order that we cannot cheaply detect. + +**`PUT /v1/subscriptions/{id}` is a partial update despite the verb**, and `price`, `partnerCost`, `currencyCode`, `startDate`, and `endDate` are all writable. A read-modify-write round-trip would re-send pricing and can overwrite the customer's rate. + +**`GET /v1/products/{id}/dependencies`** returns per-commitment `allowForQuantityIncrease`, `allowForQuantityDecrease`, `allowForEarlyCancellation`, and `cancellationFeeApplied`. These *are* discoverable and should gate the UI. + +**Rate limit:** 1000 successful calls/minute. + +**Pax8's own OpenAPI is unreliable.** The create-order reference alone contains four defects: a `required` list naming a `companyId` property the schema doesn't define, an example sending `commitmentTermID` where the schema says `commitmentTermId`, an example sending a response-only `subscriptionId` on a create, and a malformed UUID. Treat the spec as a hint and `isMock` as the truth. + +### Unverified — must be confirmed against live credentials before implementation + +- **Does `POST /v1/orders` return `lineItems[].subscriptionId` populated synchronously, or is it null until provisioning completes?** Two research passes disagreed. The submit pipeline's linking step depends on this. If it can be null, the order line records `subscription_id_pending` and the nightly sync fills it in by matching product + company. +- Whether failed calls count against the rate limit, and whether `Retry-After` is returned. +- Whether write permission on an API key is granted separately in the Pax8 partner portal (a documented `403 "insufficient permissions"` exists). Worth confirming with the Pax8 rep before build, since it's the difference between working in test and 403ing in prod. + +## Design + +### 1. The order is a staged intent ledger + +Because Pax8 offers no idempotency key and no order status, **our row is the record of whether money was spent, and Pax8 is not.** The table is a claim ticket punched exactly once, not a receipt. + +**`pax8_orders`** — header. Partner-axis. + +| Column | Notes | +|---|---| +| `id` | uuid pk | +| `partner_id` | NOT NULL — tenancy axis | +| `org_id` | linkage column, NOT NULL, composite FK `(org_id, partner_id) → organizations(id, partner_id)` | +| `integration_id` | composite FK `(integration_id, partner_id) → pax8_integrations(id, partner_id)` | +| `pax8_company_id` | the mapped Pax8 company | +| `status` | `draft` \| `awaiting_details` \| `ready` \| `submitting` \| `completed` \| `partially_failed` \| `failed` \| `cancelled` | +| `source` | `direct` \| `quote` | +| `source_quote_id` | nullable FK → `quotes` | +| `dedupe_key` | UNIQUE — the idempotency guard | +| `pax8_order_id` | returned by Pax8 on success | +| `created_by`, `submitted_by`, `submitted_at` | | + +**`pax8_order_lines`** — one row per *action*, because Pax8 splits the three verbs across three endpoints. + +| Column | Notes | +|---|---| +| `id`, `order_id`, `partner_id`, `org_id` | | +| `action` | `new_subscription` \| `change_quantity` \| `cancel` | +| `submit_state` | `pending` → `in_flight` → `succeeded` \| `failed` \| `needs_reconcile` | +| `pax8_product_id`, `catalog_item_id` | `new_subscription` only | +| `billing_term` | `Monthly` \| `Annual` \| `2-Year` \| `3-Year` \| `One-Time` \| `Trial` \| `Activation` | +| `commitment_term_id` | required iff the product's `requiresCommitment` | +| `quantity` | new quantity (for `change_quantity`, the absolute target — not a delta) | +| `provisioning_details` | jsonb, `[{key, values[]}]` | +| `target_subscription_id` | `change_quantity` / `cancel` | +| `cancel_date` | `cancel` only, optional | +| `result_subscription_id` | populated on success | +| `contract_line_id` | the line this order bills through | +| `source_quote_line_id` | nullable | +| `error` | raw Pax8 `details[]`, verbatim | + +Per-line state is not overengineering. One order can batch a new purchase, a seat bump, and a cancel; the POST can succeed while a PUT 422s. A single order-level status would either lie or discard the successful half. + +**Why Pax8-specific tables and not a generic `distributor_orders`.** Commitment terms, the provisioning-details key/value model, and the three-endpoint split are Pax8-shaped. TD SYNNEX — today lookup-and-pricing only — orders nothing like this. Generalizing now would buy a wrong abstraction. If TD SYNNEX ordering ships, extract then. + +**Tenancy: partner-axis (shape 3),** matching the five existing `pax8_*` tables. Policies on `breeze_has_partner_access(partner_id)`; `org_id` excluded from org auto-discovery; integrity via composite FKs. Ordering is an MSP-side act — an org-scoped token must never see it. Register in `PARTNER_TENANT_TABLES` and add the `org_id` exclusions in `rls-coverage.integration.test.ts` in the same PR. + +### 2. Authoring path A — direct, on the org page + +A **Pax8 tab on the org record**, showing three things: + +- The company mapping, with an inline mapper and a clear empty state when the org isn't mapped. +- Breeze's ledger-derived seat counts — what we believe the customer has. +- The Pax8 snapshot alongside them, with a **drift badge** wherever the two disagree. + +Actions accumulate into a single open draft order for that org: + +- **Add product** → picker over Pax8-mapped catalog items → term, commitment, quantity → a provisioning form generated from `GET /products/{id}/provision-details`, rendering `Input` as free text, `Single-Value` as a select over `possibleValues`, `Multi-Value` as a multiselect. +- **Inline seat +/-** on an existing subscription → a `change_quantity` line. Gated on `allowForQuantityIncrease` / `allowForQuantityDecrease`. +- **Cancel** → a `cancel` line. Gated on `allowForEarlyCancellation`, warning when `cancellationFeeApplied`. + +Then one **Review & Submit** screen for the whole order. + +Two preconditions enforced up front rather than eaten as a 422: the org must have a `pax8_company_mappings` row, and Pax8 requires the company be Active with primary admin, billing, and technical contacts on file. + +### 3. Authoring path B — quote acceptance stages an order + +Quote lines already snapshot `catalog_item_id`, and `pax8_product_mappings` resolves a catalog item to a Pax8 product — so a Pax8-backed quote line is identifiable at accept time **with no schema change to quotes**. + +In the post-commit tail of `acceptQuote()` (alongside where it already mints the Stripe pay link), Breeze builds a `pax8_orders` row with `source='quote'`, one `new_subscription` line per Pax8-backed quote line, each carrying the `contract_line_id` that `quoteToContract` just created. It lands in `awaiting_details`: the customer bought it, the contract exists, and the only thing outstanding is provisioning input the customer could never have supplied. + +**A customer's approval never places a vendor order.** It stages one. A technician reviews and submits. + +The quote path only produces `new_subscription` lines. Changes and cancels are servicing actions, not sales, and belong on the direct path. + +**Discoverability** needs no new machinery: the accepted quote's detail page shows the order it staged and links to it, and the org's Pax8 tab shows it pending. Both are places a tech working the account already looks. No notification system, no queue page. The customer is not waiting on this — the MSP technician is the user. + +### 4. Submit pipeline + +Lines are grouped by action. All `new_subscription` lines batch into **one** `POST /v1/orders` (`parentLineItemNumber` lets dependent items reference siblings within the same order). Each `change_quantity` and each `cancel` fires its own `PUT` / `DELETE`. + +**`isMock=true` is a hard gate on every submit.** Since Pax8 won't tell us which provisioning fields are required or how they branch, the mock call is the only machine-checkable oracle for completeness. On 422 we surface Pax8's raw `details[]` verbatim next to the offending line rather than pre-judging validity from a spec we have already caught lying four times. + +**The `PUT` sends only `quantity`.** Nothing else, ever — `price`, `partnerCost`, and `currencyCode` are writable and a read-modify-write would overwrite the customer's rate. This gets a dedicated unit test asserting the request body has exactly one key. + +### 5. Money-safety rules + +**Claim before you fire.** The line flips to `in_flight` in a committed transaction *before* the HTTP call, guarded by the unique `dedupe_key`. A concurrent submit loses the race and is rejected. + +**Never blind-retry.** A timeout or 5xx does not mean the order failed — it means we do not know. The line goes to `needs_reconcile`, never `failed`, and nothing automatic re-sends it. A human sees "we may have already ordered this" and clicks **Reconcile**, which pulls `GET /v1/orders?companyId=` and `GET /v1/subscriptions?companyId=`, matches on product + quantity, and establishes what actually landed. Auto-retry here is how you buy 200 licenses instead of 100. + +**Bill from what succeeded.** In the same transaction that marks a line `succeeded`, Breeze writes the resulting quantity onto the linked `contract_line`. Ordering and billing are one atomic act: we can never provision without billing, or bill without provisioning. + +### 6. Breeze is the billing source of truth + +The contract line's quantity is written by **our order ledger**, never by a Pax8 sync. We do not read `Subscription.quantity` for any purpose that touches money. + +Consequently, `applyEnabledPax8ContractLineLinks()` **stops writing to `contract_lines` entirely.** It is demoted to drift detection: the nightly sync still pulls the snapshot, but only to *compare* against Breeze's ledger and raise a flag when the two disagree. That flag is the signal that someone changed seats directly in the Pax8 portal, bypassing Breeze. Pax8 becomes a check, never a driver. + +This is a behavior change to shipped code and must be called out in the release notes — partners with `sync_enabled` links will see contract-line quantities stop auto-updating from Pax8, which is the point. + +### 7. Provisioning status: no polling worker + +`POST /v1/orders` returns the subscription identifier, which is all we need to link the order to what we bought (see the unverified item above). Beyond that, provisioning status is **not load-bearing** — Breeze is the billing truth, and the Pax8 marketplace *vendor* emails the technician directly on fulfillment (a documented vendor obligation, step 4 of Pax8's provisioning contract). + +So the org tab displays a last-known status from the existing nightly sync (`Active`, `PendingAutomated`, `WaitingForDetails`), honestly labeled as up to a day stale. No new polling worker, no backoff logic, no timeout guessed against an SLA Pax8 does not publish. + +`WaitingForDetails` means our provisioning payload was incomplete — `isMock` should have caught that pre-submit, and the nightly sync surfacing it is an adequate backstop for something that shouldn't happen. + +## Out of scope (deliberately) + +**The `PROVISIONING` webhook topic — next phase.** It is the only mechanism in the entire Pax8 partner surface that can tell a technician *why* an order is stuck: Pax8's changelog describes it emitting task creation, status transitions (Ready → Executing → Finished), errors, and cancellations, filterable by client, product, subscription, and status. There is no REST endpoint to query any of it — to have durable, answerable stuck-order state you must persist the webhook stream yourself. (`pax8_integrations.webhook_secret_encrypted` already exists, unused, in anticipation.) + +It is deferred for a concrete reason beyond scope: **Pax8 does not publish the topic strings or the status enum.** The changelog says "Finished" in one sentence and "COMPLETED" in the next. The receiver cannot be fully spec'd from documentation — it needs `GET /webhooks/topic-definitions` called with live credentials first. Also note webhook emission is permission-sensitive: if the Pax8 account lacks permission on an object, no event fires at all, silently. + +**The Vendor Provisioning API** (`/provision-requests`, `/provision-attempts`, `/provision-results`, `/provisioners`) is **not available to us.** It sits under an OpenAPI title of "Vendor Provisioning Endpoints" and Pax8 defines the audience as marketplace vendors fulfilling orders, with vendor-issued credentials. We are the `Partner`, not the `Provisioner`. Do not build against it. + +**Generic distributor ordering.** See §1. + +**Scheduled/future-dated orders.** Pax8 explicitly does not support them via API (the sole exception being `cancelDate` on a cancel). + +## Testing + +- `pax8OrdersPartnerRls.integration.test.ts` — cross-partner forge fails 42501; org-scoped token sees nothing. +- Real-Postgres integration test: a successful order line writes the contract-line quantity **in the same transaction** as `succeeded`. +- Real-Postgres integration test: a second concurrent submit of the same order is rejected by the `dedupe_key` unique constraint. +- Unit test: the `change_quantity` request body contains `quantity` and no other key. +- Unit test: a timeout on submit lands the line in `needs_reconcile`, never `failed`, and no retry is issued. +- Unit test: `isMock` 422 blocks the real submit and surfaces `details[]` verbatim. +- Contract test: register both new tables in `PARTNER_TENANT_TABLES` and the `org_id` auto-discovery exclusion list. + +## Follow-up work this design surfaces + +1. **File a bug for the shipped billing defect.** `applyEnabledPax8ContractLineLinks()` bills customers off a Pax8 quantity Pax8 does not itself bill from. This design fixes it as a side effect, but partners are exposed *today* and the exposure predates this work. +2. **Confirm with Pax8 whether API write permission is granted separately** on the partner-portal API key, before implementation starts. +3. **`PROVISIONING` webhook receiver** — its own spec, after `topic-definitions` is called with live credentials. From 0987e80740657a687920be7b896abb99ec90afe2 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Mon, 13 Jul 2026 22:29:03 -0600 Subject: [PATCH 02/40] docs(pax8): implementation plan for Pax8 ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten tasks, TDD throughout. Also corrects the spec: quote-acceptance stages the order INSIDE the accept transaction (as Phase 5, alongside contract creation), not in the post-commit tail — the tail exists only for Redis/BullMQ side effects, and a staged order referencing rolled-back contract lines would be unfixable. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-13-pax8-ordering.md | 1431 +++++++++++++++++ .../specs/2026-07-13-pax8-ordering-design.md | 6 +- 2 files changed, 1436 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-07-13-pax8-ordering.md diff --git a/docs/superpowers/plans/2026-07-13-pax8-ordering.md b/docs/superpowers/plans/2026-07-13-pax8-ordering.md new file mode 100644 index 0000000000..063c303c64 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-pax8-ordering.md @@ -0,0 +1,1431 @@ +# Pax8 Ordering Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let an MSP technician place, change, and cancel Pax8 subscriptions from inside Breeze — either directly on a customer's org page, or as the fulfillment step of a quote the customer approved. + +**Architecture:** Two new partner-axis tables (`pax8_orders`, `pax8_order_lines`) form a staged intent ledger. Both authoring paths (org tab, quote acceptance) write a draft order; a technician submits it. Submit runs an `isMock` dry-run, claims each line in a committed transaction before the HTTP call, and on success writes the resulting quantity onto the linked contract line in the same transaction. Breeze — not Pax8 — is the source of truth for billable seat counts. + +**Tech Stack:** Hono (API), Drizzle ORM, PostgreSQL + RLS, Vitest, React islands (web), hand-written SQL migrations. + +**Spec:** `docs/superpowers/specs/2026-07-13-pax8-ordering-design.md` + +## Global Constraints + +- **Read the spec first.** Every task assumes its "Pax8 API contract" section, especially the four documented defects in Pax8's own OpenAPI. Do not trust the vendor spec over `isMock`. +- **Tenancy: partner-axis (RLS shape 3).** Both new tables get `partner_id NOT NULL` with policies on `public.breeze_has_partner_access(partner_id)`. `org_id` is a linkage column, NOT a tenancy axis, and must be excluded from org auto-discovery in the contract test. +- **Migrations are idempotent and never edited once shipped.** `IF NOT EXISTS` / `DO $$`. No inner `BEGIN;`/`COMMIT;` — `autoMigrate` wraps each file in a transaction. +- **`PUT /v1/subscriptions/{id}` sends `quantity` and nothing else.** `price`, `partnerCost`, and `currencyCode` are writable on that endpoint; a read-modify-write would overwrite the customer's rate. +- **Never blind-retry a write.** A timeout or 5xx means *unknown*, not *failed*. The line goes to `needs_reconcile`. +- **No new DB enums.** Follow the existing `pax8_*` convention: `varchar` columns with SQL `CHECK` constraints, plus TypeScript union types. (Drizzle `pgEnum` + `z.enum(x.enumValues)` breaks the schema mocks used by unit tests.) +- **Permissions:** `PERMISSIONS.BILLING_MANAGE` on all routes; `requireMfa()` on every write, matching `routes/pax8.ts`. +- **Money in `numeric(12,2)`**, quantities included, matching `pax8_subscription_snapshots.quantity`. + +--- + +## File Structure + +**Create:** +| File | Responsibility | +|---|---| +| `apps/api/migrations/2026-07-13-a-pax8-ordering.sql` | Both tables, CHECKs, indexes, composite FKs, RLS | +| `apps/api/src/db/schema/pax8Orders.ts` | Drizzle definitions for the two new tables | +| `packages/shared/src/types/pax8-enums.ts` | SSOT for action / status / billing-term unions | +| `apps/api/src/services/pax8OrderService.ts` | Draft authoring: create, add/remove lines, validate preconditions | +| `apps/api/src/services/pax8OrderSubmit.ts` | The submit pipeline + reconcile. The money-critical file | +| `apps/api/src/routes/pax8Orders.ts` | HTTP surface, mounted under the existing `/api/v1/pax8` | +| `apps/api/src/services/pax8Drift.ts` | Ledger-vs-Pax8 drift comparison (replaces the quantity push) | +| `apps/web/src/components/organizations/Pax8OrgTab.tsx` | Org-page tab shell | +| `apps/web/src/components/organizations/Pax8OrderBuilder.tsx` | Add product, provisioning form, review & submit | +| `apps/web/src/lib/api/pax8Orders.ts` | Web API client | + +**Modify:** +| File | Change | +|---|---| +| `apps/api/src/services/pax8Client.ts` | Add write methods; `requestJson` currently hardcodes GET | +| `apps/api/src/services/pax8SyncService.ts:232-265` | `applyEnabledPax8ContractLineLinks` stops writing `contract_lines` | +| `apps/api/src/services/quoteAcceptService.ts` | New Phase 5: stage the order in-transaction | +| `apps/api/src/db/schema/index.ts` | Export the new schema module | +| `apps/api/src/index.ts:919` | Mount `pax8OrderRoutes` | +| `apps/api/src/services/tenantCascade.ts:226` | Add both tables (alphabetical order is load-bearing) | +| `apps/api/src/__tests__/integration/rls-coverage.integration.test.ts` | `PARTNER_TENANT_TABLES` + org-discovery exclusion | + +--- + +## Task 1: Schema + migration + tenancy registration + +**Files:** +- Create: `apps/api/migrations/2026-07-13-a-pax8-ordering.sql` +- Create: `apps/api/src/db/schema/pax8Orders.ts` +- Create: `packages/shared/src/types/pax8-enums.ts` +- Modify: `apps/api/src/db/schema/index.ts` +- Modify: `apps/api/src/services/tenantCascade.ts:226` +- Modify: `apps/api/src/__tests__/integration/rls-coverage.integration.test.ts` +- Test: `apps/api/src/__tests__/integration/pax8OrdersPartnerRls.integration.test.ts` + +**Interfaces:** +- Produces: `pax8Orders`, `pax8OrderLines` Drizzle tables. `PAX8_ORDER_ACTIONS`, `PAX8_ORDER_STATUSES`, `PAX8_SUBMIT_STATES`, `PAX8_BILLING_TERMS` const arrays and their `Pax8OrderAction` / `Pax8OrderStatus` / `Pax8SubmitState` / `Pax8BillingTerm` union types. + +- [ ] **Step 1: Write the shared enums (SSOT)** + +Create `packages/shared/src/types/pax8-enums.ts`: + +```ts +// Pax8 order vocabularies. Append-only — order is load-bearing for any UI that +// renders these in sequence, and DB CHECK constraints mirror these lists. + +export const PAX8_ORDER_ACTIONS = ['new_subscription', 'change_quantity', 'cancel'] as const; +export type Pax8OrderAction = (typeof PAX8_ORDER_ACTIONS)[number]; + +export const PAX8_ORDER_STATUSES = [ + 'draft', + 'awaiting_details', + 'ready', + 'submitting', + 'completed', + 'partially_failed', + 'failed', + 'cancelled', +] as const; +export type Pax8OrderStatus = (typeof PAX8_ORDER_STATUSES)[number]; + +export const PAX8_SUBMIT_STATES = [ + 'pending', + 'in_flight', + 'succeeded', + 'failed', + 'needs_reconcile', +] as const; +export type Pax8SubmitState = (typeof PAX8_SUBMIT_STATES)[number]; + +// Verbatim from Pax8's CreateLineItem.billingTerm enum. These strings are sent +// on the wire exactly as written — do not lowercase or reformat them. +export const PAX8_BILLING_TERMS = ['Monthly', 'Annual', '2-Year', '3-Year', 'One-Time', 'Trial', 'Activation'] as const; +export type Pax8BillingTerm = (typeof PAX8_BILLING_TERMS)[number]; + +export const PAX8_ORDER_SOURCES = ['direct', 'quote'] as const; +export type Pax8OrderSource = (typeof PAX8_ORDER_SOURCES)[number]; +``` + +Export it from `packages/shared/src/types/index.ts` alongside the existing type exports (follow whatever re-export form that file already uses). + +- [ ] **Step 2: Write the migration** + +Create `apps/api/migrations/2026-07-13-a-pax8-ordering.sql`: + +```sql +-- Pax8 ordering: staged intent ledger. +-- Pax8 has no idempotency key and no order status field, so THIS TABLE — not +-- Pax8 — is the record of whether money was spent. A line is claimed +-- (submit_state='in_flight') in a committed txn before the HTTP call. +-- Partner-axis (RLS shape 3), matching the five existing pax8_* tables: +-- org_id is a linkage column, never the tenancy axis. + +CREATE TABLE IF NOT EXISTS pax8_orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + integration_id UUID NOT NULL, + partner_id UUID NOT NULL REFERENCES partners(id), + org_id UUID NOT NULL, + pax8_company_id VARCHAR(64) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'draft', + source VARCHAR(10) NOT NULL DEFAULT 'direct', + source_quote_id UUID REFERENCES quotes(id) ON DELETE SET NULL, + dedupe_key VARCHAR(120) NOT NULL, + pax8_order_id VARCHAR(64), + error TEXT, + created_by UUID REFERENCES users(id), + submitted_by UUID REFERENCES users(id), + submitted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT pax8_orders_status_chk CHECK (status IN ( + 'draft','awaiting_details','ready','submitting','completed','partially_failed','failed','cancelled')), + CONSTRAINT pax8_orders_source_chk CHECK (source IN ('direct','quote')), + CONSTRAINT pax8_orders_integration_partner_fkey + FOREIGN KEY (integration_id, partner_id) + REFERENCES pax8_integrations(id, partner_id) ON DELETE CASCADE, + CONSTRAINT pax8_orders_org_partner_fkey + FOREIGN KEY (org_id, partner_id) + REFERENCES organizations(id, partner_id) ON DELETE CASCADE +); + +-- The idempotency guard. A concurrent submit of the same intent loses this race. +CREATE UNIQUE INDEX IF NOT EXISTS pax8_orders_dedupe_key_uq + ON pax8_orders(partner_id, dedupe_key); +CREATE INDEX IF NOT EXISTS pax8_orders_partner_idx ON pax8_orders(partner_id); +CREATE INDEX IF NOT EXISTS pax8_orders_org_idx ON pax8_orders(org_id); +CREATE INDEX IF NOT EXISTS pax8_orders_status_idx ON pax8_orders(partner_id, status); +CREATE INDEX IF NOT EXISTS pax8_orders_quote_idx ON pax8_orders(source_quote_id); +-- Target for the order_lines composite FK. +CREATE UNIQUE INDEX IF NOT EXISTS pax8_orders_id_partner_idx ON pax8_orders(id, partner_id); + +CREATE TABLE IF NOT EXISTS pax8_order_lines ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + order_id UUID NOT NULL, + partner_id UUID NOT NULL REFERENCES partners(id), + org_id UUID NOT NULL, + action VARCHAR(20) NOT NULL, + submit_state VARCHAR(20) NOT NULL DEFAULT 'pending', + pax8_product_id VARCHAR(64), + catalog_item_id UUID, + billing_term VARCHAR(20), + commitment_term_id VARCHAR(64), + quantity NUMERIC(12,2), + provisioning_details JSONB NOT NULL DEFAULT '[]'::jsonb, + target_subscription_id VARCHAR(64), + cancel_date DATE, + result_subscription_id VARCHAR(64), + contract_line_id UUID, + source_quote_line_id UUID, + error TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT pax8_order_lines_action_chk CHECK (action IN ( + 'new_subscription','change_quantity','cancel')), + CONSTRAINT pax8_order_lines_state_chk CHECK (submit_state IN ( + 'pending','in_flight','succeeded','failed','needs_reconcile')), + -- Each action carries a different payload; enforce the shape rather than + -- trusting the service layer. A cancel with a quantity is a bug, not data. + CONSTRAINT pax8_order_lines_action_payload_chk CHECK ( + (action = 'new_subscription' + AND pax8_product_id IS NOT NULL AND billing_term IS NOT NULL + AND quantity IS NOT NULL AND quantity > 0 AND target_subscription_id IS NULL) + OR (action = 'change_quantity' + AND target_subscription_id IS NOT NULL AND quantity IS NOT NULL AND quantity >= 0) + OR (action = 'cancel' + AND target_subscription_id IS NOT NULL AND quantity IS NULL) + ), + CONSTRAINT pax8_order_lines_order_partner_fkey + FOREIGN KEY (order_id, partner_id) + REFERENCES pax8_orders(id, partner_id) ON DELETE CASCADE, + CONSTRAINT pax8_order_lines_org_partner_fkey + FOREIGN KEY (org_id, partner_id) + REFERENCES organizations(id, partner_id) ON DELETE CASCADE, + CONSTRAINT pax8_order_lines_catalog_item_partner_fkey + FOREIGN KEY (catalog_item_id, partner_id) + REFERENCES catalog_items(id, partner_id) ON DELETE SET NULL, + CONSTRAINT pax8_order_lines_contract_line_org_fkey + FOREIGN KEY (contract_line_id, org_id) + REFERENCES contract_lines(id, org_id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS pax8_order_lines_order_idx ON pax8_order_lines(order_id); +CREATE INDEX IF NOT EXISTS pax8_order_lines_partner_idx ON pax8_order_lines(partner_id); +CREATE INDEX IF NOT EXISTS pax8_order_lines_org_idx ON pax8_order_lines(org_id); +CREATE INDEX IF NOT EXISTS pax8_order_lines_contract_line_idx ON pax8_order_lines(contract_line_id); +-- Finds lines stranded mid-flight (crash between claim and result). +CREATE INDEX IF NOT EXISTS pax8_order_lines_inflight_idx + ON pax8_order_lines(submit_state) WHERE submit_state IN ('in_flight','needs_reconcile'); + +ALTER TABLE pax8_orders ENABLE ROW LEVEL SECURITY; +ALTER TABLE pax8_orders FORCE ROW LEVEL SECURITY; +ALTER TABLE pax8_order_lines ENABLE ROW LEVEL SECURITY; +ALTER TABLE pax8_order_lines FORCE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS breeze_partner_isolation_select ON pax8_orders; +DROP POLICY IF EXISTS breeze_partner_isolation_insert ON pax8_orders; +DROP POLICY IF EXISTS breeze_partner_isolation_update ON pax8_orders; +DROP POLICY IF EXISTS breeze_partner_isolation_delete ON pax8_orders; +CREATE POLICY breeze_partner_isolation_select ON pax8_orders + FOR SELECT USING (public.breeze_has_partner_access(partner_id)); +CREATE POLICY breeze_partner_isolation_insert ON pax8_orders + FOR INSERT WITH CHECK (public.breeze_has_partner_access(partner_id)); +CREATE POLICY breeze_partner_isolation_update ON pax8_orders + FOR UPDATE USING (public.breeze_has_partner_access(partner_id)) + WITH CHECK (public.breeze_has_partner_access(partner_id)); +CREATE POLICY breeze_partner_isolation_delete ON pax8_orders + FOR DELETE USING (public.breeze_has_partner_access(partner_id)); + +DROP POLICY IF EXISTS breeze_partner_isolation_select ON pax8_order_lines; +DROP POLICY IF EXISTS breeze_partner_isolation_insert ON pax8_order_lines; +DROP POLICY IF EXISTS breeze_partner_isolation_update ON pax8_order_lines; +DROP POLICY IF EXISTS breeze_partner_isolation_delete ON pax8_order_lines; +CREATE POLICY breeze_partner_isolation_select ON pax8_order_lines + FOR SELECT USING (public.breeze_has_partner_access(partner_id)); +CREATE POLICY breeze_partner_isolation_insert ON pax8_order_lines + FOR INSERT WITH CHECK (public.breeze_has_partner_access(partner_id)); +CREATE POLICY breeze_partner_isolation_update ON pax8_order_lines + FOR UPDATE USING (public.breeze_has_partner_access(partner_id)) + WITH CHECK (public.breeze_has_partner_access(partner_id)); +CREATE POLICY breeze_partner_isolation_delete ON pax8_order_lines + FOR DELETE USING (public.breeze_has_partner_access(partner_id)); +``` + +- [ ] **Step 3: Write the Drizzle schema** + +Create `apps/api/src/db/schema/pax8Orders.ts`: + +```ts +import { + pgTable, uuid, varchar, text, timestamp, jsonb, numeric, date, integer, + index, uniqueIndex, foreignKey, +} from 'drizzle-orm/pg-core'; +import { partners, organizations } from './orgs'; +import { users } from './users'; +import { catalogItems } from './catalog'; +import { contractLines } from './contracts'; +import { quotes } from './quotes'; +import { pax8Integrations } from './pax8'; + +export const pax8Orders = pgTable('pax8_orders', { + id: uuid('id').primaryKey().defaultRandom(), + integrationId: uuid('integration_id').notNull(), + partnerId: uuid('partner_id').notNull().references(() => partners.id), + orgId: uuid('org_id').notNull(), + pax8CompanyId: varchar('pax8_company_id', { length: 64 }).notNull(), + status: varchar('status', { length: 20 }).notNull().default('draft'), + source: varchar('source', { length: 10 }).notNull().default('direct'), + sourceQuoteId: uuid('source_quote_id').references(() => quotes.id, { onDelete: 'set null' }), + dedupeKey: varchar('dedupe_key', { length: 120 }).notNull(), + pax8OrderId: varchar('pax8_order_id', { length: 64 }), + error: text('error'), + createdBy: uuid('created_by').references(() => users.id), + submittedBy: uuid('submitted_by').references(() => users.id), + submittedAt: timestamp('submitted_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}, (table) => ({ + dedupeKeyIdx: uniqueIndex('pax8_orders_dedupe_key_uq').on(table.partnerId, table.dedupeKey), + idPartnerIdx: uniqueIndex('pax8_orders_id_partner_idx').on(table.id, table.partnerId), + partnerIdx: index('pax8_orders_partner_idx').on(table.partnerId), + orgIdx: index('pax8_orders_org_idx').on(table.orgId), + statusIdx: index('pax8_orders_status_idx').on(table.partnerId, table.status), + quoteIdx: index('pax8_orders_quote_idx').on(table.sourceQuoteId), + integrationPartnerFk: foreignKey({ + columns: [table.integrationId, table.partnerId], + foreignColumns: [pax8Integrations.id, pax8Integrations.partnerId], + name: 'pax8_orders_integration_partner_fkey', + }).onDelete('cascade'), + orgPartnerFk: foreignKey({ + columns: [table.orgId, table.partnerId], + foreignColumns: [organizations.id, organizations.partnerId], + name: 'pax8_orders_org_partner_fkey', + }).onDelete('cascade'), +})); + +export const pax8OrderLines = pgTable('pax8_order_lines', { + id: uuid('id').primaryKey().defaultRandom(), + orderId: uuid('order_id').notNull(), + partnerId: uuid('partner_id').notNull().references(() => partners.id), + orgId: uuid('org_id').notNull(), + action: varchar('action', { length: 20 }).notNull(), + submitState: varchar('submit_state', { length: 20 }).notNull().default('pending'), + pax8ProductId: varchar('pax8_product_id', { length: 64 }), + catalogItemId: uuid('catalog_item_id'), + billingTerm: varchar('billing_term', { length: 20 }), + commitmentTermId: varchar('commitment_term_id', { length: 64 }), + quantity: numeric('quantity', { precision: 12, scale: 2 }), + provisioningDetails: jsonb('provisioning_details').notNull().default([]), + targetSubscriptionId: varchar('target_subscription_id', { length: 64 }), + cancelDate: date('cancel_date'), + resultSubscriptionId: varchar('result_subscription_id', { length: 64 }), + contractLineId: uuid('contract_line_id'), + sourceQuoteLineId: uuid('source_quote_line_id'), + error: text('error'), + sortOrder: integer('sort_order').notNull().default(0), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}, (table) => ({ + orderIdx: index('pax8_order_lines_order_idx').on(table.orderId), + partnerIdx: index('pax8_order_lines_partner_idx').on(table.partnerId), + orgIdx: index('pax8_order_lines_org_idx').on(table.orgId), + contractLineIdx: index('pax8_order_lines_contract_line_idx').on(table.contractLineId), + orderPartnerFk: foreignKey({ + columns: [table.orderId, table.partnerId], + foreignColumns: [pax8Orders.id, pax8Orders.partnerId], + name: 'pax8_order_lines_order_partner_fkey', + }).onDelete('cascade'), + orgPartnerFk: foreignKey({ + columns: [table.orgId, table.partnerId], + foreignColumns: [organizations.id, organizations.partnerId], + name: 'pax8_order_lines_org_partner_fkey', + }).onDelete('cascade'), + catalogItemPartnerFk: foreignKey({ + columns: [table.catalogItemId, table.partnerId], + foreignColumns: [catalogItems.id, catalogItems.partnerId], + name: 'pax8_order_lines_catalog_item_partner_fkey', + }).onDelete('set null'), + contractLineOrgFk: foreignKey({ + columns: [table.contractLineId, table.orgId], + foreignColumns: [contractLines.id, contractLines.orgId], + name: 'pax8_order_lines_contract_line_org_fkey', + }).onDelete('set null'), +})); +``` + +Add `export * from './pax8Orders';` to `apps/api/src/db/schema/index.ts`, directly after the existing pax8 export (~line 100). + +- [ ] **Step 4: Register tenancy** + +In `apps/api/src/services/tenantCascade.ts`, add to the cascade list in **alphabetical order** (it sorts by `localeCompare`; getting this wrong breaks the cascade contract test). `pax8_order_lines` and `pax8_orders` sort *before* `pax8_company_mappings`? No — check: `pax8_company_mappings` < `pax8_contract_line_links` < `pax8_order_lines` < `pax8_orders` < `pax8_subscription_snapshots`. Insert both between `pax8_contract_line_links` and `pax8_subscription_snapshots`: + +```ts + 'pax8_company_mappings', + 'pax8_contract_line_links', + 'pax8_order_lines', + 'pax8_orders', + 'pax8_subscription_snapshots', +``` + +In `apps/api/src/__tests__/integration/rls-coverage.integration.test.ts`: + +Add both to `PARTNER_TENANT_TABLES` (the `Map`), next to the other pax8 entries: + +```ts + ['pax8_orders', 'partner_id'], + ['pax8_order_lines', 'partner_id'], +``` + +And add both to the org-auto-discovery exclusion set (the `Set` that already lists `'pax8_company_mappings'`, `'pax8_subscription_snapshots'`, `'pax8_contract_line_links'`), extending the existing comment block: + +```ts + 'pax8_company_mappings', + 'pax8_subscription_snapshots', + 'pax8_contract_line_links', + // pax8_orders / pax8_order_lines (2026-07-13, ordering): same shape — org_id + // is the customer the order is FOR, not the tenancy axis. Ordering is an + // MSP-side act; an org-scoped token must never see one. + 'pax8_orders', + 'pax8_order_lines', +``` + +- [ ] **Step 5: Write the failing RLS test** + +Create `apps/api/src/__tests__/integration/pax8OrdersPartnerRls.integration.test.ts`. Copy the structure of the existing `pax8-rls.integration.test.ts` (same file directory) — reuse its fixture helpers verbatim rather than inventing new ones. The suite must prove: + +```ts +it('rejects a cross-partner forged order insert with 42501', async () => { + // Partner A's context, forging partner B's partner_id. + await expect( + withPartnerContext(partnerA.id, () => + db.insert(pax8Orders).values({ + integrationId: integrationB.id, + partnerId: partnerB.id, + orgId: orgB.id, + pax8CompanyId: 'forged-co', + dedupeKey: 'forge-test-1', + }), + ), + ).rejects.toMatchObject({ code: '42501' }); +}); + +it('hides another partner\'s orders from SELECT', async () => { + const rows = await withPartnerContext(partnerA.id, () => + db.select().from(pax8Orders).where(eq(pax8Orders.id, orderB.id)), + ); + expect(rows).toHaveLength(0); +}); + +it('rejects a second order with the same (partner_id, dedupe_key)', async () => { + await expect( + withPartnerContext(partnerA.id, () => + db.insert(pax8Orders).values({ ...validOrderA, dedupeKey: existingOrderA.dedupeKey }), + ), + ).rejects.toMatchObject({ code: '23505' }); +}); + +it('rejects a cancel line carrying a quantity (action payload CHECK)', async () => { + await expect( + withPartnerContext(partnerA.id, () => + db.insert(pax8OrderLines).values({ + orderId: orderA.id, partnerId: partnerA.id, orgId: orgA.id, + action: 'cancel', targetSubscriptionId: 'sub-1', quantity: '5.00', + }), + ), + ).rejects.toMatchObject({ code: '23514' }); +}); +``` + +**Do not memoize the partner fixtures across tests** — a shared memoized fixture is how a forge test goes vacuously green (it ends up forging against itself). + +- [ ] **Step 6: Run the tests — expect failure** + +```bash +cd apps/api && pnpm vitest run --config vitest.integration.config.ts pax8OrdersPartnerRls +``` +Expected: FAIL — `relation "pax8_orders" does not exist`. + +- [ ] **Step 7: Apply the migration and re-run** + +```bash +export DATABASE_URL="postgresql://breeze:breeze@localhost:5432/breeze" +cd apps/api && pnpm db:migrate && pnpm vitest run --config vitest.integration.config.ts pax8OrdersPartnerRls +``` +Expected: PASS, 4 tests. + +- [ ] **Step 8: Verify as the unprivileged role** + +```bash +docker exec -it breeze-postgres psql -U breeze_app -d breeze \ + -c "INSERT INTO pax8_orders (integration_id, partner_id, org_id, pax8_company_id, dedupe_key) VALUES (gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), 'x', 'x');" +``` +Expected: `ERROR: new row violates row-level security policy for table "pax8_orders"`. + +- [ ] **Step 9: Check for drift, run the contract test, commit** + +```bash +pnpm db:check-drift +cd apps/api && pnpm vitest run --config vitest.integration.config.ts rls-coverage +git add apps/api/migrations/2026-07-13-a-pax8-ordering.sql apps/api/src/db/schema/pax8Orders.ts apps/api/src/db/schema/index.ts packages/shared/src/types/pax8-enums.ts packages/shared/src/types/index.ts apps/api/src/services/tenantCascade.ts apps/api/src/__tests__/integration/rls-coverage.integration.test.ts apps/api/src/__tests__/integration/pax8OrdersPartnerRls.integration.test.ts +git commit -m "feat(pax8): add pax8_orders + pax8_order_lines staged intent ledger" +``` + +--- + +## Task 2: Pax8 write client + +**Files:** +- Modify: `apps/api/src/services/pax8Client.ts` +- Test: `apps/api/src/services/pax8Client.test.ts` + +**Interfaces:** +- Consumes: nothing from Task 1. +- Produces, on `Pax8Client`: + - `createOrder(input: Pax8CreateOrderInput, opts?: { isMock?: boolean }): Promise` + - `updateSubscriptionQuantity(subscriptionId: string, quantity: number): Promise` + - `cancelSubscription(subscriptionId: string, cancelDate?: string | null): Promise` + - `getProvisionDetails(productId: string): Promise` + - `getProductDependencies(productId: string): Promise` + - Types `Pax8CreateOrderInput`, `Pax8OrderLineInput`, `Pax8OrderResult`, `Pax8ProvisionDetail`, `Pax8Commitment`, `Pax8ProductDependencies`. + +- [ ] **Step 1: Write the failing tests** + +Append to `apps/api/src/services/pax8Client.test.ts` (create it if absent, following the existing service-test style — inject a stub `fetch` via `Pax8ClientOptions.fetch`): + +```ts +import { describe, it, expect, vi } from 'vitest'; +import { Pax8Client, Pax8ApiError } from './pax8Client'; + +function clientWith(fetchImpl: ReturnType) { + return new Pax8Client({ + credentials: { clientId: 'id', clientSecret: 'secret', accessToken: 'tok', accessTokenExpiresAt: new Date(Date.now() + 3_600_000) }, + fetch: fetchImpl as never, + }); +} + +function jsonResponse(body: unknown, status = 200) { + return { ok: status < 400, status, json: async () => body, text: async () => JSON.stringify(body) } as Response; +} + +describe('Pax8Client.updateSubscriptionQuantity', () => { + it('sends ONLY quantity — never price, partnerCost, or currencyCode', async () => { + const doFetch = vi.fn().mockResolvedValue(jsonResponse({ id: 'sub-1', quantity: 11 })); + await clientWith(doFetch).updateSubscriptionQuantity('sub-1', 11); + + const [url, init] = doFetch.mock.calls[0]; + expect(url).toBe('https://api.pax8.com/v1/subscriptions/sub-1'); + expect(init.method).toBe('PUT'); + // The whole point: PUT is a partial update and price IS writable. A body + // with any extra key can silently overwrite the customer's rate. + expect(JSON.parse(init.body)).toEqual({ quantity: 11 }); + }); +}); + +describe('Pax8Client.cancelSubscription', () => { + it('DELETEs with no body and no cancelDate when none given', async () => { + const doFetch = vi.fn().mockResolvedValue({ ok: true, status: 204, text: async () => '' } as Response); + await clientWith(doFetch).cancelSubscription('sub-9'); + + const [url, init] = doFetch.mock.calls[0]; + expect(url).toBe('https://api.pax8.com/v1/subscriptions/sub-9'); + expect(init.method).toBe('DELETE'); + expect(init.body).toBeUndefined(); + }); + + it('passes cancelDate as a query param', async () => { + const doFetch = vi.fn().mockResolvedValue({ ok: true, status: 204, text: async () => '' } as Response); + await clientWith(doFetch).cancelSubscription('sub-9', '2026-09-01'); + expect(doFetch.mock.calls[0][0]).toBe('https://api.pax8.com/v1/subscriptions/sub-9?cancelDate=2026-09-01'); + }); +}); + +describe('Pax8Client.createOrder', () => { + it('posts companyId + lineItems and sets isMock when asked', async () => { + const doFetch = vi.fn().mockResolvedValue(jsonResponse({ id: 'ord-1', lineItems: [{ id: 'li-1', subscriptionId: 'sub-1' }] })); + const res = await clientWith(doFetch).createOrder({ + companyId: 'co-1', + lineItems: [{ + lineItemNumber: 1, + productId: 'prod-1', + quantity: 5, + billingTerm: 'Monthly', + provisioningDetails: [{ key: 'msDomain', values: ['acme'] }], + }], + }, { isMock: true }); + + const [url, init] = doFetch.mock.calls[0]; + expect(url).toBe('https://api.pax8.com/v1/orders?isMock=true'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body)).toEqual({ + companyId: 'co-1', + lineItems: [{ + lineItemNumber: 1, productId: 'prod-1', quantity: 5, billingTerm: 'Monthly', + provisioningDetails: [{ key: 'msDomain', values: ['acme'] }], + }], + }); + expect(res.pax8OrderId).toBe('ord-1'); + expect(res.lineItems[0].subscriptionId).toBe('sub-1'); + }); + + it('surfaces Pax8 422 details verbatim on Pax8ApiError.body', async () => { + const body = { status: 422, message: 'Invalid order', details: [{ message: 'msDomain is required' }] }; + const doFetch = vi.fn().mockResolvedValue({ ok: false, status: 422, text: async () => JSON.stringify(body) } as Response); + await expect(clientWith(doFetch).createOrder({ companyId: 'co-1', lineItems: [] })) + .rejects.toMatchObject({ name: 'Pax8ApiError', status: 422 }); + }); +}); + +describe('Pax8Client.getProvisionDetails', () => { + it('returns the discoverable field descriptors', async () => { + const doFetch = vi.fn().mockResolvedValue(jsonResponse({ content: [ + { key: 'msCustExists', label: 'Existing Microsoft account?', valueType: 'Single-Value', possibleValues: ['No', 'Yes'] }, + { key: 'msDomain', label: 'Domain prefix', valueType: 'Input', possibleValues: null }, + ] })); + const details = await clientWith(doFetch).getProvisionDetails('prod-1'); + expect(doFetch.mock.calls[0][0]).toBe('https://api.pax8.com/v1/products/prod-1/provision-details'); + expect(details).toHaveLength(2); + expect(details[1]).toMatchObject({ key: 'msDomain', valueType: 'Input', possibleValues: null }); + }); +}); +``` + +- [ ] **Step 2: Run — expect failure** + +```bash +cd apps/api && pnpm vitest run src/services/pax8Client.test.ts +``` +Expected: FAIL — `client.updateSubscriptionQuantity is not a function`. + +- [ ] **Step 3: Generalize `requestJson` to take a method and body** + +`requestJson` at `pax8Client.ts:300` hardcodes a GET (it never sets `method`). Replace it with a method-aware version, keeping the existing GET call sites working by defaulting to GET: + +```ts + private async requestJson( + path: string, + query: Record = {}, + init: { method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; body?: unknown } = {}, + ): Promise { + const token = await this.getAccessToken(); + const url = new URL(`${this.apiBaseUrl}${path.startsWith('/') ? path : `/${path}`}`); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) url.searchParams.set(key, String(value)); + } + + const method = init.method ?? 'GET'; + const headers: Record = { + authorization: `Bearer ${token}`, + accept: 'application/json', + }; + if (init.body !== undefined) headers['content-type'] = 'application/json'; + + const res = await this.doFetch(url.toString(), { + method, + headers, + body: init.body === undefined ? undefined : JSON.stringify(init.body), + timeoutMs: DEFAULT_TIMEOUT_MS, + } as RequestInit & { timeoutMs?: number }); + + if (!res.ok) { + const body = await res.text().catch(() => ''); + // Pax8 puts per-line-item validation failures in `details[]`. Keep the raw + // body — the UI shows it verbatim rather than guessing at what's wrong, + // because requiredness is NOT discoverable from their spec. + throw new Pax8ApiError(`Pax8 API returned ${res.status}`, res.status, body.slice(0, 4000)); + } + if (res.status === 204) return null; + return res.json(); + } +``` + +Note the two changes beyond the method: a 204 returns `null` (cancel returns no content), and the error body slice grows from 500 to 4000 chars so a multi-line `details[]` survives. + +- [ ] **Step 4: Add the write methods and their types** + +Add near the other exported interfaces: + +```ts +export interface Pax8ProvisioningDetailInput { + key: string; + values: string[]; // ALWAYS an array, even for a single scalar input. +} + +export interface Pax8OrderLineInput { + lineItemNumber: number; + productId: string; + quantity: number; + billingTerm: string; + commitmentTermId?: string; + provisioningDetails?: Pax8ProvisioningDetailInput[]; +} + +export interface Pax8CreateOrderInput { + companyId: string; + lineItems: Pax8OrderLineInput[]; + orderedBy?: 'Pax8 Partner' | 'Customer' | 'Pax8'; + orderedByUserEmail?: string; +} + +export interface Pax8OrderResult { + pax8OrderId: string | null; + lineItems: Array<{ lineItemNumber: number | null; productId: string | null; subscriptionId: string | null }>; +} + +export interface Pax8ProvisionDetail { + key: string; + label: string | null; + description: string | null; + valueType: 'Input' | 'Single-Value' | 'Multi-Value' | null; + possibleValues: string[] | null; +} + +export interface Pax8Commitment { + id: string; + term: string | null; + allowForQuantityIncrease: boolean; + allowForQuantityDecrease: boolean; + allowForEarlyCancellation: boolean; + cancellationFeeApplied: boolean; +} + +export interface Pax8ProductDependencies { + commitments: Pax8Commitment[]; +} +``` + +And the methods on `Pax8Client`: + +```ts + /** + * POST /v1/orders. Pax8 has NO idempotency key — calling this twice creates + * two real, billable orders, and Order.createdDate is a DATE (not a timestamp) + * so you cannot tell them apart afterward. Callers MUST claim their intent row + * in a committed transaction before invoking this, and MUST NOT retry on + * timeout. See pax8OrderSubmit.ts. + * + * `isMock: true` validates without touching Pax8's database. It is the ONLY + * machine-checkable oracle for whether provisioningDetails are complete, + * because their provision-details endpoint does not expose requiredness. + */ + async createOrder(input: Pax8CreateOrderInput, opts: { isMock?: boolean } = {}): Promise { + const payload = await this.requestJson( + '/orders', + opts.isMock ? { isMock: true } : {}, + { method: 'POST', body: input }, + ); + const record = asRecord(payload); + const lineItems = extractArray(record?.lineItems).map((raw) => { + const li = asRecord(raw); + return { + lineItemNumber: li ? firstNumber(li, ['lineItemNumber']) : null, + productId: li ? firstString(li, ['productId', 'product_id']) : null, + subscriptionId: li ? firstString(li, ['subscriptionId', 'subscription_id']) : null, + }; + }); + return { pax8OrderId: record ? firstString(record, ['id', 'orderId']) : null, lineItems }; + } + + /** + * PUT /v1/subscriptions/{id}. Despite the verb this is a PARTIAL update, and + * `price`, `partnerCost`, `currencyCode`, `startDate`, and `endDate` are all + * writable. We send `quantity` and nothing else — a read-modify-write would + * re-send pricing and can overwrite the customer's rate. Do not "helpfully" + * add fields to this body. + */ + async updateSubscriptionQuantity(subscriptionId: string, quantity: number): Promise { + await this.requestJson( + `/subscriptions/${encodeURIComponent(subscriptionId)}`, + {}, + { method: 'PUT', body: { quantity } }, + ); + } + + /** DELETE /v1/subscriptions/{id}. No body. Cancel is terminal — Pax8 exposes no reactivate. */ + async cancelSubscription(subscriptionId: string, cancelDate?: string | null): Promise { + await this.requestJson( + `/subscriptions/${encodeURIComponent(subscriptionId)}`, + cancelDate ? { cancelDate } : {}, + { method: 'DELETE' }, + ); + } + + async getProvisionDetails(productId: string): Promise { + const payload = await this.requestJson(`/products/${encodeURIComponent(productId)}/provision-details`); + return extractArray(payload).map((raw): Pax8ProvisionDetail | null => { + const r = asRecord(raw); + const key = r ? firstString(r, ['key']) : null; + if (!r || !key) return null; + const valueType = firstString(r, ['valueType']); + const possible = Array.isArray(r.possibleValues) + ? r.possibleValues.filter((v): v is string => typeof v === 'string') + : null; + return { + key, + label: firstString(r, ['label']), + description: firstString(r, ['description']), + valueType: (valueType as Pax8ProvisionDetail['valueType']) ?? null, + possibleValues: possible, + }; + }).filter((d): d is Pax8ProvisionDetail => d !== null); + } + + async getProductDependencies(productId: string): Promise { + const payload = await this.requestJson(`/products/${encodeURIComponent(productId)}/dependencies`); + const root = asRecord(payload); + const commitments = extractArray(root?.commitmentDependencies).map((raw): Pax8Commitment | null => { + const r = asRecord(raw); + const id = r ? firstString(r, ['id']) : null; + if (!r || !id) return null; + return { + id, + term: firstString(r, ['term']), + allowForQuantityIncrease: r.allowForQuantityIncrease === true, + allowForQuantityDecrease: r.allowForQuantityDecrease === true, + allowForEarlyCancellation: r.allowForEarlyCancellation === true, + cancellationFeeApplied: r.cancellationFeeApplied === true, + }; + }).filter((c): c is Pax8Commitment => c !== null); + return { commitments }; + } +``` + +- [ ] **Step 5: Run — expect pass** + +```bash +cd apps/api && pnpm vitest run src/services/pax8Client.test.ts +``` +Expected: PASS. Then re-run the existing pax8 suites to prove the `requestJson` change didn't break the read paths: + +```bash +cd apps/api && pnpm vitest run pax8 +``` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add apps/api/src/services/pax8Client.ts apps/api/src/services/pax8Client.test.ts +git commit -m "feat(pax8): add order/subscription write methods to Pax8Client" +``` + +--- + +## Task 3: Draft order authoring service + +**Files:** +- Create: `apps/api/src/services/pax8OrderService.ts` +- Test: `apps/api/src/services/pax8OrderService.test.ts` + +**Interfaces:** +- Consumes: `pax8Orders`, `pax8OrderLines` (Task 1); `Pax8OrderAction`, `Pax8BillingTerm` (Task 1). +- Produces: + - `getOrCreateDraftOrder(input: { partnerId, orgId, actorUserId }): Promise` + - `addOrderLine(input: AddOrderLineInput): Promise` + - `removeOrderLine(input: { partnerId, orderId, lineId }): Promise<{ removed: boolean }>` + - `getOrderWithLines(input: { partnerId, orderId }): Promise<{ order: Pax8OrderRow; lines: Pax8OrderLineRow[] }>` + - `Pax8OrderError` (class with `.status`) + - `buildDedupeKey(orderId: string): string` + +- [ ] **Step 1: Write the failing tests** + +Create `apps/api/src/services/pax8OrderService.test.ts`. Mock `../db` following the Drizzle-mock pattern already used by the sibling service tests (see `breeze-testing` skill; the schema proxy mock needs a `has` trap). Cover: + +```ts +describe('getOrCreateDraftOrder', () => { + it('throws 409 when the org has no Pax8 company mapping', async () => { + mockCompanyMappingLookup(null); + await expect(getOrCreateDraftOrder({ partnerId: 'p1', orgId: 'o1', actorUserId: 'u1' })) + .rejects.toMatchObject({ status: 409, message: expect.stringContaining('not mapped to a Pax8 company') }); + }); + + it('reuses the existing open draft rather than creating a second one', async () => { + mockCompanyMappingLookup({ pax8CompanyId: 'co-1', integrationId: 'i1' }); + mockExistingDraft({ id: 'ord-existing', status: 'draft' }); + const order = await getOrCreateDraftOrder({ partnerId: 'p1', orgId: 'o1', actorUserId: 'u1' }); + expect(order.id).toBe('ord-existing'); + expect(insertSpy).not.toHaveBeenCalled(); + }); +}); + +describe('addOrderLine', () => { + it('rejects a change_quantity whose commitment forbids a decrease', async () => { + mockSubscriptionSnapshot({ pax8SubscriptionId: 'sub-1', quantity: '10.00', orgId: 'o1' }); + mockDependencies({ commitments: [{ id: 'c1', allowForQuantityDecrease: false, allowForQuantityIncrease: true }] }); + await expect(addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'change_quantity', + targetSubscriptionId: 'sub-1', quantity: '5.00', + })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('decrease') }); + }); + + it('rejects a line targeting a subscription in a different org', async () => { + mockSubscriptionSnapshot({ pax8SubscriptionId: 'sub-1', orgId: 'OTHER-ORG' }); + await expect(addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'cancel', targetSubscriptionId: 'sub-1', + })).rejects.toMatchObject({ status: 403 }); + }); + + it('refuses to modify an order that is not draft/awaiting_details', async () => { + mockOrder({ id: 'ord-1', status: 'submitting' }); + await expect(addOrderLine({ partnerId: 'p1', orderId: 'ord-1', action: 'cancel', targetSubscriptionId: 'sub-1' })) + .rejects.toMatchObject({ status: 409 }); + }); +}); + +describe('buildDedupeKey', () => { + it('is stable for the same order', () => { + expect(buildDedupeKey('ord-1')).toBe(buildDedupeKey('ord-1')); + }); +}); +``` + +- [ ] **Step 2: Run — expect failure (module not found)** + +```bash +cd apps/api && pnpm vitest run src/services/pax8OrderService.test.ts +``` + +- [ ] **Step 3: Implement the service** + +Create `apps/api/src/services/pax8OrderService.ts`. Key behaviors, in order: + +```ts +import { and, eq, inArray } from 'drizzle-orm'; +import { db } from '../db'; +import { pax8Orders, pax8OrderLines, pax8CompanyMappings, pax8SubscriptionSnapshots } from '../db/schema'; +import type { Pax8OrderAction, Pax8BillingTerm } from '@breeze/shared'; + +export class Pax8OrderError extends Error { + constructor(message: string, public readonly status: 400 | 403 | 404 | 409 | 422) { + super(message); + this.name = 'Pax8OrderError'; + } +} + +export type Pax8OrderRow = typeof pax8Orders.$inferSelect; +export type Pax8OrderLineRow = typeof pax8OrderLines.$inferSelect; + +/** Stable per-order. The unique index on (partner_id, dedupe_key) is what makes + * a concurrent submit lose the race — see pax8OrderSubmit.claimLine. */ +export function buildDedupeKey(orderId: string): string { + return `order:${orderId}`; +} + +const MUTABLE_STATUSES = new Set(['draft', 'awaiting_details']); +``` + +`getOrCreateDraftOrder` must: +1. Look up the org's `pax8_company_mappings` row (by `partnerId` + `orgId`, `ignored = false`). If absent or `orgId` unset → `Pax8OrderError('This organization is not mapped to a Pax8 company. Map it before ordering.', 409)`. +2. Return the existing order for that org whose status is in `MUTABLE_STATUSES`, if one exists. +3. Otherwise insert one with `status: 'draft'`, `source: 'direct'`, `dedupeKey: buildDedupeKey()` — generate the id client-side (`crypto.randomUUID()`) so the dedupe key can reference it in the same insert. + +`addOrderLine` must, for every action: +1. Load the order; reject if its status is not in `MUTABLE_STATUSES` → 409. +2. For `change_quantity` and `cancel`: load the `pax8_subscription_snapshots` row by `(integrationId, pax8SubscriptionId)`. Reject if missing (404) or if its `orgId` ≠ the order's `orgId` (403 — this is the cross-tenant guard, do not skip it). +3. For `change_quantity`: fetch `getProductDependencies(productId)` via `createPax8ClientForIntegration`, and if the new quantity is below the snapshot quantity, require `allowForQuantityDecrease`; if above, require `allowForQuantityIncrease`. Reject with 422 naming the direction. +4. For `cancel`: require `allowForEarlyCancellation`; reject 422 otherwise. +5. For `new_subscription`: require `pax8ProductId`, a `billingTerm` in `PAX8_BILLING_TERMS`, and `quantity > 0`. +6. Insert the line. The DB `CHECK` constraint is the backstop; these checks exist to produce a readable message instead of a 23514. + +The Pax8 HTTP calls in steps 3–4 must run via `runOutsideDbContext(...)` — never hold a pooled connection across an HTTP round-trip (#1697). + +- [ ] **Step 4: Run — expect pass** + +```bash +cd apps/api && pnpm vitest run src/services/pax8OrderService.test.ts +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/services/pax8OrderService.ts apps/api/src/services/pax8OrderService.test.ts +git commit -m "feat(pax8): draft order authoring service with commitment guards" +``` + +--- + +## Task 4: The submit pipeline + +This is the money-critical file. Every rule in it exists because Pax8 has no idempotency key. + +**Files:** +- Create: `apps/api/src/services/pax8OrderSubmit.ts` +- Test: `apps/api/src/services/pax8OrderSubmit.test.ts` +- Test: `apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts` + +**Interfaces:** +- Consumes: `Pax8Client.createOrder / updateSubscriptionQuantity / cancelSubscription` (Task 2); `pax8Orders`, `pax8OrderLines` (Task 1); `Pax8OrderError` (Task 3). +- Produces: + - `preflightOrder(input: { partnerId, orderId }): Promise<{ ok: true } | { ok: false; errorBody: string }>` + - `submitOrder(input: { partnerId, orderId, actorUserId }): Promise` where `SubmitResult = { orderId: string; status: Pax8OrderStatus; lines: Array<{ lineId: string; submitState: Pax8SubmitState; error: string | null }> }` + - `reconcileOrder(input: { partnerId, orderId }): Promise<{ resolved: number; stillUnknown: number }>` + +- [ ] **Step 1: Write the failing unit tests** + +Create `apps/api/src/services/pax8OrderSubmit.test.ts`: + +```ts +describe('submitOrder', () => { + it('runs the isMock preflight BEFORE any real write, and aborts on 422', async () => { + const client = stubClient(); + client.createOrder.mockRejectedValueOnce(new Pax8ApiError('Pax8 API returned 422', 422, '{"details":[{"message":"msDomain is required"}]}')); + + const res = await submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1' }); + + // Exactly ONE call — the mock. The real order was never attempted. + expect(client.createOrder).toHaveBeenCalledTimes(1); + expect(client.createOrder.mock.calls[0][1]).toEqual({ isMock: true }); + expect(res.status).toBe('failed'); + expect(res.lines[0].error).toContain('msDomain is required'); + }); + + it('marks a line needs_reconcile — NOT failed — when the write times out', async () => { + const client = stubClient(); + client.createOrder + .mockResolvedValueOnce({ pax8OrderId: null, lineItems: [] }) // isMock preflight OK + .mockRejectedValueOnce(Object.assign(new Error('aborted'), { name: 'AbortError' })); + + const res = await submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1' }); + + expect(res.lines[0].submitState).toBe('needs_reconcile'); + // A timeout means UNKNOWN. Retrying here is how you buy the licenses twice. + expect(client.createOrder).toHaveBeenCalledTimes(2); // preflight + the one real attempt + }); + + it('does not re-send a line already in_flight', async () => { + mockOrderLines([{ id: 'l1', submitState: 'in_flight', action: 'new_subscription' }]); + const client = stubClient(); + await expect(submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1' })) + .rejects.toMatchObject({ status: 409 }); + expect(client.createOrder).not.toHaveBeenCalled(); + }); + + it('records partially_failed when the POST succeeds but a PUT 422s', async () => { + const client = stubClient(); + client.createOrder + .mockResolvedValueOnce({ pax8OrderId: null, lineItems: [] }) + .mockResolvedValueOnce({ pax8OrderId: 'ord-x', lineItems: [{ lineItemNumber: 1, productId: 'prod-1', subscriptionId: 'sub-new' }] }); + client.updateSubscriptionQuantity.mockRejectedValueOnce(new Pax8ApiError('Pax8 API returned 422', 422, '{"message":"seat decrease not allowed"}')); + + const res = await submitOrder({ partnerId: 'p1', orderId: 'ord-1', actorUserId: 'u1' }); + + expect(res.status).toBe('partially_failed'); + expect(res.lines.find((l) => l.lineId === 'line-new')!.submitState).toBe('succeeded'); + expect(res.lines.find((l) => l.lineId === 'line-change')!.submitState).toBe('failed'); + }); +}); +``` + +- [ ] **Step 2: Write the failing integration test (real Postgres)** + +Create `apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts`. This one proves the atomicity claim that a mocked test cannot: + +```ts +it('writes the contract-line quantity in the SAME transaction that marks the line succeeded', async () => { + // A new_subscription line linked to a manual contract line, quantity 7. + const { orderId, lineId, contractLineId } = await seedOrderWithContractLine({ quantity: '7.00' }); + + await submitOrder({ partnerId: partner.id, orderId, actorUserId: user.id }); + + const [line] = await db.select().from(pax8OrderLines).where(eq(pax8OrderLines.id, lineId)); + const [cl] = await db.select().from(contractLines).where(eq(contractLines.id, contractLineId)); + expect(line.submitState).toBe('succeeded'); + expect(cl.manualQuantity).toBe('7.00'); // ordering and billing are ONE act +}); + +it('leaves the contract line untouched when the order fails', async () => { + const { orderId, contractLineId } = await seedOrderWithContractLine({ quantity: '7.00', failWith: 422 }); + await submitOrder({ partnerId: partner.id, orderId, actorUserId: user.id }); + const [cl] = await db.select().from(contractLines).where(eq(contractLines.id, contractLineId)); + expect(cl.manualQuantity).toBeNull(); +}); + +it('rejects a concurrent second submit via the dedupe_key unique index', async () => { + const { orderId } = await seedOrderWithContractLine({ quantity: '3.00' }); + const results = await Promise.allSettled([ + submitOrder({ partnerId: partner.id, orderId, actorUserId: user.id }), + submitOrder({ partnerId: partner.id, orderId, actorUserId: user.id }), + ]); + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1); +}); +``` + +- [ ] **Step 3: Run both — expect failure** + +```bash +cd apps/api && pnpm vitest run src/services/pax8OrderSubmit.test.ts +cd apps/api && pnpm vitest run --config vitest.integration.config.ts pax8OrderSubmit +``` + +- [ ] **Step 4: Implement `preflightOrder`** + +Builds the `Pax8CreateOrderInput` from the order's `new_subscription` lines (assigning `lineItemNumber` from `sort_order + 1`) and calls `client.createOrder(input, { isMock: true })`. Returns `{ ok: false, errorBody }` on `Pax8ApiError` with the raw `.body`, `{ ok: true }` otherwise. An order with no `new_subscription` lines skips the preflight and returns `{ ok: true }` — `isMock` only validates orders, not subscription updates. + +- [ ] **Step 5: Implement `submitOrder`** + +The sequence, and none of it is negotiable: + +``` +1. Load order + lines. Reject 409 if status not in {draft, awaiting_details, ready} + or if ANY line is already in_flight (a crashed prior submit — force reconcile). +2. Flip order -> 'submitting' in a COMMITTED txn. If the (partner_id, dedupe_key) + unique index rejects, a concurrent submit won it: throw 409. +3. Preflight: preflightOrder(). On failure, mark every new_subscription line + 'failed' with the raw body, order -> 'failed', return. NOTHING was sent. +4. Claim ALL lines: submit_state 'pending' -> 'in_flight', COMMITTED, before any + HTTP call. If the process dies after this, the lines are visibly in_flight and + a human must reconcile — which is exactly the intent. +5. runOutsideDbContext(...) around the HTTP work: + a. All new_subscription lines -> ONE client.createOrder(input) (no isMock) + b. Each change_quantity line -> client.updateSubscriptionQuantity(subId, qty) + c. Each cancel line -> client.cancelSubscription(subId, cancelDate) + Classify each outcome: + - resolved OK -> succeeded + - Pax8ApiError with a status 4xx -> failed (Pax8 definitively rejected it) + - anything else (timeout, 5xx, + network, no status) -> needs_reconcile (UNKNOWN — never retry) +6. Persist results. For EACH succeeded line, in the SAME transaction: + - set result_subscription_id (matched from the createOrder response by + lineItemNumber, falling back to productId) + - if contract_line_id is set AND action != 'cancel': + UPDATE contract_lines SET manual_quantity = + WHERE id = contract_line_id AND line_type = 'manual' + - if action = 'cancel' AND contract_line_id is set: + UPDATE contract_lines SET manual_quantity = '0' + Order status: all succeeded -> 'completed'; any needs_reconcile or a mix -> + 'partially_failed'; all failed -> 'failed'. +``` + +The `contract_lines` write is guarded on `line_type = 'manual'` exactly as `applyEnabledPax8ContractLineLinks` does today — a `per_seat`/`per_device` line resolves its quantity at bill time and must not be overwritten. + +- [ ] **Step 6: Implement `reconcileOrder`** + +For each `needs_reconcile` line, fetch `GET /v1/orders?companyId=` and `GET /v1/subscriptions?companyId=` (the client's existing `listSubscriptions`, filtered) and match on `productId` + `quantity`. A match → the write landed: mark `succeeded` and run the same contract-line write as step 6 above. No match → mark `failed`. **`reconcileOrder` never issues a write to Pax8.** It only reads and re-classifies. + +Note in a comment that `Order.createdDate` is a date, not a timestamp, so same-day disambiguation is coarse — this is why reconcile is human-triggered and not automatic. + +- [ ] **Step 7: Run both suites — expect pass** + +```bash +cd apps/api && pnpm vitest run src/services/pax8OrderSubmit.test.ts +cd apps/api && pnpm vitest run --config vitest.integration.config.ts pax8OrderSubmit +``` + +- [ ] **Step 8: Commit** + +```bash +git add apps/api/src/services/pax8OrderSubmit.ts apps/api/src/services/pax8OrderSubmit.test.ts apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts +git commit -m "feat(pax8): order submit pipeline with isMock preflight and no-blind-retry" +``` + +--- + +## Task 5: HTTP routes + +**Files:** +- Create: `apps/api/src/routes/pax8Orders.ts` +- Modify: `apps/api/src/index.ts` (near line 919, where `pax8Routes` is mounted) +- Test: `apps/api/src/routes/pax8Orders.test.ts` + +**Interfaces:** +- Consumes: every export of Tasks 3 and 4. +- Produces: `pax8OrderRoutes` (a `Hono` instance), mounted at `/api/v1/pax8/orders`. + +Routes, all `requireScope('partner', 'system')` + `BILLING_MANAGE`, writes additionally `requireMfa()`: + +| Method | Path | Purpose | +|---|---|---| +| GET | `/orders?orgId=` | List orders for an org (or all pending for the partner) | +| GET | `/orders/:id` | Order + lines | +| POST | `/orders` | `getOrCreateDraftOrder` | +| POST | `/orders/:id/lines` | `addOrderLine` | +| DELETE | `/orders/:id/lines/:lineId` | `removeOrderLine` | +| POST | `/orders/:id/preflight` | `preflightOrder` — returns the raw Pax8 422 body on failure | +| POST | `/orders/:id/submit` | `submitOrder` (MFA) | +| POST | `/orders/:id/reconcile` | `reconcileOrder` (MFA) | +| GET | `/products/:productId/provision-details` | Proxy for the dynamic form | +| GET | `/products/:productId/dependencies` | Commitment terms + the allowFor* flags | + +Copy the `resolvePartnerId(auth, requested)` helper from `routes/pax8.ts:31` verbatim — an org-scoped token must be refused with *"Pax8 ordering is managed at partner scope"*, exactly as the existing routes refuse it. Every write calls `writeRouteAudit(...)` following the pattern already in `routes/pax8.ts`. + +Map `Pax8OrderError.status` onto the HTTP status; map `Pax8ApiError` to a 502 carrying the raw body. + +- [ ] **Step 1: Write the failing route tests** — assert the org-scope refusal (403), that `POST /orders/:id/submit` without MFA is rejected, and that a `Pax8OrderError(…, 409)` surfaces as a 409 with its message. +- [ ] **Step 2: Run — expect failure.** `cd apps/api && pnpm vitest run src/routes/pax8Orders.test.ts` +- [ ] **Step 3: Implement the routes.** +- [ ] **Step 4: Mount** in `apps/api/src/index.ts` next to the existing `app.route('/api/v1/pax8', pax8Routes)`: `app.route('/api/v1/pax8', pax8OrderRoutes)`. Hono merges the two routers on the same prefix; keep the order-routes mount *after* the existing one so the more specific `/orders/*` paths are unambiguous. +- [ ] **Step 5: Run — expect pass.** +- [ ] **Step 6: Commit.** `git commit -m "feat(pax8): order routes"` + +--- + +## Task 6: Stage the order on quote acceptance + +**Files:** +- Modify: `apps/api/src/services/quoteAcceptService.ts` (after the Phase 4 contract loop, ~line 250-255) +- Create: `apps/api/src/services/quoteToPax8Order.ts` +- Test: `apps/api/src/services/quoteToPax8Order.test.ts` +- Test: `apps/api/src/__tests__/integration/quoteAcceptPax8Order.integration.test.ts` + +**Interfaces:** +- Consumes: `pax8Orders`, `pax8OrderLines` (Task 1); `buildDedupeKey` (Task 3). +- Produces: `stagePax8OrderFromQuote(input: StagePax8OrderInput): Promise<{ orderId: string | null; lineCount: number }>` — returns `{ orderId: null, lineCount: 0 }` when the quote has no Pax8-backed lines. + +- [ ] **Step 1: Write the failing tests** + +`quoteToPax8Order.test.ts`: + +```ts +it('returns null when the quote has no Pax8-backed lines', async () => { + const res = await stagePax8OrderFromQuote({ ...baseInput, lines: [{ catalogItemId: 'cat-plain', ... }] }); + expect(res.orderId).toBeNull(); +}); + +it('stages one new_subscription line per Pax8-backed quote line', async () => { ... }); + +it('attaches the contract line created by Phase 4, matched on catalog_item_id', async () => { ... }); + +it('leaves contract_line_id null for a one_time Pax8 line', async () => { + // one_time lines bill on the invoice and produce NO contract line. + const res = await stagePax8OrderFromQuote({ ...baseInput, lines: [pax8Line({ recurrence: 'one_time' })] }); + expect(stagedLines[0].contractLineId).toBeNull(); +}); + +it('claims each contract line at most once when two quote lines share a catalog item', async () => { ... }); +``` + +`quoteAcceptPax8Order.integration.test.ts` (real Postgres) — the one that matters: + +```ts +it('stages the order INSIDE the accept transaction, atomic with the contracts', async () => { + const quote = await seedSentQuoteWithPax8Line(); + const res = await acceptQuote({ quoteId: quote.id, signerName: 'A Customer' }); + + const [order] = await db.select().from(pax8Orders).where(eq(pax8Orders.sourceQuoteId, quote.id)); + expect(order.status).toBe('awaiting_details'); + expect(order.source).toBe('quote'); + + const [line] = await db.select().from(pax8OrderLines).where(eq(pax8OrderLines.orderId, order.id)); + expect(line.action).toBe('new_subscription'); + expect(line.contractLineId).not.toBeNull(); // wired to the contract Phase 4 made + expect(res.contractIds).toContain(contractIdOf(line.contractLineId)); +}); + +it('stages nothing when the accept rolls back', async () => { + const quote = await seedSentQuoteWithPax8Line(); + await expect(acceptQuoteWithForcedFailureAfterPhase5(quote.id)).rejects.toThrow(); + const orders = await db.select().from(pax8Orders).where(eq(pax8Orders.sourceQuoteId, quote.id)); + expect(orders).toHaveLength(0); // the whole point of staging in-transaction +}); +``` + +- [ ] **Step 2: Run — expect failure.** + +- [ ] **Step 3: Implement `stagePax8OrderFromQuote`** + +It must: +1. Resolve which quote lines are Pax8-backed: join `catalog_item_id` → `pax8_product_mappings.catalog_item_id` (scoped to the partner's active integration). A line with no mapping is not Pax8-backed and is skipped. +2. Return early with `{ orderId: null, lineCount: 0 }` if none. +3. Require the quote's org to have a `pax8_company_mappings` row. If it doesn't, **do not throw** — a missing mapping must never block a customer's acceptance. Stage the order anyway with `status: 'awaiting_details'` and set `order.error` to *"Organization is not mapped to a Pax8 company — map it before submitting."* The technician fixes it before submitting. +4. Re-read `contract_lines` for the contracts Phase 4 just created (`inArray(contractLines.contractId, contractIds)`), and match each Pax8-backed quote line to a contract line on `catalog_item_id`, claiming each contract line at most once (a `Set` of claimed ids). A `one_time` line gets `contractLineId: null`. +5. Insert the `pax8_orders` row (`source: 'quote'`, `sourceQuoteId`, `status: 'awaiting_details'`, `dedupeKey: buildDedupeKey(id)`) and its lines, mapping `quantity` from the quote line's `quantity`, `billingTerm` from the quote line's `recurrence` (`monthly` → `'Monthly'`, `annual` → `'Annual'`, `one_time` → `'One-Time'`), and `pax8ProductId` from the mapping. +6. Leave `provisioningDetails` as `[]` — that is precisely what the technician supplies before submit. + +- [ ] **Step 4: Wire it into `acceptQuote` as Phase 5** + +In `quoteAcceptService.ts`, immediately after the `for (const spec of contractSpecs)` loop that populates `contractIds`, and **before** the final `SELECT` that builds the return value: + +```ts + // Phase 5: stage a Pax8 order for any Pax8-backed lines. IN-TRANSACTION, + // alongside Phase 4 — a staged order that references contract lines which + // rolled back would be unfixable. Nothing is sent to Pax8 here: the customer's + // approval stages the order, a technician submits it. Provisioning details are + // the technician's job (the customer cannot supply an M365 tenant domain). + const pax8Staged = await stagePax8OrderFromQuote({ + quoteId: quote.id, + orgId: quote.orgId, + partnerId: quote.partnerId, + contractIds, + lines: lines.map((l) => ({ + id: l.id, + catalogItemId: l.catalogItemId ?? null, + quantity: l.quantity, + recurrence: l.recurrence, + customerVisible: l.customerVisible, + })), + actorUserId: params.actorUserId ?? null, + }); +``` + +Add `pax8OrderId: pax8Staged.orderId` to the returned object so the accept response can surface it, and extend the `AcceptQuoteResult` type accordingly. Update `emitAcceptInvoiceIssued`'s callers only if they destructure the result exhaustively (they don't — it takes a `Pick`, so no change is needed). + +- [ ] **Step 5: Run both suites, plus the full quote suite** (this touches the accept path — regressions here break every quote): + +```bash +cd apps/api && pnpm vitest run quote +cd apps/api && pnpm vitest run --config vitest.integration.config.ts quoteAcceptPax8Order +``` +Expected: PASS, with no pre-existing quote test broken. + +- [ ] **Step 6: Commit.** `git commit -m "feat(pax8): stage a Pax8 order on quote acceptance (in-transaction Phase 5)"` + +--- + +## Task 7: Demote the nightly quantity push to drift detection + +This changes shipped behavior. See the spec's "billing-truth problem". + +**Files:** +- Modify: `apps/api/src/services/pax8SyncService.ts:232-265` +- Create: `apps/api/src/services/pax8Drift.ts` +- Test: `apps/api/src/services/pax8Drift.test.ts` +- Modify: `apps/api/src/services/pax8SyncService.test.ts` (the existing apply-links tests now assert the opposite) + +**Interfaces:** +- Produces: `detectPax8Drift(integrationId: string): Promise` where `Pax8DriftRow = { contractLineId: string; orgId: string; pax8SubscriptionId: string; productName: string | null; breezeQuantity: string; pax8Quantity: string }`. + +- [ ] **Step 1: Write the failing test** + +```ts +// NOTE: applyEnabledPax8ContractLineLinks is RENAMED to +// recordPax8SubscriptionObservations in Step 3. Write the test against the new +// name; it will not compile until the rename lands, which is the point. +describe('recordPax8SubscriptionObservations', () => { + it('NO LONGER writes contract_lines.manual_quantity', async () => { + // Pax8's Subscription.quantity is stale and does not match what Pax8 invoices. + // Breeze's order ledger is the source of truth. This sync must never write it. + await recordPax8SubscriptionObservations('int-1'); + expect(contractLinesUpdateSpy).not.toHaveBeenCalled(); + }); +}); + +describe('detectPax8Drift', () => { + it('reports a link whose Pax8 quantity differs from the contract line', async () => { + mockLink({ contractLineId: 'cl-1', manualQuantity: '5.00', snapshotQuantity: '8.00' }); + const drift = await detectPax8Drift('int-1'); + expect(drift).toEqual([expect.objectContaining({ + contractLineId: 'cl-1', breezeQuantity: '5.00', pax8Quantity: '8.00', + })]); + }); + + it('reports nothing when they agree', async () => { + mockLink({ contractLineId: 'cl-1', manualQuantity: '5.00', snapshotQuantity: '5.00' }); + expect(await detectPax8Drift('int-1')).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run — expect failure** (the current implementation *does* write). + +- [ ] **Step 3: Rewrite `applyEnabledPax8ContractLineLinks`** + +Delete the `db.update(contractLines)` call entirely. Keep the link-row bookkeeping (`lastAppliedQuantity` / `lastAppliedAt` become "last *observed*") and rename the function to `recordPax8SubscriptionObservations`. + +**Sweep every consumer of the renamed output before calling this done** — the `Pax8SyncResult` field rename (`appliedContractLines` → `observedContractLines`) is an output-contract change, and its readers live outside this file: + +```bash +grep -rn "applyEnabledPax8ContractLineLinks\|appliedContractLines" apps/ packages/ +``` + +Known call sites: `pax8SyncService.ts:317` (inside `syncPax8Integration`), the `Pax8SyncResult` interface at `pax8SyncService.ts:267-274`, `jobs/pax8SyncWorker.ts` (logs the counts), and `routes/pax8.ts` (`POST /sync` returns them to the UI). If `apps/web/src/components/integrations/Pax8Integration.tsx` renders the count, update it too. + +Leave a comment at the top explaining *why*, in full: + +```ts +/** + * Records what Pax8 currently REPORTS for each linked subscription. It does NOT + * write contract_lines.manual_quantity — that was the old behavior and it was a + * billing bug: Pax8's API Subscription.quantity is stale and does not match the + * seat counts Pax8 actually invoices the partner for, so every sync_enabled link + * was feeding a wrong number into the contract billing sweep and out onto the + * customer's invoice. + * + * Breeze's order ledger (pax8_orders / pax8_order_lines) is the source of truth + * for billable quantity: we know what the customer has because every add, change, + * and cancel went through us. Pax8 is now only a DRIFT DETECTOR — see + * detectPax8Drift(), which surfaces the disagreement (someone changed seats in + * the Pax8 portal, bypassing Breeze) instead of silently overwriting the bill. + */ +``` + +- [ ] **Step 4: Implement `detectPax8Drift`** in `pax8Drift.ts` — the same join the old function used, returning the rows where `contract_lines.manual_quantity IS DISTINCT FROM pax8_subscription_snapshots.quantity` rather than updating them. + +- [ ] **Step 5: Expose it** — add `GET /api/v1/pax8/drift?integrationId=` to `routes/pax8Orders.ts` (read perm, no MFA). + +- [ ] **Step 6: Run the full pax8 suite** — the existing `pax8SyncService.test.ts` assertions about applying quantities must be inverted, not deleted, so the regression stays pinned. + +```bash +cd apps/api && pnpm vitest run pax8 +``` + +- [ ] **Step 7: Commit.** + +```bash +git commit -m "fix(pax8): stop billing customers off Pax8's stale subscription quantity + +Pax8's API Subscription.quantity does not match the seat counts Pax8 +invoices. The nightly sync was pushing it into contract_lines.manual_quantity, +which the contract billing sweep then invoiced the customer from. Breeze's +order ledger is now the source of truth; the sync only detects drift." +``` + +--- + +## Task 8: Web — the Pax8 tab on the org page + +**Files:** +- Create: `apps/web/src/lib/api/pax8Orders.ts` +- Create: `apps/web/src/components/organizations/Pax8OrgTab.tsx` +- Create: `apps/web/src/components/organizations/Pax8OrderBuilder.tsx` +- Create: `apps/web/src/components/organizations/Pax8ProvisioningForm.tsx` +- Modify: the org detail page's tab registry (follow `DeviceDetails.tsx` — tabs key off `window.location.hash`, never a query param) +- Test: `apps/web/src/components/organizations/Pax8OrderBuilder.test.tsx` + +All mutations go through `runAction` (`apps/web/src/lib/runAction.ts`) — the `no-silent-mutations` test enforces it. + +The provisioning form renders from `GET /pax8/products/:id/provision-details`: `valueType: 'Input'` → text field; `'Single-Value'` → select over `possibleValues`; `'Multi-Value'` → multiselect. **Every field is optional in the UI** — requiredness is not discoverable from Pax8, so the form must not invent it. The Submit button runs the preflight first and renders Pax8's raw `details[]` inline against the offending line when it fails. That error text is the only reliable statement of what's missing. + +The subscription list shows Breeze's ledger quantity as the primary number, the Pax8-reported quantity secondary, and a drift badge when they disagree. + +- [ ] **Step 1: Write the failing component test** — assert that a `Single-Value` provisioning field renders a ` setSelectedProductId(event.target.value)} className="h-10 w-full rounded-md border bg-background px-3"> + + {products.map((product) => )} + + + + + + {selectedProduct && ( +
+ {metadataLoading ?

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

: metadataError ? ( +
+

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

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

{message}

)}{line.error &&

{line.error}

}
{line.action.replaceAll('_', ' ')}{displayQuantity(line.quantity)}{line.submitState.replaceAll('_', ' ')}
+ {mutable && line.action === 'new_subscription' && } + {mutable && } +
+
+ + {editingLineId && ( +
+

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

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

{message}

)}
} + +
+ {needsReconcile ? : mutable && } + {order.status === 'completed' && {t('pax8.order.completed')}} +
+ + ); +} diff --git a/apps/web/src/components/organizations/Pax8OrgTab.test.tsx b/apps/web/src/components/organizations/Pax8OrgTab.test.tsx new file mode 100644 index 0000000000..b2cfd8fa25 --- /dev/null +++ b/apps/web/src/components/organizations/Pax8OrgTab.test.tsx @@ -0,0 +1,129 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import Pax8OrgTab, { Pax8SubscriptionTable } from './Pax8OrgTab'; +import { + listPax8Companies, + listPax8Orders, + listPax8Products, + listPax8Subscriptions, + getProductDependencies, + addPax8OrderLine, +} from '../../lib/api/pax8Orders'; + +vi.mock('../../lib/api/pax8Orders', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listPax8Companies: vi.fn(), + listPax8Orders: vi.fn(), + listPax8Products: vi.fn(), + listPax8Subscriptions: vi.fn(), + getProductDependencies: vi.fn(), + addPax8OrderLine: vi.fn(), + }; +}); + +const response = (payload: unknown) => Promise.resolve(new Response(JSON.stringify(payload), { + status: 200, headers: { 'content-type': 'application/json' }, +})); + +beforeEach(() => { + vi.clearAllMocks(); + window.location.hash = '#pax8'; +}); + +describe('Pax8 subscription ledger display', () => { + it('uses Breeze quantity as primary and never invents Pax8 zero', () => { + render( + , + ); + + expect(screen.getByTestId('pax8-breeze-quantity-snapshot-1')).toHaveTextContent('12'); + expect(screen.getByTestId('pax8-reported-quantity-snapshot-1')).toHaveTextContent('Not reported'); + expect(screen.queryByTestId('pax8-drift-snapshot-1')).not.toBeInTheDocument(); + }); + + it('shows drift only when Pax8 reported a known disagreement', () => { + render( + , + ); + + expect(screen.getByTestId('pax8-drift-snapshot-2')).toBeInTheDocument(); + }); +}); + +describe('Pax8 organization mapping state', () => { + it('teaches the next mapping action when the org is unmapped', async () => { + vi.mocked(listPax8Companies).mockImplementation(() => response({ + data: [{ + pax8CompanyId: 'company-1', pax8CompanyName: 'Acme', status: 'Active', + mappedOrgId: null, mappedOrgName: null, ignored: false, lastSeenAt: null, + }], + integrationId: 'integration-1', + })); + vi.mocked(listPax8Subscriptions).mockImplementation(() => response({ data: [], integrationId: 'integration-1' })); + vi.mocked(listPax8Orders).mockImplementation(() => response({ data: [] })); + vi.mocked(listPax8Products).mockImplementation(() => response({ data: [] })); + + render(); + + expect(await screen.findByTestId('pax8-mapping-empty')).toHaveTextContent(/map this organization/i); + expect(screen.getByRole('combobox', { name: /pax8 company/i })).toBeInTheDocument(); + expect(screen.getByTestId('pax8-new-order')).toBeDisabled(); + }); + + it('blocks a quantity increase when the active commitment forbids it', async () => { + vi.mocked(listPax8Companies).mockImplementation(() => response({ + data: [{ + pax8CompanyId: 'company-1', pax8CompanyName: 'Acme', status: 'Active', + mappedOrgId: 'org-1', mappedOrgName: 'Acme', ignored: false, lastSeenAt: null, + }], integrationId: 'integration-1', + })); + vi.mocked(listPax8Subscriptions).mockImplementation(() => response({ + data: [{ + id: 'snapshot-1', pax8SubscriptionId: 'sub-1', productId: 'prod-1', productName: 'Microsoft 365', + status: 'Active', breezeQuantity: '5.00', quantity: '5.00', quantityKnown: true, + lastSeenAt: '2026-07-14T00:00:00Z', contractLineId: 'contract-line-1', + }], integrationId: 'integration-1', + })); + vi.mocked(listPax8Orders).mockImplementation(() => response({ data: [] })); + vi.mocked(listPax8Products).mockImplementation(() => response({ data: [{ + pax8ProductId: 'prod-1', catalogItemId: 'cat-1', catalogName: 'Microsoft 365', + }] })); + vi.mocked(getProductDependencies).mockImplementation(() => response({ data: { commitments: [{ + id: 'commit-1', term: 'Annual', allowForQuantityIncrease: false, + allowForQuantityDecrease: true, allowForEarlyCancellation: true, cancellationFeeApplied: false, + }] } })); + + render(); + const quantity = await screen.findByRole('spinbutton', { name: /target quantity/i }); + await userEvent.clear(quantity); + await userEvent.type(quantity, '6'); + await userEvent.click(screen.getByRole('button', { name: /stage change/i })); + + expect(await screen.findByRole('alert')).toHaveTextContent(/does not allow quantity increases/i); + expect(addPax8OrderLine).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/organizations/Pax8OrgTab.tsx b/apps/web/src/components/organizations/Pax8OrgTab.tsx new file mode 100644 index 0000000000..0b928d15b6 --- /dev/null +++ b/apps/web/src/components/organizations/Pax8OrgTab.tsx @@ -0,0 +1,242 @@ +import '@/lib/i18n'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { AlertTriangle, Building2, PackagePlus, RefreshCw } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { navigateTo } from '../../lib/navigation'; +import { ActionError, handleActionError, runAction } from '../../lib/runAction'; +import { TableSkeleton } from '../billing/shared/TableSkeleton'; +import { + addPax8OrderLine, + createPax8Order, + getPax8Order, + getProductDependencies, + listPax8Companies, + listPax8Orders, + listPax8Products, + listPax8Subscriptions, + mapPax8Company, + readData, + type Pax8Commitment, + type Pax8Company, + type Pax8Order, + type Pax8OrderBundle, + type Pax8OrderLine, + type Pax8ProductDependencies, + type Pax8ProductOption, + type Pax8Subscription, +} from '../../lib/api/pax8Orders'; +import Pax8OrderBuilder from './Pax8OrderBuilder'; +import { displayQuantity } from './pax8OrderUi'; + +const onUnauthorized = () => void navigateTo('/login', { replace: true }); +const mutableStatuses = new Set(['draft', 'awaiting_details']); + +function selectedOrderFromHash(): string | null { + if (typeof window === 'undefined') return null; + const match = /^#pax8\/([0-9a-f-]{36})$/i.exec(window.location.hash); + return match?.[1] ?? null; +} + +function isKnownDrift(subscription: Pax8Subscription): boolean { + return subscription.quantityKnown + && subscription.breezeQuantity != null + && Number(subscription.breezeQuantity) !== Number(subscription.quantity); +} + +export function Pax8SubscriptionTable({ + subscriptions, + onChangeQuantity, + onCancel, + busyId = null, +}: { + subscriptions: Pax8Subscription[]; + onChangeQuantity: (subscription: Pax8Subscription, quantity: string) => void; + onCancel: (subscription: Pax8Subscription) => void; + busyId?: string | null; +}) { + const { t } = useTranslation('settings'); + const [drafts, setDrafts] = useState>({}); + if (subscriptions.length === 0) { + return
{t('pax8.subscriptions.empty')}
; + } + return ( +
+ + + {subscriptions.map((subscription) => { + const unavailable = !subscription.productId || !subscription.contractLineId || subscription.breezeQuantity == null; + const quantity = drafts[subscription.id] ?? subscription.breezeQuantity ?? ''; + return + + + + + + + ; + })} +
{t('pax8.subscriptions.product')}{t('pax8.subscriptions.breeze')}{t('pax8.subscriptions.pax8')}{t('pax8.subscriptions.status')}{t('pax8.subscriptions.observed')}{t('pax8.subscriptions.actions')}

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

{subscription.pax8SubscriptionId}

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

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

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

{loadError}

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

{t('pax8.title')}

{t('pax8.description')}

+ +

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

{mappedCompany ? <>

{mappedCompany.pax8CompanyName}

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

{!mappingReady &&

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

} :

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

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

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

{cancelCandidate.commitment.cancellationFeeApplied &&

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

}
} + +

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

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

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

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

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

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

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

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

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

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

{field.description}

+ )} +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/organizations/pax8OrderUi.ts b/apps/web/src/components/organizations/pax8OrderUi.ts new file mode 100644 index 0000000000..cbeae44f11 --- /dev/null +++ b/apps/web/src/components/organizations/pax8OrderUi.ts @@ -0,0 +1,48 @@ +export interface PreflightErrors { + byLine: Map; + order: string[]; +} + +function record(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null; +} + +/** Pax8 varies detail keys; preserve readable raw messages and fail safely. */ +export function extractPax8PreflightErrors(body: unknown): PreflightErrors { + const result: PreflightErrors = { byLine: new Map(), order: [] }; + const root = record(body); + const details = Array.isArray(root?.details) ? root.details : []; + for (const raw of details) { + const detail = record(raw); + if (!detail) continue; + const message = [detail.message, detail.detail, detail.error, detail.description] + .find((candidate) => typeof candidate === 'string' && candidate.trim()) as string | undefined; + if (!message) continue; + const numberValue = detail.lineItemNumber ?? detail.line_item_number; + const lineItemNumber = typeof numberValue === 'number' + ? numberValue + : typeof numberValue === 'string' && /^\d+$/.test(numberValue) + ? Number(numberValue) + : null; + if (lineItemNumber === null) { + result.order.push(message); + } else { + const messages = result.byLine.get(lineItemNumber) ?? []; + messages.push(message); + result.byLine.set(lineItemNumber, messages); + } + } + if (details.length === 0 && typeof root?.error === 'string' && root.error.trim()) { + result.order.push(root.error); + } + return result; +} + +export function displayQuantity(value: string | null | undefined): string { + if (value == null || value.trim() === '') return '—'; + const parsed = Number(value); + if (!Number.isFinite(parsed)) return value; + return parsed.toLocaleString(undefined, { maximumFractionDigits: 2 }); +} diff --git a/apps/web/src/components/settings/OrgSettingsPage.test.tsx b/apps/web/src/components/settings/OrgSettingsPage.test.tsx index 9e6467eb82..44c0233db4 100644 --- a/apps/web/src/components/settings/OrgSettingsPage.test.tsx +++ b/apps/web/src/components/settings/OrgSettingsPage.test.tsx @@ -32,6 +32,7 @@ vi.mock('./OrgSecuritySettings', () => ({ default: () =>
})); vi.mock('./OrgRemoteAccessSettings', () => ({ default: () =>
})); vi.mock('./OrgTicketSettingsEditor', () => ({ default: () =>
})); +vi.mock('../organizations/Pax8OrgTab', () => ({ default: ({ orgId }: { orgId: string }) =>
{orgId}
})); const fetchWithAuthMock = vi.mocked(fetchWithAuth); const useOrgStoreMock = vi.mocked(useOrgStore); @@ -336,6 +337,23 @@ describe('OrgSettingsPage sidebar nav & save-state honesty', () => { expect(screen.getByTestId('remote-access')).not.toBeNull(); }); + it('keeps a selected Pax8 order deep link active through hashchange and back navigation', async () => { + window.location.hash = '#pax8/44444444-4444-4444-8444-444444444444'; + render(); + + const link = await screen.findByRole('link', { name: /^pax8$/i }); + expect(link.getAttribute('aria-current')).toBe('page'); + expect(screen.getByTestId('pax8-org-tab')).toHaveTextContent('org-1'); + + window.location.hash = '#general'; + window.dispatchEvent(new HashChangeEvent('hashchange')); + await screen.findByTestId('org-name-input'); + + window.location.hash = '#pax8/55555555-5555-4555-8555-555555555555'; + window.dispatchEvent(new HashChangeEvent('hashchange')); + expect(await screen.findByTestId('pax8-org-tab')).toBeInTheDocument(); + }); + it('offers the compact section select for narrow viewports', async () => { render(); diff --git a/apps/web/src/components/settings/OrgSettingsPage.tsx b/apps/web/src/components/settings/OrgSettingsPage.tsx index 46e070c611..06a367b11b 100644 --- a/apps/web/src/components/settings/OrgSettingsPage.tsx +++ b/apps/web/src/components/settings/OrgSettingsPage.tsx @@ -15,6 +15,7 @@ import { Globe, Monitor, Paintbrush, + PackageOpen, ScrollText, Shield, Ticket @@ -38,10 +39,11 @@ import { fetchWithAuth } from '../../stores/auth'; import { navigateTo } from '@/lib/navigation'; import { runAction, ActionError } from '@/lib/runAction'; import { formatDate, formatTime as formatUserTime } from '@/lib/dateTimeFormat'; +import Pax8OrgTab from '../organizations/Pax8OrgTab'; type TabKey = | 'general' | 'branding' | 'portal' | 'notifications' | 'security' - | 'approval-security' | 'event-logs' | 'remote-access' | 'ticketing' | 'contracts' | 'billing'; + | 'approval-security' | 'event-logs' | 'remote-access' | 'ticketing' | 'contracts' | 'billing' | 'pax8'; // Grouped sidebar definition — same anatomy as PartnerSettingsPage (shared // SettingsSectionNav). Hashes are the section keys (already kebab-case). @@ -52,6 +54,7 @@ const TAB_GROUPS: (Omit & { items: (SettingsNavGroup[ { key: 'general', hash: 'general', label: 'orgSettingsPage.nav.general', description: 'orgSettingsPage.nav.generalDescription', icon: Building2 }, { key: 'contracts', hash: 'contracts', label: 'orgSettingsPage.nav.contracts', description: 'orgSettingsPage.nav.contractsDescription', icon: FileSignature }, { key: 'billing', hash: 'billing', label: 'orgSettingsPage.nav.billing', description: 'orgSettingsPage.nav.billingDescription', icon: CreditCard }, + { key: 'pax8', hash: 'pax8', label: 'orgSettingsPage.nav.pax8', description: 'orgSettingsPage.nav.pax8Description', icon: PackageOpen }, ], }, { @@ -85,7 +88,8 @@ const TAB_BY_KEY = Object.fromEntries(ALL_TABS.map(t => [t.key, t])) as Record
) : null; + case 'pax8': + return effectiveOrgId ? ( +
+ +
+ ) : null; case 'general': default: return ( diff --git a/apps/web/src/lib/api/pax8Orders.test.ts b/apps/web/src/lib/api/pax8Orders.test.ts new file mode 100644 index 0000000000..ebf19b7e94 --- /dev/null +++ b/apps/web/src/lib/api/pax8Orders.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fetchWithAuth } from '../../stores/auth'; +import { + listPax8Orders, + readData, + updatePax8OrderLine, +} from './pax8Orders'; + +vi.mock('../../stores/auth', () => ({ fetchWithAuth: vi.fn() })); + +beforeEach(() => vi.clearAllMocks()); + +describe('Pax8 ordering API client', () => { + it('scopes the order list to the selected organization', () => { + vi.mocked(fetchWithAuth).mockResolvedValue(new Response()); + void listPax8Orders('org/one'); + expect(fetchWithAuth).toHaveBeenCalledWith('/pax8/orders?orgId=org%2Fone'); + }); + + it('sends only the staged-line editable fields through PATCH', () => { + vi.mocked(fetchWithAuth).mockResolvedValue(new Response()); + void updatePax8OrderLine('order-1', 'line-1', { + commitmentTermId: 'commit-1', + provisioningDetails: [{ key: 'domain', values: ['acme.example'] }], + }); + expect(fetchWithAuth).toHaveBeenCalledWith('/pax8/orders/order-1/lines/line-1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + commitmentTermId: 'commit-1', + provisioningDetails: [{ key: 'domain', values: ['acme.example'] }], + }), + }); + }); + + it('fails closed on a successful response without the expected data envelope', async () => { + const response = new Response(JSON.stringify({ ok: true }), { + status: 200, headers: { 'content-type': 'application/json' }, + }); + await expect(readData(response, 'Malformed Pax8 response')).rejects.toThrow('Malformed Pax8 response'); + }); +}); diff --git a/apps/web/src/lib/api/pax8Orders.ts b/apps/web/src/lib/api/pax8Orders.ts new file mode 100644 index 0000000000..0ccdcc5b3b --- /dev/null +++ b/apps/web/src/lib/api/pax8Orders.ts @@ -0,0 +1,137 @@ +import { fetchWithAuth } from '../../stores/auth'; + +export type Pax8OrderStatus = + | 'draft' | 'awaiting_details' | 'ready' | 'submitting' | 'completed' + | 'partially_failed' | 'failed' | 'cancelled' | 'needs_reconcile'; +export type Pax8OrderAction = 'new_subscription' | 'change_quantity' | 'cancel'; + +export interface ProvisioningValue { key: string; values: string[] } +export interface Pax8ProvisionField { + key: string; + label: string | null; + description: string | null; + valueType: 'Input' | 'Single-Value' | 'Multi-Value' | null; + possibleValues: string[] | null; +} +export interface Pax8Commitment { + id: string; + term: string | null; + allowForQuantityIncrease: boolean; + allowForQuantityDecrease: boolean; + allowForEarlyCancellation: boolean; + cancellationFeeApplied: boolean; +} +export interface Pax8ProductDependencies { commitments: Pax8Commitment[] } + +export interface Pax8ProductOption { + pax8ProductId: string; + catalogItemId: string; + catalogName: string; + catalogSku: string | null; + catalogDescription: string | null; + productName: string | null; + vendorSkuId: string | null; + billingFrequency: string | null; + commitmentTermMonths: number | null; +} + +export interface Pax8Order { + id: string; + integrationId: string; + partnerId: string; + orgId: string; + pax8CompanyId: string | null; + status: Pax8OrderStatus; + source: 'direct' | 'quote'; + sourceQuoteId: string | null; + pax8OrderId: string | null; + error: string | null; + submittedAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface Pax8OrderLine { + id: string; + orderId: string; + action: Pax8OrderAction; + submitState: 'pending' | 'in_flight' | 'succeeded' | 'failed' | 'needs_reconcile'; + pax8ProductId: string | null; + catalogItemId: string | null; + billingTerm: string | null; + commitmentTermId: string | null; + quantity: string | null; + provisioningDetails: ProvisioningValue[]; + targetSubscriptionId: string | null; + resultSubscriptionId: string | null; + contractLineId: string | null; + sourceQuoteLineId: string | null; + error: string | null; + sortOrder: number; +} + +export interface Pax8OrderBundle { order: Pax8Order; lines: Pax8OrderLine[] } +export interface Pax8Company { + pax8CompanyId: string; + pax8CompanyName: string; + status: string | null; + mappedOrgId: string | null; + mappedOrgName: string | null; + ignored: boolean; + lastSeenAt: string | null; +} +export interface Pax8Subscription { + id: string; + pax8SubscriptionId: string; + productId: string | null; + productName: string | null; + status: string | null; + billingTerm?: string | null; + breezeQuantity: string | null; + quantity: string; + quantityKnown: boolean; + lastSeenAt: string | null; + contractLineId?: string | null; +} + +const JSON_HEADERS = { 'Content-Type': 'application/json' }; +const json = (body: unknown): RequestInit => ({ headers: JSON_HEADERS, body: JSON.stringify(body) }); + +export async function readData(response: Response, fallback: string): Promise { + const payload = await response.json().catch(() => null) as { data?: T; error?: string } | null; + if (!response.ok) throw new Error(payload?.error || fallback); + if (!payload || !Object.prototype.hasOwnProperty.call(payload, 'data')) throw new Error(fallback); + return payload.data as T; +} + +export const listPax8Companies = () => fetchWithAuth('/pax8/companies'); +export const listPax8Products = () => fetchWithAuth('/pax8/products'); +export const listPax8Orders = (orgId: string) => + fetchWithAuth(`/pax8/orders?orgId=${encodeURIComponent(orgId)}`); +export const getPax8Order = (orderId: string) => + fetchWithAuth(`/pax8/orders/${encodeURIComponent(orderId)}`); +export const listPax8Subscriptions = (orgId: string) => + fetchWithAuth(`/pax8/subscriptions?orgId=${encodeURIComponent(orgId)}&limit=100`); +export const getProvisionDetails = (productId: string) => + fetchWithAuth(`/pax8/products/${encodeURIComponent(productId)}/provision-details`); +export const getProductDependencies = (productId: string) => + fetchWithAuth(`/pax8/products/${encodeURIComponent(productId)}/dependencies`); + +export const mapPax8Company = (body: { integrationId: string; pax8CompanyId: string; orgId: string }) => + fetchWithAuth('/pax8/companies/map', { method: 'POST', ...json(body) }); +export const createPax8Order = (orgId: string) => + fetchWithAuth('/pax8/orders', { method: 'POST', ...json({ orgId }) }); +export const addPax8OrderLine = (orderId: string, body: unknown) => + fetchWithAuth(`/pax8/orders/${encodeURIComponent(orderId)}/lines`, { method: 'POST', ...json(body) }); +export const updatePax8OrderLine = (orderId: string, lineId: string, body: unknown) => + fetchWithAuth(`/pax8/orders/${encodeURIComponent(orderId)}/lines/${encodeURIComponent(lineId)}`, { + method: 'PATCH', ...json(body), + }); +export const removePax8OrderLine = (orderId: string, lineId: string) => + fetchWithAuth(`/pax8/orders/${encodeURIComponent(orderId)}/lines/${encodeURIComponent(lineId)}`, { method: 'DELETE' }); +export const preflightPax8Order = (orderId: string) => + fetchWithAuth(`/pax8/orders/${encodeURIComponent(orderId)}/preflight`, { method: 'POST' }); +export const submitPax8Order = (orderId: string) => + fetchWithAuth(`/pax8/orders/${encodeURIComponent(orderId)}/submit`, { method: 'POST' }); +export const reconcilePax8Order = (orderId: string) => + fetchWithAuth(`/pax8/orders/${encodeURIComponent(orderId)}/reconcile`, { method: 'POST' }); diff --git a/apps/web/src/locales/de-DE/settings.json b/apps/web/src/locales/de-DE/settings.json index 6cc72debb6..4d18c6b4ce 100644 --- a/apps/web/src/locales/de-DE/settings.json +++ b/apps/web/src/locales/de-DE/settings.json @@ -2354,6 +2354,8 @@ "contractsDescription": "Wiederkehrende Vereinbarungen", "billing": "Abrechnung", "billingDescription": "Steuer- und Rechnungsadresse", + "pax8": "Pax8-Marktplatz", + "pax8Description": "Abonnements und Bestellungen", "portalBranding": "Portal & Branding", "branding": "Branding", "brandingDescription": "Portalthema und Visuals", @@ -2422,6 +2424,50 @@ "saveType": "Der Organisationstyp konnte nicht gespeichert werden" } }, + "pax8": { + "title": "Pax8-Abonnements", "description": "Abrechenbare Mengen prüfen, Änderungen vormerken und Pax8-Bestellungen senden.", + "actions": { "newOrder": "Neue Bestellung", "retry": "Erneut versuchen" }, + "states": { "loadingProduct": "Produktanforderungen werden geladen…" }, + "products": { "emptyReason": "Vor der Bestellung aktive Katalogprodukte Pax8 zuordnen." }, + "mapping": { + "title": "Unternehmenszuordnung", "company": "Pax8-Unternehmen", "choose": "Unternehmen auswählen", "map": "Unternehmen zuordnen", + "empty": "Diese Organisation vor der Bestellung ihrem Pax8-Unternehmen zuordnen.", "noIntegration": "Zuerst Pax8 in den Partnerintegrationen verbinden und synchronisieren.", + "unknownStatus": "Status nicht gemeldet", "activeRequired": "Das zugeordnete Pax8-Unternehmen muss vor dem Vormerken einer Bestellung aktiv sein." + }, + "subscriptions": { + "title": "Abonnement-Ledger", "description": "Breeze-Mengen sind für die Abrechnung maßgeblich. Pax8-Werte sind die zuletzt beobachteten Werte.", + "empty": "Für diese Organisation wurden keine Pax8-Abonnements beobachtet.", "product": "Produkt", "breeze": "Breeze-Menge", "pax8": "Pax8 gemeldet", + "status": "Zustand", "observed": "Zuletzt beobachtet", "actions": "Aktionen", "unknownProduct": "Unbekanntes Produkt", "unknownStatus": "Unbekannt", + "neverObserved": "Nie", "notLinked": "Nicht verknüpft", "notReported": "Nicht gemeldet", "drift": "Abweichung", + "targetQuantity": "Zielmenge für {{product}}", "stageChange": "Änderung vormerken", "cancel": "Kündigen", + "actionUnavailable": "Dieses Abonnement vor einer Änderung mit einer manuellen Vertragszeile verknüpfen.", + "commitmentUnavailable": "Die aktive Bindung kann nicht sicher ermittelt werden. Pax8-Daten vor der Bestellung aktualisieren.", + "increaseBlocked": "Diese Bindung erlaubt keine Mengenerhöhung.", "decreaseBlocked": "Diese Bindung erlaubt keine Mengenreduzierung.", + "cancelBlocked": "Diese Bindung erlaubt keine vorzeitige Kündigung.", "confirmCancel": "Kündigung für {{product}} vormerken?", + "feeWarning": "Pax8 meldet eine Gebühr für die vorzeitige Kündigung.", "confirm": "Kündigung vormerken", "keep": "Abonnement behalten" + }, + "orders": { "title": "Bestellungen", "description": "Offene Arbeit und Bestellverlauf dieser Organisation.", "empty": "Noch keine Pax8-Bestellungen.", "quoteOrder": "Angebotserfüllung", "directOrder": "Direktbestellung" }, + "order": { + "title": "Bestellung erstellen", "back": "Zurück zu Pax8", "quoteSource": "Aus einem angenommenen Angebot vorgemerkt", "directSource": "Direkte Organisationsbestellung", + "addProduct": "Produkt hinzufügen", "product": "Produkt", "chooseProduct": "Zugeordnetes Produkt auswählen", "billingTerm": "Abrechnungszeitraum", "quantity": "Menge", + "commitment": "Bindung", "noCommitmentSelected": "Keine Bindung ausgewählt", "addToOrder": "Zur Bestellung hinzufügen", + "item": "Position", "action": "Aktion", "state": "Status", "actions": "Aktionen", "empty": "Diese Bestellung enthält noch keine Positionen.", "unknownItem": "Unbekannte Position", + "editDetails": "Details bearbeiten", "remove": "Position entfernen", "editProvisioning": "Bereitstellungsdetails", "saveDetails": "Details speichern", "cancelEdit": "Abbrechen", + "reviewSubmit": "Vorprüfung und senden", "reconcile": "Abgleichen", "completed": "Bestellung abgeschlossen" + }, + "provisioning": { "optional": "Freiwillig", "none": "Dieses Produkt meldet keine Bereitstellungsfelder.", "unsupported": "Pax8 hat einen nicht unterstützten Feldtyp zurückgegeben." }, + "toasts": { + "companyMapped": "Pax8-Unternehmen zugeordnet", "draftReady": "Pax8-Entwurf bereit", "changeStaged": "Mengenänderung vorgemerkt", "cancelStaged": "Kündigung vorgemerkt", + "lineAdded": "Produkt zur Bestellung hinzugefügt", "lineUpdated": "Bereitstellungsdetails gespeichert", "lineRemoved": "Bestellposition entfernt", + "preflightPassed": "Pax8-Vorprüfung bestanden", "submitted": "Pax8-Bestellung gesendet", "submitNeedsAttention": "Einige Positionen benötigen Aufmerksamkeit", "reconciled": "Pax8-Bestellung abgeglichen" + }, + "errors": { + "load": "Pax8-Daten konnten nicht geladen werden.", "loadOrder": "Die Pax8-Bestellung konnte nicht geladen werden.", "loadProduct": "Produktanforderungen konnten nicht geladen werden.", + "mapCompany": "Das Pax8-Unternehmen konnte nicht zugeordnet werden.", "createDraft": "Der Pax8-Entwurf konnte nicht erstellt werden.", "stageChange": "Die Mengenänderung konnte nicht vorgemerkt werden.", + "stageCancel": "Die Kündigung konnte nicht vorgemerkt werden.", "addLine": "Das Produkt konnte nicht hinzugefügt werden.", "updateLine": "Bereitstellungsdetails konnten nicht gespeichert werden.", + "removeLine": "Die Bestellposition konnte nicht entfernt werden.", "preflight": "Pax8-Vorprüfung fehlgeschlagen.", "submit": "Die Pax8-Bestellung konnte nicht gesendet werden.", "reconcile": "Die Pax8-Bestellung konnte nicht abgeglichen werden." + } + }, "orgTicketSettingsEditor": { "loading": "Ticketeinstellungen werden geladen…", "errors": { diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json index 5987c36baf..a95e63bf49 100644 --- a/apps/web/src/locales/en/settings.json +++ b/apps/web/src/locales/en/settings.json @@ -2407,6 +2407,8 @@ "contractsDescription": "Recurring agreements", "billing": "Billing", "billingDescription": "Tax and billing address", + "pax8": "Pax8", + "pax8Description": "Subscriptions and ordering", "portalBranding": "Portal & Branding", "branding": "Branding", "brandingDescription": "Portal theme and visuals", @@ -2475,6 +2477,54 @@ "saveType": "Failed to save organization type" } }, + "pax8": { + "title": "Pax8 subscriptions", + "description": "Review billable quantities, stage changes, and submit Pax8 orders.", + "actions": { "newOrder": "New order", "retry": "Try again" }, + "states": { "loadingProduct": "Loading product requirements…" }, + "products": { "emptyReason": "Map active catalog products to Pax8 before ordering." }, + "mapping": { + "title": "Company mapping", "company": "Pax8 company", "choose": "Choose a company", "map": "Map company", + "empty": "Map this organization to its Pax8 company before ordering.", "noIntegration": "Connect and sync Pax8 in partner integrations first.", + "unknownStatus": "Status not reported", "activeRequired": "The mapped Pax8 company must be Active before an order can be staged." + }, + "subscriptions": { + "title": "Subscription ledger", "description": "Breeze quantities are authoritative for billing. Pax8 values are last-known observations.", + "empty": "No Pax8 subscriptions have been observed for this organization.", "product": "Product", "breeze": "Breeze quantity", "pax8": "Pax8 reported", + "status": "Status", "observed": "Last observed", "actions": "Actions", "unknownProduct": "Unknown product", "unknownStatus": "Unknown", + "neverObserved": "Never", "notLinked": "Not linked", "notReported": "Not reported", "drift": "Drift", + "targetQuantity": "Target quantity for {{product}}", "stageChange": "Stage change", "cancel": "Cancel", + "actionUnavailable": "Link this subscription to a manual contract line before changing it.", + "commitmentUnavailable": "The active commitment cannot be identified safely. Refresh Pax8 data before ordering.", + "increaseBlocked": "This commitment does not allow quantity increases.", "decreaseBlocked": "This commitment does not allow quantity decreases.", + "cancelBlocked": "This commitment does not allow early cancellation.", "confirmCancel": "Stage cancellation for {{product}}?", + "feeWarning": "Pax8 reports that an early-cancellation fee applies.", "confirm": "Stage cancellation", "keep": "Keep subscription" + }, + "orders": { + "title": "Orders", "description": "Open staged work and order history for this organization.", "empty": "No Pax8 orders yet.", + "quoteOrder": "Quote fulfillment order", "directOrder": "Direct order" + }, + "order": { + "title": "Order builder", "back": "Back to Pax8", "quoteSource": "Staged from an accepted quote", "directSource": "Direct organization order", + "addProduct": "Add product", "product": "Product", "chooseProduct": "Choose a mapped product", "billingTerm": "Billing term", "quantity": "Quantity", + "commitment": "Commitment", "noCommitmentSelected": "No commitment selected", "addToOrder": "Add to order", + "item": "Item", "action": "Action", "state": "State", "actions": "Actions", "empty": "This order has no lines yet.", "unknownItem": "Unknown item", + "editDetails": "Edit details", "remove": "Remove line", "editProvisioning": "Provisioning details", "saveDetails": "Save details", "cancelEdit": "Cancel", + "reviewSubmit": "Preflight and submit", "reconcile": "Reconcile", "completed": "Order completed" + }, + "provisioning": { "optional": "Optional", "none": "This product reports no provisioning fields.", "unsupported": "Pax8 returned an unsupported field type." }, + "toasts": { + "companyMapped": "Pax8 company mapped", "draftReady": "Pax8 draft ready", "changeStaged": "Quantity change staged", "cancelStaged": "Cancellation staged", + "lineAdded": "Product added to the order", "lineUpdated": "Provisioning details saved", "lineRemoved": "Order line removed", + "preflightPassed": "Pax8 preflight passed", "submitted": "Pax8 order submitted", "submitNeedsAttention": "Pax8 order finished with items that need attention", "reconciled": "Pax8 order reconciled" + }, + "errors": { + "load": "Pax8 data could not be loaded.", "loadOrder": "The Pax8 order could not be loaded.", "loadProduct": "Product requirements could not be loaded.", + "mapCompany": "The Pax8 company could not be mapped.", "createDraft": "The Pax8 draft could not be created.", "stageChange": "The quantity change could not be staged.", + "stageCancel": "The cancellation could not be staged.", "addLine": "The product could not be added.", "updateLine": "Provisioning details could not be saved.", + "removeLine": "The order line could not be removed.", "preflight": "Pax8 preflight failed.", "submit": "The Pax8 order could not be submitted.", "reconcile": "The Pax8 order could not be reconciled." + } + }, "orgTicketSettingsEditor": { "loading": "Loading ticket settings…", "errors": { diff --git a/apps/web/src/locales/es-419/settings.json b/apps/web/src/locales/es-419/settings.json index c315c99700..099f91b17e 100644 --- a/apps/web/src/locales/es-419/settings.json +++ b/apps/web/src/locales/es-419/settings.json @@ -2354,6 +2354,8 @@ "contractsDescription": "Acuerdos recurrentes", "billing": "Facturación", "billingDescription": "Dirección fiscal y de facturación", + "pax8": "Marketplace Pax8", + "pax8Description": "Suscripciones y pedidos", "portalBranding": "Portal y marca", "branding": "Marca", "brandingDescription": "Tema del portal y elementos visuales", @@ -2422,6 +2424,49 @@ "saveType": "No se pudo guardar el tipo de organización" } }, + "pax8": { + "title": "Suscripciones de Pax8", "description": "Revisa cantidades facturables, prepara cambios y envía pedidos de Pax8.", + "actions": { "newOrder": "Nuevo pedido", "retry": "Reintentar" }, "states": { "loadingProduct": "Cargando requisitos del producto…" }, + "products": { "emptyReason": "Asocia productos activos del catálogo con Pax8 antes de pedir." }, + "mapping": { + "title": "Asociación de empresa", "company": "Empresa de Pax8", "choose": "Elegir una empresa", "map": "Asociar empresa", + "empty": "Asocia esta organización con su empresa de Pax8 antes de pedir.", "noIntegration": "Primero conecta y sincroniza Pax8 en las integraciones del socio.", + "unknownStatus": "Estado no informado", "activeRequired": "La empresa asociada de Pax8 debe estar activa antes de preparar un pedido." + }, + "subscriptions": { + "title": "Registro de suscripciones", "description": "Las cantidades de Breeze son la fuente de facturación. Los valores de Pax8 son observaciones recientes.", + "empty": "No se observaron suscripciones de Pax8 para esta organización.", "product": "Producto", "breeze": "Cantidad de Breeze", "pax8": "Informado por Pax8", + "status": "Estado", "observed": "Última observación", "actions": "Acciones", "unknownProduct": "Producto desconocido", "unknownStatus": "Desconocido", + "neverObserved": "Nunca", "notLinked": "Sin vínculo", "notReported": "No informado", "drift": "Diferencia", + "targetQuantity": "Cantidad objetivo para {{product}}", "stageChange": "Preparar cambio", "cancel": "Cancelar", + "actionUnavailable": "Vincula esta suscripción a una línea manual de contrato antes de cambiarla.", + "commitmentUnavailable": "No se puede identificar con seguridad el compromiso activo. Actualiza los datos de Pax8.", + "increaseBlocked": "Este compromiso no permite aumentar la cantidad.", "decreaseBlocked": "Este compromiso no permite reducir la cantidad.", + "cancelBlocked": "Este compromiso no permite cancelación anticipada.", "confirmCancel": "¿Preparar la cancelación de {{product}}?", + "feeWarning": "Pax8 informa que se aplica una tarifa por cancelación anticipada.", "confirm": "Preparar cancelación", "keep": "Conservar suscripción" + }, + "orders": { "title": "Pedidos", "description": "Trabajo preparado e historial de pedidos de esta organización.", "empty": "Aún no hay pedidos de Pax8.", "quoteOrder": "Pedido de cumplimiento de cotización", "directOrder": "Pedido directo" }, + "order": { + "title": "Preparar pedido", "back": "Volver a Pax8", "quoteSource": "Preparado desde una cotización aceptada", "directSource": "Pedido directo de la organización", + "addProduct": "Agregar producto", "product": "Producto", "chooseProduct": "Elegir un producto asociado", "billingTerm": "Plazo de facturación", "quantity": "Cantidad", + "commitment": "Compromiso", "noCommitmentSelected": "Sin compromiso seleccionado", "addToOrder": "Agregar al pedido", + "item": "Elemento", "action": "Acción", "state": "Estado", "actions": "Acciones", "empty": "Este pedido aún no tiene líneas.", "unknownItem": "Elemento desconocido", + "editDetails": "Editar detalles", "remove": "Quitar línea", "editProvisioning": "Detalles de aprovisionamiento", "saveDetails": "Guardar detalles", "cancelEdit": "Cancelar", + "reviewSubmit": "Validar y enviar", "reconcile": "Conciliar", "completed": "Pedido completado" + }, + "provisioning": { "optional": "Opcional", "none": "Este producto no informa campos de aprovisionamiento.", "unsupported": "Pax8 devolvió un tipo de campo no compatible." }, + "toasts": { + "companyMapped": "Empresa de Pax8 asociada", "draftReady": "Borrador de Pax8 listo", "changeStaged": "Cambio de cantidad preparado", "cancelStaged": "Cancelación preparada", + "lineAdded": "Producto agregado al pedido", "lineUpdated": "Detalles de aprovisionamiento guardados", "lineRemoved": "Línea de pedido eliminada", + "preflightPassed": "Validación de Pax8 aprobada", "submitted": "Pedido de Pax8 enviado", "submitNeedsAttention": "Algunos elementos necesitan atención", "reconciled": "Pedido de Pax8 conciliado" + }, + "errors": { + "load": "No se pudieron cargar los datos de Pax8.", "loadOrder": "No se pudo cargar el pedido de Pax8.", "loadProduct": "No se pudieron cargar los requisitos del producto.", + "mapCompany": "No se pudo asociar la empresa de Pax8.", "createDraft": "No se pudo crear el borrador de Pax8.", "stageChange": "No se pudo preparar el cambio de cantidad.", + "stageCancel": "No se pudo preparar la cancelación.", "addLine": "No se pudo agregar el producto.", "updateLine": "No se pudieron guardar los detalles de aprovisionamiento.", + "removeLine": "No se pudo quitar la línea del pedido.", "preflight": "Falló la validación de Pax8.", "submit": "No se pudo enviar el pedido de Pax8.", "reconcile": "No se pudo conciliar el pedido de Pax8." + } + }, "orgTicketSettingsEditor": { "loading": "Cargando configuración de tickets…", "errors": { diff --git a/apps/web/src/locales/fr-FR/settings.json b/apps/web/src/locales/fr-FR/settings.json index dcee2e92bd..a633e23ba2 100644 --- a/apps/web/src/locales/fr-FR/settings.json +++ b/apps/web/src/locales/fr-FR/settings.json @@ -2354,6 +2354,8 @@ "contractsDescription": "Accords récurrents", "billing": "Facturation", "billingDescription": "Adresse fiscale et de facturation", + "pax8": "Marché Pax8", + "pax8Description": "Abonnements et commandes", "portalBranding": "Portail & Branding", "branding": "Image de marque", "brandingDescription": "Thème du portail et visuels", @@ -2422,6 +2424,49 @@ "saveType": "Impossible d’enregistrer le type d’organisation" } }, + "pax8": { + "title": "Abonnements Pax8", "description": "Vérifiez les quantités facturables, préparez les changements et envoyez les commandes Pax8.", + "actions": { "newOrder": "Nouvelle commande", "retry": "Réessayer" }, "states": { "loadingProduct": "Chargement des exigences du produit…" }, + "products": { "emptyReason": "Associez des produits actifs du catalogue à Pax8 avant de commander." }, + "mapping": { + "title": "Association de l’entreprise", "company": "Entreprise Pax8", "choose": "Choisir une entreprise", "map": "Associer l’entreprise", + "empty": "Associez cette organisation à son entreprise Pax8 avant de commander.", "noIntegration": "Connectez et synchronisez d’abord Pax8 dans les intégrations partenaire.", + "unknownStatus": "État non communiqué", "activeRequired": "L’entreprise Pax8 associée doit être active avant de préparer une commande." + }, + "subscriptions": { + "title": "Registre des abonnements", "description": "Les quantités Breeze font foi pour la facturation. Les valeurs Pax8 sont les dernières observations.", + "empty": "Aucun abonnement Pax8 observé pour cette organisation.", "product": "Produit", "breeze": "Quantité Breeze", "pax8": "Valeur Pax8", + "status": "État", "observed": "Dernière observation", "actions": "Opérations", "unknownProduct": "Produit inconnu", "unknownStatus": "Inconnu", + "neverObserved": "Jamais", "notLinked": "Non lié", "notReported": "Non communiqué", "drift": "Écart", + "targetQuantity": "Quantité cible pour {{product}}", "stageChange": "Préparer le changement", "cancel": "Résilier", + "actionUnavailable": "Liez cet abonnement à une ligne de contrat manuelle avant de le modifier.", + "commitmentUnavailable": "L’engagement actif ne peut pas être identifié de façon sûre. Actualisez les données Pax8.", + "increaseBlocked": "Cet engagement n’autorise pas l’augmentation de quantité.", "decreaseBlocked": "Cet engagement n’autorise pas la réduction de quantité.", + "cancelBlocked": "Cet engagement n’autorise pas la résiliation anticipée.", "confirmCancel": "Préparer la résiliation de {{product}} ?", + "feeWarning": "Pax8 signale des frais de résiliation anticipée.", "confirm": "Préparer la résiliation", "keep": "Conserver l’abonnement" + }, + "orders": { "title": "Commandes", "description": "Travail préparé et historique des commandes de cette organisation.", "empty": "Aucune commande Pax8.", "quoteOrder": "Commande issue d’un devis", "directOrder": "Commande directe" }, + "order": { + "title": "Préparer la commande", "back": "Retour à Pax8", "quoteSource": "Préparée depuis un devis accepté", "directSource": "Commande directe de l’organisation", + "addProduct": "Ajouter un produit", "product": "Produit", "chooseProduct": "Choisir un produit associé", "billingTerm": "Période de facturation", "quantity": "Quantité", + "commitment": "Engagement", "noCommitmentSelected": "Aucun engagement sélectionné", "addToOrder": "Ajouter à la commande", + "item": "Élément", "action": "Opération", "state": "État", "actions": "Opérations", "empty": "Cette commande ne contient encore aucune ligne.", "unknownItem": "Élément inconnu", + "editDetails": "Modifier les détails", "remove": "Supprimer la ligne", "editProvisioning": "Détails de provisionnement", "saveDetails": "Enregistrer", "cancelEdit": "Annuler", + "reviewSubmit": "Prévalider et envoyer", "reconcile": "Rapprocher", "completed": "Commande terminée" + }, + "provisioning": { "optional": "Facultatif", "none": "Ce produit ne fournit aucun champ de provisionnement.", "unsupported": "Pax8 a renvoyé un type de champ non pris en charge." }, + "toasts": { + "companyMapped": "Entreprise Pax8 associée", "draftReady": "Brouillon Pax8 prêt", "changeStaged": "Changement de quantité préparé", "cancelStaged": "Résiliation préparée", + "lineAdded": "Produit ajouté à la commande", "lineUpdated": "Détails de provisionnement enregistrés", "lineRemoved": "Ligne de commande supprimée", + "preflightPassed": "Prévalidation Pax8 réussie", "submitted": "Commande Pax8 envoyée", "submitNeedsAttention": "Certains éléments nécessitent une intervention", "reconciled": "Commande Pax8 rapprochée" + }, + "errors": { + "load": "Impossible de charger les données Pax8.", "loadOrder": "Impossible de charger la commande Pax8.", "loadProduct": "Impossible de charger les exigences du produit.", + "mapCompany": "Impossible d’associer l’entreprise Pax8.", "createDraft": "Impossible de créer le brouillon Pax8.", "stageChange": "Impossible de préparer le changement de quantité.", + "stageCancel": "Impossible de préparer la résiliation.", "addLine": "Impossible d’ajouter le produit.", "updateLine": "Impossible d’enregistrer les détails de provisionnement.", + "removeLine": "Impossible de supprimer la ligne de commande.", "preflight": "La prévalidation Pax8 a échoué.", "submit": "Impossible d’envoyer la commande Pax8.", "reconcile": "Impossible de rapprocher la commande Pax8." + } + }, "orgTicketSettingsEditor": { "loading": "Chargement des paramètres du ticket...", "errors": { diff --git a/apps/web/src/locales/pt-BR/settings.json b/apps/web/src/locales/pt-BR/settings.json index 5c1008cfd6..e974ce9b45 100644 --- a/apps/web/src/locales/pt-BR/settings.json +++ b/apps/web/src/locales/pt-BR/settings.json @@ -2407,6 +2407,8 @@ "contractsDescription": "Acordos recorrentes", "billing": "Cobrança", "billingDescription": "Endereço fiscal e de cobrança", + "pax8": "Marketplace Pax8", + "pax8Description": "Assinaturas e pedidos", "portalBranding": "Portal e identidade visual", "branding": "Identidade visual", "brandingDescription": "Tema e recursos visuais do portal", @@ -2475,6 +2477,22 @@ "saveType": "Falha ao salvar o tipo de organização" } }, + "pax8": { + "title": "Assinaturas Pax8", "description": "Revise quantidades faturáveis, prepare alterações e envie pedidos Pax8.", + "actions": { "newOrder": "Novo pedido", "retry": "Tentar novamente" }, "states": { "loadingProduct": "Carregando requisitos do produto…" }, + "products": { "emptyReason": "Mapeie produtos ativos do catálogo para a Pax8 antes de pedir." }, + "mapping": { "title": "Mapeamento da empresa", "company": "Empresa Pax8", "choose": "Escolher uma empresa", "map": "Mapear empresa", "empty": "Mapeie esta organização para sua empresa Pax8 antes de pedir.", "noIntegration": "Primeiro conecte e sincronize a Pax8 nas integrações do parceiro.", "unknownStatus": "Status não informado", "activeRequired": "A empresa Pax8 mapeada deve estar ativa antes de preparar um pedido." }, + "subscriptions": { + "title": "Registro de assinaturas", "description": "As quantidades do Breeze são a fonte do faturamento. Os valores Pax8 são as observações mais recentes.", + "empty": "Nenhuma assinatura Pax8 foi observada para esta organização.", "product": "Produto", "breeze": "Quantidade Breeze", "pax8": "Informado pela Pax8", "status": "Situação", "observed": "Última observação", "actions": "Ações", "unknownProduct": "Produto desconhecido", "unknownStatus": "Desconhecido", "neverObserved": "Nunca", "notLinked": "Não vinculado", "notReported": "Não informado", "drift": "Divergência", + "targetQuantity": "Quantidade desejada para {{product}}", "stageChange": "Preparar alteração", "cancel": "Cancelar", "actionUnavailable": "Vincule esta assinatura a uma linha manual de contrato antes de alterá-la.", "commitmentUnavailable": "Não foi possível identificar com segurança o compromisso ativo. Atualize os dados Pax8.", "increaseBlocked": "Este compromisso não permite aumentar a quantidade.", "decreaseBlocked": "Este compromisso não permite reduzir a quantidade.", "cancelBlocked": "Este compromisso não permite cancelamento antecipado.", "confirmCancel": "Preparar o cancelamento de {{product}}?", "feeWarning": "A Pax8 informa que há uma taxa de cancelamento antecipado.", "confirm": "Preparar cancelamento", "keep": "Manter assinatura" + }, + "orders": { "title": "Pedidos", "description": "Trabalho preparado e histórico de pedidos desta organização.", "empty": "Ainda não há pedidos Pax8.", "quoteOrder": "Pedido de proposta", "directOrder": "Pedido direto" }, + "order": { "title": "Preparar pedido", "back": "Voltar para Pax8", "quoteSource": "Preparado a partir de uma proposta aceita", "directSource": "Pedido direto da organização", "addProduct": "Adicionar produto", "product": "Produto", "chooseProduct": "Escolher um produto mapeado", "billingTerm": "Período de cobrança", "quantity": "Quantidade", "commitment": "Compromisso", "noCommitmentSelected": "Nenhum compromisso selecionado", "addToOrder": "Adicionar ao pedido", "item": "Produto", "action": "Ação", "state": "Estado", "actions": "Ações", "empty": "Este pedido ainda não tem linhas.", "unknownItem": "Item desconhecido", "editDetails": "Editar detalhes", "remove": "Remover linha", "editProvisioning": "Detalhes de provisionamento", "saveDetails": "Salvar detalhes", "cancelEdit": "Cancelar", "reviewSubmit": "Validar e enviar", "reconcile": "Reconciliar", "completed": "Pedido concluído" }, + "provisioning": { "optional": "Opcional", "none": "Este produto não informa campos de provisionamento.", "unsupported": "A Pax8 retornou um tipo de campo não compatível." }, + "toasts": { "companyMapped": "Empresa Pax8 mapeada", "draftReady": "Rascunho Pax8 pronto", "changeStaged": "Alteração de quantidade preparada", "cancelStaged": "Cancelamento preparado", "lineAdded": "Produto adicionado ao pedido", "lineUpdated": "Detalhes de provisionamento salvos", "lineRemoved": "Linha do pedido removida", "preflightPassed": "Validação Pax8 aprovada", "submitted": "Pedido Pax8 enviado", "submitNeedsAttention": "Alguns itens precisam de atenção", "reconciled": "Pedido Pax8 reconciliado" }, + "errors": { "load": "Não foi possível carregar os dados Pax8.", "loadOrder": "Não foi possível carregar o pedido Pax8.", "loadProduct": "Não foi possível carregar os requisitos do produto.", "mapCompany": "Não foi possível mapear a empresa Pax8.", "createDraft": "Não foi possível criar o rascunho Pax8.", "stageChange": "Não foi possível preparar a alteração de quantidade.", "stageCancel": "Não foi possível preparar o cancelamento.", "addLine": "Não foi possível adicionar o produto.", "updateLine": "Não foi possível salvar os detalhes de provisionamento.", "removeLine": "Não foi possível remover a linha do pedido.", "preflight": "A validação Pax8 falhou.", "submit": "Não foi possível enviar o pedido Pax8.", "reconcile": "Não foi possível reconciliar o pedido Pax8." } + }, "orgTicketSettingsEditor": { "loading": "Carregando configurações de chamados…", "errors": { From 41bb4123d695ec269b2b7c36f89fe5a66738bde4 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Tue, 14 Jul 2026 06:03:53 -0600 Subject: [PATCH 22/40] fix(pax8): harden organization ordering workflow --- .../pax8OrderSubmit.integration.test.ts | 9 ++ .../pax8OrdersPartnerRls.integration.test.ts | 9 ++ apps/api/src/routes/pax8.test.ts | 54 +++++++++++ apps/api/src/routes/pax8.ts | 20 +++- apps/api/src/routes/pax8Orders.test.ts | 8 ++ apps/api/src/routes/pax8Orders.ts | 1 - apps/api/src/services/pax8CompanyReadiness.ts | 54 +++++++++++ .../api/src/services/pax8OrderService.test.ts | 95 ++++++++++++++++++- apps/api/src/services/pax8OrderService.ts | 74 +++++++++++++-- .../src/services/pax8OrderSubmitRepository.ts | 18 +++- .../organizations/Pax8OrderBuilder.test.tsx | 59 ++++++++++++ .../organizations/Pax8OrgTab.test.tsx | 92 ++++++++++++++++++ .../components/organizations/Pax8OrgTab.tsx | 23 ++++- .../organizations/Pax8ProvisioningForm.tsx | 36 +++++-- apps/web/src/lib/api/pax8Orders.ts | 7 ++ apps/web/src/locales/de-DE/settings.json | 6 +- apps/web/src/locales/en/settings.json | 6 +- apps/web/src/locales/es-419/settings.json | 6 +- apps/web/src/locales/fr-FR/settings.json | 6 +- apps/web/src/locales/pt-BR/settings.json | 6 +- 20 files changed, 545 insertions(+), 44 deletions(-) create mode 100644 apps/api/src/services/pax8CompanyReadiness.ts diff --git a/apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts b/apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts index dfb613c5be..345b38aec6 100644 --- a/apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts +++ b/apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts @@ -18,6 +18,13 @@ import { createOrganization, createPartner, createUser } from './db-utils'; import { getTestDb } from './setup'; const runDb = it.runIf(!!process.env.DATABASE_URL); +const READY_COMPANY_METADATA = { + contacts: [{ types: [ + { type: 'Admin', primary: true }, + { type: 'Billing', primary: true }, + { type: 'Technical', primary: true }, + ] }], +}; async function seedOrder(options: { action?: 'new_subscription' | 'cancel' } = {}) { return withSystemDbAccessContext(async () => { @@ -38,6 +45,8 @@ async function seedOrder(options: { action?: 'new_subscription' | 'cancel' } = { pax8CompanyId: 'company-1', pax8CompanyName: 'Acme', orgId: org.id, + status: 'Active', + metadata: READY_COMPANY_METADATA, }); const [contract] = await db.insert(contracts).values({ partnerId: partner.id, diff --git a/apps/api/src/__tests__/integration/pax8OrdersPartnerRls.integration.test.ts b/apps/api/src/__tests__/integration/pax8OrdersPartnerRls.integration.test.ts index 8e50850e7c..bf31c157cf 100644 --- a/apps/api/src/__tests__/integration/pax8OrdersPartnerRls.integration.test.ts +++ b/apps/api/src/__tests__/integration/pax8OrdersPartnerRls.integration.test.ts @@ -27,6 +27,13 @@ import { getOrCreateDraftOrder, listPax8Orders } from '../../services/pax8OrderS import { createOrganization, createPartner, createUser } from './db-utils'; const runDb = it.runIf(!!process.env.DATABASE_URL); +const READY_COMPANY_METADATA = { + contacts: [{ types: [ + { type: 'Admin', primary: true }, + { type: 'Billing', primary: true }, + { type: 'Technical', primary: true }, + ] }], +}; function partnerContext(partnerId: string): DbAccessContext { return { @@ -73,6 +80,8 @@ async function seed() { pax8CompanyId: 'pax8-co-a', pax8CompanyName: 'Customer A', orgId: orgA.id, + status: 'Active', + metadata: READY_COMPANY_METADATA, }); const [orderA] = await db diff --git a/apps/api/src/routes/pax8.test.ts b/apps/api/src/routes/pax8.test.ts index 8491bef5dc..4e197db130 100644 --- a/apps/api/src/routes/pax8.test.ts +++ b/apps/api/src/routes/pax8.test.ts @@ -69,6 +69,7 @@ vi.mock('../db/schema', () => ({ ignored: 'pax8_company_mappings.ignored', lastSeenAt: 'pax8_company_mappings.last_seen_at', updatedAt: 'pax8_company_mappings.updated_at', + metadata: 'pax8_company_mappings.metadata', integrationId: 'pax8_company_mappings.integration_id', partnerId: 'pax8_company_mappings.partner_id', }, @@ -90,6 +91,7 @@ vi.mock('../db/schema', () => ({ unitCost: 'pax8_subscription_snapshots.unit_cost', currencyCode: 'pax8_subscription_snapshots.currency_code', lastSeenAt: 'pax8_subscription_snapshots.last_seen_at', + raw: 'pax8_subscription_snapshots.raw', }, pax8ContractLineLinks: { id: 'pax8_contract_line_links.id', @@ -207,6 +209,16 @@ function mockSubscriptionSelectOnce(integrationRows: unknown[], snapshotRows: un return { whereSpy, getConditions: () => whereConditions }; } +function mockCompanySelectOnce(integrationRows: unknown[], companyRows: unknown[]) { + mockSelectOnce(integrationRows); + const chain: Record = {}; + chain.from = vi.fn(() => chain); + chain.leftJoin = vi.fn(() => chain); + chain.where = vi.fn(() => chain); + chain.orderBy = vi.fn(async () => companyRows); + vi.mocked(db.select).mockReturnValueOnce(chain as any); +} + describe('pax8 routes', () => { let app: Hono; @@ -237,6 +249,30 @@ describe('pax8 routes', () => { expect(res.status).toBe(403); }); + it('GET /companies returns bounded ordering readiness without contact PII or metadata', async () => { + const integration = { id: '44444444-4444-4444-4444-444444444444', partnerId: authState.partnerId }; + mockCompanySelectOnce([integration], [{ + pax8CompanyId: 'company-1', pax8CompanyName: 'Acme', status: 'Active', mappedOrgId: ORG_A, + mappedOrgName: 'Acme', ignored: false, lastSeenAt: null, updatedAt: null, + metadata: { + contacts: [{ email: 'private@example.com', types: [ + { type: 'Admin', primary: true }, { type: 'Billing', primary: true }, { type: 'Technical', primary: true }, + ] }], + }, + }]); + + const res = await app.request('/pax8/companies'); + + expect(res.status).toBe(200); + const body = await res.json() as any; + expect(body.data[0]).toMatchObject({ + statusActive: true, primaryAdminReady: true, primaryBillingReady: true, + primaryTechnicalReady: true, orderReady: true, + }); + expect(body.data[0]).not.toHaveProperty('metadata'); + expect(JSON.stringify(body)).not.toContain('private@example.com'); + }); + it('requires credentials when creating a Pax8 integration', async () => { mockSelectOnce([]); @@ -481,6 +517,24 @@ describe('pax8 routes', () => { }); }); + it('GET /subscriptions projects active commitment evidence without exposing the raw snapshot', async () => { + const integration = { id: '44444444-4444-4444-4444-444444444444', partnerId: authState.partnerId }; + mockSubscriptionSelectOnce([integration], [{ + id: 'snap-1', orgId: ORG_A, + raw: { commitment: { id: 'active-commitment', secret: 'do-not-return' } }, + }]); + + const res = await app.request(`/pax8/subscriptions?orgId=${ORG_A}`); + + expect(res.status).toBe(200); + const body = await res.json() as any; + expect(body.data[0]).toMatchObject({ + activeCommitmentId: 'active-commitment', activeCommitmentAmbiguous: false, + }); + expect(body.data[0]).not.toHaveProperty('raw'); + expect(JSON.stringify(body)).not.toContain('do-not-return'); + }); + it('GET /subscriptions with inaccessible orgId returns 403', async () => { authState.canAccessOrg = false; mockSubscriptionSelectOnce([{ id: '44444444-4444-4444-4444-444444444444', partnerId: authState.partnerId, name: 'Pax8', apiBaseUrl: 'https://api.pax8.com', tokenUrl: 'https://login.pax8.com', isActive: true, lastSyncAt: null, lastSyncStatus: null, lastSyncError: null, createdAt: new Date(), updatedAt: new Date() }], []); diff --git a/apps/api/src/routes/pax8.ts b/apps/api/src/routes/pax8.ts index c4028389fc..70d2ddf619 100644 --- a/apps/api/src/routes/pax8.ts +++ b/apps/api/src/routes/pax8.ts @@ -19,6 +19,8 @@ import { DEFAULT_PAX8_API_BASE_URL, DEFAULT_PAX8_TOKEN_URL } from '../services/p import { createPax8ClientForIntegration, linkPax8SubscriptionToContractLine, mapPax8Company, unlinkPax8Subscription } from '../services/pax8SyncService'; import { enqueuePax8Sync } from '../jobs/pax8SyncWorker'; import { captureException } from '../services/sentry'; +import { pax8CompanyOrderReadiness } from '../services/pax8CompanyReadiness'; +import { snapshotActiveCommitmentEvidence } from '../services/pax8OrderService'; export const pax8Routes = new Hono(); @@ -301,12 +303,19 @@ pax8Routes.get('/companies', partnerScopes, readPerm, zValidator('query', compan ignored: pax8CompanyMappings.ignored, lastSeenAt: pax8CompanyMappings.lastSeenAt, updatedAt: pax8CompanyMappings.updatedAt, + metadata: pax8CompanyMappings.metadata, }) .from(pax8CompanyMappings) .leftJoin(organizations, eq(pax8CompanyMappings.orgId, organizations.id)) .where(eq(pax8CompanyMappings.integrationId, integration.id)) .orderBy(pax8CompanyMappings.pax8CompanyName, pax8CompanyMappings.pax8CompanyId); - return c.json({ data: rows, integrationId: integration.id }); + return c.json({ + data: rows.map(({ metadata, ...row }) => ({ + ...row, + ...pax8CompanyOrderReadiness(row.status, metadata), + })), + integrationId: integration.id, + }); }); pax8Routes.post('/companies/map', partnerScopes, writePerm, requireMfa(), zValidator('json', companyMapSchema), async (c) => { @@ -390,6 +399,7 @@ pax8Routes.get('/subscriptions', partnerScopes, readPerm, zValidator('query', su syncEnabled: pax8ContractLineLinks.syncEnabled, lastObservedQuantity: pax8ContractLineLinks.lastObservedQuantity, lastObservedAt: pax8ContractLineLinks.lastObservedAt, + raw: pax8SubscriptionSnapshots.raw, }) .from(pax8SubscriptionSnapshots) .leftJoin(pax8CompanyMappings, and( @@ -406,7 +416,13 @@ pax8Routes.get('/subscriptions', partnerScopes, readPerm, zValidator('query', su .orderBy(desc(pax8SubscriptionSnapshots.lastSeenAt)) .limit(query.limit); - return c.json({ data: rows, integrationId: integration.id }); + return c.json({ + data: rows.map(({ raw, ...row }) => ({ + ...row, + ...snapshotActiveCommitmentEvidence(raw), + })), + integrationId: integration.id, + }); }); pax8Routes.post('/subscriptions/link', partnerScopes, writePerm, requireMfa(), zValidator('json', linkSchema), async (c) => { diff --git a/apps/api/src/routes/pax8Orders.test.ts b/apps/api/src/routes/pax8Orders.test.ts index 2c8b72d9b5..61871a78a7 100644 --- a/apps/api/src/routes/pax8Orders.test.ts +++ b/apps/api/src/routes/pax8Orders.test.ts @@ -612,6 +612,14 @@ describe('Pax8 order route handlers', () => { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'unknown' }), }); expect(badLine.status).toBe(400); + const clientOwnedPosition = await request(`/orders/${ORDER_ID}/lines`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + action: 'new_subscription', pax8ProductId: 'prod-1', billingTerm: 'Monthly', + quantity: '1.00', sortOrder: 99, + }), + }); + expect(clientOwnedPosition.status).toBe(400); expect(mocks.addOrderLine).not.toHaveBeenCalled(); expect(mocks.writeRouteAudit).not.toHaveBeenCalled(); }); diff --git a/apps/api/src/routes/pax8Orders.ts b/apps/api/src/routes/pax8Orders.ts index c1ac5f64d8..854aa063ff 100644 --- a/apps/api/src/routes/pax8Orders.ts +++ b/apps/api/src/routes/pax8Orders.ts @@ -92,7 +92,6 @@ const addLineSchema = z.object({ targetSubscriptionId: z.string().trim().min(1).max(64).optional(), cancelDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), contractLineId: z.string().guid().optional(), - sortOrder: z.number().int().min(0).max(100_000).optional(), }).strict(); const updateLineSchema = z.object({ diff --git a/apps/api/src/services/pax8CompanyReadiness.ts b/apps/api/src/services/pax8CompanyReadiness.ts new file mode 100644 index 0000000000..813f5c6e4d --- /dev/null +++ b/apps/api/src/services/pax8CompanyReadiness.ts @@ -0,0 +1,54 @@ +type JsonRecord = Record; + +export interface Pax8CompanyOrderReadiness { + statusActive: boolean; + primaryAdminReady: boolean; + primaryBillingReady: boolean; + primaryTechnicalReady: boolean; + orderReady: boolean; +} + +function asRecord(value: unknown): JsonRecord | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as JsonRecord + : null; +} + +/** + * Projects only the ordering capability required by Pax8. Contact PII and the + * raw company payload never leave the server. Missing contact evidence is + * deliberately unknown/not-ready even when the company says Active. + */ +export function pax8CompanyOrderReadiness( + status: string | null | undefined, + metadata: unknown, +): Pax8CompanyOrderReadiness { + const statusActive = status?.trim().toLowerCase() === 'active'; + const record = asRecord(metadata); + const contacts = Array.isArray(record?.contacts) ? record.contacts : []; + const primaryTypes = new Set(); + + for (const candidate of contacts) { + const contact = asRecord(candidate); + if (!contact || !Array.isArray(contact.types)) continue; + for (const value of contact.types) { + const type = asRecord(value); + if (type?.primary !== true || typeof type.type !== 'string') continue; + primaryTypes.add(type.type.trim().toLowerCase()); + } + } + + const primaryAdminReady = primaryTypes.has('admin'); + const primaryBillingReady = primaryTypes.has('billing'); + const primaryTechnicalReady = primaryTypes.has('technical'); + return { + statusActive, + primaryAdminReady, + primaryBillingReady, + primaryTechnicalReady, + orderReady: statusActive + && primaryAdminReady + && primaryBillingReady + && primaryTechnicalReady, + }; +} diff --git a/apps/api/src/services/pax8OrderService.test.ts b/apps/api/src/services/pax8OrderService.test.ts index 0acee2f66f..f10850933e 100644 --- a/apps/api/src/services/pax8OrderService.test.ts +++ b/apps/api/src/services/pax8OrderService.test.ts @@ -36,7 +36,9 @@ vi.mock('../db/schema', () => new Proxy({ id: 'pax8_order_lines.id', orderId: 'pax8_order_lines.order_id', partnerId: 'pax8_order_lines.partner_id', + orgId: 'pax8_order_lines.org_id', action: 'pax8_order_lines.action', + sortOrder: 'pax8_order_lines.sort_order', }, pax8CompanyMappings: { partnerId: 'pax8_company_mappings.partner_id', @@ -195,7 +197,18 @@ function selectRowsOnce(rows: unknown[]) { } function mockCompanyMappingLookup(mapping: Record | null) { - selectRowsOnce(mapping ? [{ orgId: 'o1', ...mapping }] : []); + selectRowsOnce(mapping ? [{ + orgId: 'o1', + status: 'Active', + metadata: { + contacts: [{ types: [ + { type: 'Admin', primary: true }, + { type: 'Billing', primary: true }, + { type: 'Technical', primary: true }, + ] }], + }, + ...mapping, + }] : []); } function mockOrder(overrides: Record = {}) { @@ -289,6 +302,28 @@ describe('getOrCreateDraftOrder', () => { }); }); + it.each([ + ['contact evidence is absent', { metadata: null }], + ['the company is inactive', { status: 'Inactive' }], + ['the primary admin contact is missing', { metadata: { contacts: [{ types: [ + { type: 'Billing', primary: true }, { type: 'Technical', primary: true }, + ] }] } }], + ['the primary billing contact is missing', { metadata: { contacts: [{ types: [ + { type: 'Admin', primary: true }, { type: 'Technical', primary: true }, + ] }] } }], + ['the primary technical contact is missing', { metadata: { contacts: [{ types: [ + { type: 'Admin', primary: true }, { type: 'Billing', primary: true }, + ] }] } }], + ])('fails closed before creating a draft when %s', async (_name, mapping) => { + mockCompanyMappingLookup({ pax8CompanyId: 'co-1', integrationId: 'i1', ...mapping }); + selectRowsOnce([]); + insertReturningOnce([{ ...baseOrder, id: 'should-not-create' }]); + + await expect(getOrCreateDraftOrder({ partnerId: 'p1', orgId: 'o1', actorUserId: 'u1' })) + .rejects.toMatchObject({ status: 422, message: expect.stringContaining('ready') }); + expect(mocks.db.insert).not.toHaveBeenCalled(); + }); + it('reuses the existing open draft rather than creating a second one', async () => { mockCompanyMappingLookup({ pax8CompanyId: 'co-1', integrationId: 'i1' }); selectRowsOnce([{ ...baseOrder, id: 'ord-existing' }]); @@ -344,6 +379,34 @@ describe('getOrCreateDraftOrder', () => { }); describe('addOrderLine', () => { + it('fails closed before staging a direct line when company contact evidence is absent', async () => { + mocks.db.select.mockImplementation((selection?: Record) => { + let rows: unknown[] = []; + const chain: Record = {}; + chain.from = vi.fn((table: unknown) => { + rows = Object.prototype.hasOwnProperty.call(selection ?? {}, 'maxSortOrder') + ? [{ maxSortOrder: null }] + : containsValue(table, 'pax8_company_mappings.ignored') + ? [{ orgId: 'o1', pax8CompanyId: 'co-1', integrationId: 'i1', status: 'Active', metadata: null }] + : [{ ...baseOrder, source: 'direct' }]; + return chain; + }); + chain.where = vi.fn(() => chain); + chain.for = vi.fn(() => chain); + chain.limit = vi.fn(async () => rows); + chain.then = (resolve: (value: unknown[]) => unknown, reject: (error: unknown) => unknown) => + Promise.resolve(rows).then(resolve, reject); + return chain; + }); + insertReturningOnce([{ id: 'should-not-stage' }]); + + await expect(addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'new_subscription', + pax8ProductId: 'prod-1', billingTerm: 'Monthly', quantity: '1.00', + })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('ready') }); + expect(mocks.db.insert).not.toHaveBeenCalled(); + }); + it('rejects a change_quantity whose commitment forbids a decrease', async () => { mockOrder(); mockSubscriptionSnapshot(); @@ -595,6 +658,7 @@ describe('addOrderLine', () => { return new Promise((resolve) => { resolveDependencies = resolve; }); }); mockOrder(); + selectRowsOnce([{ maxSortOrder: null }]); insertReturningOnce([{ id: 'line-1', orderId: 'ord-1', partnerId: 'p1', orgId: 'o1', action: 'change_quantity' }]); const pending = addOrderLine({ @@ -703,6 +767,7 @@ describe('addOrderLine', () => { it('inserts a valid new subscription line with order tenancy fields', async () => { mockOrder({ status: 'awaiting_details' }); mockOrder({ status: 'awaiting_details' }); + selectRowsOnce([{ maxSortOrder: null }]); const insert = insertReturningOnce([{ id: 'line-1', orderId: 'ord-1', @@ -728,9 +793,37 @@ describe('addOrderLine', () => { orgId: 'o1', action: 'new_subscription', submitState: 'pending', + sortOrder: 0, })); }); + it('allocates distinct deterministic positions for consecutive direct lines', async () => { + const orders = [ + [{ ...baseOrder }], [{ ...baseOrder }], + [{ ...baseOrder }], [{ ...baseOrder }], + ]; + const positions = [[{ maxSortOrder: null }], [{ maxSortOrder: 0 }]]; + mocks.db.select.mockImplementation((selection?: Record) => queryChain( + Object.prototype.hasOwnProperty.call(selection ?? {}, 'maxSortOrder') + ? positions.shift() ?? [] + : orders.shift() ?? [], + )); + const firstInsert = insertReturningOnce([{ id: 'line-1' }]); + const secondInsert = insertReturningOnce([{ id: 'line-2' }]); + + await addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'new_subscription', + pax8ProductId: 'prod-1', billingTerm: 'Monthly', quantity: '1.00', + }); + await addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'new_subscription', + pax8ProductId: 'prod-2', billingTerm: 'Monthly', quantity: '2.00', + }); + + expect(firstInsert.values).toHaveBeenCalledWith(expect.objectContaining({ sortOrder: 0 })); + expect(secondInsert.values).toHaveBeenCalledWith(expect.objectContaining({ sortOrder: 1 })); + }); + it('rejects when the order becomes immutable before the final insert', async () => { mockOrder({ status: 'draft' }); const finalOrderQuery = mockOrder({ status: 'submitting' }); diff --git a/apps/api/src/services/pax8OrderService.ts b/apps/api/src/services/pax8OrderService.ts index 36a97b4ae5..f38ff32396 100644 --- a/apps/api/src/services/pax8OrderService.ts +++ b/apps/api/src/services/pax8OrderService.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; import { PAX8_BILLING_TERMS, type Pax8BillingTerm, type Pax8OrderAction } from '@breeze/shared'; -import { and, asc, desc, eq, inArray, notInArray, sql, type SQL } from 'drizzle-orm'; +import { and, asc, desc, eq, inArray, max, notInArray, sql, type SQL } from 'drizzle-orm'; import { db, runOutsideDbContext, @@ -17,6 +17,7 @@ import { catalogItems, } from '../db/schema'; import { createPax8ClientForIntegration } from './pax8SyncService'; +import { pax8CompanyOrderReadiness } from './pax8CompanyReadiness'; import type { Pax8Commitment } from './pax8Client'; export class Pax8OrderError extends Error { @@ -46,7 +47,6 @@ export interface AddOrderLineInput { cancelDate?: string; contractLineId?: string; sourceQuoteLineId?: string; - sortOrder?: number; } /** Stable per-order. The unique index on (partner_id, dedupe_key) is what makes @@ -209,15 +209,26 @@ function activeCommitmentIds(raw: unknown): string[] { return [...ids]; } +export function snapshotActiveCommitmentEvidence(raw: unknown): { + activeCommitmentId: string | null; + activeCommitmentAmbiguous: boolean; +} { + const ids = activeCommitmentIds(raw); + return { + activeCommitmentId: ids.length === 1 ? ids[0]! : null, + activeCommitmentAmbiguous: ids.length > 1, + }; +} + function activeCommitment(raw: unknown, commitments: Pax8Commitment[]): Pax8Commitment { - const activeIds = activeCommitmentIds(raw); - if (activeIds.length > 1) { + const evidence = snapshotActiveCommitmentEvidence(raw); + if (evidence.activeCommitmentAmbiguous) { throw new Pax8OrderError( 'The Pax8 subscription snapshot contains ambiguous active commitment identifiers.', 422, ); } - const [activeId] = activeIds; + const activeId = evidence.activeCommitmentId; if (activeId) { const matches = commitments.filter((commitment) => commitment.id === activeId); if (matches.length === 1) return matches[0]!; @@ -290,6 +301,37 @@ function validateActionPayload(input: AddOrderLineInput): void { } } +function requireCompanyOrderReady(mapping: { + status?: string | null; + metadata?: unknown; +}): void { + if (!pax8CompanyOrderReadiness(mapping.status, mapping.metadata).orderReady) { + throw new Pax8OrderError( + 'The mapped Pax8 company is not ready for ordering. It must be Active with primary Admin, Billing, and Technical contacts.', + 422, + ); + } +} + +async function requireCurrentCompanyOrderReady(order: Pax8OrderRow): Promise { + const mappings = await db + .select({ + status: pax8CompanyMappings.status, + metadata: pax8CompanyMappings.metadata, + }) + .from(pax8CompanyMappings) + .where(and( + eq(pax8CompanyMappings.integrationId, order.integrationId), + eq(pax8CompanyMappings.partnerId, order.partnerId), + eq(pax8CompanyMappings.orgId, order.orgId), + eq(pax8CompanyMappings.ignored, false), + )); + if (mappings.length !== 1) { + throw new Pax8OrderError('Resolve the Pax8 company mapping before staging this order.', 422); + } + requireCompanyOrderReady(mappings[0]!); +} + export async function getOrCreateDraftOrder(input: { partnerId: string; orgId: string; @@ -311,6 +353,7 @@ export async function getOrCreateDraftOrder(input: { 409, ); } + requireCompanyOrderReady(mapping); const existing = await findMutableDirectOrder(input.partnerId, input.orgId); if (existing) return existing; @@ -345,6 +388,9 @@ export async function addOrderLine(input: AddOrderLineInput): Promise loadOrder(input.partnerId, input.orderId)); requireMutableOrder(order); + if (order.source === 'direct') { + await withPartnerDbContext(input.partnerId, () => requireCurrentCompanyOrderReady(order)); + } validateActionPayload(input); if (input.action === 'change_quantity' || input.action === 'cancel') { @@ -401,6 +447,19 @@ export async function addOrderLine(input: AddOrderLineInput): Promise 100_000) { + throw new Pax8OrderError('The Pax8 order has too many lines.', 422); + } + return db .insert(pax8OrderLines) .values({ @@ -419,7 +478,7 @@ export async function addOrderLine(input: AddOrderLineInput): Promise eq(pax8OrderLines.partnerId, order.partnerId), eq(pax8OrderLines.orgId, order.orgId), eq(pax8OrderLines.orderId, order.id), - )); + )) + .orderBy(asc(pax8OrderLines.sortOrder), asc(pax8OrderLines.id)); } async function resolveCompany(order: Pax8OrderRow): Promise { const mappings = await db - .select({ pax8CompanyId: pax8CompanyMappings.pax8CompanyId }) + .select({ + pax8CompanyId: pax8CompanyMappings.pax8CompanyId, + status: pax8CompanyMappings.status, + metadata: pax8CompanyMappings.metadata, + }) .from(pax8CompanyMappings) .where(and( eq(pax8CompanyMappings.integrationId, order.integrationId), @@ -93,6 +99,12 @@ async function resolveCompany(order: Pax8OrderRow): Promise { if (mappings.length !== 1) { throw new Pax8OrderError('Multiple Pax8 companies are mapped to this organization; resolve the mapping before ordering.', 422); } + if (!pax8CompanyOrderReadiness(mappings[0]!.status, mappings[0]!.metadata).orderReady) { + throw new Pax8OrderError( + 'The mapped Pax8 company is not ready for ordering. It must be Active with primary Admin, Billing, and Technical contacts.', + 422, + ); + } return mappings[0]!.pax8CompanyId; } diff --git a/apps/web/src/components/organizations/Pax8OrderBuilder.test.tsx b/apps/web/src/components/organizations/Pax8OrderBuilder.test.tsx index 77bd9175e3..f41aa4d95b 100644 --- a/apps/web/src/components/organizations/Pax8OrderBuilder.test.tsx +++ b/apps/web/src/components/organizations/Pax8OrderBuilder.test.tsx @@ -41,6 +41,28 @@ describe('Pax8 provisioning details', () => { expect(select.required).toBe(false); }); + it('clears an optional Single-Value field without adding a synthetic select option', async () => { + const onChange = vi.fn(); + function Harness() { + const [value, setValue] = useState>([]); + return { setValue(next); onChange(next); }} + />; + } + render(); + + const select = screen.getByTestId('pax8-provision-region') as HTMLSelectElement; + await userEvent.selectOptions(select, 'CA'); + expect([...select.options].map((option) => option.value)).toEqual(['US', 'CA']); + await userEvent.click(screen.getByRole('button', { name: /clear region/i })); + + expect(onChange).toHaveBeenLastCalledWith([]); + expect(select.value).toBe(''); + expect([...select.options].map((option) => option.value)).toEqual(['US', 'CA']); + }); + it('keeps every field optional and supports an accessible native multiselect', async () => { const onChange = vi.fn(); function Harness() { @@ -109,4 +131,41 @@ describe('Pax8 preflight errors', () => { expect(await screen.findByTestId('pax8-line-error-line-1')).toHaveTextContent('Tenant domain must be supplied.'); expect(submitPax8Order).not.toHaveBeenCalled(); }); + + it('correlates a raw lineItemNumber 2 error to the second sorted line', async () => { + vi.mocked(preflightPax8Order).mockResolvedValue(new Response(JSON.stringify({ + details: [{ lineItemNumber: 2, message: 'Second line needs a tenant domain.' }], + }), { status: 422, headers: { 'content-type': 'application/json' } })); + + const line = (id: string, sortOrder: number) => ({ + id, orderId: '44444444-4444-4444-8444-444444444444', action: 'new_subscription' as const, + submitState: 'pending' as const, pax8ProductId: 'prod-1', catalogItemId: 'cat-1', billingTerm: 'Monthly', + commitmentTermId: null, quantity: '1.00', provisioningDetails: [], targetSubscriptionId: null, + resultSubscriptionId: null, contractLineId: `contract-${id}`, sourceQuoteLineId: null, + error: null, sortOrder, + }); + render(); + + await userEvent.click(screen.getByTestId('pax8-submit')); + expect(await screen.findByTestId('pax8-line-error-line-2')).toHaveTextContent('Second line needs a tenant domain.'); + expect(screen.queryByTestId('pax8-line-error-line-1')).not.toBeInTheDocument(); + expect(submitPax8Order).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/components/organizations/Pax8OrgTab.test.tsx b/apps/web/src/components/organizations/Pax8OrgTab.test.tsx index b2cfd8fa25..50755e5453 100644 --- a/apps/web/src/components/organizations/Pax8OrgTab.test.tsx +++ b/apps/web/src/components/organizations/Pax8OrgTab.test.tsx @@ -10,6 +10,9 @@ import { listPax8Subscriptions, getProductDependencies, addPax8OrderLine, + getPax8Order, + preflightPax8Order, + submitPax8Order, } from '../../lib/api/pax8Orders'; vi.mock('../../lib/api/pax8Orders', async (importOriginal) => { @@ -22,6 +25,9 @@ vi.mock('../../lib/api/pax8Orders', async (importOriginal) => { listPax8Subscriptions: vi.fn(), getProductDependencies: vi.fn(), addPax8OrderLine: vi.fn(), + getPax8Order: vi.fn(), + preflightPax8Order: vi.fn(), + submitPax8Order: vi.fn(), }; }); @@ -126,4 +132,90 @@ describe('Pax8 organization mapping state', () => { expect(await screen.findByRole('alert')).toHaveTextContent(/does not allow quantity increases/i); expect(addPax8OrderLine).not.toHaveBeenCalled(); }); + + it('uses the snapshot active commitment when multiple dependency commitments exist', async () => { + vi.mocked(listPax8Companies).mockImplementation(() => response({ data: [{ + pax8CompanyId: 'company-1', pax8CompanyName: 'Acme', status: 'Active', mappedOrgId: 'org-1', + mappedOrgName: 'Acme', ignored: false, lastSeenAt: null, orderReady: true, + primaryAdminReady: true, primaryBillingReady: true, primaryTechnicalReady: true, + }], integrationId: 'integration-1' })); + vi.mocked(listPax8Subscriptions).mockImplementation(() => response({ data: [{ + id: 'snapshot-1', pax8SubscriptionId: 'sub-1', productId: 'prod-1', productName: 'Microsoft 365', + status: 'Active', breezeQuantity: '5.00', quantity: '5.00', quantityKnown: true, + activeCommitmentId: 'active', activeCommitmentAmbiguous: false, + lastSeenAt: '2026-07-14T00:00:00Z', contractLineId: 'contract-line-1', + }], integrationId: 'integration-1' })); + vi.mocked(listPax8Orders).mockImplementation(() => response({ data: [{ + id: '44444444-4444-4444-8444-444444444444', integrationId: 'integration-1', partnerId: 'partner-1', + orgId: 'org-1', pax8CompanyId: 'company-1', status: 'draft', source: 'direct', sourceQuoteId: null, + pax8OrderId: null, error: null, submittedAt: null, createdAt: '2026-07-14T00:00:00Z', updatedAt: '2026-07-14T00:00:00Z', + }] })); + vi.mocked(listPax8Products).mockImplementation(() => response({ data: [{ + pax8ProductId: 'prod-1', catalogItemId: 'cat-1', catalogName: 'Microsoft 365', + }] })); + vi.mocked(getProductDependencies).mockImplementation(() => response({ data: { commitments: [ + { id: 'other', term: 'Annual', allowForQuantityIncrease: false, allowForQuantityDecrease: false, allowForEarlyCancellation: false, cancellationFeeApplied: false }, + { id: 'active', term: 'Monthly', allowForQuantityIncrease: true, allowForQuantityDecrease: true, allowForEarlyCancellation: true, cancellationFeeApplied: false }, + ] } })); + vi.mocked(addPax8OrderLine).mockImplementation(() => response({ data: { id: 'line-1' } })); + + render(); + const quantity = await screen.findByRole('spinbutton', { name: /target quantity/i }); + await userEvent.clear(quantity); + await userEvent.type(quantity, '6'); + await userEvent.click(screen.getByRole('button', { name: /stage change/i })); + + await vi.waitFor(() => expect(addPax8OrderLine).toHaveBeenCalledTimes(1)); + }); + + it('fails closed when the snapshot active commitment forbids the requested action', async () => { + vi.mocked(listPax8Companies).mockImplementation(() => response({ data: [{ + pax8CompanyId: 'company-1', pax8CompanyName: 'Acme', status: 'Active', mappedOrgId: 'org-1', + mappedOrgName: 'Acme', ignored: false, lastSeenAt: null, orderReady: true, + primaryAdminReady: true, primaryBillingReady: true, primaryTechnicalReady: true, + }], integrationId: 'integration-1' })); + vi.mocked(listPax8Subscriptions).mockImplementation(() => response({ data: [{ + id: 'snapshot-1', pax8SubscriptionId: 'sub-1', productId: 'prod-1', productName: 'Microsoft 365', + status: 'Active', breezeQuantity: '5.00', quantity: '5.00', quantityKnown: true, + activeCommitmentId: 'blocked', activeCommitmentAmbiguous: false, + lastSeenAt: '2026-07-14T00:00:00Z', contractLineId: 'contract-line-1', + }], integrationId: 'integration-1' })); + vi.mocked(listPax8Orders).mockImplementation(() => response({ data: [] })); + vi.mocked(listPax8Products).mockImplementation(() => response({ data: [] })); + vi.mocked(getProductDependencies).mockImplementation(() => response({ data: { commitments: [ + { id: 'allowed', term: 'Annual', allowForQuantityIncrease: true, allowForQuantityDecrease: true, allowForEarlyCancellation: true, cancellationFeeApplied: false }, + { id: 'blocked', term: 'Monthly', allowForQuantityIncrease: false, allowForQuantityDecrease: true, allowForEarlyCancellation: true, cancellationFeeApplied: false }, + ] } })); + + render(); + const quantity = await screen.findByRole('spinbutton', { name: /target quantity/i }); + await userEvent.clear(quantity); + await userEvent.type(quantity, '6'); + await userEvent.click(screen.getByRole('button', { name: /stage change/i })); + + expect(await screen.findByRole('alert')).toHaveTextContent(/does not allow quantity increases/i); + expect(addPax8OrderLine).not.toHaveBeenCalled(); + }); + + it('fails closed for a deep-linked order belonging to another organization', async () => { + window.location.hash = '#pax8/44444444-4444-4444-8444-444444444444'; + vi.mocked(listPax8Companies).mockImplementation(() => response({ data: [], integrationId: 'integration-1' })); + vi.mocked(listPax8Subscriptions).mockImplementation(() => response({ data: [], integrationId: 'integration-1' })); + vi.mocked(listPax8Orders).mockImplementation(() => response({ data: [] })); + vi.mocked(listPax8Products).mockImplementation(() => response({ data: [] })); + vi.mocked(getPax8Order).mockImplementation(() => response({ data: { + order: { + id: '44444444-4444-4444-8444-444444444444', integrationId: 'integration-1', partnerId: 'partner-1', + orgId: 'org-2', pax8CompanyId: 'company-2', status: 'draft', source: 'direct', sourceQuoteId: null, + pax8OrderId: null, error: null, submittedAt: null, createdAt: '2026-07-14T00:00:00Z', updatedAt: '2026-07-14T00:00:00Z', + }, lines: [], + } })); + + render(); + + expect(await screen.findByRole('alert')).toHaveTextContent(/organization/i); + expect(screen.queryByTestId('pax8-submit')).not.toBeInTheDocument(); + expect(preflightPax8Order).not.toHaveBeenCalled(); + expect(submitPax8Order).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/components/organizations/Pax8OrgTab.tsx b/apps/web/src/components/organizations/Pax8OrgTab.tsx index 0b928d15b6..a8e78f7ccc 100644 --- a/apps/web/src/components/organizations/Pax8OrgTab.tsx +++ b/apps/web/src/components/organizations/Pax8OrgTab.tsx @@ -128,12 +128,15 @@ export default function Pax8OrgTab({ orgId }: { orgId: string }) { if (!selectedOrderId) { setBundle(null); return; } setBundleLoading(true); try { - setBundle(await getPax8Order(selectedOrderId).then((response) => readData(response, t('pax8.errors.loadOrder')))); + const loaded = await getPax8Order(selectedOrderId) + .then((response) => readData(response, t('pax8.errors.loadOrder'))); + if (loaded.order.orgId !== orgId) throw new Error(t('pax8.errors.foreignOrder')); + setBundle(loaded); } catch (error) { setActionError(error instanceof Error ? error.message : t('pax8.errors.loadOrder')); setBundle(null); } finally { setBundleLoading(false); } - }, [selectedOrderId, t]); + }, [orgId, selectedOrderId, t]); useEffect(() => { void load(); }, [load]); useEffect(() => { void loadBundle(); }, [loadBundle]); @@ -145,7 +148,7 @@ export default function Pax8OrgTab({ orgId }: { orgId: string }) { const mappedCompany = companies.find((company) => company.mappedOrgId === orgId && !company.ignored) ?? null; const mappingOptions = companies.filter((company) => !company.ignored && (!company.mappedOrgId || company.mappedOrgId === orgId)); - const mappingReady = mappedCompany?.status?.toLowerCase() === 'active'; + const mappingReady = mappedCompany?.orderReady === true; const selectOrder = (id: string | null) => { setSelectedOrderId(id); @@ -175,11 +178,21 @@ export default function Pax8OrgTab({ orgId }: { orgId: string }) { const singleCommitment = async (subscription: Pax8Subscription): Promise => { if (!subscription.productId) return null; const dependencies = await getProductDependencies(subscription.productId).then((response) => readData(response, t('pax8.errors.loadProduct'))); - if (dependencies.commitments.length !== 1) { + if (subscription.activeCommitmentAmbiguous) { setActionError(t('pax8.subscriptions.commitmentUnavailable')); return null; } - return dependencies.commitments[0]!; + if (subscription.activeCommitmentId) { + const matches = dependencies.commitments.filter( + (commitment) => commitment.id === subscription.activeCommitmentId, + ); + if (matches.length === 1) return matches[0]!; + setActionError(t('pax8.subscriptions.commitmentUnavailable')); + return null; + } + if (dependencies.commitments.length === 1) return dependencies.commitments[0]!; + setActionError(t('pax8.subscriptions.commitmentUnavailable')); + return null; }; const stageQuantity = async (subscription: Pax8Subscription, target: string) => { diff --git a/apps/web/src/components/organizations/Pax8ProvisioningForm.tsx b/apps/web/src/components/organizations/Pax8ProvisioningForm.tsx index be1aff5a8a..8ba3b65b18 100644 --- a/apps/web/src/components/organizations/Pax8ProvisioningForm.tsx +++ b/apps/web/src/components/organizations/Pax8ProvisioningForm.tsx @@ -42,16 +42,32 @@ export function Pax8ProvisioningForm({ {field.valueType === 'Single-Value' ? ( - +
+ + {values.length > 0 && ( + + )} +
) : field.valueType === 'Multi-Value' ? ( setBillingTerm(event.target.value as typeof billingTerm)} className="h-10 w-full rounded-md border bg-background px-3"> - {PAX8_BILLING_TERMS.map((term) => )} + {PAX8_BILLING_TERMS.map((term) => )}
; } diff --git a/apps/web/src/components/organizations/pax8OrderUi.ts b/apps/web/src/components/organizations/pax8OrderUi.ts index cbeae44f11..c9a6e2b7e2 100644 --- a/apps/web/src/components/organizations/pax8OrderUi.ts +++ b/apps/web/src/components/organizations/pax8OrderUi.ts @@ -1,3 +1,42 @@ +import type { Pax8BillingTerm } from '@breeze/shared'; +import type { Pax8OrderAction, Pax8OrderStatus, Pax8OrderLine } from '../../lib/api/pax8Orders'; + +export const PAX8_ORDER_STATUS_I18N_KEYS: Record = { + draft: 'pax8.enums.orderStatus.draft', + awaiting_details: 'pax8.enums.orderStatus.awaitingDetails', + ready: 'pax8.enums.orderStatus.ready', + submitting: 'pax8.enums.orderStatus.submitting', + completed: 'pax8.enums.orderStatus.completed', + partially_failed: 'pax8.enums.orderStatus.partiallyFailed', + failed: 'pax8.enums.orderStatus.failed', + cancelled: 'pax8.enums.orderStatus.cancelled', + needs_reconcile: 'pax8.enums.orderStatus.needsReconcile', +}; + +export const PAX8_ORDER_ACTION_I18N_KEYS: Record = { + new_subscription: 'pax8.enums.lineAction.newSubscription', + change_quantity: 'pax8.enums.lineAction.changeQuantity', + cancel: 'pax8.enums.lineAction.cancel', +}; + +export const PAX8_SUBMIT_STATE_I18N_KEYS: Record = { + pending: 'pax8.enums.submitState.pending', + in_flight: 'pax8.enums.submitState.inFlight', + succeeded: 'pax8.enums.submitState.succeeded', + failed: 'pax8.enums.submitState.failed', + needs_reconcile: 'pax8.enums.submitState.needsReconcile', +}; + +export const PAX8_BILLING_TERM_I18N_KEYS: Record = { + Monthly: 'pax8.enums.billingTerm.monthly', + Annual: 'pax8.enums.billingTerm.annual', + '2-Year': 'pax8.enums.billingTerm.twoYear', + '3-Year': 'pax8.enums.billingTerm.threeYear', + 'One-Time': 'pax8.enums.billingTerm.oneTime', + Trial: 'pax8.enums.billingTerm.trial', + Activation: 'pax8.enums.billingTerm.activation', +}; + export interface PreflightErrors { byLine: Map; order: string[]; diff --git a/apps/web/src/lib/api/pax8Orders.test.ts b/apps/web/src/lib/api/pax8Orders.test.ts index ebf19b7e94..2ee7d4d267 100644 --- a/apps/web/src/lib/api/pax8Orders.test.ts +++ b/apps/web/src/lib/api/pax8Orders.test.ts @@ -1,6 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; import { fetchWithAuth } from '../../stores/auth'; import { + addPax8OrderLine, + type AddPax8OrderLineRequest, listPax8Orders, readData, updatePax8OrderLine, @@ -11,6 +13,9 @@ vi.mock('../../stores/auth', () => ({ fetchWithAuth: vi.fn() })); beforeEach(() => vi.clearAllMocks()); describe('Pax8 ordering API client', () => { + it('does not expose tenant linkage as client-controlled add-line input', () => { + expectTypeOf().not.toHaveProperty('contractLineId'); + }); it('scopes the order list to the selected organization', () => { vi.mocked(fetchWithAuth).mockResolvedValue(new Response()); void listPax8Orders('org/one'); @@ -33,6 +38,24 @@ describe('Pax8 ordering API client', () => { }); }); + it('does not send a client-selected contract line when staging a subscription change', () => { + vi.mocked(fetchWithAuth).mockResolvedValue(new Response()); + void addPax8OrderLine('order-1', { + action: 'change_quantity', + targetSubscriptionId: 'subscription-1', + quantity: '12', + }); + expect(fetchWithAuth).toHaveBeenCalledWith('/pax8/orders/order-1/lines', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'change_quantity', + targetSubscriptionId: 'subscription-1', + quantity: '12', + }), + }); + }); + it('fails closed on a successful response without the expected data envelope', async () => { const response = new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' }, diff --git a/apps/web/src/lib/api/pax8Orders.ts b/apps/web/src/lib/api/pax8Orders.ts index e874b9758b..5fb1ccc28e 100644 --- a/apps/web/src/lib/api/pax8Orders.ts +++ b/apps/web/src/lib/api/pax8Orders.ts @@ -1,4 +1,5 @@ import { fetchWithAuth } from '../../stores/auth'; +import type { Pax8BillingTerm } from '@breeze/shared'; export type Pax8OrderStatus = | 'draft' | 'awaiting_details' | 'ready' | 'submitting' | 'completed' @@ -71,6 +72,17 @@ export interface Pax8OrderLine { } export interface Pax8OrderBundle { order: Pax8Order; lines: Pax8OrderLine[] } +export interface AddPax8OrderLineRequest { + action: Pax8OrderAction; + pax8ProductId?: string; + catalogItemId?: string; + billingTerm?: Pax8BillingTerm; + commitmentTermId?: string; + quantity?: string; + provisioningDetails?: ProvisioningValue[]; + targetSubscriptionId?: string; + cancelDate?: string; +} export interface Pax8Company { pax8CompanyId: string; pax8CompanyName: string; @@ -128,7 +140,7 @@ export const mapPax8Company = (body: { integrationId: string; pax8CompanyId: str fetchWithAuth('/pax8/companies/map', { method: 'POST', ...json(body) }); export const createPax8Order = (orgId: string) => fetchWithAuth('/pax8/orders', { method: 'POST', ...json({ orgId }) }); -export const addPax8OrderLine = (orderId: string, body: unknown) => +export const addPax8OrderLine = (orderId: string, body: AddPax8OrderLineRequest) => fetchWithAuth(`/pax8/orders/${encodeURIComponent(orderId)}/lines`, { method: 'POST', ...json(body) }); export const updatePax8OrderLine = (orderId: string, lineId: string, body: unknown) => fetchWithAuth(`/pax8/orders/${encodeURIComponent(orderId)}/lines/${encodeURIComponent(lineId)}`, { diff --git a/apps/web/src/locales/de-DE/settings.json b/apps/web/src/locales/de-DE/settings.json index 4e17e477dd..633e0ea283 100644 --- a/apps/web/src/locales/de-DE/settings.json +++ b/apps/web/src/locales/de-DE/settings.json @@ -2429,6 +2429,12 @@ "actions": { "newOrder": "Neue Bestellung", "retry": "Erneut versuchen" }, "states": { "loadingProduct": "Produktanforderungen werden geladen…" }, "products": { "emptyReason": "Vor der Bestellung aktive Katalogprodukte Pax8 zuordnen." }, + "enums": { + "orderStatus": { "draft": "Entwurf", "awaitingDetails": "Details ausstehend", "ready": "Bereit", "submitting": "Wird gesendet", "completed": "Abgeschlossen", "partiallyFailed": "Teilweise fehlgeschlagen", "failed": "Fehlgeschlagen", "cancelled": "Storniert", "needsReconcile": "Abgleich erforderlich" }, + "lineAction": { "newSubscription": "Neues Abonnement", "changeQuantity": "Menge ändern", "cancel": "Abonnement kündigen" }, + "submitState": { "pending": "Ausstehend", "inFlight": "In Bearbeitung", "succeeded": "Erfolgreich", "failed": "Fehlgeschlagen", "needsReconcile": "Abgleich erforderlich" }, + "billingTerm": { "monthly": "Monatlich", "annual": "Jährlich", "twoYear": "2 Jahre", "threeYear": "3 Jahre", "oneTime": "Einmalig", "trial": "Testphase", "activation": "Aktivierung" } + }, "mapping": { "title": "Unternehmenszuordnung", "company": "Pax8-Unternehmen", "choose": "Unternehmen auswählen", "map": "Unternehmen zuordnen", "empty": "Diese Organisation vor der Bestellung ihrem Pax8-Unternehmen zuordnen.", "noIntegration": "Zuerst Pax8 in den Partnerintegrationen verbinden und synchronisieren.", diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json index 6bac6e5877..a56e157515 100644 --- a/apps/web/src/locales/en/settings.json +++ b/apps/web/src/locales/en/settings.json @@ -2483,6 +2483,12 @@ "actions": { "newOrder": "New order", "retry": "Try again" }, "states": { "loadingProduct": "Loading product requirements…" }, "products": { "emptyReason": "Map active catalog products to Pax8 before ordering." }, + "enums": { + "orderStatus": { "draft": "Draft", "awaitingDetails": "Awaiting details", "ready": "Ready", "submitting": "Submitting", "completed": "Completed", "partiallyFailed": "Partially failed", "failed": "Failed", "cancelled": "Cancelled", "needsReconcile": "Needs reconciliation" }, + "lineAction": { "newSubscription": "New subscription", "changeQuantity": "Change quantity", "cancel": "Cancel subscription" }, + "submitState": { "pending": "Pending", "inFlight": "In progress", "succeeded": "Succeeded", "failed": "Failed", "needsReconcile": "Needs reconciliation" }, + "billingTerm": { "monthly": "Monthly", "annual": "Annual", "twoYear": "2-year", "threeYear": "3-year", "oneTime": "One-time", "trial": "Trial", "activation": "Activation" } + }, "mapping": { "title": "Company mapping", "company": "Pax8 company", "choose": "Choose a company", "map": "Map company", "empty": "Map this organization to its Pax8 company before ordering.", "noIntegration": "Connect and sync Pax8 in partner integrations first.", diff --git a/apps/web/src/locales/es-419/settings.json b/apps/web/src/locales/es-419/settings.json index 50e733df53..4db57fc465 100644 --- a/apps/web/src/locales/es-419/settings.json +++ b/apps/web/src/locales/es-419/settings.json @@ -2428,6 +2428,12 @@ "title": "Suscripciones de Pax8", "description": "Revisa cantidades facturables, prepara cambios y envía pedidos de Pax8.", "actions": { "newOrder": "Nuevo pedido", "retry": "Reintentar" }, "states": { "loadingProduct": "Cargando requisitos del producto…" }, "products": { "emptyReason": "Asocia productos activos del catálogo con Pax8 antes de pedir." }, + "enums": { + "orderStatus": { "draft": "Borrador", "awaitingDetails": "Esperando detalles", "ready": "Listo", "submitting": "Enviando", "completed": "Completado", "partiallyFailed": "Falló parcialmente", "failed": "Falló", "cancelled": "Cancelado", "needsReconcile": "Requiere conciliación" }, + "lineAction": { "newSubscription": "Nueva suscripción", "changeQuantity": "Cambiar cantidad", "cancel": "Cancelar suscripción" }, + "submitState": { "pending": "Pendiente", "inFlight": "En curso", "succeeded": "Correcto", "failed": "Falló", "needsReconcile": "Requiere conciliación" }, + "billingTerm": { "monthly": "Mensual", "annual": "Anual", "twoYear": "2 años", "threeYear": "3 años", "oneTime": "Pago único", "trial": "Prueba", "activation": "Activación" } + }, "mapping": { "title": "Asociación de empresa", "company": "Empresa de Pax8", "choose": "Elegir una empresa", "map": "Asociar empresa", "empty": "Asocia esta organización con su empresa de Pax8 antes de pedir.", "noIntegration": "Primero conecta y sincroniza Pax8 en las integraciones del socio.", diff --git a/apps/web/src/locales/fr-FR/settings.json b/apps/web/src/locales/fr-FR/settings.json index 9fa1929532..72c83fdb3d 100644 --- a/apps/web/src/locales/fr-FR/settings.json +++ b/apps/web/src/locales/fr-FR/settings.json @@ -2428,6 +2428,12 @@ "title": "Abonnements Pax8", "description": "Vérifiez les quantités facturables, préparez les changements et envoyez les commandes Pax8.", "actions": { "newOrder": "Nouvelle commande", "retry": "Réessayer" }, "states": { "loadingProduct": "Chargement des exigences du produit…" }, "products": { "emptyReason": "Associez des produits actifs du catalogue à Pax8 avant de commander." }, + "enums": { + "orderStatus": { "draft": "Brouillon", "awaitingDetails": "En attente de détails", "ready": "Prête", "submitting": "Envoi en cours", "completed": "Terminée", "partiallyFailed": "Échec partiel", "failed": "Échec", "cancelled": "Annulée", "needsReconcile": "Rapprochement requis" }, + "lineAction": { "newSubscription": "Nouvel abonnement", "changeQuantity": "Modifier la quantité", "cancel": "Résilier l’abonnement" }, + "submitState": { "pending": "En attente", "inFlight": "En cours", "succeeded": "Réussie", "failed": "Échec", "needsReconcile": "Rapprochement requis" }, + "billingTerm": { "monthly": "Mensuelle", "annual": "Annuelle", "twoYear": "2 ans", "threeYear": "3 ans", "oneTime": "Paiement unique", "trial": "Essai", "activation": "Mise en service" } + }, "mapping": { "title": "Association de l’entreprise", "company": "Entreprise Pax8", "choose": "Choisir une entreprise", "map": "Associer l’entreprise", "empty": "Associez cette organisation à son entreprise Pax8 avant de commander.", "noIntegration": "Connectez et synchronisez d’abord Pax8 dans les intégrations partenaire.", diff --git a/apps/web/src/locales/pt-BR/settings.json b/apps/web/src/locales/pt-BR/settings.json index 9fdb5c44ca..f04101068c 100644 --- a/apps/web/src/locales/pt-BR/settings.json +++ b/apps/web/src/locales/pt-BR/settings.json @@ -2481,6 +2481,12 @@ "title": "Assinaturas Pax8", "description": "Revise quantidades faturáveis, prepare alterações e envie pedidos Pax8.", "actions": { "newOrder": "Novo pedido", "retry": "Tentar novamente" }, "states": { "loadingProduct": "Carregando requisitos do produto…" }, "products": { "emptyReason": "Mapeie produtos ativos do catálogo para a Pax8 antes de pedir." }, + "enums": { + "orderStatus": { "draft": "Rascunho", "awaitingDetails": "Aguardando detalhes", "ready": "Pronto", "submitting": "Enviando", "completed": "Concluído", "partiallyFailed": "Falha parcial", "failed": "Falhou", "cancelled": "Cancelado", "needsReconcile": "Requer reconciliação" }, + "lineAction": { "newSubscription": "Nova assinatura", "changeQuantity": "Alterar quantidade", "cancel": "Cancelar assinatura" }, + "submitState": { "pending": "Pendente", "inFlight": "Em andamento", "succeeded": "Concluído", "failed": "Falhou", "needsReconcile": "Requer reconciliação" }, + "billingTerm": { "monthly": "Mensal", "annual": "Anual", "twoYear": "2 anos", "threeYear": "3 anos", "oneTime": "Pagamento único", "trial": "Avaliação", "activation": "Ativação" } + }, "mapping": { "title": "Mapeamento da empresa", "company": "Empresa Pax8", "choose": "Escolher uma empresa", "map": "Mapear empresa", "empty": "Mapeie esta organização para sua empresa Pax8 antes de pedir.", "noIntegration": "Primeiro conecte e sincronize a Pax8 nas integrações do parceiro.", "unknownStatus": "Status não informado", "activeRequired": "A empresa Pax8 mapeada deve estar ativa e ter contatos principais Administrativo, Faturamento e Técnico." }, "subscriptions": { "title": "Registro de assinaturas", "description": "As quantidades do Breeze são a fonte do faturamento. Os valores Pax8 são as observações mais recentes.", From ab45be09d15df3914dbf7da3b773c5ed70541651 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Tue, 14 Jul 2026 07:42:00 -0600 Subject: [PATCH 29/40] fix(pax8): align recovery affordances --- .../organizations/Pax8OrderBuilder.test.tsx | 23 +++++++++++++++++-- .../organizations/Pax8OrderBuilder.tsx | 10 +++++--- apps/web/src/lib/api/pax8Orders.test.ts | 8 +++++++ apps/web/src/lib/api/pax8Orders.ts | 6 ++++- 4 files changed, 41 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/organizations/Pax8OrderBuilder.test.tsx b/apps/web/src/components/organizations/Pax8OrderBuilder.test.tsx index 1cc12c16f1..de9dae7669 100644 --- a/apps/web/src/components/organizations/Pax8OrderBuilder.test.tsx +++ b/apps/web/src/components/organizations/Pax8OrderBuilder.test.tsx @@ -243,8 +243,9 @@ describe('Pax8 order mutation affordances', () => { }); it.each([ - ['a needs-reconcile line', 'draft', ['needs_reconcile']], - ['an in-flight line', 'draft', ['in_flight']], + ['a needs-reconcile parent with a needs-reconcile line', 'needs_reconcile', ['needs_reconcile']], + ['a needs-reconcile parent with an in-flight line', 'needs_reconcile', ['in_flight']], + ['a submitting parent with an in-flight line', 'submitting', ['in_flight']], ['a submitting order whose lines are all pending', 'submitting', ['pending', 'pending']], ] as const)('offers recovery for %s', (_label, status, submitStates) => { render(); @@ -253,6 +254,24 @@ describe('Pax8 order mutation affordances', () => { expect(screen.queryByTestId('pax8-submit')).not.toBeInTheDocument(); }); + it.each([ + ['draft', ['in_flight']], + ['awaiting_details', ['needs_reconcile']], + ] as const)('does not offer recovery for malformed %s parent state', (status, submitStates) => { + render(); + + expect(screen.queryByTestId('pax8-reconcile')).not.toBeInTheDocument(); + expect(screen.queryByTestId('pax8-submit')).not.toBeInTheDocument(); + }); + + it('uses a WCAG-AA dark recovery treatment with visible hover and focus states', () => { + render(); + + expect(screen.getByTestId('pax8-reconcile')).toHaveClass( + 'bg-amber-800', 'text-white', 'hover:bg-amber-900', 'focus-visible:ring-2', + ); + }); + it('does not offer false recovery for submitting orders with a completed line', () => { render(); diff --git a/apps/web/src/components/organizations/Pax8OrderBuilder.tsx b/apps/web/src/components/organizations/Pax8OrderBuilder.tsx index e717854ff5..a751a37cb6 100644 --- a/apps/web/src/components/organizations/Pax8OrderBuilder.tsx +++ b/apps/web/src/components/organizations/Pax8OrderBuilder.tsx @@ -63,8 +63,12 @@ export default function Pax8OrderBuilder({ const { order, lines } = bundle; const mutable = mutableStatuses.has(order.status); const directMutable = mutable && order.source === 'direct'; - const canReconcile = lines.some((line) => line.submitState === 'needs_reconcile' || line.submitState === 'in_flight') - || (order.status === 'submitting' && lines.length > 0 && lines.every((line) => line.submitState === 'pending')); + const hasInFlight = lines.some((line) => line.submitState === 'in_flight'); + const hasUnknown = lines.some((line) => line.submitState === 'in_flight' || line.submitState === 'needs_reconcile'); + const allPending = lines.length > 0 && lines.every((line) => line.submitState === 'pending'); + const canReconcile = (order.status === 'submitting' && (hasInFlight || allPending)) + || (order.status === 'needs_reconcile' && hasUnknown); + const canSubmit = mutable && lines.every((line) => line.submitState === 'pending'); const [selectedProductId, setSelectedProductId] = useState(''); const [quantity, setQuantity] = useState('1'); const [billingTerm, setBillingTerm] = useState<(typeof PAX8_BILLING_TERMS)[number]>('Monthly'); @@ -351,7 +355,7 @@ export default function Pax8OrderBuilder({ {preflightErrors.order.length > 0 &&
{preflightErrors.order.map((message) =>

{message}

)}
}
- {canReconcile ? : mutable && } + {canReconcile ? : canSubmit && } {order.status === 'completed' && {t('pax8.order.completed')}}
diff --git a/apps/web/src/lib/api/pax8Orders.test.ts b/apps/web/src/lib/api/pax8Orders.test.ts index 2ee7d4d267..4465432c75 100644 --- a/apps/web/src/lib/api/pax8Orders.test.ts +++ b/apps/web/src/lib/api/pax8Orders.test.ts @@ -6,6 +6,7 @@ import { listPax8Orders, readData, updatePax8OrderLine, + type UpdatePax8OrderLineRequest, } from './pax8Orders'; vi.mock('../../stores/auth', () => ({ fetchWithAuth: vi.fn() })); @@ -16,6 +17,13 @@ describe('Pax8 ordering API client', () => { it('does not expose tenant linkage as client-controlled add-line input', () => { expectTypeOf().not.toHaveProperty('contractLineId'); }); + + it('limits staged-line PATCH input to provisioning fields', () => { + expectTypeOf().not.toHaveProperty('contractLineId'); + expectTypeOf().not.toHaveProperty('action'); + expectTypeOf().not.toHaveProperty('quantity'); + expectTypeOf().not.toHaveProperty('pax8ProductId'); + }); it('scopes the order list to the selected organization', () => { vi.mocked(fetchWithAuth).mockResolvedValue(new Response()); void listPax8Orders('org/one'); diff --git a/apps/web/src/lib/api/pax8Orders.ts b/apps/web/src/lib/api/pax8Orders.ts index 5fb1ccc28e..f1c5022c0a 100644 --- a/apps/web/src/lib/api/pax8Orders.ts +++ b/apps/web/src/lib/api/pax8Orders.ts @@ -83,6 +83,10 @@ export interface AddPax8OrderLineRequest { targetSubscriptionId?: string; cancelDate?: string; } +export interface UpdatePax8OrderLineRequest { + commitmentTermId?: string | null; + provisioningDetails?: ProvisioningValue[]; +} export interface Pax8Company { pax8CompanyId: string; pax8CompanyName: string; @@ -142,7 +146,7 @@ export const createPax8Order = (orgId: string) => fetchWithAuth('/pax8/orders', { method: 'POST', ...json({ orgId }) }); export const addPax8OrderLine = (orderId: string, body: AddPax8OrderLineRequest) => fetchWithAuth(`/pax8/orders/${encodeURIComponent(orderId)}/lines`, { method: 'POST', ...json(body) }); -export const updatePax8OrderLine = (orderId: string, lineId: string, body: unknown) => +export const updatePax8OrderLine = (orderId: string, lineId: string, body: UpdatePax8OrderLineRequest) => fetchWithAuth(`/pax8/orders/${encodeURIComponent(orderId)}/lines/${encodeURIComponent(lineId)}`, { method: 'PATCH', ...json(body), }); From d1aad1705601315fbb575895809aa47286efc5c8 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Tue, 14 Jul 2026 07:47:41 -0600 Subject: [PATCH 30/40] fix(pax8): harden order authoring integrity --- .../pax8OrderSubmit.integration.test.ts | 130 +++++++++- apps/api/src/routes/pax8Orders.test.ts | 13 + apps/api/src/routes/pax8Orders.ts | 1 - .../api/src/services/pax8OrderService.test.ts | 171 +++++++++++-- apps/api/src/services/pax8OrderService.ts | 238 +++++++++++++++++- .../src/services/pax8OrderSubmitRepository.ts | 36 ++- 6 files changed, 541 insertions(+), 48 deletions(-) diff --git a/apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts b/apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts index e1ab7c589b..092d490112 100644 --- a/apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts +++ b/apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts @@ -6,10 +6,14 @@ import { db, withSystemDbAccessContext } from '../../db'; import { contractLines, contracts, + catalogItems, pax8CompanyMappings, + pax8ContractLineLinks, pax8Integrations, pax8OrderLines, pax8Orders, + pax8ProductMappings, + pax8SubscriptionSnapshots, } from '../../db/schema'; import { Pax8ApiError } from '../../services/pax8Client'; import { createPax8OrderSubmitService } from '../../services/pax8OrderSubmit'; @@ -43,6 +47,23 @@ async function seedOrder(options: { tokenUrl: 'https://api.pax8.com/v1/token', }).returning(); if (!integration) throw new Error('failed to seed integration'); + const [catalogItem] = await db.insert(catalogItems).values({ + partnerId: partner.id, + itemType: 'software', + name: 'Pax8 submit product', + billingType: 'recurring', + billingFrequency: 'monthly', + unitPrice: '10.00', + taxable: false, + }).returning(); + if (!catalogItem) throw new Error('failed to seed catalog item'); + await db.insert(pax8ProductMappings).values({ + integrationId: integration.id, + partnerId: partner.id, + pax8ProductId: 'product-1', + productName: 'Pax8 submit product', + catalogItemId: catalogItem.id, + }); await db.insert(pax8CompanyMappings).values({ integrationId: integration.id, partnerId: partner.id, @@ -66,7 +87,7 @@ async function seedOrder(options: { lineType: 'manual', description: 'Pax8 seats', unitPrice: '10.00', - manualQuantity: null, + manualQuantity: options.action === 'cancel' ? '7.00' : null, }).returning(); if (!contractLine) throw new Error('failed to seed contract line'); const [order] = await db.insert(pax8Orders).values({ @@ -87,13 +108,34 @@ async function seedOrder(options: { orgId: org.id, action, pax8ProductId: action === 'new_subscription' ? 'product-1' : null, + catalogItemId: action === 'new_subscription' ? catalogItem.id : null, billingTerm: action === 'new_subscription' ? 'Monthly' : null, quantity: action === 'new_subscription' ? '7.00' : null, targetSubscriptionId: action === 'cancel' ? 'subscription-cancel' : null, contractLineId: contractLine.id, }).returning(); if (!line) throw new Error('failed to seed order line'); - return { partner, org, user, order, line, contractLine }; + if (action === 'cancel') { + const [snapshot] = await db.insert(pax8SubscriptionSnapshots).values({ + integrationId: integration.id, + partnerId: partner.id, + pax8CompanyId: 'company-1', + pax8SubscriptionId: 'subscription-cancel', + orgId: org.id, + productId: 'product-1', + quantity: '99.00', + quantityKnown: false, + }).returning(); + if (!snapshot) throw new Error('failed to seed subscription snapshot'); + await db.insert(pax8ContractLineLinks).values({ + integrationId: integration.id, + partnerId: partner.id, + orgId: org.id, + subscriptionSnapshotId: snapshot.id, + contractLineId: contractLine.id, + }); + } + return { partner, org, user, order, line, contractLine, integration, catalogItem }; }); } @@ -158,6 +200,78 @@ describe('Pax8 submit pipeline (real Postgres)', () => { } }); + runDb('rejects a staged future cancellation before any vendor or billing write', async () => { + const fixture = await seedOrder({ action: 'cancel' }); + await withSystemDbAccessContext(() => db.update(pax8OrderLines) + .set({ cancelDate: '2999-01-01' }) + .where(eq(pax8OrderLines.id, fixture.line.id))); + const client = successfulClient(); + const { service, createClient } = serviceHarness(client); + + await expect(service.submitOrder({ + partnerId: fixture.partner.id, + orderId: fixture.order.id, + actorUserId: fixture.user.id, + })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('future') }); + + expect(createClient).not.toHaveBeenCalled(); + expect(client.cancelSubscription).not.toHaveBeenCalled(); + const state = await withSystemDbAccessContext(async () => { + const [order] = await db.select().from(pax8Orders).where(eq(pax8Orders.id, fixture.order.id)); + const [line] = await db.select().from(pax8OrderLines).where(eq(pax8OrderLines.id, fixture.line.id)); + const [billing] = await db.select().from(contractLines).where(eq(contractLines.id, fixture.contractLine.id)); + return { order, line, billing }; + }); + expect(state.order?.status).toBe('ready'); + expect(state.line?.submitState).toBe('pending'); + expect(state.billing?.manualQuantity).toBe('7.00'); + }); + + runDb('rejects a same-org unrelated staged contract line before vendor execution', async () => { + const fixture = await seedOrder({ action: 'cancel' }); + const [contract] = await withSystemDbAccessContext(() => db.select() + .from(contracts).where(eq(contracts.orgId, fixture.org.id))); + const [unrelated] = await withSystemDbAccessContext(() => db.insert(contractLines).values({ + contractId: contract!.id, + orgId: fixture.org.id, + lineType: 'manual', + description: 'Unrelated same-org line', + unitPrice: '1.00', + manualQuantity: '33.00', + }).returning()); + await withSystemDbAccessContext(() => db.update(pax8OrderLines) + .set({ contractLineId: unrelated!.id }) + .where(eq(pax8OrderLines.id, fixture.line.id))); + const client = successfulClient(); + + await expect(serviceWithClient(client).submitOrder({ + partnerId: fixture.partner.id, + orderId: fixture.order.id, + actorUserId: fixture.user.id, + })).rejects.toMatchObject({ status: 409, message: expect.stringContaining('no longer matches') }); + + expect(client.cancelSubscription).not.toHaveBeenCalled(); + const [unrelatedAfter] = await withSystemDbAccessContext(() => db.select() + .from(contractLines).where(eq(contractLines.id, unrelated!.id))); + expect(unrelatedAfter?.manualQuantity).toBe('33.00'); + }); + + runDb('rechecks an active direct product mapping at submit time', async () => { + const fixture = await seedOrder({ source: 'direct' }); + await withSystemDbAccessContext(() => db.update(catalogItems) + .set({ isActive: false }) + .where(eq(catalogItems.id, fixture.catalogItem.id))); + const client = successfulClient(); + + await expect(serviceWithClient(client).submitOrder({ + partnerId: fixture.partner.id, + orderId: fixture.order.id, + actorUserId: fixture.user.id, + })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('active') }); + + expect(client.createOrder).not.toHaveBeenCalled(); + }); + runDb('atomically claims one submit, persists billing success, and keeps rejected billing untouched', async () => { const fixture = await seedOrder(); const client = successfulClient(); @@ -188,15 +302,6 @@ describe('Pax8 submit pipeline (real Postgres)', () => { rejectedClient.createOrder.mockReset() .mockRejectedValueOnce(new Pax8ApiError('Pax8 API returned 422', 422, raw)); const rejectedService = serviceWithClient(rejectedClient); - await withSystemDbAccessContext(() => db.insert(pax8OrderLines).values({ - orderId: rejectedFixture.order.id, - partnerId: rejectedFixture.partner.id, - orgId: rejectedFixture.org.id, - action: 'change_quantity', - targetSubscriptionId: 'mixed-subscription', - quantity: '3.00', - })); - const result = await rejectedService.submitOrder({ partnerId: rejectedFixture.partner.id, orderId: rejectedFixture.order.id, @@ -206,7 +311,7 @@ describe('Pax8 submit pipeline (real Postgres)', () => { const [contractLine] = await withSystemDbAccessContext(() => db.select().from(contractLines).where(eq(contractLines.id, rejectedFixture.contractLine.id))); expect(result.status).toBe('failed'); - expect(result.lines).toHaveLength(2); + expect(result.lines).toHaveLength(1); expect(result.lines.every((line) => line.submitState === 'failed' && line.error === raw)).toBe(true); expect(contractLine?.manualQuantity).toBeNull(); expect(rejectedClient.createOrder).toHaveBeenCalledTimes(1); @@ -368,6 +473,7 @@ describe('Pax8 submit pipeline (real Postgres)', () => { action: 'change_quantity', targetSubscriptionId: 'subscription-cancel', quantity: '2.00', + contractLineId: duplicateFixture.contractLine.id, })); const duplicateClient = successfulClient(); await expect(serviceWithClient(duplicateClient).submitOrder({ diff --git a/apps/api/src/routes/pax8Orders.test.ts b/apps/api/src/routes/pax8Orders.test.ts index 61871a78a7..96ad20d853 100644 --- a/apps/api/src/routes/pax8Orders.test.ts +++ b/apps/api/src/routes/pax8Orders.test.ts @@ -396,6 +396,19 @@ describe('Pax8 order route handlers', () => { })); }); + it('rejects caller-controlled contract linkage at the public add-line boundary', async () => { + const res = await request(`/orders/${ORDER_ID}/lines`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + action: 'cancel', targetSubscriptionId: 'sub-1', contractLineId: LINE_ID, + }), + }); + + expect(res.status).toBe(400); + expect(mocks.addOrderLine).not.toHaveBeenCalled(); + expect(mocks.writeRouteAudit).not.toHaveBeenCalled(); + }); + it('updates only mutable staged-line provisioning fields and audits the change', async () => { const res = await request(`/orders/${ORDER_ID}/lines/${LINE_ID}`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, diff --git a/apps/api/src/routes/pax8Orders.ts b/apps/api/src/routes/pax8Orders.ts index 854aa063ff..28c889c6a1 100644 --- a/apps/api/src/routes/pax8Orders.ts +++ b/apps/api/src/routes/pax8Orders.ts @@ -91,7 +91,6 @@ const addLineSchema = z.object({ provisioningDetails: z.array(provisioningDetailSchema).max(200).optional(), targetSubscriptionId: z.string().trim().min(1).max(64).optional(), cancelDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), - contractLineId: z.string().guid().optional(), }).strict(); const updateLineSchema = z.object({ diff --git a/apps/api/src/services/pax8OrderService.test.ts b/apps/api/src/services/pax8OrderService.test.ts index 586aa17389..dfaca27b73 100644 --- a/apps/api/src/services/pax8OrderService.test.ts +++ b/apps/api/src/services/pax8OrderService.test.ts @@ -46,9 +46,25 @@ vi.mock('../db/schema', () => new Proxy({ ignored: 'pax8_company_mappings.ignored', }, pax8SubscriptionSnapshots: { + id: 'pax8_subscription_snapshots.id', integrationId: 'pax8_subscription_snapshots.integration_id', partnerId: 'pax8_subscription_snapshots.partner_id', + orgId: 'pax8_subscription_snapshots.org_id', pax8SubscriptionId: 'pax8_subscription_snapshots.pax8_subscription_id', + productId: 'pax8_subscription_snapshots.product_id', + }, + pax8ContractLineLinks: { + integrationId: 'pax8_contract_line_links.integration_id', + partnerId: 'pax8_contract_line_links.partner_id', + orgId: 'pax8_contract_line_links.org_id', + subscriptionSnapshotId: 'pax8_contract_line_links.subscription_snapshot_id', + contractLineId: 'pax8_contract_line_links.contract_line_id', + }, + contractLines: { + id: 'contract_lines.id', + orgId: 'contract_lines.org_id', + lineType: 'contract_lines.line_type', + manualQuantity: 'contract_lines.manual_quantity', }, pax8Integrations: { id: 'pax8_integrations.id', @@ -123,6 +139,7 @@ function queryChain(rows: unknown[]) { const chain: Record = {}; chain.from = vi.fn(() => chain); chain.where = vi.fn(() => chain); + chain.innerJoin = vi.fn(() => chain); chain.for = vi.fn(() => chain); chain.orderBy = vi.fn(() => chain); chain.limit = vi.fn(async () => rows); @@ -217,6 +234,7 @@ function mockOrder(overrides: Record = {}) { function mockSubscriptionSnapshot(overrides: Record = {}) { selectRowsOnce([{ ...baseSnapshot, ...overrides }]); + selectRowsOnce([{ contractLineId: 'contract-line-1', manualQuantity: '10.00' }]); } function mockDependencies(dependencies: Record) { @@ -248,6 +266,7 @@ function deleteReturningOnce(rows: unknown[]) { } beforeEach(() => { + vi.useRealTimers(); mocks.db.select.mockReset(); mocks.db.insert.mockReset(); mocks.db.delete.mockReset(); @@ -397,6 +416,49 @@ describe('getOrCreateDraftOrder', () => { }); describe('addOrderLine', () => { + it('rejects public line additions to quote-staged orders even while mutable', async () => { + mockOrder({ source: 'quote', status: 'awaiting_details' }); + + await expect(addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'new_subscription', + pax8ProductId: 'prod-1', catalogItemId: 'catalog-1', billingTerm: 'Monthly', quantity: '1.00', + })).rejects.toMatchObject({ status: 409, message: expect.stringContaining('quote') }); + + expect(mocks.db.insert).not.toHaveBeenCalled(); + }); + + it('rejects a future cancellation date at authoring time', async () => { + vi.setSystemTime(new Date('2026-07-14T23:59:59Z')); + mockOrder(); + + await expect(addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'cancel', + targetSubscriptionId: 'sub-1', cancelDate: '2026-07-15', + })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('future') }); + + expect(mocks.createPax8ClientForIntegration).not.toHaveBeenCalled(); + }); + + it('rejects a normalized-but-invalid UTC cancellation date', async () => { + mockOrder(); + + await expect(addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'cancel', + targetSubscriptionId: 'sub-1', cancelDate: '2026-02-30', + })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('valid UTC') }); + + expect(mocks.createPax8ClientForIntegration).not.toHaveBeenCalled(); + }); + + it('rejects caller-supplied contract linkage for a direct new subscription', async () => { + mockOrder(); + + await expect(addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'new_subscription', + pax8ProductId: 'prod-1', catalogItemId: 'catalog-1', billingTerm: 'Monthly', quantity: '1.00', + contractLineId: 'untrusted-line', + })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('contract line') }); + }); it('fails closed before staging a direct line when company contact evidence is absent', async () => { mocks.db.select.mockImplementation((selection?: Record) => { let rows: unknown[] = []; @@ -428,6 +490,7 @@ describe('addOrderLine', () => { it('rechecks direct-order readiness under a share lock in the final line transaction', async () => { mockOrder({ source: 'direct' }); mockCompanyMappingLookup({ pax8CompanyId: 'co-1', integrationId: 'i1' }); + selectRowsOnce([{ pax8ProductId: 'prod-1', catalogItemId: 'catalog-1' }]); mockOrder({ source: 'direct' }); const finalMapping = mockCompanyMappingLookup({ pax8CompanyId: 'co-1', integrationId: 'i1', metadata: null, @@ -436,7 +499,7 @@ describe('addOrderLine', () => { await expect(addOrderLine({ partnerId: 'p1', orderId: 'ord-1', action: 'new_subscription', - pax8ProductId: 'prod-1', billingTerm: 'Monthly', quantity: '1.00', + pax8ProductId: 'prod-1', catalogItemId: 'catalog-1', billingTerm: 'Monthly', quantity: '1.00', })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('ready') }); expect(finalMapping.for).toHaveBeenCalledWith('share'); @@ -445,7 +508,7 @@ describe('addOrderLine', () => { it('rejects a change_quantity whose commitment forbids a decrease', async () => { mockOrder(); - mockSubscriptionSnapshot(); + mockSubscriptionSnapshot({ quantity: '0.00', quantityKnown: false }); mockDependencies({ commitments: [{ id: 'c1', @@ -488,7 +551,7 @@ describe('addOrderLine', () => { it('rejects a change_quantity whose commitment forbids an increase', async () => { mockOrder(); - mockSubscriptionSnapshot(); + mockSubscriptionSnapshot({ quantity: '100.00' }); mockDependencies({ commitments: [{ id: 'c1', @@ -638,6 +701,62 @@ describe('addOrderLine', () => { })).rejects.toMatchObject({ status: 404 }); }); + it('fails closed when the exact linked manual contract line has no Breeze quantity', async () => { + mockOrder(); + selectRowsOnce([{ ...baseSnapshot, quantity: '0.00', quantityKnown: false }]); + selectRowsOnce([{ contractLineId: 'contract-line-1', manualQuantity: null }]); + + await expect(addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'change_quantity', + targetSubscriptionId: 'sub-1', quantity: '5.00', + })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('manual contract quantity') }); + + expect(mocks.createPax8ClientForIntegration).not.toHaveBeenCalled(); + }); + + it('rejects a same-org but unrelated caller contract line and never stages it', async () => { + mockOrder(); + mockSubscriptionSnapshot(); + + await expect(addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'cancel', targetSubscriptionId: 'sub-1', + contractLineId: 'same-org-unrelated-line', + })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('does not match') }); + + expect(mocks.createPax8ClientForIntegration).not.toHaveBeenCalled(); + expect(mocks.db.insert).not.toHaveBeenCalled(); + }); + + it('rejects an unmapped or mismatched direct product/catalog tuple', async () => { + mockOrder(); + selectRowsOnce([]); + + await expect(addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'new_subscription', + pax8ProductId: 'prod-1', catalogItemId: 'unrelated-catalog', billingTerm: 'Monthly', quantity: '1.00', + })).rejects.toMatchObject({ status: 422, message: expect.stringContaining('mapped') }); + + expect(mocks.db.insert).not.toHaveBeenCalled(); + }); + + it('rejects when the exact subscription link changes before the final insert', async () => { + mockOrder(); + mockSubscriptionSnapshot(); + mockDependencies({ commitments: [{ + id: 'c1', allowForQuantityDecrease: true, allowForQuantityIncrease: true, allowForEarlyCancellation: true, + }] }); + mockOrder(); + selectRowsOnce([{ contractLineId: 'replacement-line', manualQuantity: '10.00' }]); + insertReturningOnce([{ id: 'wrong-line' }]); + + await expect(addOrderLine({ + partnerId: 'p1', orderId: 'ord-1', action: 'change_quantity', + targetSubscriptionId: 'sub-1', quantity: '11.00', + })).rejects.toMatchObject({ status: 409, message: expect.stringContaining('linkage changed') }); + + expect(mocks.db.insert).not.toHaveBeenCalled(); + }); + it('rejects an early cancellation forbidden by the commitment', async () => { mockOrder(); mockSubscriptionSnapshot(); @@ -696,8 +815,9 @@ describe('addOrderLine', () => { }); mockOrder({ source: 'direct' }); const finalMapping = mockCompanyMappingLookup({ pax8CompanyId: 'co-1', integrationId: 'i1' }); + selectRowsOnce([{ contractLineId: 'contract-line-1', manualQuantity: '10.00' }]); selectRowsOnce([{ maxSortOrder: null }]); - insertReturningOnce([{ id: 'line-1', orderId: 'ord-1', partnerId: 'p1', orgId: 'o1', action: 'change_quantity' }]); + const insert = insertReturningOnce([{ id: 'line-1', orderId: 'ord-1', partnerId: 'p1', orgId: 'o1', action: 'change_quantity' }]); const pending = addOrderLine({ partnerId: 'p1', orderId: 'ord-1', action: 'change_quantity', @@ -713,10 +833,13 @@ describe('addOrderLine', () => { expect(clientLookupDepth).toBe(1); expect(httpDepth).toBe(0); - expect(contextExitsAtHttp).toBe(4); + expect(contextExitsAtHttp).toBe(5); expect(insertStartedBeforeHttpResolved).toBe(false); expect(mocks.contextDepth).toBe(0); - expect(mocks.contextExits).toBe(5); + expect(mocks.contextExits).toBe(6); + expect(insert.values).toHaveBeenCalledWith(expect.objectContaining({ + contractLineId: 'contract-line-1', + })); expect(finalMapping.for).toHaveBeenCalledWith('share'); for (const [context] of mocks.withDbAccessContext.mock.calls) { expect(context).toMatchObject({ @@ -805,7 +928,9 @@ describe('addOrderLine', () => { it('inserts a valid new subscription line with order tenancy fields', async () => { mockOrder({ status: 'awaiting_details' }); + selectRowsOnce([{ pax8ProductId: 'prod-1', catalogItemId: 'catalog-1' }]); mockOrder({ status: 'awaiting_details' }); + selectRowsOnce([{ pax8ProductId: 'prod-1', catalogItemId: 'catalog-1' }]); selectRowsOnce([{ maxSortOrder: null }]); const insert = insertReturningOnce([{ id: 'line-1', @@ -820,6 +945,7 @@ describe('addOrderLine', () => { orderId: 'ord-1', action: 'new_subscription', pax8ProductId: 'prod-1', + catalogItemId: 'catalog-1', billingTerm: 'Annual', quantity: '2.00', provisioningDetails: [{ key: 'domain', values: ['example.com'] }], @@ -837,26 +963,26 @@ describe('addOrderLine', () => { }); it('allocates distinct deterministic positions for consecutive direct lines', async () => { - const orders = [ - [{ ...baseOrder }], [{ ...baseOrder }], - [{ ...baseOrder }], [{ ...baseOrder }], - ]; - const positions = [[{ maxSortOrder: null }], [{ maxSortOrder: 0 }]]; - mocks.db.select.mockImplementation((selection?: Record) => queryChain( - Object.prototype.hasOwnProperty.call(selection ?? {}, 'maxSortOrder') - ? positions.shift() ?? [] - : orders.shift() ?? [], - )); + mockOrder(); + selectRowsOnce([{ pax8ProductId: 'prod-1', catalogItemId: 'catalog-1' }]); + mockOrder(); + selectRowsOnce([{ pax8ProductId: 'prod-1', catalogItemId: 'catalog-1' }]); + selectRowsOnce([{ maxSortOrder: null }]); + mockOrder(); + selectRowsOnce([{ pax8ProductId: 'prod-2', catalogItemId: 'catalog-2' }]); + mockOrder(); + selectRowsOnce([{ pax8ProductId: 'prod-2', catalogItemId: 'catalog-2' }]); + selectRowsOnce([{ maxSortOrder: 0 }]); const firstInsert = insertReturningOnce([{ id: 'line-1' }]); const secondInsert = insertReturningOnce([{ id: 'line-2' }]); await addOrderLine({ partnerId: 'p1', orderId: 'ord-1', action: 'new_subscription', - pax8ProductId: 'prod-1', billingTerm: 'Monthly', quantity: '1.00', + pax8ProductId: 'prod-1', catalogItemId: 'catalog-1', billingTerm: 'Monthly', quantity: '1.00', }); await addOrderLine({ partnerId: 'p1', orderId: 'ord-1', action: 'new_subscription', - pax8ProductId: 'prod-2', billingTerm: 'Monthly', quantity: '2.00', + pax8ProductId: 'prod-2', catalogItemId: 'catalog-2', billingTerm: 'Monthly', quantity: '2.00', }); expect(firstInsert.values).toHaveBeenCalledWith(expect.objectContaining({ sortOrder: 0 })); @@ -865,6 +991,7 @@ describe('addOrderLine', () => { it('rejects when the order becomes immutable before the final insert', async () => { mockOrder({ status: 'draft' }); + selectRowsOnce([{ pax8ProductId: 'prod-1', catalogItemId: 'catalog-1' }]); const finalOrderQuery = mockOrder({ status: 'submitting' }); insertReturningOnce([{ id: 'wrongly-inserted-line' }]); @@ -873,6 +1000,7 @@ describe('addOrderLine', () => { orderId: 'ord-1', action: 'new_subscription', pax8ProductId: 'prod-1', + catalogItemId: 'catalog-1', billingTerm: 'Monthly', quantity: '1.00', })).rejects.toMatchObject({ status: 409 }); @@ -883,6 +1011,13 @@ describe('addOrderLine', () => { }); describe('removeOrderLine', () => { + it('rejects removal from a quote-staged order even while mutable', async () => { + mockOrder({ source: 'quote', status: 'awaiting_details' }); + + await expect(removeOrderLine({ partnerId: 'p1', orderId: 'ord-1', lineId: 'line-1' })) + .rejects.toMatchObject({ status: 409, message: expect.stringContaining('quote') }); + expect(mocks.db.delete).not.toHaveBeenCalled(); + }); it('refuses to remove a line from an immutable order', async () => { mockOrder({ status: 'ready' }); diff --git a/apps/api/src/services/pax8OrderService.ts b/apps/api/src/services/pax8OrderService.ts index 504dc37e4b..e1221a7927 100644 --- a/apps/api/src/services/pax8OrderService.ts +++ b/apps/api/src/services/pax8OrderService.ts @@ -13,8 +13,10 @@ import { pax8OrderLines, pax8Orders, pax8ProductMappings, + pax8ContractLineLinks, pax8SubscriptionSnapshots, catalogItems, + contractLines, } from '../db/schema'; import { createPax8ClientForIntegration } from './pax8SyncService'; import { pax8CompanyOrderReadiness } from './pax8CompanyReadiness'; @@ -59,11 +61,11 @@ const MUTABLE_STATUSES = new Set(['draft', 'awaiting_details']); const BILLING_TERMS = new Set(PAX8_BILLING_TERMS); const MUTABLE_DIRECT_ORDER_UNIQUE_INDEX = 'pax8_orders_one_mutable_direct_per_org_uq'; -function partnerDbContext(partnerId: string): DbAccessContext { +function partnerDbContext(partnerId: string, orgId?: string): DbAccessContext { return { scope: 'partner', - orgId: null, - accessibleOrgIds: null, + orgId: orgId ?? null, + accessibleOrgIds: orgId ? [orgId] : null, accessiblePartnerIds: [partnerId], userId: null, currentPartnerId: partnerId, @@ -75,8 +77,8 @@ function partnerDbContext(partnerId: string): DbAccessContext { * transaction. Exit defensively before opening each short partner context so * an accidental ambient caller still cannot make these phases reuse its tx. */ -function withPartnerDbContext(partnerId: string, fn: () => Promise): Promise { - return runOutsideDbContext(() => withDbAccessContext(partnerDbContext(partnerId), fn)); +function withPartnerDbContext(partnerId: string, fn: () => Promise, orgId?: string): Promise { + return runOutsideDbContext(() => withDbAccessContext(partnerDbContext(partnerId, orgId), fn)); } function requireMutableOrder(order: Pax8OrderRow): void { @@ -85,6 +87,33 @@ function requireMutableOrder(order: Pax8OrderRow): void { } } +function requirePubliclyMutableOrder(order: Pax8OrderRow): void { + requireMutableOrder(order); + if (order.source === 'quote') { + throw new Pax8OrderError( + 'quote-staged Pax8 order lines are immutable; only provisioning details may be updated.', + 409, + ); + } +} + +function utcDateString(now = new Date()): string { + return now.toISOString().slice(0, 10); +} + +export function requireImmediateCancelDate(cancelDate: string | null | undefined, now = new Date()): void { + if (!cancelDate) return; + const parsed = new Date(`${cancelDate}T00:00:00.000Z`); + if (!/^\d{4}-\d{2}-\d{2}$/.test(cancelDate) + || Number.isNaN(parsed.getTime()) + || parsed.toISOString().slice(0, 10) !== cancelDate) { + throw new Pax8OrderError('Cancellation date must be a valid UTC calendar date.', 422); + } + if (cancelDate > utcDateString(now)) { + throw new Pax8OrderError('future-dated Pax8 cancellations are not supported.', 422); + } +} + async function loadOrder(partnerId: string, orderId: string): Promise { const [order] = await db .select() @@ -276,6 +305,9 @@ function validateActionPayload(input: AddOrderLineInput): void { if (input.targetSubscriptionId) { throw new Pax8OrderError('A new subscription must not target an existing subscription.', 422); } + if (input.contractLineId) { + throw new Pax8OrderError('A direct new subscription cannot supply a contract line.', 422); + } return; } case 'change_quantity': { @@ -295,12 +327,139 @@ function validateActionPayload(input: AddOrderLineInput): void { if (input.quantity !== undefined) { throw new Pax8OrderError('A cancellation must not include a quantity.', 422); } + requireImmediateCancelDate(input.cancelDate); return; default: throw new Pax8OrderError('Unsupported Pax8 order action.', 422); } } +type IntegrityReader = Pick; + +interface DirectProductMapping { + pax8ProductId: string; + catalogItemId: string; +} + +async function currentDirectProductMapping( + reader: IntegrityReader, + order: Pax8OrderRow, + input: { pax8ProductId: string; catalogItemId: string }, + lock: boolean, +): Promise { + const query = reader + .select({ + pax8ProductId: pax8ProductMappings.pax8ProductId, + catalogItemId: pax8ProductMappings.catalogItemId, + }) + .from(pax8ProductMappings) + .innerJoin(pax8Integrations, and( + eq(pax8ProductMappings.integrationId, pax8Integrations.id), + eq(pax8ProductMappings.partnerId, pax8Integrations.partnerId), + )) + .innerJoin(catalogItems, and( + eq(pax8ProductMappings.catalogItemId, catalogItems.id), + eq(pax8ProductMappings.partnerId, catalogItems.partnerId), + )) + .where(and( + eq(pax8ProductMappings.integrationId, order.integrationId), + eq(pax8ProductMappings.partnerId, order.partnerId), + eq(pax8ProductMappings.pax8ProductId, input.pax8ProductId), + eq(pax8ProductMappings.catalogItemId, input.catalogItemId), + eq(pax8Integrations.isActive, true), + eq(catalogItems.isActive, true), + )); + const rows = lock ? await query.for('share') : await query; + if (rows.length !== 1 || !rows[0]!.catalogItemId) { + throw new Pax8OrderError( + 'Select an active Pax8 product that is mapped to the supplied active catalog item.', + 422, + ); + } + return rows[0] as DirectProductMapping; +} + +interface LinkedManualContractLine { + contractLineId: string; + manualQuantity: string; +} + +async function currentLinkedManualContractLine( + reader: IntegrityReader, + order: Pax8OrderRow, + targetSubscriptionId: string, + lock: boolean, +): Promise { + const query = reader + .select({ + contractLineId: pax8ContractLineLinks.contractLineId, + manualQuantity: contractLines.manualQuantity, + }) + .from(pax8ContractLineLinks) + .innerJoin(pax8SubscriptionSnapshots, and( + eq(pax8ContractLineLinks.subscriptionSnapshotId, pax8SubscriptionSnapshots.id), + eq(pax8ContractLineLinks.integrationId, pax8SubscriptionSnapshots.integrationId), + eq(pax8ContractLineLinks.partnerId, pax8SubscriptionSnapshots.partnerId), + eq(pax8ContractLineLinks.orgId, pax8SubscriptionSnapshots.orgId), + )) + .innerJoin(contractLines, and( + eq(pax8ContractLineLinks.contractLineId, contractLines.id), + eq(pax8ContractLineLinks.orgId, contractLines.orgId), + )) + .where(and( + eq(pax8ContractLineLinks.integrationId, order.integrationId), + eq(pax8ContractLineLinks.partnerId, order.partnerId), + eq(pax8ContractLineLinks.orgId, order.orgId), + eq(pax8SubscriptionSnapshots.pax8SubscriptionId, targetSubscriptionId), + eq(contractLines.lineType, 'manual' as never), + )); + const rows = lock ? await query.for('share') : await query; + if (rows.length !== 1 || rows[0]!.manualQuantity === null) { + throw new Pax8OrderError( + 'The target Pax8 subscription must have exactly one linked Breeze manual contract quantity.', + 422, + ); + } + return rows[0] as LinkedManualContractLine; +} + +export async function validateDirectOrderLinesForSubmit( + order: Pax8OrderRow, + lines: Pax8OrderLineRow[], +): Promise { + const validated: Pax8OrderLineRow[] = []; + for (const line of lines) { + if (line.action === 'new_subscription') { + if (order.source === 'quote') { + validated.push(line); + continue; + } + if (!line.pax8ProductId || !line.catalogItemId) { + throw new Pax8OrderError('A direct subscription requires a mapped catalog item.', 422); + } + await currentDirectProductMapping(db, order, { + pax8ProductId: line.pax8ProductId, + catalogItemId: line.catalogItemId, + }, true); + if (line.contractLineId) { + throw new Pax8OrderError('A direct new subscription cannot carry a contract line.', 422); + } + validated.push(line); + continue; + } + if (!line.targetSubscriptionId) { + throw new Pax8OrderError('A subscription action has no target subscription.', 422); + } + requireImmediateCancelDate(line.action === 'cancel' ? line.cancelDate : null); + const linked = await currentLinkedManualContractLine(db, order, line.targetSubscriptionId, true); + if (line.contractLineId !== linked.contractLineId) { + throw new Pax8OrderError('The staged contract line no longer matches the target Pax8 subscription.', 409); + } + validated.push({ ...line, contractLineId: linked.contractLineId }); + } + return validated; +} + function requireCompanyOrderReady(mapping: { status?: string | null; metadata?: unknown; @@ -415,13 +574,23 @@ export async function getOrCreateDraftOrder(input: { export async function addOrderLine(input: AddOrderLineInput): Promise { const order = await withPartnerDbContext(input.partnerId, () => loadOrder(input.partnerId, input.orderId)); - requireMutableOrder(order); + requirePubliclyMutableOrder(order); if (order.source === 'direct') { await withPartnerDbContext(input.partnerId, () => requireCurrentCompanyOrderReady(order)); } validateActionPayload(input); - if (input.action === 'change_quantity' || input.action === 'cancel') { + let derivedContractLine: LinkedManualContractLine | null = null; + let authoringBaseline: string | null = null; + if (input.action === 'new_subscription') { + if (!input.catalogItemId) { + throw new Pax8OrderError('A mapped catalog item is required for a direct subscription.', 422); + } + await withPartnerDbContext(input.partnerId, () => currentDirectProductMapping(db, order, { + pax8ProductId: input.pax8ProductId!, + catalogItemId: input.catalogItemId!, + }, false)); + } else { const [snapshot] = await withPartnerDbContext(input.partnerId, () => db .select() .from(pax8SubscriptionSnapshots) @@ -438,6 +607,12 @@ export async function addOrderLine(input: AddOrderLineInput): Promise + currentLinkedManualContractLine(db, order, input.targetSubscriptionId!, false), order.orgId); + authoringBaseline = derivedContractLine.manualQuantity; + if (input.contractLineId && input.contractLineId !== derivedContractLine.contractLineId) { + throw new Pax8OrderError('The supplied contract line does not match the target Pax8 subscription.', 422); + } const { client } = await withPartnerDbContext(input.partnerId, () => createPax8ClientForIntegration(order.integrationId)); @@ -446,7 +621,7 @@ export async function addOrderLine(input: AddOrderLineInput): Promise(bundle: SubmitBundle, fn: () => Promise): Prom )); } +function withOrderScopeDbContext( + partnerId: string, + orgId: string, + fn: () => Promise, +): Promise { + return runOutsideDbContext(() => withDbAccessContext(orderDbContext(partnerId, orgId), fn)); +} + type VersionedOrder = Pax8OrderRow & { rowVersion: string }; async function findOrder(partnerId: string, orderId: string): Promise { @@ -263,11 +277,25 @@ export const pax8OrderSubmitRepository: Pax8OrderSubmitRepository = { }); }, - claimOrder(input) { - return withPartnerDbContext(input.partnerId, async () => { + async claimOrder(input) { + // The parent is partner-axis, so discover its org in one bounded read. + // Re-open the final CAS transaction with that single org authorized so + // forced RLS permits the exact contract-line integrity join. + const discovered = await withPartnerDbContext(input.partnerId, () => + findOrder(input.partnerId, input.orderId)); + return withOrderScopeDbContext(input.partnerId, discovered.orgId, async () => { const existing = await findOrder(input.partnerId, input.orderId); + if (existing.orgId !== discovered.orgId) { + throw new Pax8OrderError('The Pax8 order organization changed while claiming it.', 409); + } assertSubmittable(existing); const companyId = await resolveCompany(existing); + let lines = await findOrderLines(existing); + if (lines.length === 0) throw new Pax8OrderError('Add at least one line before submitting the Pax8 order.', 422); + for (const line of lines) { + if (line.action === 'cancel') requireImmediateCancelDate(line.cancelDate); + } + lines = await validateDirectOrderLinesForSubmit(existing, lines); const now = new Date(); const [claimed] = await db .update(pax8Orders) @@ -298,8 +326,6 @@ export const pax8OrderSubmitRepository: Pax8OrderSubmitRepository = { if (!claimed) { throw new Pax8OrderError('Another submit won the order claim, or an earlier write requires reconciliation.', 409); } - const lines = await findOrderLines(claimed); - if (lines.length === 0) throw new Pax8OrderError('Add at least one line before submitting the Pax8 order.', 422); if (lines.some((line) => line.submitState !== 'pending')) { throw new Pax8OrderError('An earlier Pax8 line write requires reconciliation.', 409); } From 5b0d3a4f8ac7f2d8f32d44d362acc2bf75cfc3c8 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Tue, 14 Jul 2026 08:03:40 -0600 Subject: [PATCH 31/40] fix(pax8): close submit authorization races --- ...16-pax8-order-line-authorized-baseline.sql | 22 +++ .../pax8OrderSubmit.integration.test.ts | 125 ++++++++++++++++++ apps/api/src/db/schema/pax8.test.ts | 16 +++ apps/api/src/db/schema/pax8Orders.ts | 1 + .../api/src/services/pax8OrderService.test.ts | 27 ++++ apps/api/src/services/pax8OrderService.ts | 31 ++++- .../pax8OrderSubmitRepository.test.ts | 115 ++++++++++++++++ .../src/services/pax8OrderSubmitRepository.ts | 15 ++- 8 files changed, 341 insertions(+), 11 deletions(-) create mode 100644 apps/api/migrations/2026-07-16-pax8-order-line-authorized-baseline.sql create mode 100644 apps/api/src/services/pax8OrderSubmitRepository.test.ts diff --git a/apps/api/migrations/2026-07-16-pax8-order-line-authorized-baseline.sql b/apps/api/migrations/2026-07-16-pax8-order-line-authorized-baseline.sql new file mode 100644 index 0000000000..48328272a5 --- /dev/null +++ b/apps/api/migrations/2026-07-16-pax8-order-line-authorized-baseline.sql @@ -0,0 +1,22 @@ +-- Preserve the Breeze manual quantity against which a direct Pax8 quantity +-- change was authorized. Submit must match this value under lock before any +-- vendor write so a later billing edit cannot invert increase/decrease policy. + +ALTER TABLE pax8_order_lines + ADD COLUMN IF NOT EXISTS authorized_baseline_quantity NUMERIC(12,2); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'pax8_order_lines_authorized_baseline_chk' + AND conrelid = 'pax8_order_lines'::regclass + ) THEN + ALTER TABLE pax8_order_lines + ADD CONSTRAINT pax8_order_lines_authorized_baseline_chk CHECK ( + authorized_baseline_quantity IS NULL + OR (action = 'change_quantity' AND authorized_baseline_quantity >= 0) + ); + END IF; +END $$; diff --git a/apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts b/apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts index 092d490112..313f25e2f3 100644 --- a/apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts +++ b/apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts @@ -18,6 +18,7 @@ import { import { Pax8ApiError } from '../../services/pax8Client'; import { createPax8OrderSubmitService } from '../../services/pax8OrderSubmit'; import { pax8OrderSubmitRepository } from '../../services/pax8OrderSubmitRepository'; +import { updateOrderLine } from '../../services/pax8OrderService'; import { createOrganization, createPartner, createUser } from './db-utils'; import { getTestDb } from './setup'; @@ -172,6 +173,22 @@ function successfulClient() { }; } +async function seedChangeOrder(options: { baseline: string | null; current: string }) { + const fixture = await seedOrder({ action: 'cancel' }); + await withSystemDbAccessContext(async () => { + await db.update(contractLines) + .set({ manualQuantity: options.current }) + .where(eq(contractLines.id, fixture.contractLine.id)); + await db.update(pax8OrderLines).set({ + action: 'change_quantity', + quantity: '15.00', + cancelDate: null, + authorizedBaselineQuantity: options.baseline, + }).where(eq(pax8OrderLines.id, fixture.line.id)); + }); + return fixture; +} + describe('Pax8 submit pipeline (real Postgres)', () => { runDb('rejects unready direct and quote-staged orders before client creation or writes', async () => { for (const source of ['direct', 'quote'] as const) { @@ -272,6 +289,113 @@ describe('Pax8 submit pipeline (real Postgres)', () => { expect(client.createOrder).not.toHaveBeenCalled(); }); + runDb('fails closed when the manual quantity changed after direction authorization', async () => { + const fixture = await seedChangeOrder({ baseline: '10.00', current: '20.00' }); + const client = successfulClient(); + const { service, createClient } = serviceHarness(client); + + await expect(service.submitOrder({ + partnerId: fixture.partner.id, + orderId: fixture.order.id, + actorUserId: fixture.user.id, + })).rejects.toMatchObject({ status: 409, message: expect.stringContaining('changed') }); + + expect(createClient).not.toHaveBeenCalled(); + expect(client.updateSubscriptionQuantity).not.toHaveBeenCalled(); + const state = await withSystemDbAccessContext(async () => { + const [orderRow] = await db.select().from(pax8Orders).where(eq(pax8Orders.id, fixture.order.id)); + const [lineRow] = await db.select().from(pax8OrderLines).where(eq(pax8OrderLines.id, fixture.line.id)); + const [billing] = await db.select().from(contractLines).where(eq(contractLines.id, fixture.contractLine.id)); + return { orderRow, lineRow, billing }; + }); + expect(state.orderRow?.status).toBe('ready'); + expect(state.lineRow?.submitState).toBe('pending'); + expect(state.billing?.manualQuantity).toBe('20.00'); + }); + + runDb('fails closed for a legacy quantity change without an authorization baseline', async () => { + const fixture = await seedChangeOrder({ baseline: null, current: '10.00' }); + const client = successfulClient(); + + await expect(serviceWithClient(client).submitOrder({ + partnerId: fixture.partner.id, + orderId: fixture.order.id, + actorUserId: fixture.user.id, + })).rejects.toMatchObject({ status: 409, message: expect.stringContaining('baseline') }); + expect(client.updateSubscriptionQuantity).not.toHaveBeenCalled(); + }); + + runDb('submits an unchanged authorized baseline and records the new billing quantity', async () => { + const fixture = await seedChangeOrder({ baseline: '10.00', current: '10.00' }); + const client = successfulClient(); + + await serviceWithClient(client).submitOrder({ + partnerId: fixture.partner.id, + orderId: fixture.order.id, + actorUserId: fixture.user.id, + }); + + expect(client.updateSubscriptionQuantity).toHaveBeenCalledWith('subscription-cancel', 15); + const [billing] = await withSystemDbAccessContext(() => db.select() + .from(contractLines).where(eq(contractLines.id, fixture.contractLine.id))); + expect(billing?.manualQuantity).toBe('15.00'); + }); + + runDb('uses post-lock PATCH values when PATCH wins and rejects PATCH when claim wins', async () => { + const patchWins = await seedOrder(); + await withSystemDbAccessContext(() => db.update(pax8Orders) + .set({ status: 'awaiting_details' }) + .where(eq(pax8Orders.id, patchWins.order.id))); + let locked!: () => void; + const lockedPromise = new Promise((resolve) => { locked = resolve; }); + let release!: () => void; + const releasePromise = new Promise((resolve) => { release = resolve; }); + const patchTransaction = getTestDb().transaction(async (tx) => { + await tx.execute(sql`SELECT id FROM pax8_orders WHERE id = ${patchWins.order.id} FOR UPDATE`); + locked(); + await releasePromise; + await tx.update(pax8OrderLines).set({ + commitmentTermId: 'patched-commitment', + provisioningDetails: [{ key: 'domain', values: ['patched.example'] }], + }).where(eq(pax8OrderLines.id, patchWins.line.id)); + }); + await lockedPromise; + const patchWinsClient = successfulClient(); + const submitPending = serviceWithClient(patchWinsClient).submitOrder({ + partnerId: patchWins.partner.id, + orderId: patchWins.order.id, + actorUserId: patchWins.user.id, + }); + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(patchWinsClient.createOrder).not.toHaveBeenCalled(); + release(); + await patchTransaction; + await submitPending; + expect(patchWinsClient.createOrder).toHaveBeenNthCalledWith(2, expect.objectContaining({ + lineItems: [expect.objectContaining({ + commitmentTermId: 'patched-commitment', + provisioningDetails: [{ key: 'domain', values: ['patched.example'] }], + })], + })); + + const claimWins = await seedOrder(); + await withSystemDbAccessContext(() => db.update(pax8Orders) + .set({ status: 'awaiting_details' }) + .where(eq(pax8Orders.id, claimWins.order.id))); + const claimWinsClient = successfulClient(); + await serviceWithClient(claimWinsClient).submitOrder({ + partnerId: claimWins.partner.id, + orderId: claimWins.order.id, + actorUserId: claimWins.user.id, + }); + await expect(updateOrderLine({ + partnerId: claimWins.partner.id, + orderId: claimWins.order.id, + lineId: claimWins.line.id, + provisioningDetails: [{ key: 'domain', values: ['too-late.example'] }], + })).rejects.toMatchObject({ status: 409 }); + }); + runDb('atomically claims one submit, persists billing success, and keeps rejected billing untouched', async () => { const fixture = await seedOrder(); const client = successfulClient(); @@ -473,6 +597,7 @@ describe('Pax8 submit pipeline (real Postgres)', () => { action: 'change_quantity', targetSubscriptionId: 'subscription-cancel', quantity: '2.00', + authorizedBaselineQuantity: '7.00', contractLineId: duplicateFixture.contractLine.id, })); const duplicateClient = successfulClient(); diff --git a/apps/api/src/db/schema/pax8.test.ts b/apps/api/src/db/schema/pax8.test.ts index 8287db50db..adc7039a4d 100644 --- a/apps/api/src/db/schema/pax8.test.ts +++ b/apps/api/src/db/schema/pax8.test.ts @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'; import { getTableConfig } from 'drizzle-orm/pg-core'; import { describe, expect, it } from 'vitest'; import { pax8SubscriptionSnapshots } from './pax8'; +import { pax8OrderLines } from './pax8Orders'; describe('Pax8 subscription quantity evidence defaults', () => { it('treats legacy rows as unknown in both Drizzle and the migration', () => { @@ -17,3 +18,18 @@ describe('Pax8 subscription quantity evidence defaults', () => { expect(migration).toMatch(/quantity_known boolean NOT NULL DEFAULT false/i); }); }); + +describe('Pax8 quantity authorization baseline', () => { + it('is represented in Drizzle and added by an idempotent fix-forward migration', () => { + const column = getTableConfig(pax8OrderLines).columns + .find((candidate) => candidate.name === 'authorized_baseline_quantity'); + expect(column).toMatchObject({ notNull: false }); + + const migration = readFileSync(fileURLToPath(new URL( + '../../../migrations/2026-07-16-pax8-order-line-authorized-baseline.sql', + import.meta.url, + )), 'utf8'); + expect(migration).toMatch(/ADD COLUMN IF NOT EXISTS authorized_baseline_quantity NUMERIC\(12,2\)/i); + expect(migration).toMatch(/action = 'change_quantity'/i); + }); +}); diff --git a/apps/api/src/db/schema/pax8Orders.ts b/apps/api/src/db/schema/pax8Orders.ts index 2b9fed224a..12515a31c2 100644 --- a/apps/api/src/db/schema/pax8Orders.ts +++ b/apps/api/src/db/schema/pax8Orders.ts @@ -74,6 +74,7 @@ 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 }), + authorizedBaselineQuantity: numeric('authorized_baseline_quantity', { precision: 12, scale: 2 }), provisioningDetails: jsonb('provisioning_details').notNull().default([]), targetSubscriptionId: varchar('target_subscription_id', { length: 64 }), cancelDate: date('cancel_date'), diff --git a/apps/api/src/services/pax8OrderService.test.ts b/apps/api/src/services/pax8OrderService.test.ts index dfaca27b73..19cdd19d9d 100644 --- a/apps/api/src/services/pax8OrderService.test.ts +++ b/apps/api/src/services/pax8OrderService.test.ts @@ -39,6 +39,7 @@ vi.mock('../db/schema', () => new Proxy({ orgId: 'pax8_order_lines.org_id', action: 'pax8_order_lines.action', sortOrder: 'pax8_order_lines.sort_order', + authorizedBaselineQuantity: 'pax8_order_lines.authorized_baseline_quantity', }, pax8CompanyMappings: { partnerId: 'pax8_company_mappings.partner_id', @@ -114,6 +115,7 @@ import { listPax8Orders, removeOrderLine, updateOrderLine, + validateDirectOrderLinesForSubmit, } from './pax8OrderService'; const baseOrder = { @@ -714,6 +716,30 @@ describe('addOrderLine', () => { expect(mocks.createPax8ClientForIntegration).not.toHaveBeenCalled(); }); + it('fails closed at submit validation for a legacy change line without an authorization baseline', async () => { + selectRowsOnce([{ contractLineId: 'contract-line-1', manualQuantity: '10.00' }]); + + await expect(validateDirectOrderLinesForSubmit( + { ...baseOrder, source: 'direct' } as never, + [{ + id: 'line-1', action: 'change_quantity', targetSubscriptionId: 'sub-1', + contractLineId: 'contract-line-1', authorizedBaselineQuantity: null, + } as never], + )).rejects.toMatchObject({ status: 409, message: expect.stringContaining('baseline') }); + }); + + it('fails closed at submit when the linked Breeze quantity changed since authorization', async () => { + selectRowsOnce([{ contractLineId: 'contract-line-1', manualQuantity: '20.00' }]); + + await expect(validateDirectOrderLinesForSubmit( + { ...baseOrder, source: 'direct' } as never, + [{ + id: 'line-1', action: 'change_quantity', targetSubscriptionId: 'sub-1', + contractLineId: 'contract-line-1', authorizedBaselineQuantity: '10.00', + } as never], + )).rejects.toMatchObject({ status: 409, message: expect.stringContaining('changed') }); + }); + it('rejects a same-org but unrelated caller contract line and never stages it', async () => { mockOrder(); mockSubscriptionSnapshot(); @@ -839,6 +865,7 @@ describe('addOrderLine', () => { expect(mocks.contextExits).toBe(6); expect(insert.values).toHaveBeenCalledWith(expect.objectContaining({ contractLineId: 'contract-line-1', + authorizedBaselineQuantity: '10.00', })); expect(finalMapping.for).toHaveBeenCalledWith('share'); for (const [context] of mocks.withDbAccessContext.mock.calls) { diff --git a/apps/api/src/services/pax8OrderService.ts b/apps/api/src/services/pax8OrderService.ts index e1221a7927..ad4a404c45 100644 --- a/apps/api/src/services/pax8OrderService.ts +++ b/apps/api/src/services/pax8OrderService.ts @@ -164,6 +164,13 @@ function numericQuantity(value: string | undefined): number | null { return Number.isFinite(parsed) ? parsed : null; } +function sameNumericQuantity(left: string | null | undefined, right: string | null | undefined): boolean { + if (left === null || left === undefined || right === null || right === undefined) return false; + const leftNumber = Number(left); + const rightNumber = Number(right); + return Number.isFinite(leftNumber) && Number.isFinite(rightNumber) && leftNumber === rightNumber; +} + type JsonRecord = Record; const COMMITMENT_ID_KEYS = [ @@ -455,6 +462,20 @@ export async function validateDirectOrderLinesForSubmit( if (line.contractLineId !== linked.contractLineId) { throw new Pax8OrderError('The staged contract line no longer matches the target Pax8 subscription.', 409); } + if (line.action === 'change_quantity') { + if (line.authorizedBaselineQuantity === null) { + throw new Pax8OrderError( + 'This legacy quantity change has no authorization baseline; remove and stage it again.', + 409, + ); + } + if (!sameNumericQuantity(line.authorizedBaselineQuantity, linked.manualQuantity)) { + throw new Pax8OrderError( + 'The linked Breeze contract quantity changed since this Pax8 action was authorized; remove and stage it again.', + 409, + ); + } + } validated.push({ ...line, contractLineId: linked.contractLineId }); } return validated; @@ -679,11 +700,10 @@ export async function addOrderLine(input: AddOrderLineInput): Promise ({ + db: { select: vi.fn(), update: vi.fn() }, + validateLines: vi.fn(), + events: [] as string[], +})); + +vi.mock('../db', () => ({ + db: mocks.db, + runOutsideDbContext: (fn: () => unknown) => fn(), + withDbAccessContext: (_context: unknown, fn: () => unknown) => fn(), +})); + +vi.mock('./pax8OrderService', () => { + class Pax8OrderError extends Error { + constructor(message: string, public readonly status: number) { + super(message); + } + } + return { + Pax8OrderError, + requireImmediateCancelDate: vi.fn(), + validateDirectOrderLinesForSubmit: mocks.validateLines, + }; +}); + +vi.mock('./pax8SyncService', () => ({ createPax8ClientForIntegration: vi.fn() })); + +import { pax8OrderSubmitRepository } from './pax8OrderSubmitRepository'; + +const READY_METADATA = { + contacts: [{ types: [ + { type: 'Admin', primary: true }, + { type: 'Billing', primary: true }, + { type: 'Technical', primary: true }, + ] }], +}; + +const order = { + id: 'order-1', integrationId: 'integration-1', partnerId: 'partner-1', orgId: 'org-1', + pax8CompanyId: 'company-1', status: 'ready', source: 'quote', sourceQuoteId: null, + dedupeKey: 'order:order-1', pax8OrderId: null, error: null, createdBy: 'user-1', + submittedBy: null, submittedAt: null, createdAt: new Date(), updatedAt: new Date(), + rowVersion: '7', +} as const; + +function selectChain(rows: unknown[], terminal: 'limit' | 'for' | 'orderBy', event?: string) { + const chain: Record = {}; + chain.from = vi.fn(() => chain); + chain.where = vi.fn(() => chain); + chain.limit = vi.fn(async () => { + if (terminal === 'limit' && event) mocks.events.push(event); + return rows; + }); + chain.for = vi.fn(async () => { + if (terminal === 'for' && event) mocks.events.push(event); + return rows; + }); + chain.orderBy = vi.fn(async () => { + if (terminal === 'orderBy' && event) mocks.events.push(event); + return rows; + }); + return chain; +} + +beforeEach(() => { + mocks.db.select.mockReset(); + mocks.db.update.mockReset(); + mocks.validateLines.mockReset(); + mocks.events.length = 0; +}); + +describe('pax8OrderSubmitRepository.claimOrder', () => { + it('reads and returns the authoritative line only after the parent claim lock', async () => { + const patchedLine = { + id: 'line-1', orderId: order.id, partnerId: order.partnerId, orgId: order.orgId, + action: 'new_subscription', submitState: 'pending', pax8ProductId: 'product-1', + catalogItemId: 'catalog-1', billingTerm: 'Monthly', commitmentTermId: 'commit-new', + quantity: '2.00', authorizedBaselineQuantity: null, + provisioningDetails: [{ key: 'domain', values: ['patched.example'] }], + targetSubscriptionId: null, cancelDate: null, resultSubscriptionId: null, + contractLineId: null, sourceQuoteLineId: 'quote-line-1', error: null, sortOrder: 0, + createdAt: new Date(), updatedAt: new Date(), + }; + mocks.db.select + .mockReturnValueOnce(selectChain([order], 'limit', 'org-discovered')) + .mockReturnValueOnce(selectChain([order], 'limit', 'order-reloaded')) + .mockReturnValueOnce(selectChain([{ + pax8CompanyId: 'company-1', status: 'Active', metadata: READY_METADATA, + }], 'for', 'company-locked')) + .mockReturnValueOnce(selectChain([patchedLine], 'orderBy', 'lines-read')); + const returning = vi.fn(async () => { + mocks.events.push('parent-claimed'); + return [{ ...order, status: 'submitting', submittedAt: new Date() }]; + }); + mocks.db.update.mockReturnValue({ + set: vi.fn(() => ({ where: vi.fn(() => ({ returning })) })), + }); + mocks.validateLines.mockImplementation(async (_order, lines) => { + mocks.events.push('lines-validated'); + return lines; + }); + + const bundle = await pax8OrderSubmitRepository.claimOrder({ + partnerId: order.partnerId, + orderId: order.id, + actorUserId: 'actor-1', + }); + + expect(mocks.events.indexOf('parent-claimed')).toBeLessThan(mocks.events.indexOf('lines-read')); + expect(mocks.events.indexOf('lines-read')).toBeLessThan(mocks.events.indexOf('lines-validated')); + expect(bundle.lines).toEqual([patchedLine]); + }); +}); diff --git a/apps/api/src/services/pax8OrderSubmitRepository.ts b/apps/api/src/services/pax8OrderSubmitRepository.ts index a8b1d825ee..8a32671aba 100644 --- a/apps/api/src/services/pax8OrderSubmitRepository.ts +++ b/apps/api/src/services/pax8OrderSubmitRepository.ts @@ -290,12 +290,6 @@ export const pax8OrderSubmitRepository: Pax8OrderSubmitRepository = { } assertSubmittable(existing); const companyId = await resolveCompany(existing); - let lines = await findOrderLines(existing); - if (lines.length === 0) throw new Pax8OrderError('Add at least one line before submitting the Pax8 order.', 422); - for (const line of lines) { - if (line.action === 'cancel') requireImmediateCancelDate(line.cancelDate); - } - lines = await validateDirectOrderLinesForSubmit(existing, lines); const now = new Date(); const [claimed] = await db .update(pax8Orders) @@ -326,9 +320,18 @@ export const pax8OrderSubmitRepository: Pax8OrderSubmitRepository = { if (!claimed) { throw new Pax8OrderError('Another submit won the order claim, or an earlier write requires reconciliation.', 409); } + // The parent transition owns the row lock that every authoring mutation + // must acquire. Read only after that lock: if PATCH committed first we + // see its values; if this claim won, PATCH wakes to a non-mutable parent. + let lines = await findOrderLines(claimed); + if (lines.length === 0) throw new Pax8OrderError('Add at least one line before submitting the Pax8 order.', 422); if (lines.some((line) => line.submitState !== 'pending')) { throw new Pax8OrderError('An earlier Pax8 line write requires reconciliation.', 409); } + for (const line of lines) { + if (line.action === 'cancel') requireImmediateCancelDate(line.cancelDate); + } + lines = await validateDirectOrderLinesForSubmit(claimed, lines); const targets = lines .filter((line) => line.action !== 'new_subscription') .map((line) => line.targetSubscriptionId); From d88b1a32f4e71d0a08e3ff7d3da7dd6492096711 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Tue, 14 Jul 2026 08:16:14 -0600 Subject: [PATCH 32/40] fix(pax8): reopen safe orders for restaging --- .../pax8OrderSubmit.integration.test.ts | 50 ++++++++++-- apps/api/src/services/pax8OrderService.ts | 13 ++- .../pax8OrderSubmitRepository.test.ts | 81 +++++++++++++++++++ .../src/services/pax8OrderSubmitRepository.ts | 46 ++++++++++- 4 files changed, 177 insertions(+), 13 deletions(-) diff --git a/apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts b/apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts index 313f25e2f3..ca0ad5c2a3 100644 --- a/apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts +++ b/apps/api/src/__tests__/integration/pax8OrderSubmit.integration.test.ts @@ -18,7 +18,7 @@ import { import { Pax8ApiError } from '../../services/pax8Client'; import { createPax8OrderSubmitService } from '../../services/pax8OrderSubmit'; import { pax8OrderSubmitRepository } from '../../services/pax8OrderSubmitRepository'; -import { updateOrderLine } from '../../services/pax8OrderService'; +import { removeOrderLine, updateOrderLine } from '../../services/pax8OrderService'; import { createOrganization, createPartner, createUser } from './db-utils'; import { getTestDb } from './setup'; @@ -173,8 +173,12 @@ function successfulClient() { }; } -async function seedChangeOrder(options: { baseline: string | null; current: string }) { - const fixture = await seedOrder({ action: 'cancel' }); +async function seedChangeOrder(options: { + baseline: string | null; + current: string; + source?: 'direct' | 'quote'; +}) { + const fixture = await seedOrder({ action: 'cancel', source: options.source }); await withSystemDbAccessContext(async () => { await db.update(contractLines) .set({ manualQuantity: options.current }) @@ -290,7 +294,7 @@ describe('Pax8 submit pipeline (real Postgres)', () => { }); runDb('fails closed when the manual quantity changed after direction authorization', async () => { - const fixture = await seedChangeOrder({ baseline: '10.00', current: '20.00' }); + const fixture = await seedChangeOrder({ baseline: '10.00', current: '20.00', source: 'direct' }); const client = successfulClient(); const { service, createClient } = serviceHarness(client); @@ -308,13 +312,39 @@ describe('Pax8 submit pipeline (real Postgres)', () => { const [billing] = await db.select().from(contractLines).where(eq(contractLines.id, fixture.contractLine.id)); return { orderRow, lineRow, billing }; }); - expect(state.orderRow?.status).toBe('ready'); + expect(state.orderRow?.status).toBe('draft'); expect(state.lineRow?.submitState).toBe('pending'); expect(state.billing?.manualQuantity).toBe('20.00'); + await expect(removeOrderLine({ + partnerId: fixture.partner.id, + orderId: fixture.order.id, + lineId: fixture.line.id, + })).resolves.toEqual({ removed: true }); + await withSystemDbAccessContext(() => db.insert(pax8OrderLines).values({ + orderId: fixture.order.id, + partnerId: fixture.partner.id, + orgId: fixture.org.id, + action: 'change_quantity', + targetSubscriptionId: 'subscription-cancel', + quantity: '25.00', + authorizedBaselineQuantity: '20.00', + contractLineId: fixture.contractLine.id, + })); + const restagedClient = successfulClient(); + await serviceWithClient(restagedClient).submitOrder({ + partnerId: fixture.partner.id, + orderId: fixture.order.id, + actorUserId: fixture.user.id, + }); + expect(restagedClient.updateSubscriptionQuantity) + .toHaveBeenCalledWith('subscription-cancel', 25); + const [restagedBilling] = await withSystemDbAccessContext(() => db.select() + .from(contractLines).where(eq(contractLines.id, fixture.contractLine.id))); + expect(restagedBilling?.manualQuantity).toBe('25.00'); }); runDb('fails closed for a legacy quantity change without an authorization baseline', async () => { - const fixture = await seedChangeOrder({ baseline: null, current: '10.00' }); + const fixture = await seedChangeOrder({ baseline: null, current: '10.00', source: 'direct' }); const client = successfulClient(); await expect(serviceWithClient(client).submitOrder({ @@ -323,6 +353,14 @@ describe('Pax8 submit pipeline (real Postgres)', () => { actorUserId: fixture.user.id, })).rejects.toMatchObject({ status: 409, message: expect.stringContaining('baseline') }); expect(client.updateSubscriptionQuantity).not.toHaveBeenCalled(); + const [orderAfter] = await withSystemDbAccessContext(() => db.select() + .from(pax8Orders).where(eq(pax8Orders.id, fixture.order.id))); + expect(orderAfter?.status).toBe('draft'); + await expect(removeOrderLine({ + partnerId: fixture.partner.id, + orderId: fixture.order.id, + lineId: fixture.line.id, + })).resolves.toEqual({ removed: true }); }); runDb('submits an unchanged authorized baseline and records the new billing quantity', async () => { diff --git a/apps/api/src/services/pax8OrderService.ts b/apps/api/src/services/pax8OrderService.ts index ad4a404c45..21aca451b6 100644 --- a/apps/api/src/services/pax8OrderService.ts +++ b/apps/api/src/services/pax8OrderService.ts @@ -32,6 +32,13 @@ export class Pax8OrderError extends Error { } } +export class Pax8OrderRestageRequiredError extends Pax8OrderError { + constructor(message: string) { + super(message, 409); + this.name = 'Pax8OrderRestageRequiredError'; + } +} + export type Pax8OrderRow = typeof pax8Orders.$inferSelect; export type Pax8OrderLineRow = typeof pax8OrderLines.$inferSelect; @@ -464,15 +471,13 @@ export async function validateDirectOrderLinesForSubmit( } if (line.action === 'change_quantity') { if (line.authorizedBaselineQuantity === null) { - throw new Pax8OrderError( + throw new Pax8OrderRestageRequiredError( 'This legacy quantity change has no authorization baseline; remove and stage it again.', - 409, ); } if (!sameNumericQuantity(line.authorizedBaselineQuantity, linked.manualQuantity)) { - throw new Pax8OrderError( + throw new Pax8OrderRestageRequiredError( 'The linked Breeze contract quantity changed since this Pax8 action was authorized; remove and stage it again.', - 409, ); } } diff --git a/apps/api/src/services/pax8OrderSubmitRepository.test.ts b/apps/api/src/services/pax8OrderSubmitRepository.test.ts index 8a682c5aca..1b92595708 100644 --- a/apps/api/src/services/pax8OrderSubmitRepository.test.ts +++ b/apps/api/src/services/pax8OrderSubmitRepository.test.ts @@ -18,8 +18,10 @@ vi.mock('./pax8OrderService', () => { super(message); } } + class Pax8OrderRestageRequiredError extends Pax8OrderError {} return { Pax8OrderError, + Pax8OrderRestageRequiredError, requireImmediateCancelDate: vi.fn(), validateDirectOrderLinesForSubmit: mocks.validateLines, }; @@ -112,4 +114,83 @@ describe('pax8OrderSubmitRepository.claimOrder', () => { expect(mocks.events.indexOf('lines-read')).toBeLessThan(mocks.events.indexOf('lines-validated')); expect(bundle.lines).toEqual([patchedLine]); }); + + it('commits a safe direct ready-to-draft recovery before surfacing a baseline conflict', async () => { + const directOrder = { ...order, source: 'direct' as const }; + const legacyLine = { + id: 'line-legacy', orderId: order.id, partnerId: order.partnerId, orgId: order.orgId, + action: 'change_quantity', submitState: 'pending', targetSubscriptionId: 'sub-1', + contractLineId: 'contract-line-1', authorizedBaselineQuantity: null, + }; + mocks.db.select + .mockReturnValueOnce(selectChain([directOrder], 'limit')) + .mockReturnValueOnce(selectChain([directOrder], 'limit')) + .mockReturnValueOnce(selectChain([{ + pax8CompanyId: 'company-1', status: 'Active', metadata: READY_METADATA, + }], 'for')) + .mockReturnValueOnce(selectChain([legacyLine], 'orderBy')); + const claimedAt = new Date('2026-07-14T12:00:00.000Z'); + const claimReturning = vi.fn(async () => [{ + ...directOrder, status: 'submitting', submittedAt: claimedAt, + }]); + const resetReturning = vi.fn(async () => { + mocks.events.push('draft-reset-committed'); + return [{ id: order.id }]; + }); + const claimSet = vi.fn(() => ({ where: vi.fn(() => ({ returning: claimReturning })) })); + const resetSet = vi.fn(() => ({ where: vi.fn(() => ({ returning: resetReturning })) })); + mocks.db.update + .mockReturnValueOnce({ set: claimSet }) + .mockReturnValueOnce({ set: resetSet }); + const { Pax8OrderRestageRequiredError } = await import('./pax8OrderService'); + mocks.validateLines.mockRejectedValueOnce(new Pax8OrderRestageRequiredError( + 'This legacy quantity change has no authorization baseline; remove and stage it again.', + 409, + )); + + await expect(pax8OrderSubmitRepository.claimOrder({ + partnerId: order.partnerId, + orderId: order.id, + actorUserId: 'actor-1', + })).rejects.toMatchObject({ status: 409, message: expect.stringContaining('stage it again') }); + + expect(resetSet).toHaveBeenCalledWith(expect.objectContaining({ + status: 'draft', submittedBy: null, submittedAt: null, + })); + expect(mocks.events).toContain('draft-reset-committed'); + }); + + it('never demotes a quote order for a restage-required conflict', async () => { + const quoteLine = { + id: 'line-quote', orderId: order.id, partnerId: order.partnerId, orgId: order.orgId, + action: 'change_quantity', submitState: 'pending', targetSubscriptionId: 'sub-1', + contractLineId: 'contract-line-1', authorizedBaselineQuantity: null, + }; + mocks.db.select + .mockReturnValueOnce(selectChain([order], 'limit')) + .mockReturnValueOnce(selectChain([order], 'limit')) + .mockReturnValueOnce(selectChain([{ + pax8CompanyId: 'company-1', status: 'Active', metadata: READY_METADATA, + }], 'for')) + .mockReturnValueOnce(selectChain([quoteLine], 'orderBy')); + const claimReturning = vi.fn(async () => [{ + ...order, status: 'submitting', submittedAt: new Date('2026-07-14T12:00:00.000Z'), + }]); + mocks.db.update.mockReturnValueOnce({ + set: vi.fn(() => ({ where: vi.fn(() => ({ returning: claimReturning })) })), + }); + const { Pax8OrderRestageRequiredError } = await import('./pax8OrderService'); + mocks.validateLines.mockRejectedValueOnce(new Pax8OrderRestageRequiredError( + 'Quote conflict must remain non-actionable.', + 409, + )); + + await expect(pax8OrderSubmitRepository.claimOrder({ + partnerId: order.partnerId, + orderId: order.id, + actorUserId: 'actor-1', + })).rejects.toMatchObject({ status: 409 }); + + expect(mocks.db.update).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/api/src/services/pax8OrderSubmitRepository.ts b/apps/api/src/services/pax8OrderSubmitRepository.ts index 8a32671aba..b5469c24ee 100644 --- a/apps/api/src/services/pax8OrderSubmitRepository.ts +++ b/apps/api/src/services/pax8OrderSubmitRepository.ts @@ -14,6 +14,7 @@ import { } from '../db/schema'; import { Pax8OrderError, + Pax8OrderRestageRequiredError, requireImmediateCancelDate, validateDirectOrderLinesForSubmit, type Pax8OrderLineRow, @@ -283,7 +284,7 @@ export const pax8OrderSubmitRepository: Pax8OrderSubmitRepository = { // forced RLS permits the exact contract-line integrity join. const discovered = await withPartnerDbContext(input.partnerId, () => findOrder(input.partnerId, input.orderId)); - return withOrderScopeDbContext(input.partnerId, discovered.orgId, async () => { + const outcome = await withOrderScopeDbContext(input.partnerId, discovered.orgId, async () => { const existing = await findOrder(input.partnerId, input.orderId); if (existing.orgId !== discovered.orgId) { throw new Pax8OrderError('The Pax8 order organization changed while claiming it.', 409); @@ -331,15 +332,54 @@ export const pax8OrderSubmitRepository: Pax8OrderSubmitRepository = { for (const line of lines) { if (line.action === 'cancel') requireImmediateCancelDate(line.cancelDate); } - lines = await validateDirectOrderLinesForSubmit(claimed, lines); + try { + lines = await validateDirectOrderLinesForSubmit(claimed, lines); + } catch (error) { + if (!(error instanceof Pax8OrderRestageRequiredError) || claimed.source !== 'direct') throw error; + const reset = await db + .update(pax8Orders) + .set({ + status: 'draft', + submittedBy: null, + submittedAt: null, + error: error.message, + updatedAt: new Date(), + }) + .where(and( + eq(pax8Orders.id, claimed.id), + eq(pax8Orders.partnerId, claimed.partnerId), + eq(pax8Orders.orgId, claimed.orgId), + eq(pax8Orders.integrationId, claimed.integrationId), + eq(pax8Orders.source, 'direct'), + eq(pax8Orders.status, 'submitting'), + eq(pax8Orders.submittedAt, claimed.submittedAt!), + sql`NOT EXISTS ( + SELECT 1 FROM pax8_order_lines pol + WHERE pol.order_id = ${pax8Orders.id} + AND pol.partner_id = ${pax8Orders.partnerId} + AND pol.org_id = ${pax8Orders.orgId} + AND pol.submit_state <> 'pending' + )`, + )) + .returning({ id: pax8Orders.id }); + if (reset.length !== 1) { + throw new Pax8OrderError( + 'The Pax8 order could not be safely reopened for restaging; reconcile it before retrying.', + 409, + ); + } + return { restageError: error } as const; + } const targets = lines .filter((line) => line.action !== 'new_subscription') .map((line) => line.targetSubscriptionId); if (targets.some((target, index) => target !== null && targets.indexOf(target) !== index)) { throw new Pax8OrderError('Only one change or cancellation may target a Pax8 subscription per order.', 422); } - return { order: claimed, lines }; + return { bundle: { order: claimed, lines } } as const; }); + if ('restageError' in outcome) throw outcome.restageError; + return outcome.bundle; }, createClient(bundle) { From 0cef481e6282b751e2a69e28eb852454a469c3c5 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Tue, 14 Jul 2026 08:21:38 -0600 Subject: [PATCH 33/40] test(pax8): align restage error constructor --- apps/api/src/services/pax8OrderSubmitRepository.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/api/src/services/pax8OrderSubmitRepository.test.ts b/apps/api/src/services/pax8OrderSubmitRepository.test.ts index 1b92595708..298a6052c7 100644 --- a/apps/api/src/services/pax8OrderSubmitRepository.test.ts +++ b/apps/api/src/services/pax8OrderSubmitRepository.test.ts @@ -18,7 +18,11 @@ vi.mock('./pax8OrderService', () => { super(message); } } - class Pax8OrderRestageRequiredError extends Pax8OrderError {} + class Pax8OrderRestageRequiredError extends Pax8OrderError { + constructor(message: string) { + super(message, 409); + } + } return { Pax8OrderError, Pax8OrderRestageRequiredError, @@ -145,7 +149,6 @@ describe('pax8OrderSubmitRepository.claimOrder', () => { const { Pax8OrderRestageRequiredError } = await import('./pax8OrderService'); mocks.validateLines.mockRejectedValueOnce(new Pax8OrderRestageRequiredError( 'This legacy quantity change has no authorization baseline; remove and stage it again.', - 409, )); await expect(pax8OrderSubmitRepository.claimOrder({ @@ -182,7 +185,6 @@ describe('pax8OrderSubmitRepository.claimOrder', () => { const { Pax8OrderRestageRequiredError } = await import('./pax8OrderService'); mocks.validateLines.mockRejectedValueOnce(new Pax8OrderRestageRequiredError( 'Quote conflict must remain non-actionable.', - 409, )); await expect(pax8OrderSubmitRepository.claimOrder({ From 327d47f7c81b79b67c432656ac7ebcbeb1a6cc4f Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Tue, 14 Jul 2026 08:26:45 -0600 Subject: [PATCH 34/40] fix(i18n): annotate Pax8 dynamic keys --- .../web/src/components/organizations/Pax8OrderBuilder.tsx | 8 ++++---- apps/web/src/components/organizations/Pax8OrgTab.tsx | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/organizations/Pax8OrderBuilder.tsx b/apps/web/src/components/organizations/Pax8OrderBuilder.tsx index a751a37cb6..cdfecd758b 100644 --- a/apps/web/src/components/organizations/Pax8OrderBuilder.tsx +++ b/apps/web/src/components/organizations/Pax8OrderBuilder.tsx @@ -257,7 +257,7 @@ export default function Pax8OrderBuilder({

- {t(PAX8_ORDER_STATUS_I18N_KEYS[order.status])} + {t(/* i18n-dynamic */ PAX8_ORDER_STATUS_I18N_KEYS[order.status])} @@ -281,7 +281,7 @@ export default function Pax8OrderBuilder({