From b4434ad571e98fdb9a85fa8f91ba9c364361eb20 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 24 Sep 2026 16:49:49 +0200 Subject: [PATCH] feat(sdk): Hybrid Link tier transitions Type hybridResolution (keep/split) and hybridMerge on tier quotes, quote.hybridTransition with its status, Client.projects.listHybridMergeCandidates, and the hybrid_resolution_required / hybrid_transition_ineligible codes. Pin the Platform snapshot and ledger to API commit feaf0734a. --- CHANGELOG.md | 7 + README.md | 7 +- contracts/README.md | 23 +- contracts/coverage.json | 21 +- contracts/openapi.platform.json | 287 +++++++++++- contracts/source.json | 10 +- packages/typescript/README.md | 54 +++ packages/typescript/src/errors.ts | 2 + packages/typescript/src/index.ts | 13 + packages/typescript/src/platform/projects.ts | 18 + packages/typescript/src/platform/sessions.ts | 18 +- packages/typescript/src/platform/types.ts | 124 ++++- .../test/coverage-reconciliation.test.ts | 2 +- packages/typescript/test/coverage.test.ts | 6 +- packages/typescript/test/error-body.test.ts | 7 +- .../test/hybrid-tier-transitions.test.ts | 443 ++++++++++++++++++ 16 files changed, 1015 insertions(+), 27 deletions(-) create mode 100644 packages/typescript/test/hybrid-tier-transitions.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b942569f..90e268d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## 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_resolution_required` and + `hybrid_transition_ineligible`. - Browser Calls hold locally gathered ICE candidates until the platform answers the call's offer, then send them. Candidates sent earlier were refused with `409` because no media session existed yet, and were lost. diff --git a/README.md b/README.md index 1ab6fdbf..a724810d 100644 --- a/README.md +++ b/README.md @@ -295,10 +295,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 e9e5aa44..677d0389 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -41,7 +41,24 @@ published SDK package before updating their pinned dependency. ## Full snapshots -The snapshots are byte-identical copies of the Messaging and Platform OpenAPI +The current snapshots are byte-identical copies of the Messaging and Platform +OpenAPI files at `polymorfa/polymorfa` commit +`feaf0734a99e24124e06cc04af52b46fdc5158f1` on the coordinated API branch +`t3code/hybrid-pro-downgrade` (not yet merged to API `dev`; based on merged +API `dev` commit `4d1d47fad`). The Messaging file is unchanged from +`5cba4237`. 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`. Four fingerprints changed: +`quoteSessionTierChange`, `getSessionTierChange` and `setSessionTierOverride` +(the shared request and result schemas; each keeps its typed method) and the +excluded Console `updateProject` (a new `409`). `hybrid_resolution_required` +and `hybrid_transition_ineligible` are documented in operation prose rather +than the public error enum; `POLYMORFA_ERROR_CODES` lists both. Repin to the +merged API `dev` commit before merging this SDK change. + +The preceding snapshots were byte-identical copies of the Messaging and Platform OpenAPI files at merged `polymorfa/polymorfa` API `dev` commit `5cba4237fcec1ef776e8d3727c7ecc4d8f0207a3`. This revision integrates Hybrid Link and the `campaign.launched`, `campaign.resumed`, and @@ -160,12 +177,12 @@ too, with their payload types. | Status | Operations | | ------------------- | ---------: | -| Covered | 379 | +| Covered | 380 | | Missing | 0 | | Excluded | 130 | | Partial | 0 | | Changed fingerprint | 0 | -| Total | 509 | +| Total | 510 | 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 d86e228b..77d8d6a1 100644 --- a/contracts/coverage.json +++ b/contracts/coverage.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceCommit": "5cba4237fcec1ef776e8d3727c7ecc4d8f0207a3", + "sourceCommit": "feaf0734a99e24124e06cc04af52b46fdc5158f1", "excludedContracts": [ { "family": "graph", @@ -3784,7 +3784,7 @@ "method": "GET", "path": "/platform/sessions/{sessionId}/tier-quotes/{quoteId}", "operationId": "getSessionTierChange", - "fingerprint": "26a366bd27b1e77700ca427f137f3f01c99c398f41f95edb8008af2bcb19aefe", + "fingerprint": "602247de1ae72206a3207b1cd8d7ce65fec0a7b386716f6b5e24fd85f79b5454", "typescript": { "status": "covered", "method": "Client.sessions.retrieveTierChange" @@ -4050,7 +4050,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." @@ -4150,7 +4150,7 @@ "method": "PATCH", "path": "/platform/sessions/{sessionId}", "operationId": "setSessionTierOverride", - "fingerprint": "a28a3700b2e1ca7362a57de40598959640bbf31d709d929623206bc3ecd45f1d", + "fingerprint": "42e924cd6ea94896fa447071dfa78b152f06475f06d5be200ed9d5cffdab1f96", "typescript": { "status": "covered", "method": "Client.sessions.setTierOverride" @@ -5189,7 +5189,7 @@ "method": "POST", "path": "/platform/sessions/{sessionId}/tier-quotes", "operationId": "quoteSessionTierChange", - "fingerprint": "7bd49359f1b8a15cd21477573209924c7ea1532d0f4b258eb0022a30a8c4593a", + "fingerprint": "a9e13a9e870e108da6669c7a1bd79655df499ab2c03f22f5e4bcea89f621d185", "typescript": { "status": "covered", "method": "Client.sessions.quoteTierChange" @@ -5560,6 +5560,17 @@ "method": "Client.projects.updateWarmupPlan" } }, + { + "family": "platform", + "method": "GET", + "path": "/platform/projects/{projectId}/hybrid-merge-candidates", + "operationId": "listHybridMergeCandidates", + "fingerprint": "a8a890a75308fba2e51b5b4ae50c2958c0256a02e30eaffda495032ed2b0fd0f", + "typescript": { + "status": "covered", + "method": "Client.projects.listHybridMergeCandidates" + } + }, { "family": "platform", "method": "PUT", diff --git a/contracts/openapi.platform.json b/contracts/openapi.platform.json index b1db4d92..d9ea3f28 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** (218 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** (99 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** (219 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** (99 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": [ { @@ -12696,7 +12696,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_resolution_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, @@ -12789,7 +12789,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, @@ -20123,7 +20123,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_resolution_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, @@ -20260,6 +20260,9 @@ }, "404": { "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" } } } @@ -20730,6 +20733,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.\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", @@ -36543,6 +36623,9 @@ "type": "string", "format": "uuid", "nullable": true + }, + "hybridTransition": { + "$ref": "#/components/schemas/NumberHybridTransition" } }, "required": [ @@ -36580,12 +36663,208 @@ 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" + } + }, + "required": [ + "id", + "name", + "transport", + "status" + ] + } + }, + "eligible": { + "type": "boolean" + }, + "ineligibleReason": { + "type": "string", + "enum": [ + "deletion_in_progress", + "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 065ea563..572d2b78 100644 --- a/contracts/source.json +++ b/contracts/source.json @@ -1,9 +1,9 @@ { "repository": "polymorfa/polymorfa", - "commit": "5cba4237fcec1ef776e8d3727c7ecc4d8f0207a3", - "branch": "dev", - "published": true, - "note": "Exact merged API dev commit integrating Hybrid Link and campaign.launched, campaign.resumed, and campaign.stopped webhook schemas from feature source 3558c289ec35aba45a498a8794a94b4919365899. Both snapshots are byte-identical to the authoritative source files. SDK package publication is separate.", + "commit": "feaf0734a99e24124e06cc04af52b46fdc5158f1", + "branch": "t3code/hybrid-pro-downgrade", + "published": false, + "note": "Coordinated API feature commit for Hybrid Link tier transitions (downgrade resolution, same-number merge and merge candidates), based on merged API dev commit 4d1d47fad. The Messaging snapshot is unchanged from API dev 5cba4237; the Platform snapshot adds the tier-transition contract. 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": "4e5aa9b8890b8963d704df22efbbce7c6bd9ac09b9253f5c8215bb3966b2f2cc" + "sha256": "412fe7af0a84791ddb21957727af34ec8c9f2a95ad9b6cd8d0f22df325173d2c" } } } diff --git a/packages/typescript/README.md b/packages/typescript/README.md index bebf5ba7..cc722d1c 100644 --- a/packages/typescript/README.md +++ b/packages/typescript/README.md @@ -2118,6 +2118,60 @@ 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_resolution_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) { + const [keep, absorb] = pair.numbers; + 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`. 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. Merging requires Hybrid Link access; the API +enforces it. + ## 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 dfdb6858..9789a93a 100644 --- a/packages/typescript/src/errors.ts +++ b/packages/typescript/src/errors.ts @@ -29,6 +29,8 @@ export const POLYMORFA_ERROR_CODES = [ "hybrid_automation_unavailable", "hybrid_transport_unavailable", "hybrid_target_reference_unavailable", + "hybrid_resolution_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 49aaf94d..06110da3 100644 --- a/packages/typescript/src/index.ts +++ b/packages/typescript/src/index.ts @@ -830,6 +830,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..44633a96 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,23 @@ 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 } })`. Requires `sessions:read`. + */ + 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..6cdf4fdd 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_resolution_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 e1b55ff0..7577878e 100644 --- a/packages/typescript/src/platform/types.ts +++ b/packages/typescript/src/platform/types.ts @@ -689,11 +689,131 @@ 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_resolution_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"; + +export interface HybridMergeCandidateNumber { + readonly id: string; + readonly name: string; + readonly transport: HybridTransport; + readonly status: string; +} + +/** Two Numbers in one project that are the same WhatsApp Business number. */ +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"; @@ -708,6 +828,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 8685f421..e003605d 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("5cba4237fcec1ef776e8d3727c7ecc4d8f0207a3"); + expect(source.commit).toBe("feaf0734a99e24124e06cc04af52b46fdc5158f1"); 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 2dd449e3..f1c18aa1 100644 --- a/packages/typescript/test/coverage.test.ts +++ b/packages/typescript/test/coverage.test.ts @@ -173,9 +173,9 @@ describe("coverage checker", () => { const result = runRepositoryChecker(); expect(result.status, result.stderr).toBe(0); expect(result.report).toMatchObject({ - sourceCommit: "5cba4237fcec1ef776e8d3727c7ecc4d8f0207a3", - total: 509, - covered: 379, + sourceCommit: "feaf0734a99e24124e06cc04af52b46fdc5158f1", + total: 510, + covered: 380, partial: 0, missing: 0, excluded: 130, diff --git a/packages/typescript/test/error-body.test.ts b/packages/typescript/test/error-body.test.ts index 7e758351..af907840 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_resolution_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..054c584a --- /dev/null +++ b/packages/typescript/test/hybrid-tier-transitions.test.ts @@ -0,0 +1,443 @@ +import { readFileSync } from "node:fs"; + +import { afterEach, describe, expect, it } from "vitest"; +import { + Client, + 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 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_resolution_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", + }, + { + id: ABSORBED, + name: "support-cloud", + transport: "official_api", + status: "connected", + }, + ], + eligible: true, + }, + { + numbers: [ + { + id: CREATED, + name: "sales", + transport: "linked_devices", + status: "disconnected", + }, + { + id: "01994234-0000-7000-8000-00000000000d", + name: "sales-cloud", + transport: "official_api", + status: "connected", + }, + ], + 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(); +} + +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, + }), + ); + expect( + Object.keys(candidate.properties!.numbers!.items!.properties!).sort(), + ).toEqual(["id", "name", "status", "transport"]); + }); +});