diff --git a/CHANGELOG.md b/CHANGELOG.md index 9734fea4..d2cdee22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,16 @@ ## Unreleased +- Hybrid Link tier transitions. `Client.sessions.quoteTierChange` accepts + `hybridResolution` (`keep` one connection or `split` into two Numbers) for a + Hybrid Link Number leaving Pro, or `hybridMerge` to merge a same-number pair + on an upgrade to Pro. `NumberTierChange.quote.hybridTransition` reports the + plan and its `status`. `Client.projects.listHybridMergeCandidates` lists + mergeable pairs. `PolymorfaErrorCode` adds `hybrid_choice_required` and + `hybrid_transition_ineligible`. - `Session` gains optional `newChatCapping` (`NewChatCapping`): WhatsApp's per-number new-chat cap, returned by `sessions.retrieve` for linked-device numbers. API contract: polymorfa/polymorfa#403. - - Brazil payment orders (beta, requires team enrollment, an Official API Number and Meta payments eligibility in Brazil). `messages.send` accepts `orderDetails` (Pix dynamic code, payment link or boleto, itemized or diff --git a/README.md b/README.md index 078b5370..ee5a2652 100644 --- a/README.md +++ b/README.md @@ -305,10 +305,11 @@ The organization view also exposes these management resources: customer incidents - `projects`: list, create, request production enrollment, approve, and cancel; retrieve and update Safe Mode, warm-up, Ban Insurance evidence, and Health - policy settings + policy settings; list same-number pairs that can merge into Hybrid Link - `sessions`: list, start, stop, or delete one session; stop or delete a bounded - batch; review and confirm a tier change; create a testing session; and - retrieve or update the session Safe Mode override + batch; review and confirm a tier change, including the Hybrid Link + resolution or merge; create a testing session; and retrieve or update the + session Safe Mode override - `campaigns`: list, create, retrieve, update, delete, lifecycle actions, analytics, events, and paged or appended recipients. The single-campaign operations require the owning `projectId`. This resource is available only diff --git a/contracts/README.md b/contracts/README.md index 153db0a8..b11817fc 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -41,6 +41,35 @@ published SDK package before updating their pinned dependency. ## Full snapshots +The current snapshots are byte-identical copies of the Messaging and Platform +OpenAPI files at `polymorfa/polymorfa` commit +`fcf7b21015dd185e987b5aa3580e8af5f4c7f1a4` on the coordinated API branch +`t3code/hybrid-pro-downgrade` (API PR #404, not yet merged to API `dev`). The +Messaging file is byte-identical to the SDK `dev` pin `e818ba6`. The Platform +file adds Hybrid Link tier transitions: `hybridResolution` and `hybridMerge` on +`NumberTierQuoteRequest`, `quote.hybridTransition` on `NumberTierChange`, and +`GET /platform/projects/{projectId}/hybrid-merge-candidates`, covered by +`Client.projects.listHybridMergeCandidates`. Each candidate Number reports +`canBeAbsorbed` (false with hosted message storage), and the list returns +`403 feature_unavailable` without live Hybrid Link access. A missing choice +returns `409 hybrid_choice_required`, and a queued change rejected for that +reason reports `failureReason: "hybrid_choice_required"`. +`hybrid_choice_required` and `hybrid_transition_ineligible` are documented in +operation prose rather than the public error enum; `POLYMORFA_ERROR_CODES` +lists both. Four fingerprints changed: the three tier-quote operations and the +excluded Console `updateProject`. + +The branch also contains API `dev` through `5ce6290bc`, whose PR #428 adds six +Platform operations for hosted Flow endpoints and Flow encryption keys +(`GET`/`PUT`/`DELETE /platform/flows/{flowId}/endpoint`, +`GET /platform/flows/{flowId}/endpoint/receipts`, +`GET /platform/flow-encryption-keys` and +`POST /platform/flow-encryption-keys/rotate`). No SDK change covers them yet; +the ledger excludes them with that reason. Repin to the merged API `dev` commit +before merging this SDK change. + +The preceding snapshots were described as follows. + The two public Flow draft reads in the pinned Platform snapshot are covered by `Client.project(projectId).flows.list` and `.retrieve`. The API declares both public, project-bound, and `sessions:read`. @@ -232,12 +261,12 @@ signed events with these names still decode as unknown events. | Status | Operations | | ------------------- | ---------: | -| Covered | 406 | +| Covered | 421 | | Missing | 0 | -| Excluded | 130 | +| Excluded | 136 | | Partial | 0 | | Changed fingerprint | 0 | -| Total | 536 | +| Total | 557 | This revision adds test event triggering (`POST /messaging/testing/{projectId}/events`) and fixture listing diff --git a/contracts/coverage.json b/contracts/coverage.json index 1d9510ee..4c8c10b6 100644 --- a/contracts/coverage.json +++ b/contracts/coverage.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceCommit": "e818ba62830d3727a7822379bb46c1fece90ef1b", + "sourceCommit": "fcf7b21015dd185e987b5aa3580e8af5f4c7f1a4", "excludedContracts": [ { "family": "graph", @@ -3990,7 +3990,7 @@ "method": "GET", "path": "/platform/sessions/{sessionId}/tier-quotes/{quoteId}", "operationId": "getSessionTierChange", - "fingerprint": "26a366bd27b1e77700ca427f137f3f01c99c398f41f95edb8008af2bcb19aefe", + "fingerprint": "602247de1ae72206a3207b1cd8d7ce65fec0a7b386716f6b5e24fd85f79b5454", "typescript": { "status": "covered", "method": "Client.sessions.retrieveTierChange" @@ -4278,7 +4278,7 @@ "method": "PATCH", "path": "/console/projects/{projectId}", "operationId": "updateProject", - "fingerprint": "1d124460d20e14953ecf03af3372e69a31b59c512a4b27568c8537d936a9ed17", + "fingerprint": "17294266cd2773fd99470bd94aab7b9a01adba28b3718c31a312a8ac00e36985", "typescript": { "status": "excluded", "reason": "Console-only operation requires dashboard identity; not available to SDK credentials." @@ -4377,7 +4377,7 @@ "method": "PATCH", "path": "/platform/sessions/{sessionId}", "operationId": "setSessionTierOverride", - "fingerprint": "a28a3700b2e1ca7362a57de40598959640bbf31d709d929623206bc3ecd45f1d", + "fingerprint": "42e924cd6ea94896fa447071dfa78b152f06475f06d5be200ed9d5cffdab1f96", "typescript": { "status": "covered", "method": "Client.sessions.setTierOverride" @@ -5165,6 +5165,72 @@ "method": "Client.project(projectId).flows.upload" } }, + { + "family": "platform", + "method": "DELETE", + "path": "/platform/flows/{flowId}/endpoint", + "operationId": "deleteFlowEndpoint", + "fingerprint": "c282b7d48c759117c33fbc797616541664c01da36679335444e53d48d37e4e6a", + "typescript": { + "status": "excluded", + "reason": "Hosted Flow endpoint and Flow encryption key operations from API PR #428 have no SDK owner yet; they are outside the Hybrid Link tier-transition change and need a separate SDK PR." + } + }, + { + "family": "platform", + "method": "GET", + "path": "/platform/flow-encryption-keys", + "operationId": "getFlowEncryptionKey", + "fingerprint": "2cef096982f4a550dff1c3cded7248685c28d433c36bd2304d99a193f8cda000", + "typescript": { + "status": "excluded", + "reason": "Hosted Flow endpoint and Flow encryption key operations from API PR #428 have no SDK owner yet; they are outside the Hybrid Link tier-transition change and need a separate SDK PR." + } + }, + { + "family": "platform", + "method": "GET", + "path": "/platform/flows/{flowId}/endpoint", + "operationId": "getFlowEndpoint", + "fingerprint": "12354b56aedcccd9a81fae27d6deb93da3e6490e07192cb8137770312bafe5ff", + "typescript": { + "status": "excluded", + "reason": "Hosted Flow endpoint and Flow encryption key operations from API PR #428 have no SDK owner yet; they are outside the Hybrid Link tier-transition change and need a separate SDK PR." + } + }, + { + "family": "platform", + "method": "GET", + "path": "/platform/flows/{flowId}/endpoint/receipts", + "operationId": "listFlowEndpointReceipts", + "fingerprint": "861ecbba5343f1b72688b9c8da9fb2a678de49cf10abf16794464e8870c68427", + "typescript": { + "status": "excluded", + "reason": "Hosted Flow endpoint and Flow encryption key operations from API PR #428 have no SDK owner yet; they are outside the Hybrid Link tier-transition change and need a separate SDK PR." + } + }, + { + "family": "platform", + "method": "POST", + "path": "/platform/flow-encryption-keys/rotate", + "operationId": "rotateFlowEncryptionKey", + "fingerprint": "44534cbc6d6ee4f783e3f1782dfa4f638f5c94fe9cb176f93fb74c18dc8f2457", + "typescript": { + "status": "excluded", + "reason": "Hosted Flow endpoint and Flow encryption key operations from API PR #428 have no SDK owner yet; they are outside the Hybrid Link tier-transition change and need a separate SDK PR." + } + }, + { + "family": "platform", + "method": "PUT", + "path": "/platform/flows/{flowId}/endpoint", + "operationId": "setFlowEndpoint", + "fingerprint": "cefa55fe9a7793bc12f590909c8620dd9b82a7f793750167505bce84143ae5a4", + "typescript": { + "status": "excluded", + "reason": "Hosted Flow endpoint and Flow encryption key operations from API PR #428 have no SDK owner yet; they are outside the Hybrid Link tier-transition change and need a separate SDK PR." + } + }, { "family": "platform", "method": "POST", @@ -5503,7 +5569,7 @@ "method": "POST", "path": "/platform/sessions/{sessionId}/tier-quotes", "operationId": "quoteSessionTierChange", - "fingerprint": "7bd49359f1b8a15cd21477573209924c7ea1532d0f4b258eb0022a30a8c4593a", + "fingerprint": "a9e13a9e870e108da6669c7a1bd79655df499ab2c03f22f5e4bcea89f621d185", "typescript": { "status": "covered", "method": "Client.sessions.quoteTierChange" @@ -5852,6 +5918,17 @@ "method": "Client.projects.updateWarmupPlan" } }, + { + "family": "platform", + "method": "GET", + "path": "/platform/projects/{projectId}/hybrid-merge-candidates", + "operationId": "listHybridMergeCandidates", + "fingerprint": "e4fdcbb9deffd164c07bd7f10b50455f339fed3cb0d8817a4344eac0f8af49ee", + "typescript": { + "status": "covered", + "method": "Client.projects.listHybridMergeCandidates" + } + }, { "family": "platform", "method": "PUT", diff --git a/contracts/openapi.platform.json b/contracts/openapi.platform.json index 009272da..81dfa9ec 100644 --- a/contracts/openapi.platform.json +++ b/contracts/openapi.platform.json @@ -3,7 +3,7 @@ "info": { "title": "Polymorfa Console & Management API", "version": "1.0.0", - "description": "Complete console/management surface. Resource-oriented REST: `{param}` path variables, single-word action sub-resources.\n\nNamespaces decide inclusion:\n- `/platform/…` — **public registry** (228 endpoints): each endpoint declares either a scoped `pmfa_` organization key or a verified live dashboard JWT, or dashboard-only authorization when a specific user's authority is required. Only implemented handlers are published to customers and SDKs.\n- `/console/…` — **console-only** (101 endpoints): verified dashboard identity. EXCLUDED from the published spec; reason per endpoint.\n\nThe published customer spec = this document filtered to `x-audience: public` endpoints with an implemented runtime handler.\nMost transitional body/response schemas remain loose (`additionalProperties: true`); session-control operations use explicit strict schemas." + "description": "Complete console/management surface. Resource-oriented REST: `{param}` path variables, single-word action sub-resources.\n\nNamespaces decide inclusion:\n- `/platform/…` — **public registry** (235 endpoints): each endpoint declares either a scoped `pmfa_` organization key or a verified live dashboard JWT, or dashboard-only authorization when a specific user's authority is required. Only implemented handlers are published to customers and SDKs.\n- `/console/…` — **console-only** (101 endpoints): verified dashboard identity. EXCLUDED from the published spec; reason per endpoint.\n\nThe published customer spec = this document filtered to `x-audience: public` endpoints with an implemented runtime handler.\nMost transitional body/response schemas remain loose (`additionalProperties: true`); session-control operations use explicit strict schemas." }, "servers": [ { @@ -16283,7 +16283,7 @@ "sessions" ], "summary": "Preview a number tier change", - "description": "Returns the full credit charge and effective time before confirmation. Null tierOverride restores project inheritance. Creating a quote does not change the number or consume credit. Quotes expire after ten minutes and are invalidated when the number or price changes.\n\nRequires `sessions:manage`. Project tokens only access resources in their own project.", + "description": "Returns the full credit charge and effective time before confirmation. Null tierOverride restores project inheritance. Creating a quote does not change the number or consume credit. Quotes expire after ten minutes and are invalidated when the number or price changes.\n\n**Hybrid Link.** A Hybrid Link Number leaving Pro requires `hybridResolution`: keep one connection, or split the Number into two Standard Numbers; without it the quote returns `409` with code `hybrid_choice_required`. The choice runs when the paid Pro window ends. An upgrade to Pro can merge two same-number Numbers with `hybridMerge`; the Number in the path keeps its ID. An ineligible change returns `409` with code `hybrid_transition_ineligible`.\n\nRequires `sessions:manage`. Project tokens only access resources in their own project.", "x-audience": "public", "x-polymorfa-resource-boundary": "session", "x-excluded-from-public": false, @@ -16376,7 +16376,7 @@ "sessions" ], "summary": "Read a number tier change", - "description": "Reads the quote and durable result for a number in the authenticated project scope.\n\nRequires `sessions:read`. Project tokens only access resources in their own project.", + "description": "Reads the quote and durable result for a number in the authenticated project scope.\n\n**Hybrid Link.** For a Hybrid Link change, `quote.hybridTransition.status` reports the connection change separately from the tier change: `scheduled`, `running`, `completed`, `failed` or `cancelled`.\n\nRequires `sessions:read`. Project tokens only access resources in their own project.", "x-audience": "public", "x-polymorfa-resource-boundary": "session", "x-excluded-from-public": false, @@ -22791,7 +22791,7 @@ "flows" ], "summary": "Upload a Flow to a Number", - "description": "Sends the saved Flow draft to the WhatsApp Business Account of one Number. The first upload to a Number, or the first upload after the Number's Flow was deprecated, creates a WhatsApp Flow and needs `categories`; later uploads replace the JSON of that Flow while it is still a WhatsApp draft. The response lists the validation errors WhatsApp reported, and `uploadState` is `valid` only when there are none. The Number needs an active WhatsApp Business Platform connection and the Cloud onboarding beta; test Numbers use the simulation. Flows with a data endpoint are refused. A supplied `requestId` makes retries return the original operation instead of sending again.\n\nRequires `sessions:manage`. Project tokens only access resources in their own project.", + "description": "Sends the saved Flow draft to the WhatsApp Business Account of one Number. The first upload to a Number, or the first upload after the Number's Flow was deprecated, creates a WhatsApp Flow and needs `categories`; later uploads replace the JSON of that Flow while it is still a WhatsApp draft. The response lists the validation errors WhatsApp reported, and `uploadState` is `valid` only when there are none. The Number needs an active WhatsApp Business Platform connection and the Cloud onboarding beta; test Numbers use the simulation. A Flow with `data_api_version` needs an enabled data endpoint for the Number (see Configure a Flow's data endpoint); its endpoint URI is attached during the upload. Test Numbers cannot use data endpoints. A supplied `requestId` makes retries return the original operation instead of sending again.\n\nRequires `sessions:manage`. Project tokens only access resources in their own project.", "x-audience": "public", "x-polymorfa-resource-boundary": "project", "x-excluded-from-public": false, @@ -22889,7 +22889,7 @@ "flows" ], "summary": "Publish a Flow on a Number", - "description": "Publishes the Number's WhatsApp Flow. The last upload must match the saved draft and have no validation errors. A published Flow cannot be changed or deleted; it can only be deprecated. The Flow counts as published only after WhatsApp confirms it: when the response is lost, the operation state is `uncertain` until a provider read settles it, and publishing is never sent twice.\n\nRequires `sessions:manage`. Project tokens only access resources in their own project.", + "description": "Publishes the Number's WhatsApp Flow. The last upload must match the saved draft, and for a Flow with a data endpoint the endpoint's current URI, and have no validation errors. A published Flow cannot be changed or deleted; it can only be deprecated. The Flow counts as published only after WhatsApp confirms it: when the response is lost, the operation state is `uncertain` until a provider read settles it, and publishing is never sent twice.\n\nRequires `sessions:manage`. Project tokens only access resources in their own project.", "x-audience": "public", "x-polymorfa-resource-boundary": "project", "x-excluded-from-public": false, @@ -23356,6 +23356,581 @@ } } }, + "/platform/flows/{flowId}/endpoint": { + "get": { + "operationId": "getFlowEndpoint", + "tags": [ + "flows" + ], + "summary": "Get a Flow's data endpoint on a Number", + "description": "Returns the data endpoint configured for a Flow on one Number, the endpoint URI WhatsApp calls, and the Number's Flow encryption custody. `endpoint` is null when none is configured.\n\nRequires `sessions:read`. Project tokens only access resources in their own project.", + "x-audience": "public", + "x-polymorfa-resource-boundary": "project", + "x-excluded-from-public": false, + "x-required-scope": "sessions:read", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "ConsoleSession": [] + } + ], + "parameters": [ + { + "name": "flowId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "projectId", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "sessionId", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "schema": { + "type": "string" + }, + "description": "Stable request correlation identifier." + }, + "Polymorfa-Version": { + "schema": { + "type": "string" + }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/FlowEndpointState" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + } + } + }, + "put": { + "operationId": "setFlowEndpoint", + "tags": [ + "flows" + ], + "summary": "Configure a Flow's data endpoint on a Number", + "description": "Creates or replaces the data endpoint of a dynamic Flow on one Number. `forward` decrypts each WhatsApp request with the Number's Polymorfa-managed key and forwards the request JSON to your HTTPS `url`, signed with `X-Polymorfa-Flow-Signature`; your JSON response is encrypted and returned to WhatsApp. `function` runs a deployed Function with the request JSON as the body. `direct` attaches your own `url` and requires your own encryption key on the Number; Polymorfa never decrypts that traffic. A new forward endpoint, a switch to forward and `rotateSigningSecret` return `signingSecret` once. Upload the Flow after configuring its endpoint so the endpoint URI is attached. Request and response contents are never stored.\n\nRequires `sessions:manage`. Project tokens only access resources in their own project.", + "x-audience": "public", + "x-polymorfa-resource-boundary": "project", + "x-excluded-from-public": false, + "x-required-scope": "sessions:manage", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "ConsoleSession": [] + } + ], + "parameters": [ + { + "name": "flowId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetFlowEndpointRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "schema": { + "type": "string" + }, + "description": "Stable request correlation identifier." + }, + "Polymorfa-Version": { + "schema": { + "type": "string" + }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/FlowEndpointSetResult" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "$ref": "#/components/responses/PaymentRequired" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "503": { + "$ref": "#/components/responses/PropagationPending" + } + } + }, + "delete": { + "operationId": "deleteFlowEndpoint", + "tags": [ + "flows" + ], + "summary": "Remove a Flow's data endpoint on a Number", + "description": "Removes the endpoint configuration. Requests to its URI are refused afterwards. Receipts are kept.\n\nRequires `sessions:manage`. Project tokens only access resources in their own project.", + "x-audience": "public", + "x-polymorfa-resource-boundary": "project", + "x-excluded-from-public": false, + "x-required-scope": "sessions:manage", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "ConsoleSession": [] + } + ], + "parameters": [ + { + "name": "flowId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "projectId", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "sessionId", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "schema": { + "type": "string" + }, + "description": "Stable request correlation identifier." + }, + "Polymorfa-Version": { + "schema": { + "type": "string" + }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/DeleteFlowResult" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/platform/flows/{flowId}/endpoint/receipts": { + "get": { + "operationId": "listFlowEndpointReceipts", + "tags": [ + "flows" + ], + "summary": "List a Flow's data endpoint receipts", + "description": "Lists up to 100 recent data-exchange receipts for a Flow, newest first. Receipts record the action, outcome, HTTP status returned to WhatsApp, an error code and timing. They never contain form data, Flow tokens or responses.\n\nRequires `sessions:read`. Project tokens only access resources in their own project.", + "x-audience": "public", + "x-polymorfa-resource-boundary": "project", + "x-excluded-from-public": false, + "x-required-scope": "sessions:read", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "ConsoleSession": [] + } + ], + "parameters": [ + { + "name": "flowId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "projectId", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "sessionId", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 25 + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "schema": { + "type": "string" + }, + "description": "Stable request correlation identifier." + }, + "Polymorfa-Version": { + "schema": { + "type": "string" + }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "array", + "maxItems": 100, + "items": { + "$ref": "#/components/schemas/FlowEndpointReceipt" + } + } + }, + "required": [ + "data" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/platform/flow-encryption-keys": { + "get": { + "operationId": "getFlowEncryptionKey", + "tags": [ + "flow-encryption-keys" + ], + "summary": "Get a Number's Flow encryption custody", + "description": "Returns whether Polymorfa holds the Number's active Flow encryption key (`managed`) or not (`customer`), and recent managed keys. Private keys are never returned.\n\nRequires `sessions:read`. Project tokens only access resources in their own project.", + "x-audience": "public", + "x-polymorfa-resource-boundary": "project", + "x-excluded-from-public": false, + "x-required-scope": "sessions:read", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "ConsoleSession": [] + } + ], + "parameters": [ + { + "name": "projectId", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "sessionId", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "schema": { + "type": "string" + }, + "description": "Stable request correlation identifier." + }, + "Polymorfa-Version": { + "schema": { + "type": "string" + }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/FlowEncryptionCustody" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + } + } + } + }, + "/platform/flow-encryption-keys/rotate": { + "post": { + "operationId": "rotateFlowEncryptionKey", + "tags": [ + "flow-encryption-keys" + ], + "summary": "Rotate a Number's managed Flow encryption key", + "description": "Generates a 2048-bit RSA key pair, keeps the private key sealed, and registers the public key with WhatsApp for the Number. Every dynamic Flow on the Number uses this key. The previous managed key keeps decrypting for 24 hours. A lost WhatsApp response is settled by reading the registered key; the key is `uncertain` until then and is never registered twice.\n\nRequires `sessions:manage`. Project tokens only access resources in their own project.", + "x-audience": "public", + "x-polymorfa-resource-boundary": "project", + "x-excluded-from-public": false, + "x-required-scope": "sessions:manage", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "ConsoleSession": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RotateFlowEncryptionKeyRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "schema": { + "type": "string" + }, + "description": "Stable request correlation identifier." + }, + "Polymorfa-Version": { + "schema": { + "type": "string" + }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/FlowEncryptionKeyRotation" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "$ref": "#/components/responses/PaymentRequired" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "503": { + "$ref": "#/components/responses/PropagationPending" + } + } + } + }, "/platform/optouts": { "get": { "operationId": "listOptOuts", @@ -24625,7 +25200,7 @@ "projects" ], "summary": "Update project settings", - "description": "Updates the display name, icon, or default number plan without changing the project ID, URL, stage, or individual number overrides.\n\nRequires a verified dashboard bearer and current organization authorization. The signed-in member must be an organization owner or admin. Organization keys and project tokens are not accepted.", + "description": "Updates the display name, icon, or default number plan without changing the project ID, URL, stage, or individual number overrides.\n\n**Hybrid Link.** A default plan without Hybrid Link returns `409` with code `hybrid_choice_required` while a Hybrid Link Number inherits the project plan. Change each such Number's tier first and choose which connection it keeps.\n\nRequires a verified dashboard bearer and current organization authorization. The signed-in member must be an organization owner or admin. Organization keys and project tokens are not accepted.", "x-audience": "console", "x-polymorfa-resource-boundary": "human_only", "x-excluded-from-public": true, @@ -24762,6 +25337,9 @@ }, "404": { "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" } } } @@ -25232,6 +25810,83 @@ } } }, + "/platform/projects/{projectId}/hybrid-merge-candidates": { + "get": { + "operationId": "listHybridMergeCandidates", + "tags": [ + "projects" + ], + "summary": "List Numbers that can merge into Hybrid Link", + "description": "Lists pairs of Numbers in the project that are the same WhatsApp Business number: one connected through Linked Devices and one through the Official API with the WhatsApp Business app (coexistence). Merge an eligible pair with a Pro tier quote on the Number you want to keep and `hybridMerge.absorbNumberId` set to the other Number. Neither side needs to pair or sign up again. Returns `403` with code `feature_unavailable` when the team has no Hybrid Link access. Each Number reports `canBeAbsorbed`; a Number with hosted message storage must be the one you keep.\n\nRequires `sessions:read`. Project tokens only access resources in their own project.", + "x-audience": "public", + "x-polymorfa-resource-boundary": "project", + "x-excluded-from-public": false, + "x-required-scope": "sessions:read", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "ConsoleSession": [] + } + ], + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "schema": { + "type": "string" + }, + "description": "Stable request correlation identifier." + }, + "Polymorfa-Version": { + "schema": { + "type": "string" + }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/HybridMergeCandidateList" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, "/platform/webhooks": { "get": { "operationId": "listOrganizationWebhooks", @@ -41327,7 +41982,7 @@ }, "definitionDigest": { "type": "string", - "description": "SHA-256 of the uploaded Flow JSON." + "description": "SHA-256 of the uploaded Flow JSON; for a Flow with a data endpoint, of the JSON and the attached endpoint URI." }, "simulated": { "type": "boolean", @@ -41490,6 +42145,414 @@ "flow" ] }, + "FlowEndpoint": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "orgId": { + "type": "string", + "format": "uuid" + }, + "projectId": { + "$ref": "#/components/schemas/ProjectId" + }, + "flowId": { + "$ref": "#/components/schemas/FlowId" + }, + "sessionId": { + "type": "string", + "format": "uuid" + }, + "mode": { + "type": "string", + "enum": [ + "forward", + "function", + "direct" + ] + }, + "url": { + "type": "string", + "format": "uri", + "nullable": true + }, + "functionId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "deploymentId": { + "type": "string", + "format": "uuid", + "nullable": true, + "description": "Pinned Function deployment; null follows the Function's active deployment." + }, + "enabled": { + "type": "boolean" + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "endpointUri": { + "type": "string", + "format": "uri", + "description": "The URL WhatsApp calls. Attached to the Flow when it is uploaded." + }, + "createdAt": { + "type": "integer", + "format": "int64" + }, + "updatedAt": { + "type": "integer", + "format": "int64" + } + }, + "required": [ + "id", + "orgId", + "projectId", + "flowId", + "sessionId", + "mode", + "url", + "functionId", + "deploymentId", + "enabled", + "revision", + "endpointUri", + "createdAt", + "updatedAt" + ] + }, + "FlowEncryptionKey": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "state": { + "type": "string", + "enum": [ + "pending", + "uncertain", + "active", + "retiring", + "retired", + "failed" + ] + }, + "fingerprint": { + "type": "string", + "description": "SHA-256 of the DER public key." + }, + "publicKey": { + "type": "string", + "description": "PEM public key." + }, + "errorCode": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "integer", + "format": "int64" + }, + "activatedAt": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "retireAfter": { + "type": "integer", + "format": "int64", + "nullable": true + } + }, + "required": [ + "id", + "state", + "fingerprint", + "publicKey", + "errorCode", + "createdAt", + "activatedAt", + "retireAfter" + ] + }, + "FlowEncryptionCustody": { + "type": "object", + "additionalProperties": false, + "properties": { + "custody": { + "type": "string", + "enum": [ + "managed", + "customer" + ], + "description": "`managed`: Polymorfa holds the Number's active key. `customer`: it does not; register your own key to use direct endpoints." + }, + "activeKeyId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "keys": { + "type": "array", + "maxItems": 10, + "items": { + "$ref": "#/components/schemas/FlowEncryptionKey" + } + } + }, + "required": [ + "custody", + "activeKeyId", + "keys" + ] + }, + "FlowEncryptionKeyRotation": { + "allOf": [ + { + "$ref": "#/components/schemas/FlowEncryptionCustody" + }, + { + "type": "object", + "properties": { + "key": { + "$ref": "#/components/schemas/FlowEncryptionKey" + } + }, + "required": [ + "key" + ] + } + ] + }, + "RotateFlowEncryptionKeyRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "projectId": { + "$ref": "#/components/schemas/ProjectId" + }, + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Number (session) name or id." + } + }, + "required": [ + "projectId", + "sessionId" + ] + }, + "FlowEndpointState": { + "type": "object", + "additionalProperties": false, + "properties": { + "endpoint": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/FlowEndpoint" + } + ], + "nullable": true + }, + "encryption": { + "$ref": "#/components/schemas/FlowEncryptionCustody" + } + }, + "required": [ + "endpoint", + "encryption" + ] + }, + "FlowEndpointSetResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "endpoint": { + "$ref": "#/components/schemas/FlowEndpoint" + }, + "signingSecret": { + "type": "string", + "description": "Returned once. Verify `X-Polymorfa-Flow-Signature: t=,v1=.\">` with it." + }, + "encryption": { + "$ref": "#/components/schemas/FlowEncryptionCustody" + } + }, + "required": [ + "endpoint", + "encryption" + ] + }, + "SetFlowEndpointRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "projectId": { + "$ref": "#/components/schemas/ProjectId" + }, + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Number (session) name or id in the Flow's project." + }, + "mode": { + "type": "string", + "enum": [ + "forward", + "function", + "direct" + ] + }, + "url": { + "type": "string", + "format": "uri", + "maxLength": 2048, + "description": "HTTPS URL for `forward` and `direct`." + }, + "functionId": { + "type": "string", + "format": "uuid", + "description": "Function for `function`." + }, + "deploymentId": { + "type": "string", + "format": "uuid", + "nullable": true, + "description": "Optional immutable deployment for `function`." + }, + "enabled": { + "type": "boolean", + "default": true + }, + "expectedRevision": { + "type": "integer", + "minimum": 1, + "description": "Replace only when the stored revision matches." + }, + "rotateSigningSecret": { + "type": "boolean", + "description": "Issue a new forwarding signing secret." + } + }, + "required": [ + "projectId", + "sessionId", + "mode" + ] + }, + "FlowEndpointReceipt": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "flowId": { + "$ref": "#/components/schemas/FlowId" + }, + "endpointId": { + "type": "string", + "format": "uuid" + }, + "sessionId": { + "type": "string", + "format": "uuid" + }, + "mode": { + "type": "string", + "enum": [ + "forward", + "function" + ] + }, + "action": { + "type": "string", + "enum": [ + "ping", + "INIT", + "data_exchange", + "BACK", + "error_notification", + null + ], + "nullable": true + }, + "outcome": { + "type": "string", + "enum": [ + "running", + "succeeded", + "rejected", + "failed", + "timeout", + "unavailable" + ] + }, + "httpStatus": { + "type": "integer", + "nullable": true, + "description": "Status returned to WhatsApp, such as 200, 421 (decryption failed), 427 (Flow token invalid), 503 or 504." + }, + "errorCode": { + "type": "string", + "nullable": true + }, + "keyId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "functionInvocationId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "durationMs": { + "type": "integer", + "nullable": true + }, + "createdAt": { + "type": "integer", + "format": "int64" + }, + "completedAt": { + "type": "integer", + "format": "int64", + "nullable": true + } + }, + "required": [ + "id", + "flowId", + "endpointId", + "sessionId", + "mode", + "action", + "outcome", + "httpStatus", + "errorCode", + "keyId", + "functionInvocationId", + "durationMs", + "createdAt", + "completedAt" + ] + }, "DeleteFlowResult": { "type": "object", "additionalProperties": false, @@ -41742,6 +42805,9 @@ "type": "string", "format": "uuid", "nullable": true + }, + "hybridTransition": { + "$ref": "#/components/schemas/NumberHybridTransition" } }, "required": [ @@ -41779,12 +42845,216 @@ null ], "nullable": true + }, + "hybridResolution": { + "$ref": "#/components/schemas/HybridResolution" + }, + "hybridMerge": { + "$ref": "#/components/schemas/HybridMerge" } }, "required": [ "tierOverride" ] }, + "HybridTransport": { + "type": "string", + "enum": [ + "linked_devices", + "official_api" + ] + }, + "HybridResolution": { + "description": "Required when a Hybrid Link Number moves to a tier without Hybrid Link. `keep` retires the other connection. `split` keeps `existingNumberTransport` on this Number and moves the other connection to a new Standard Number named `newNumberName`, without pairing or signing up again.", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "action": { + "type": "string", + "enum": [ + "keep" + ] + }, + "transport": { + "$ref": "#/components/schemas/HybridTransport" + } + }, + "required": [ + "action", + "transport" + ] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "action": { + "type": "string", + "enum": [ + "split" + ] + }, + "existingNumberTransport": { + "$ref": "#/components/schemas/HybridTransport" + }, + "newNumberName": { + "type": "string", + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$" + } + }, + "required": [ + "action", + "existingNumberTransport", + "newNumberName" + ] + } + ], + "discriminator": { + "propertyName": "action" + } + }, + "HybridMerge": { + "type": "object", + "additionalProperties": false, + "description": "Merges another Number into this one as a Hybrid Link Number. The other Number's connection moves here and the other Number is removed.", + "properties": { + "absorbNumberId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "absorbNumberId" + ] + }, + "NumberHybridTransition": { + "type": "object", + "additionalProperties": false, + "properties": { + "action": { + "type": "string", + "enum": [ + "keep", + "split", + "merge" + ] + }, + "keepTransport": { + "$ref": "#/components/schemas/HybridTransport" + }, + "existingNumberTransport": { + "$ref": "#/components/schemas/HybridTransport" + }, + "newNumberName": { + "type": "string" + }, + "absorbNumberId": { + "type": "string", + "format": "uuid" + }, + "survivingNumberId": { + "type": "string", + "format": "uuid" + }, + "newNumberId": { + "type": "string", + "format": "uuid", + "description": "The Number created by a completed split." + }, + "status": { + "type": "string", + "enum": [ + "scheduled", + "running", + "completed", + "failed", + "cancelled" + ] + }, + "failureReason": { + "type": "string", + "nullable": true + }, + "metaDisconnectRequired": { + "type": "boolean", + "description": "True after an Official API connection that shares the number with the WhatsApp Business app is dropped. Polymorfa has stopped using it; disconnect it in the WhatsApp Business app under Settings > Account > Business Platform." + }, + "effectiveAtMs": { + "type": "integer", + "nullable": true + } + }, + "required": [ + "action", + "survivingNumberId" + ] + }, + "HybridMergeCandidateList": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "numbers": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "transport": { + "$ref": "#/components/schemas/HybridTransport" + }, + "status": { + "type": "string" + }, + "canBeAbsorbed": { + "type": "boolean", + "description": "False when this Number has hosted message storage enabled. Keep this Number and absorb the other one." + } + }, + "required": [ + "id", + "name", + "transport", + "status", + "canBeAbsorbed" + ] + } + }, + "eligible": { + "type": "boolean" + }, + "ineligibleReason": { + "type": "string", + "enum": [ + "deletion_in_progress", + "transition_in_progress", + "pairing_in_progress", + "hms_enabled", + "not_coexistence", + "different_customer", + "connection_disabled", + "not_connected" + ] + } + }, + "required": [ + "numbers", + "eligible" + ] + } + }, "SessionBatchRequest": { "type": "object", "additionalProperties": false, diff --git a/contracts/source.json b/contracts/source.json index 22d9d261..ce9d767a 100644 --- a/contracts/source.json +++ b/contracts/source.json @@ -1,9 +1,9 @@ { "repository": "polymorfa/polymorfa", - "commit": "e818ba62830d3727a7822379bb46c1fece90ef1b", - "branch": "dev", - "published": true, - "note": "API dev merge of PR #433 (Brazil payment order schema fix, after PR #429), on top of PR #431 (Official groups). Includes every merged Official API, Flow lifecycle, catalog, campaign send-window and campaign conversion contract. Both snapshots are byte-identical to this exact API commit. SDK publication, CLI registry pinning, deployment and live acceptance remain separate gates.", + "commit": "fcf7b21015dd185e987b5aa3580e8af5f4c7f1a4", + "branch": "t3code/hybrid-pro-downgrade", + "published": false, + "note": "Coordinated API feature commit for Hybrid Link tier transitions (polymorfa/polymorfa#404), not yet merged to API dev. It contains API dev through 5ce6290bc, which adds the hosted Flow endpoint and Flow encryption key operations of API PR #428 (excluded until an SDK change covers them). The Messaging snapshot is byte-identical to the SDK dev pin e818ba62830d3727a7822379bb46c1fece90ef1b. Both snapshots are byte-identical to the authoritative source files at this commit. SDK package publication is separate.", "contracts": { "messaging": { "sourcePath": "apps/api/docs/openapi.json", @@ -13,7 +13,7 @@ "platform": { "sourcePath": "apps/api/docs/openapi.management.json", "snapshotPath": "contracts/openapi.platform.json", - "sha256": "c077ba0f3fbab9ffbb67a928f79a19b958ed6ee6053380e88cadf82d9f863e07" + "sha256": "73269496b558f5f74382204c312e97512bdbadeb1c7675a267071d44345493e8" } } } diff --git a/packages/typescript/README.md b/packages/typescript/README.md index b71e3e3f..4053cb77 100644 --- a/packages/typescript/README.md +++ b/packages/typescript/README.md @@ -2440,6 +2440,67 @@ purchase a replacement. Set `tierOverride: null` when quoting to restore project inheritance. The old `setTierOverride({tierOverride})` request and `billing.updateReminderSettings` method are removed. +#### Hybrid Link Numbers + +A Hybrid Link Number (Linked Devices and Official API on one Number) needs a +choice before it leaves Pro. Without `hybridResolution`, the quote fails with +`PolymorfaConflictError` and `code === "hybrid_choice_required"`. This +includes `tierOverride: null` when the project default lacks Hybrid Link. + +```ts +// Keep one connection; the other is disconnected when Pro ends. +await platform.sessions.quoteTierChange(sessionId, { + tierOverride: "standard", + hybridResolution: { action: "keep", transport: "linked_devices" }, +}); + +// Split into two Standard Numbers. This Number keeps the Official API; Linked +// Devices moves to a new Number named "support-linked" without re-pairing. +await platform.sessions.quoteTierChange(sessionId, { + tierOverride: "standard", + hybridResolution: { + action: "split", + existingNumberTransport: "official_api", + newNumberName: "support-linked", + }, +}); +``` + +The choice runs when the paid Pro window ends. To merge two Numbers that are +the same WhatsApp Business number into one Hybrid Link Number, list the pairs +and quote Pro on the Number that keeps its ID: + +```ts +const pairs = await platform.projects.listHybridMergeCandidates(projectId); +const pair = pairs.data.data.find((candidate) => candidate.eligible); +if (pair) { + // A Number with hosted message storage cannot be absorbed; keep it instead. + const [first, second] = pair.numbers; + const [keep, absorb] = second.canBeAbsorbed + ? [first, second] + : [second, first]; + await platform.sessions.quoteTierChange(keep.id, { + tierOverride: "pro", + hybridMerge: { absorbNumberId: absorb.id }, + }); +} +``` + +Send either `hybridResolution` or `hybridMerge`, never both; the SDK rejects +both before sending. `quote.hybridTransition` echoes the plan (`keep`, `split` +or `merge`), the surviving Number and the effective time. After confirmation, +`retrieveTierChange` reports `hybridTransition.status` (`scheduled`, `running`, +`completed`, `failed` or `cancelled`) separately from the tier change status, +plus `failureReason`, `newNumberId` for a completed split, and +`metaDisconnectRequired`. A queued change that the API rejects at apply time +because no choice is recorded reports `status: "rejected"` with +`failureReason: "hybrid_choice_required"`; quote again with a choice. When that flag is true, disconnect the Official API in +the WhatsApp Business app under Settings > Account > Business Platform. An +ineligible pair or Number fails with `hybrid_transition_ineligible`; the reason +appears only in the error message. A candidate's `ineligibleReason` explains why +a pair cannot merge now. Listing candidates and merging require Hybrid Link +access; without it the API returns `403 feature_unavailable`. + ## Organization access and security The organization view exposes key metadata, members, audit logs, session bans, diff --git a/packages/typescript/src/errors.ts b/packages/typescript/src/errors.ts index 4c0a8c6e..3db7aaab 100644 --- a/packages/typescript/src/errors.ts +++ b/packages/typescript/src/errors.ts @@ -30,6 +30,8 @@ export const POLYMORFA_ERROR_CODES = [ "hybrid_automation_unavailable", "hybrid_transport_unavailable", "hybrid_target_reference_unavailable", + "hybrid_choice_required", + "hybrid_transition_ineligible", "send_outcome_unknown", "command_dispatch_failed", "result_unknown", diff --git a/packages/typescript/src/index.ts b/packages/typescript/src/index.ts index a5a22a28..79bd6b04 100644 --- a/packages/typescript/src/index.ts +++ b/packages/typescript/src/index.ts @@ -930,6 +930,19 @@ export type { SessionStopResult, SessionTier, SessionTierOverrideRequest, + HybridKeepResolution, + HybridMerge, + HybridMergeCandidate, + HybridMergeCandidateNumber, + HybridMergeIneligibleReason, + HybridResolution, + HybridSplitResolution, + HybridTransport, + NumberHybridKeepTransition, + NumberHybridMergeTransition, + NumberHybridSplitTransition, + NumberHybridTransition, + NumberHybridTransitionStatus, NumberTierChange, NumberTierQuoteRequest, TierPricing, diff --git a/packages/typescript/src/platform/projects.ts b/packages/typescript/src/platform/projects.ts index 6d72aafa..460a8060 100644 --- a/packages/typescript/src/platform/projects.ts +++ b/packages/typescript/src/platform/projects.ts @@ -4,6 +4,7 @@ import type { CreateProjectRequest, CreatedProject, DataEnvelope, + HybridMergeCandidate, ProductionEnrollmentCommandResult, ProductionEnrollmentRequest, ProductionEnrollmentResult, @@ -135,6 +136,25 @@ export class ProjectsResource { ); } + /** + * Lists same-number pairs (one Linked Devices Number and one Official API + * coexistence Number) that can merge into one Hybrid Link Number. Merge an + * eligible pair with `sessions.quoteTierChange(keepId, { tierOverride: "pro", + * hybridMerge: { absorbNumberId } })`; the absorbed Number must have + * `canBeAbsorbed: true`. Requires `sessions:read` and live Hybrid Link + * access (otherwise `403 feature_unavailable`). + */ + listHybridMergeCandidates( + projectId: string, + options: RequestOptions = {}, + ): Promise>> { + return this.transport.request({ + method: "GET", + path: `/platform/projects/${encodeURIComponent(projectId)}/hybrid-merge-candidates`, + ...options, + }); + } + requestProductionEnrollment( projectId: string, body: ProductionEnrollmentRequest, diff --git a/packages/typescript/src/platform/sessions.ts b/packages/typescript/src/platform/sessions.ts index 7b88f5c9..058e20db 100644 --- a/packages/typescript/src/platform/sessions.ts +++ b/packages/typescript/src/platform/sessions.ts @@ -132,12 +132,28 @@ export class PlatformSessionsResource { }); } - /** Review the returned charge and effective time before confirming this quote. */ + /** + * Review the returned charge and effective time before confirming this quote. + * + * A Hybrid Link Number leaving Pro needs `hybridResolution` (keep one + * connection or split the Number); without it the API returns + * `hybrid_choice_required`. An upgrade to Pro can merge a same-number + * pair with `hybridMerge`; `sessionId` keeps its ID. The quote echoes the + * plan in `quote.hybridTransition`. + */ quoteTierChange( sessionId: string, body: NumberTierQuoteRequest, options: RequestOptions = {}, ): Promise>> { + if ( + body?.hybridResolution !== undefined && + body?.hybridMerge !== undefined + ) { + throw new PolymorfaValidationError( + "Send hybridResolution or hybridMerge in a tier quote, not both.", + ); + } return this.transport.request({ method: "POST", path: `${sessionPath(sessionId)}/tier-quotes`, diff --git a/packages/typescript/src/platform/types.ts b/packages/typescript/src/platform/types.ts index 6de29e92..d05b37af 100644 --- a/packages/typescript/src/platform/types.ts +++ b/packages/typescript/src/platform/types.ts @@ -780,14 +780,150 @@ export interface SessionTierOverrideRequest { readonly quoteId: string; } -export interface NumberTierQuoteRequest { +/** The two connections of a Hybrid Link Number. */ +export type HybridTransport = "linked_devices" | "official_api"; + +/** Keep one connection of a Hybrid Link Number and retire the other. */ +export interface HybridKeepResolution { + readonly action: "keep"; + readonly transport: HybridTransport; +} + +/** + * Split a Hybrid Link Number into two Standard Numbers. The Number being + * quoted keeps `existingNumberTransport`; the other connection moves to a new + * Number named `newNumberName` without pairing or signing up again. + */ +export interface HybridSplitResolution { + readonly action: "split"; + readonly existingNumberTransport: HybridTransport; + /** 1-64 characters: a letter or digit, then letters, digits, `.`, `_` or `-`. */ + readonly newNumberName: string; +} + +/** Required when a Hybrid Link Number moves to a tier without Hybrid Link. */ +export type HybridResolution = HybridKeepResolution | HybridSplitResolution; + +/** + * Merges another same-number Number into the quoted Number as a Hybrid Link + * Number. The quoted Number keeps its ID; `absorbNumberId` is removed after its + * connection moves. + */ +export interface HybridMerge { + readonly absorbNumberId: string; +} + +interface NumberTierQuoteBase { readonly projectId?: string; readonly tierOverride: "free" | "standard" | "pro" | null; } +/** + * A tier quote. Send `hybridResolution` when a Hybrid Link Number leaves Pro + * (otherwise the API returns `hybrid_choice_required`), or `hybridMerge` + * to merge a same-number pair on an upgrade to Pro. Never both. + */ +export type NumberTierQuoteRequest = + | (NumberTierQuoteBase & { + readonly hybridResolution?: undefined; + readonly hybridMerge?: undefined; + }) + | (NumberTierQuoteBase & { + readonly hybridResolution: HybridResolution; + readonly hybridMerge?: undefined; + }) + | (NumberTierQuoteBase & { + readonly hybridResolution?: undefined; + readonly hybridMerge: HybridMerge; + }); + +/** Progress of the connection change, reported separately from the tier change. */ +export type NumberHybridTransitionStatus = + "scheduled" | "running" | "completed" | "failed" | "cancelled"; + +interface NumberHybridTransitionBase { + /** The Number that keeps its ID. */ + readonly survivingNumberId: string; + /** Present once the change has been confirmed. */ + readonly status?: NumberHybridTransitionStatus; + readonly failureReason?: string | null; + /** + * True after an Official API connection that shares the number with the + * WhatsApp Business app is dropped. Disconnect it in the WhatsApp Business + * app under Settings > Account > Business Platform. + */ + readonly metaDisconnectRequired?: boolean; + readonly effectiveAtMs?: number | null; +} + +export interface NumberHybridKeepTransition extends NumberHybridTransitionBase { + readonly action: "keep"; + readonly keepTransport: HybridTransport; +} + +export interface NumberHybridSplitTransition extends NumberHybridTransitionBase { + readonly action: "split"; + readonly existingNumberTransport: HybridTransport; + readonly newNumberName: string; + /** The Number created by a completed split. */ + readonly newNumberId?: string; +} + +export interface NumberHybridMergeTransition extends NumberHybridTransitionBase { + readonly action: "merge"; + readonly absorbNumberId: string; +} + +/** The Hybrid Link plan echoed by a tier quote, with its progress once confirmed. */ +export type NumberHybridTransition = + | NumberHybridKeepTransition + | NumberHybridSplitTransition + | NumberHybridMergeTransition; + +export type HybridMergeIneligibleReason = + | "deletion_in_progress" + | "not_coexistence" + | "different_customer" + | "connection_disabled" + | "not_connected" + | "transition_in_progress" + | "pairing_in_progress" + | "hms_enabled"; + +export interface HybridMergeCandidateNumber { + readonly id: string; + readonly name: string; + readonly transport: HybridTransport; + readonly status: string; + /** + * False when this Number uses hosted message storage. It cannot be the + * absorbed Number; quote the merge on it so it keeps its ID. + */ + readonly canBeAbsorbed: boolean; +} + +/** + * Two Numbers in one project that are the same WhatsApp Business number. + * `ineligibleReason` explains a pair that cannot merge now. + */ +export interface HybridMergeCandidate { + /** One Linked Devices Number and one Official API Number. */ + readonly numbers: readonly [ + HybridMergeCandidateNumber, + HybridMergeCandidateNumber, + ]; + readonly eligible: boolean; + readonly ineligibleReason?: HybridMergeIneligibleReason; +} + export interface NumberTierChange { readonly id: string; readonly status: "quoted" | "queued" | "applied" | "rejected"; + /** + * Why a queued change was rejected, for example `hybrid_choice_required` + * when a Hybrid Link Number would leave Pro without a recorded choice, or + * `hybrid_transition_ineligible`. Quote again with a choice. + */ readonly failureReason: string | null; readonly expiresAtMs: number; readonly quote: { @@ -799,6 +935,8 @@ export interface NumberTierChange { readonly action: "upgrade" | "downgrade" | "configure"; readonly effectiveAtMs: number; readonly replacesWindowId: string | null; + /** Present when the change resolves or merges a Hybrid Link Number. */ + readonly hybridTransition?: NumberHybridTransition; }; } diff --git a/packages/typescript/test/coverage-reconciliation.test.ts b/packages/typescript/test/coverage-reconciliation.test.ts index d50213a7..f5361b73 100644 --- a/packages/typescript/test/coverage-reconciliation.test.ts +++ b/packages/typescript/test/coverage-reconciliation.test.ts @@ -196,7 +196,7 @@ describe("reconciled coverage evidence", () => { }; expect(source.repository).toBe("polymorfa/polymorfa"); // Repinning the reviewed source requires updating this regression gate too. - expect(source.commit).toBe("e818ba62830d3727a7822379bb46c1fece90ef1b"); + expect(source.commit).toBe("fcf7b21015dd185e987b5aa3580e8af5f4c7f1a4"); expect(ledger.sourceCommit).toBe(source.commit); expect(Object.keys(source.contracts).sort()).toEqual([ "messaging", diff --git a/packages/typescript/test/coverage.test.ts b/packages/typescript/test/coverage.test.ts index dc6eba8f..a2cd4497 100644 --- a/packages/typescript/test/coverage.test.ts +++ b/packages/typescript/test/coverage.test.ts @@ -173,12 +173,12 @@ describe("coverage checker", () => { const result = runRepositoryChecker(); expect(result.status, result.stderr).toBe(0); expect(result.report).toMatchObject({ - sourceCommit: "e818ba62830d3727a7822379bb46c1fece90ef1b", - total: 550, - covered: 420, + sourceCommit: "fcf7b21015dd185e987b5aa3580e8af5f4c7f1a4", + total: 557, + covered: 421, partial: 0, missing: 0, - excluded: 130, + excluded: 136, changed: 0, }); const resolutions = (result.report?.resolutions ?? []) as Array<{ diff --git a/packages/typescript/test/error-body.test.ts b/packages/typescript/test/error-body.test.ts index 7e758351..becd064a 100644 --- a/packages/typescript/test/error-body.test.ts +++ b/packages/typescript/test/error-body.test.ts @@ -180,7 +180,12 @@ describe("error codes", () => { "utf8", ); // Platform operations document these codes in prose rather than an enum. - const documentedInProse = ["payg_required", "addon_required"]; + const documentedInProse = [ + "payg_required", + "addon_required", + "hybrid_choice_required", + "hybrid_transition_ineligible", + ]; for (const code of documentedInProse) { expect(platformText).toContain(`code \`${code}\``); } diff --git a/packages/typescript/test/hybrid-tier-transitions.test.ts b/packages/typescript/test/hybrid-tier-transitions.test.ts new file mode 100644 index 00000000..19a3b03b --- /dev/null +++ b/packages/typescript/test/hybrid-tier-transitions.test.ts @@ -0,0 +1,512 @@ +import { readFileSync } from "node:fs"; + +import { afterEach, describe, expect, it } from "vitest"; +import { + Client, + PolymorfaAuthorizationError, + PolymorfaConflictError, + PolymorfaValidationError, + isKnownPolymorfaErrorCode, + type HybridMergeCandidate, + type HybridMergeIneligibleReason, + type HybridResolution, + type HybridTransport, + type NumberHybridTransition, + type NumberHybridTransitionStatus, + type NumberTierChange, + type NumberTierQuoteRequest, +} from "../src/index.js"; +import { ORGANIZATION_API_KEY } from "./support/credentials.js"; +import { startTestServer, type TestServer } from "./support/http-server.js"; + +const servers: TestServer[] = []; +afterEach(async () => { + await Promise.all(servers.splice(0).map((s) => s.close())); +}); +function client(server: TestServer) { + servers.push(server); + return new Client({ + credential: { type: "organizationApiKey", value: ORGANIZATION_API_KEY }, + baseUrl: server.url, + maxNetworkRetries: 0, + }); +} + +const SURVIVOR = "01994234-0000-7000-8000-00000000000a"; +const ABSORBED = "01994234-0000-7000-8000-00000000000b"; +const CREATED = "01994234-0000-7000-8000-00000000000c"; +const PROJECT = "01994234-0000-7000-8000-0000000000aa"; + +function tierChange( + hybridTransition: NumberHybridTransition, + overrides: Partial = {}, +): NumberTierChange { + return { + id: "01994234-0000-7000-8000-000000000001", + status: "quoted", + failureReason: null, + expiresAtMs: 1800000600000, + quote: { + tier: "standard", + tierOverride: "standard", + amountCents: 0, + priceVersion: "catalog-1", + action: "downgrade", + effectiveAtMs: 1800000000000, + replacesWindowId: null, + hybridTransition, + }, + ...overrides, + }; +} + +describe("Hybrid Link tier transitions", () => { + it.each<[string, NumberTierQuoteRequest]>([ + [ + "keep", + { + tierOverride: "standard", + hybridResolution: { action: "keep", transport: "linked_devices" }, + }, + ], + [ + "split", + { + tierOverride: "standard", + hybridResolution: { + action: "split", + existingNumberTransport: "official_api", + newNumberName: "support-linked", + }, + }, + ], + [ + "inherited downgrade", + { + tierOverride: null, + hybridResolution: { action: "keep", transport: "official_api" }, + }, + ], + [ + "merge", + { tierOverride: "pro", hybridMerge: { absorbNumberId: ABSORBED } }, + ], + ])("serializes a %s quote body exactly", async (_, body) => { + const server = await startTestServer(() => ({ + body: JSON.stringify({ + data: tierChange({ + action: "keep", + keepTransport: "linked_devices", + survivingNumberId: SURVIVOR, + }), + }), + })); + await client(server).sessions.quoteTierChange(SURVIVOR, body); + expect(server.requests).toHaveLength(1); + expect(server.requests[0]?.method).toBe("POST"); + expect(server.requests[0]?.path).toBe( + `/platform/sessions/${SURVIVOR}/tier-quotes`, + ); + expect(JSON.parse(server.requests[0]!.body)).toEqual(body); + }); + + it("refuses a quote with both a resolution and a merge before any request", async () => { + const server = await startTestServer(() => ({ body: "{}" })); + const sdk = client(server); + expect(() => + sdk.sessions.quoteTierChange(SURVIVOR, { + tierOverride: "pro", + hybridResolution: { action: "keep", transport: "linked_devices" }, + hybridMerge: { absorbNumberId: ABSORBED }, + } as unknown as NumberTierQuoteRequest), + ).toThrow(PolymorfaValidationError); + expect(server.requests).toHaveLength(0); + + // @ts-expect-error hybridResolution and hybridMerge are mutually exclusive. + const both: NumberTierQuoteRequest = { + tierOverride: "pro", + hybridResolution: { action: "keep", transport: "linked_devices" }, + hybridMerge: { absorbNumberId: ABSORBED }, + }; + // @ts-expect-error a split needs the new Number's name. + const unnamed: HybridResolution = { + action: "split", + existingNumberTransport: "official_api", + }; + expect([both, unnamed]).toHaveLength(2); + }); + + it("reads split progress, the created Number and the Meta disconnect instruction", async () => { + const statuses: NumberHybridTransitionStatus[] = [ + "scheduled", + "running", + "completed", + ]; + const server = await startTestServer((_, index) => ({ + body: JSON.stringify({ + data: tierChange( + { + action: "split", + existingNumberTransport: "linked_devices", + newNumberName: "support-official", + survivingNumberId: SURVIVOR, + status: statuses[index]!, + failureReason: null, + metaDisconnectRequired: false, + effectiveAtMs: 1800000000000, + ...(index === 2 ? { newNumberId: CREATED } : {}), + }, + { status: index === 0 ? "queued" : "applied" }, + ), + }), + })); + const sdk = client(server); + const seen: NumberHybridTransition[] = []; + for (let i = 0; i < 3; i += 1) { + const read = await sdk.sessions.retrieveTierChange( + SURVIVOR, + "01994234-0000-7000-8000-000000000001", + ); + seen.push(read.data.data.quote.hybridTransition!); + } + expect(seen.map((t) => t.status)).toEqual(statuses); + const done = seen[2]!; + expect(done.action).toBe("split"); + if (done.action !== "split") throw new Error("narrowing"); + expect(done.newNumberId).toBe(CREATED); + expect(done.newNumberName).toBe("support-official"); + expect(done.metaDisconnectRequired).toBe(false); + }); + + it("reads a queued change rejected for a missing Hybrid Link choice", async () => { + const server = await startTestServer(() => ({ + body: JSON.stringify({ + data: { + id: "01994234-0000-7000-8000-000000000001", + status: "rejected", + failureReason: "hybrid_choice_required", + expiresAtMs: 1800000600000, + quote: { + tier: "standard", + tierOverride: null, + amountCents: 0, + priceVersion: "catalog-1", + action: "downgrade", + effectiveAtMs: 1800000000000, + replacesWindowId: null, + }, + }, + }), + })); + const read = await client(server).sessions.retrieveTierChange( + SURVIVOR, + "01994234-0000-7000-8000-000000000001", + ); + expect(read.data.data).toMatchObject({ + status: "rejected", + failureReason: "hybrid_choice_required", + }); + expect(read.data.data.quote.hybridTransition).toBeUndefined(); + }); + + it("reads a failed keep and a merge plan with its absorbed Number", async () => { + const server = await startTestServer((_, index) => ({ + body: JSON.stringify({ + data: + index === 0 + ? tierChange( + { + action: "keep", + keepTransport: "linked_devices", + survivingNumberId: SURVIVOR, + status: "failed", + failureReason: "hybrid_transition.runtime_unconfirmed", + metaDisconnectRequired: false, + effectiveAtMs: null, + }, + { status: "applied" }, + ) + : tierChange({ + action: "merge", + absorbNumberId: ABSORBED, + survivingNumberId: SURVIVOR, + effectiveAtMs: 1800000000000, + }), + }), + })); + const sdk = client(server); + const failed = (await sdk.sessions.retrieveTierChange(SURVIVOR, "q")).data + .data.quote.hybridTransition!; + expect(failed).toMatchObject({ + action: "keep", + status: "failed", + failureReason: "hybrid_transition.runtime_unconfirmed", + }); + const merge = ( + await sdk.sessions.quoteTierChange(SURVIVOR, { + tierOverride: "pro", + hybridMerge: { absorbNumberId: ABSORBED }, + }) + ).data.data.quote.hybridTransition!; + expect(merge.action).toBe("merge"); + if (merge.action !== "merge") throw new Error("narrowing"); + expect(merge.absorbNumberId).toBe(ABSORBED); + expect(merge.status).toBeUndefined(); + }); + + it.each([ + [ + "hybrid_choice_required", + "choose which Hybrid Link connection to keep, or split the Number, before leaving Pro", + ], + [ + "hybrid_transition_ineligible", + "Hybrid Link change unavailable: not_same_number", + ], + ])( + "exposes %s as a typed conflict without retrying", + async (code, message) => { + const server = await startTestServer(() => ({ + status: 409, + headers: { + "content-type": "application/json", + "x-request-id": "req_hybrid", + }, + body: JSON.stringify({ + error: { + type: "conflict_error", + code, + message, + param: null, + request_id: "req_hybrid", + }, + data: null, + docs: `https://docs.polymorfa.com/api/errors#${code}`, + }), + })); + const error = await client(server) + .sessions.quoteTierChange(SURVIVOR, { tierOverride: "standard" }) + .then( + () => undefined, + (caught: unknown) => caught, + ); + expect(error).toBeInstanceOf(PolymorfaConflictError); + expect(error).toMatchObject({ + status: 409, + code, + message, + requestId: "req_hybrid", + }); + expect(isKnownPolymorfaErrorCode(code)).toBe(true); + expect(server.requests).toHaveLength(1); + }, + ); + + it("lists same-number merge candidates for a project", async () => { + const candidates: HybridMergeCandidate[] = [ + { + numbers: [ + { + id: SURVIVOR, + name: "support", + transport: "linked_devices", + status: "connected", + canBeAbsorbed: false, + }, + { + id: ABSORBED, + name: "support-cloud", + transport: "official_api", + status: "connected", + canBeAbsorbed: true, + }, + ], + eligible: true, + }, + { + numbers: [ + { + id: CREATED, + name: "sales", + transport: "linked_devices", + status: "disconnected", + canBeAbsorbed: true, + }, + { + id: "01994234-0000-7000-8000-00000000000d", + name: "sales-cloud", + transport: "official_api", + status: "connected", + canBeAbsorbed: true, + }, + ], + eligible: false, + ineligibleReason: "not_connected", + }, + ]; + const server = await startTestServer(() => ({ + body: JSON.stringify({ data: candidates }), + })); + const result = await client(server).projects.listHybridMergeCandidates( + `${PROJECT}/x`, + ); + expect(result.data.data).toEqual(candidates); + expect(server.requests.map((r) => `${r.method} ${r.path}`)).toEqual([ + `GET /platform/projects/${PROJECT}%2Fx/hybrid-merge-candidates`, + ]); + }); +}); + +interface Schema { + readonly properties?: Readonly>; + readonly enum?: readonly unknown[]; + readonly required?: readonly string[]; + readonly oneOf?: readonly Schema[]; + readonly items?: Schema; + readonly $ref?: string; +} +const platform = ( + JSON.parse( + readFileSync( + new URL("../../../contracts/openapi.platform.json", import.meta.url), + "utf8", + ), + ) as { components: { schemas: Record } } +).components.schemas; + +type AllKeys = T extends unknown ? keyof T : never; +/** A complete key list checked by the compiler against every union member. */ +function unionKeys(record: { readonly [K in AllKeys]: true }) { + return Object.keys(record).sort(); +} +function values(record: { readonly [K in T]: true }) { + return Object.keys(record).sort(); +} + +it("surfaces missing Hybrid Link access on the candidate list as a typed 403", async () => { + const server = await startTestServer(() => ({ + status: 403, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + error: { + type: "permission_error", + code: "feature_unavailable", + message: "Hybrid Link is not enabled for this team.", + param: null, + request_id: "req_forbidden", + }, + data: null, + docs: "https://docs.polymorfa.com/api/errors#feature_unavailable", + }), + })); + const error = await client(server) + .projects.listHybridMergeCandidates(PROJECT) + .then( + () => undefined, + (caught: unknown) => caught, + ); + expect(error).toBeInstanceOf(PolymorfaAuthorizationError); + expect(error).toMatchObject({ status: 403, code: "feature_unavailable" }); + expect(server.requests).toHaveLength(1); +}); + +describe("Hybrid tier transition types match the pinned Platform snapshot", () => { + it("covers every request and response field and enum", () => { + expect( + Object.keys(platform.NumberTierQuoteRequest!.properties!).sort(), + ).toEqual( + unionKeys({ + projectId: true, + tierOverride: true, + hybridResolution: true, + hybridMerge: true, + }), + ); + expect(platform.HybridTransport!.enum!.slice().sort()).toEqual( + values({ linked_devices: true, official_api: true }), + ); + const [keep, split] = platform.HybridResolution!.oneOf!; + expect(keep!.properties!.action!.enum).toEqual(["keep"]); + expect(split!.properties!.action!.enum).toEqual(["split"]); + expect( + [...Object.keys(keep!.properties!), ...Object.keys(split!.properties!)] + .filter((key, index, all) => all.indexOf(key) === index) + .sort(), + ).toEqual( + unionKeys({ + action: true, + transport: true, + existingNumberTransport: true, + newNumberName: true, + }), + ); + expect(Object.keys(platform.HybridMerge!.properties!)).toEqual([ + "absorbNumberId", + ]); + + const transition = platform.NumberHybridTransition!; + expect(Object.keys(transition.properties!).sort()).toEqual( + unionKeys({ + action: true, + keepTransport: true, + existingNumberTransport: true, + newNumberName: true, + absorbNumberId: true, + survivingNumberId: true, + newNumberId: true, + status: true, + failureReason: true, + metaDisconnectRequired: true, + effectiveAtMs: true, + }), + ); + expect(transition.required).toEqual(["action", "survivingNumberId"]); + expect(transition.properties!.action!.enum!.slice().sort()).toEqual([ + "keep", + "merge", + "split", + ]); + expect(transition.properties!.status!.enum!.slice().sort()).toEqual( + values({ + scheduled: true, + running: true, + completed: true, + failed: true, + cancelled: true, + }), + ); + expect( + platform.NumberTierChange!.properties!.quote!.properties! + .hybridTransition!.$ref, + ).toBe("#/components/schemas/NumberHybridTransition"); + + const candidate = platform.HybridMergeCandidateList!.items!; + expect(Object.keys(candidate.properties!).sort()).toEqual( + unionKeys({ + numbers: true, + eligible: true, + ineligibleReason: true, + }), + ); + expect( + candidate.properties!.ineligibleReason!.enum!.slice().sort(), + ).toEqual( + values({ + deletion_in_progress: true, + not_coexistence: true, + different_customer: true, + connection_disabled: true, + not_connected: true, + transition_in_progress: true, + pairing_in_progress: true, + hms_enabled: true, + }), + ); + expect( + Object.keys(candidate.properties!.numbers!.items!.properties!).sort(), + ).toEqual(["canBeAbsorbed", "id", "name", "status", "transport"]); + expect(candidate.properties!.numbers!.items!.required).toContain( + "canBeAbsorbed", + ); + }); +});