From fcdf52c01d6db1a2cae19b75fa11830ccc93a34a Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 24 Sep 2026 16:54:16 +0200 Subject: [PATCH 1/3] feat(cli): Number tier commands with Hybrid Link resolution and merge Add session tier quote/confirm/status and session hybrid-merge-candidates. Quote accepts --keep, --split --stay --new-number-name, and --merge-with; hybrid_resolution_required names the flags to add. CI verifies against the companion SDK source until it is published. --- .github/workflows/ci.yml | 16 + README.md | 56 ++- package.json | 3 + .../session/hybrid-merge-candidates.ts | 32 ++ src/commands/session/tier/confirm.ts | 60 +++ src/commands/session/tier/quote.ts | 89 ++++ src/commands/session/tier/status.ts | 38 ++ src/errors/present.ts | 5 +- src/services/number-tiers.ts | 213 +++++++++ test/commands/number-tiers.test.ts | 415 ++++++++++++++++++ 10 files changed, 920 insertions(+), 7 deletions(-) create mode 100644 src/commands/session/hybrid-merge-candidates.ts create mode 100644 src/commands/session/tier/confirm.ts create mode 100644 src/commands/session/tier/quote.ts create mode 100644 src/commands/session/tier/status.ts create mode 100644 src/services/number-tiers.ts create mode 100644 test/commands/number-tiers.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 144b7c9..b4aab81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,5 +27,21 @@ jobs: with: node-version: ${{ matrix.node-version }} cache: npm + # Hybrid Link tier transitions need the companion SDK PR. Verify this PR + # against that immutable source revision until its package is published. + - run: npm ci --ignore-scripts + - uses: actions/checkout@v6 + with: + persist-credentials: false + repository: polymorfa/sdks + ref: b4434ad571e98fdb9a85fa8f91ba9c364361eb20 + path: .sdk-source - run: npm ci + working-directory: .sdk-source + - run: npm run build -w @polymorfa/sdk + working-directory: .sdk-source + - run: npm pack -w @polymorfa/sdk --pack-destination .. --ignore-scripts + working-directory: .sdk-source + - run: npm install --no-save --package-lock=false --ignore-scripts ./polymorfa-sdk-*.tgz + - run: npm run build - run: npm run verify diff --git a/README.md b/README.md index 69c4d7c..0583e76 100644 --- a/README.md +++ b/README.md @@ -1894,12 +1894,56 @@ server error. Add credit or resolve the billing restriction in the Console befor retrying. HTTP 409 from an expired tier quote also requires reviewing a new quote; the CLI never creates or confirms a replacement purchase automatically. -For a tier change, use the raw API command to create a quote with -`POST /platform/sessions/{sessionId}/tier-quotes`, review the returned charge, -then explicitly submit its `quoteId` with `PATCH /platform/sessions/{sessionId}`. -Read `GET /platform/sessions/{sessionId}/tier-quotes/{quoteId}` until the result is -applied or rejected. Store the full JSON request in a file and pass `--input` when -it contains numeric values or `null`; `--field` creates string values. +### Number tier changes + +Tier changes use an organization API key. Quote first, review the credit charge +and effective time, then confirm that exact quote: + +```shell +polymorfa session tier quote support --tier pro +polymorfa session tier confirm support --quote QUOTE_UUID --yes +polymorfa session tier status support QUOTE_UUID +``` + +`--tier inherit` restores the project default tier. A quote does not change the +Number or spend credit. `confirm` requires `--yes` and accepts +`--idempotency-key`; it never creates a replacement quote. Read `status` until +the change is applied or rejected. + +A Hybrid Link Number leaving Pro needs a choice. Without one, the quote fails +with `hybrid_resolution_required` (exit code 4) and the CLI names the flags to +add. This includes `--tier inherit` when the project default lacks Hybrid Link. + +```shell +# Keep one connection; the other is disconnected when Pro ends. +polymorfa session tier quote support --tier standard --keep 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. +polymorfa session tier quote support --tier standard --split --stay official_api --new-number-name 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, then +quote Pro on the Number that keeps its ID: + +```shell +polymorfa session hybrid-merge-candidates --project PROJECT_UUID +polymorfa session tier quote KEEP_UUID --tier pro --merge-with OTHER_UUID +``` + +`--keep`, `--split` and `--merge-with` are mutually exclusive, and invalid +combinations exit with code 2 before any request. The quote, confirm and status +output shows the Hybrid Link plan and its status (`scheduled`, `running`, +`completed`, `failed` or `cancelled`) separately from the tier change, plus the +new Number ID after a split. When the output says action is required, disconnect +the Official API in the WhatsApp Business app under Settings > Account > +Business Platform > Disconnect Account. An ineligible Number or pair fails with +`hybrid_transition_ineligible` (exit code 4) and the API's reason in the message. +Merging requires Hybrid Link access, which the API enforces. + +These commands are tested against the companion SDK source, which pins API +contract `feaf0734a99e24124e06cc04af52b46fdc5158f1`. Update the registry SDK +dependency after that SDK is published and before releasing this CLI. ### Hybrid Link controls diff --git a/package.json b/package.json index 1f6bfb2..88f4a30 100644 --- a/package.json +++ b/package.json @@ -255,6 +255,9 @@ "session:configuration": { "description": "Inspect and update inherited session configuration." }, + "session:tier": { + "description": "Quote, confirm, and follow Number tier changes, including Hybrid Link choices." + }, "sip-trunk": { "description": "Connect a PBX to WhatsApp calls with SIP trunks." }, diff --git a/src/commands/session/hybrid-merge-candidates.ts b/src/commands/session/hybrid-merge-candidates.ts new file mode 100644 index 0000000..f14a829 --- /dev/null +++ b/src/commands/session/hybrid-merge-candidates.ts @@ -0,0 +1,32 @@ +import {PolymorfaConfigurationError} from '@polymorfa/sdk' + +import {BaseCommand} from '../../base-command.js' +import { + listHybridMergeCandidates, + renderMergeCandidates, +} from '../../services/number-tiers.js' + +export default class SessionHybridMergeCandidates extends BaseCommand { + static override description = `List same-number Number pairs that can merge into one Hybrid Link Number. + +Each pair is one Linked Devices Number and one Official API Number that shares the number with the WhatsApp Business app. Merge an eligible pair with \`session tier quote KEEP_UUID --tier pro --merge-with OTHER_UUID\`.` + + static override examples = [ + '<%= config.bin %> session hybrid-merge-candidates --project PROJECT_UUID', + ] + + async run(): Promise { + const {flags} = await this.parse(SessionHybridMergeCandidates) + if (flags.project === undefined || flags.project.trim().length === 0) { + throw new PolymorfaConfigurationError( + 'Provide --project with the project ID whose Numbers should be checked.', + 'project', + ) + } + const result = await listHybridMergeCandidates( + await this.platformClient(flags), + flags.project.trim(), + ) + this.writeValue(result, renderMergeCandidates(result.data), flags) + } +} diff --git a/src/commands/session/tier/confirm.ts b/src/commands/session/tier/confirm.ts new file mode 100644 index 0000000..4df31bd --- /dev/null +++ b/src/commands/session/tier/confirm.ts @@ -0,0 +1,60 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import { + confirmTierChange, + renderTierChange, +} from '../../../services/number-tiers.js' + +export default class SessionTierConfirm extends BaseCommand { + static override args = { + session: Args.string({ + description: 'Number session UUID or stable slug that was quoted.', + required: true, + }), + } + + static override description = `Confirm a reviewed Number tier quote. + +This charges the quoted credits and records any Hybrid Link choice in the quote. The CLI never creates or confirms a replacement quote; after a conflict, review a new quote.` + + static override examples = [ + '<%= config.bin %> session tier confirm support --quote QUOTE_UUID --yes', + ] + + static override flags = { + quote: Flags.string({ + description: 'Quote ID returned by `session tier quote`.', + required: true, + }), + 'idempotency-key': Flags.string({ + description: 'Identify this confirmation attempt.', + }), + yes: Flags.boolean({ + description: + 'Confirm the quoted charge and any Hybrid Link connection change.', + required: true, + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(SessionTierConfirm) + const result = await confirmTierChange( + await this.platformClient(flags), + args.session, + flags.quote, + flags.project, + flags['idempotency-key'] === undefined + ? {} + : {idempotencyKey: flags['idempotency-key']}, + ) + this.writeValue( + result, + renderTierChange( + result.data, + `Follow progress: polymorfa session tier status ${args.session} ${result.data.id}`, + ), + flags, + ) + } +} diff --git a/src/commands/session/tier/quote.ts b/src/commands/session/tier/quote.ts new file mode 100644 index 0000000..df51a96 --- /dev/null +++ b/src/commands/session/tier/quote.ts @@ -0,0 +1,89 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import { + HYBRID_TRANSPORTS, + TIER_CHOICES, + quoteTierChange, + renderTierChange, + tierQuoteRequest, +} from '../../../services/number-tiers.js' + +export default class SessionTierQuote extends BaseCommand { + static override args = { + session: Args.string({ + description: + 'Number session UUID or stable slug. For a merge, this Number keeps its ID.', + required: true, + }), + } + + static override description = `Preview a Number tier change without changing the Number or spending credit. + +A Hybrid Link Number leaving Pro needs a choice: --keep one connection, or --split into two Standard Numbers. The choice runs when the paid Pro window ends. An upgrade to Pro can merge a same-number pair with --merge-with.` + + static override examples = [ + '<%= config.bin %> session tier quote support --tier pro', + '<%= config.bin %> session tier quote support --tier standard --keep linked_devices', + '<%= config.bin %> session tier quote support --tier standard --split --stay official_api --new-number-name support-linked', + '<%= config.bin %> session tier quote NUMBER_UUID --tier pro --merge-with OTHER_NUMBER_UUID', + ] + + static override flags = { + tier: Flags.string({ + description: 'Target tier. "inherit" restores the project default tier.', + options: [...TIER_CHOICES], + required: true, + }), + keep: Flags.string({ + description: + 'Hybrid Link Number leaving Pro: keep this connection and disconnect the other.', + exclusive: ['split', 'merge-with'], + options: [...HYBRID_TRANSPORTS], + }), + split: Flags.boolean({ + description: + 'Hybrid Link Number leaving Pro: split into two Standard Numbers without re-pairing.', + exclusive: ['keep', 'merge-with'], + }), + stay: Flags.string({ + description: + 'With --split: the connection that stays on this Number; the other moves to the new Number.', + options: [...HYBRID_TRANSPORTS], + }), + 'new-number-name': Flags.string({ + description: 'With --split: name of the new Number.', + }), + 'merge-with': Flags.string({ + description: + 'With --tier pro: UUID of the same-number Number to merge into this one. It is removed after its connection moves.', + exclusive: ['keep', 'split'], + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(SessionTierQuote) + const body = tierQuoteRequest({ + tier: flags.tier as (typeof TIER_CHOICES)[number], + projectId: flags.project, + keep: flags.keep as (typeof HYBRID_TRANSPORTS)[number] | undefined, + split: flags.split, + stay: flags.stay as (typeof HYBRID_TRANSPORTS)[number] | undefined, + newNumberName: flags['new-number-name'], + mergeWith: flags['merge-with'], + }) + const result = await quoteTierChange( + await this.platformClient(flags), + args.session, + body, + ) + this.writeValue( + result, + renderTierChange( + result.data, + `Review the charge, then confirm: polymorfa session tier confirm ${args.session} --quote ${result.data.id} --yes`, + ), + flags, + ) + } +} diff --git a/src/commands/session/tier/status.ts b/src/commands/session/tier/status.ts new file mode 100644 index 0000000..eb8e486 --- /dev/null +++ b/src/commands/session/tier/status.ts @@ -0,0 +1,38 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import { + renderTierChange, + retrieveTierChange, +} from '../../../services/number-tiers.js' + +export default class SessionTierStatus extends BaseCommand { + static override args = { + session: Args.string({ + description: 'Number session UUID or stable slug.', + required: true, + }), + quote: Args.string({ + description: 'Quote ID of the tier change.', + required: true, + }), + } + + static override description = `Read a Number tier change and its Hybrid Link transition. + +The tier change is queued until applied or rejected. A Hybrid Link transition reports its own status: scheduled, running, completed, failed or cancelled.` + + static override examples = [ + '<%= config.bin %> session tier status support QUOTE_UUID', + ] + + async run(): Promise { + const {args, flags} = await this.parse(SessionTierStatus) + const result = await retrieveTierChange( + await this.platformClient(flags), + args.session, + args.quote, + ) + this.writeValue(result, renderTierChange(result.data), flags) + } +} diff --git a/src/errors/present.ts b/src/errors/present.ts index ca78f29..a404138 100644 --- a/src/errors/present.ts +++ b/src/errors/present.ts @@ -97,7 +97,10 @@ export function presentError( message: error instanceof PolymorfaError && error.code === 'send_outcome_unknown' ? `${message} Read message operation status; do not resend or change transport.` - : message, + : error instanceof PolymorfaError && + error.code === 'hybrid_resolution_required' + ? `${message} Choose --keep linked_devices|official_api, or --split --stay linked_devices|official_api --new-number-name NAME, and quote again.` + : message, ...(error instanceof PolymorfaError && error.metadata?.operationId ? {operationId: redact(error.metadata.operationId, secrets)} : {}), diff --git a/src/services/number-tiers.ts b/src/services/number-tiers.ts new file mode 100644 index 0000000..4697902 --- /dev/null +++ b/src/services/number-tiers.ts @@ -0,0 +1,213 @@ +import { + PolymorfaValidationError, + type ClientInstance, + type HybridMergeCandidate, + type HybridResolution, + type HybridTransport, + type NumberHybridTransition, + type NumberTierChange, + type NumberTierQuoteRequest, + type RequestOptions, + type ResponseMetadata, +} from '@polymorfa/sdk' + +import type {PlatformResult} from './platform-management.js' + +export const TIER_CHOICES = ['free', 'standard', 'pro', 'inherit'] as const +export type TierChoice = (typeof TIER_CHOICES)[number] +export const HYBRID_TRANSPORTS = ['linked_devices', 'official_api'] as const + +/** The API's Number name rule, checked before sending a split. */ +const NUMBER_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/ +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +export interface TierQuoteInput { + readonly tier: TierChoice + readonly projectId?: string | undefined + readonly keep?: HybridTransport | undefined + readonly split?: boolean | undefined + readonly stay?: HybridTransport | undefined + readonly newNumberName?: string | undefined + readonly mergeWith?: string | undefined +} + +/** Builds the tier quote body, refusing flag combinations the API rejects. */ +export function tierQuoteRequest( + input: TierQuoteInput, +): NumberTierQuoteRequest { + const base = { + tierOverride: input.tier === 'inherit' ? null : input.tier, + ...(input.projectId === undefined ? {} : {projectId: input.projectId}), + } + const resolution = hybridResolution(input) + if (input.mergeWith !== undefined) { + if (resolution !== undefined) { + throw new PolymorfaValidationError( + 'Use --merge-with on its own; --keep and --split resolve a Hybrid Link Number leaving Pro.', + ) + } + if (input.tier !== 'pro') { + throw new PolymorfaValidationError( + 'A Hybrid Link merge needs --tier pro.', + ) + } + const absorbNumberId = input.mergeWith.trim() + if (!UUID.test(absorbNumberId)) { + throw new PolymorfaValidationError( + '--merge-with needs the other Number UUID. List pairs with `polymorfa session hybrid-merge-candidates`.', + ) + } + return {...base, hybridMerge: {absorbNumberId}} + } + return resolution === undefined + ? base + : {...base, hybridResolution: resolution} +} + +function hybridResolution(input: TierQuoteInput): HybridResolution | undefined { + if (input.split === true) { + if (input.keep !== undefined) { + throw new PolymorfaValidationError( + 'Choose either --keep or --split, not both.', + ) + } + if (input.stay === undefined || input.newNumberName === undefined) { + throw new PolymorfaValidationError( + '--split needs --stay and --new-number-name .', + ) + } + if (!NUMBER_NAME.test(input.newNumberName)) { + throw new PolymorfaValidationError( + '--new-number-name must be 1-64 characters: a letter or digit, then letters, digits, ".", "_" or "-".', + ) + } + return { + action: 'split', + existingNumberTransport: input.stay, + newNumberName: input.newNumberName, + } + } + if (input.stay !== undefined || input.newNumberName !== undefined) { + throw new PolymorfaValidationError( + '--stay and --new-number-name are used only with --split.', + ) + } + return input.keep === undefined + ? undefined + : {action: 'keep', transport: input.keep} +} + +export async function quoteTierChange( + client: ClientInstance<'organization'>, + sessionId: string, + body: NumberTierQuoteRequest, +): Promise> { + const response = await client.sessions.quoteTierChange(sessionId, body) + return result(response.data.data, response.metadata) +} + +export async function confirmTierChange( + client: ClientInstance<'organization'>, + sessionId: string, + quoteId: string, + projectId?: string, + options: RequestOptions = {}, +): Promise> { + const response = await client.sessions.setTierOverride( + sessionId, + {quoteId, ...(projectId === undefined ? {} : {projectId})}, + options, + ) + return result(response.data.data, response.metadata) +} + +export async function retrieveTierChange( + client: ClientInstance<'organization'>, + sessionId: string, + quoteId: string, +): Promise> { + const response = await client.sessions.retrieveTierChange(sessionId, quoteId) + return result(response.data.data, response.metadata) +} + +export async function listHybridMergeCandidates( + client: ClientInstance<'organization'>, + projectId: string, +): Promise> { + const response = await client.projects.listHybridMergeCandidates(projectId) + return result(response.data.data, response.metadata) +} + +export function renderTierChange( + change: NumberTierChange, + next?: string, +): string { + const {quote} = change + const lines = [ + `Tier change ${change.id}: ${change.status}${change.failureReason ? ` (${change.failureReason})` : ''}`, + `Tier: ${quote.tier}${quote.tierOverride === null ? ' (project default)' : ''}, ${quote.action}, ${quote.amountCents} credits, effective ${isoTime(quote.effectiveAtMs)}`, + ] + if (change.status === 'quoted') + lines.push(`Quote expires ${isoTime(change.expiresAtMs)}`) + if (quote.hybridTransition !== undefined) + lines.push(...renderHybridTransition(quote.hybridTransition)) + if (next !== undefined) lines.push(next) + return lines.join('\n') +} + +function renderHybridTransition(transition: NumberHybridTransition): string[] { + const plan = + transition.action === 'keep' + ? `keep ${transition.keepTransport} on ${transition.survivingNumberId}; the other connection is disconnected` + : transition.action === 'split' + ? `split: ${transition.existingNumberTransport} stays on ${transition.survivingNumberId}; the other connection moves to new Number "${transition.newNumberName}"${transition.newNumberId === undefined ? '' : ` (${transition.newNumberId})`}` + : `merge ${transition.absorbNumberId} into ${transition.survivingNumberId}` + const lines = [ + `Hybrid Link: ${plan}`, + `Hybrid Link status: ${transition.status ?? 'not confirmed'}${transition.failureReason ? ` (${transition.failureReason})` : ''}${transition.effectiveAtMs == null ? '' : `, runs ${isoTime(transition.effectiveAtMs)}`}`, + ] + if (transition.metaDisconnectRequired === true) + lines.push( + 'Action required: disconnect the Official API in the WhatsApp Business app under Settings > Account > Business Platform > Disconnect Account.', + ) + return lines +} + +export function renderMergeCandidates( + candidates: readonly HybridMergeCandidate[], +): string { + if (candidates.length === 0) return 'No same-number pairs found.' + return candidates + .map((candidate) => + [ + ...candidate.numbers.map( + (number) => + `${number.id} (${number.name}, ${number.transport}, ${number.status})`, + ), + candidate.eligible + ? 'eligible' + : `ineligible: ${candidate.ineligibleReason ?? 'unknown'}`, + ].join('\t'), + ) + .join('\n') +} + +function isoTime(milliseconds: number): string { + return new Date(milliseconds).toISOString() +} + +function result(data: T, metadata: ResponseMetadata): PlatformResult { + return { + data, + metadata: { + attempts: metadata.attempts, + status: metadata.status, + ...(metadata.apiVersion === undefined + ? {} + : {apiVersion: metadata.apiVersion}), + ...(metadata.requestId === undefined + ? {} + : {requestId: metadata.requestId}), + }, + } +} diff --git a/test/commands/number-tiers.test.ts b/test/commands/number-tiers.test.ts new file mode 100644 index 0000000..3cbcb14 --- /dev/null +++ b/test/commands/number-tiers.test.ts @@ -0,0 +1,415 @@ +import {runCommand} from '@oclif/test' +import {afterEach, describe, expect, test} from 'vitest' + +import { + startHttpSequenceServer, + withEnvironment, + type ResponseFixture, + type TestHttpServer, +} from '../helpers/http-server.js' + +const SURVIVOR = '01994234-0000-7000-8000-00000000000a' +const ABSORBED = '01994234-0000-7000-8000-00000000000b' +const CREATED = '01994234-0000-7000-8000-00000000000c' +const QUOTE = '01994234-0000-7000-8000-000000000001' +const PROJECT = '01994234-0000-7000-8000-0000000000aa' + +function tierChange( + hybridTransition?: Record, + status = 'quoted', +) { + return { + id: QUOTE, + status, + failureReason: null, + expiresAtMs: 1_800_000_600_000, + quote: { + tier: 'standard', + tierOverride: 'standard', + amountCents: 0, + priceVersion: 'catalog-1', + action: 'downgrade', + effectiveAtMs: 1_800_000_000_000, + replacesWindowId: null, + ...(hybridTransition === undefined ? {} : {hybridTransition}), + }, + } +} + +describe('session tier commands', () => { + let server: TestHttpServer | undefined + + afterEach(async () => { + await server?.close() + server = undefined + }) + + test.each([ + ['--tier pro', {tierOverride: 'pro'}], + ['--tier inherit', {tierOverride: null}], + [ + '--tier standard --keep linked_devices', + { + tierOverride: 'standard', + hybridResolution: {action: 'keep', transport: 'linked_devices'}, + }, + ], + [ + '--tier standard --split --stay official_api --new-number-name support-linked', + { + tierOverride: 'standard', + hybridResolution: { + action: 'split', + existingNumberTransport: 'official_api', + newNumberName: 'support-linked', + }, + }, + ], + [ + `--tier pro --merge-with ${ABSORBED}`, + {tierOverride: 'pro', hybridMerge: {absorbNumberId: ABSORBED}}, + ], + [ + `--tier free --keep official_api --project ${PROJECT}`, + { + tierOverride: 'free', + projectId: PROJECT, + hybridResolution: {action: 'keep', transport: 'official_api'}, + }, + ], + ])('quote %s sends the exact request body', async (flags, body) => { + server = await startHttpSequenceServer([{body: {data: tierChange()}}]) + const result = await platform(() => + runCommand(`session tier quote ${SURVIVOR} ${flags} --json`), + ) + expect(result.stderr).toBe('') + expect(result.error).toBeUndefined() + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + `POST /platform/sessions/${SURVIVOR}/tier-quotes`, + ]) + expect(JSON.parse(server.requests[0]!.body)).toEqual(body) + expect(JSON.parse(result.stdout)).toMatchObject({data: {id: QUOTE}}) + }) + + test.each([ + [ + '--tier standard --keep linked_devices --split', + 'cannot also be provided', + ], + [ + `--tier pro --keep linked_devices --merge-with ${ABSORBED}`, + 'cannot also be provided', + ], + ['--tier standard --split', '--split needs --stay'], + ['--tier standard --split --stay official_api', '--split needs --stay'], + [ + '--tier standard --split --stay official_api --new-number-name -bad', + '--new-number-name must be 1-64 characters', + ], + ['--tier standard --stay official_api', 'used only with --split'], + [ + '--tier standard --keep linked_devices --new-number-name x', + 'used only with --split', + ], + [`--tier standard --merge-with ${ABSORBED}`, 'needs --tier pro'], + ['--tier pro --merge-with support', 'Number UUID'], + ['--tier standard --keep both', 'Expected --keep=both to be one of'], + ])('quote %s fails before sending', async (flags, message) => { + server = await startHttpSequenceServer([{body: {data: tierChange()}}]) + const result = await platform(() => + runCommand(`session tier quote ${SURVIVOR} ${flags}`), + ) + expect(exitCode(result.error)).toBe(2) + expect(result.stderr).toContain(message) + expect(server.requests).toHaveLength(0) + }) + + test('shows the Hybrid Link plan and the confirm command for a quote', async () => { + server = await startHttpSequenceServer([ + { + body: { + data: tierChange({ + action: 'split', + existingNumberTransport: 'official_api', + newNumberName: 'support-linked', + survivingNumberId: SURVIVOR, + effectiveAtMs: 1_800_000_000_000, + }), + }, + }, + ]) + const result = await platform(() => + runCommand( + `session tier quote support --tier standard --split --stay official_api --new-number-name support-linked`, + ), + ) + expect(result.error).toBeUndefined() + expect(result.stdout).toContain( + `Hybrid Link: split: official_api stays on ${SURVIVOR}; the other connection moves to new Number "support-linked"`, + ) + expect(result.stdout).toContain('Hybrid Link status: not confirmed') + expect(result.stdout).toContain( + `polymorfa session tier confirm support --quote ${QUOTE} --yes`, + ) + }) + + test('explains hybrid_resolution_required with the flags to add', async () => { + server = await startHttpSequenceServer([ + conflict( + 'hybrid_resolution_required', + 'choose which Hybrid Link connection to keep, or split the Number, before leaving Pro', + ), + ]) + const human = await platform(() => + runCommand(`session tier quote support --tier standard`), + ) + expect(exitCode(human.error)).toBe(4) + expect(human.stderr).toContain( + 'Choose --keep linked_devices|official_api, or --split --stay linked_devices|official_api --new-number-name NAME, and quote again.', + ) + expect(human.stderr).toContain('Request ID: req_hybrid') + + const json = await platform(() => + runCommand(`session tier quote support --tier inherit --json`), + ) + expect(exitCode(json.error)).toBe(4) + expect(JSON.parse(json.stderr)).toMatchObject({ + error: { + category: 'conflict', + code: 'hybrid_resolution_required', + retryable: false, + }, + }) + expect(server.requests).toHaveLength(2) + }) + + test('reports hybrid_transition_ineligible with the API reason', async () => { + server = await startHttpSequenceServer([ + conflict( + 'hybrid_transition_ineligible', + 'Hybrid Link change unavailable: not_same_number', + ), + ]) + const result = await platform(() => + runCommand( + `session tier quote ${SURVIVOR} --tier pro --merge-with ${ABSORBED} --json`, + ), + ) + expect(exitCode(result.error)).toBe(4) + expect(JSON.parse(result.stderr)).toMatchObject({ + error: { + code: 'hybrid_transition_ineligible', + message: 'Hybrid Link change unavailable: not_same_number', + }, + }) + }) + + test('confirm requires --yes and sends only the quote ID', async () => { + server = await startHttpSequenceServer([ + { + body: { + data: tierChange( + { + action: 'keep', + keepTransport: 'linked_devices', + survivingNumberId: SURVIVOR, + status: 'scheduled', + failureReason: null, + metaDisconnectRequired: false, + effectiveAtMs: 1_800_000_000_000, + }, + 'applied', + ), + }, + }, + ]) + const refused = await platform(() => + runCommand(`session tier confirm support --quote ${QUOTE}`), + ) + expect(exitCode(refused.error)).toBe(2) + expect(refused.stderr).toContain('Missing required flag yes') + expect(server.requests).toHaveLength(0) + + const confirmed = await platform(() => + runCommand( + `session tier confirm support --quote ${QUOTE} --yes --idempotency-key confirm-1`, + ), + ) + expect(confirmed.error).toBeUndefined() + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'PATCH /platform/sessions/support', + ]) + expect(JSON.parse(server.requests[0]!.body)).toEqual({quoteId: QUOTE}) + expect(server.requests[0]!.headers['idempotency-key']).toBe('confirm-1') + expect(confirmed.stdout).toContain( + `Hybrid Link: keep linked_devices on ${SURVIVOR}`, + ) + expect(confirmed.stdout).toContain('Hybrid Link status: scheduled') + expect(confirmed.stdout).toContain( + `polymorfa session tier status support ${QUOTE}`, + ) + }) + + test('status shows a completed split, the new Number and the disconnect instruction', async () => { + server = await startHttpSequenceServer([ + { + body: { + data: tierChange( + { + action: 'split', + existingNumberTransport: 'linked_devices', + newNumberName: 'support-cloud', + survivingNumberId: SURVIVOR, + newNumberId: CREATED, + status: 'completed', + failureReason: null, + metaDisconnectRequired: true, + effectiveAtMs: 1_800_000_000_000, + }, + 'applied', + ), + }, + }, + ]) + const result = await platform(() => + runCommand(`session tier status support ${QUOTE}`), + ) + expect(result.error).toBeUndefined() + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + `GET /platform/sessions/support/tier-quotes/${QUOTE}`, + ]) + expect(result.stdout).toContain(`Tier change ${QUOTE}: applied`) + expect(result.stdout).toContain(`(${CREATED})`) + expect(result.stdout).toContain('Hybrid Link status: completed') + expect(result.stdout).toContain( + 'Action required: disconnect the Official API in the WhatsApp Business app', + ) + }) + + test('status shows a failed transition reason in JSON and human output', async () => { + const failed = tierChange( + { + action: 'merge', + absorbNumberId: ABSORBED, + survivingNumberId: SURVIVOR, + status: 'failed', + failureReason: 'hybrid_transition.runtime_unconfirmed', + metaDisconnectRequired: false, + effectiveAtMs: null, + }, + 'applied', + ) + server = await startHttpSequenceServer([{body: {data: failed}}]) + const human = await platform(() => + runCommand(`session tier status support ${QUOTE}`), + ) + expect(human.stdout).toContain(`merge ${ABSORBED} into ${SURVIVOR}`) + expect(human.stdout).toContain( + 'Hybrid Link status: failed (hybrid_transition.runtime_unconfirmed)', + ) + const json = await platform(() => + runCommand(`session tier status support ${QUOTE} --json`), + ) + expect(JSON.parse(json.stdout)).toMatchObject({data: failed}) + }) + + test('lists merge candidates for an explicit project', async () => { + const candidates = [ + { + 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', + }, + ] + server = await startHttpSequenceServer([{body: {data: candidates}}]) + const missing = await platform(() => + runCommand('session hybrid-merge-candidates'), + ) + expect(exitCode(missing.error)).toBe(2) + expect(missing.stderr).toContain('Provide --project') + expect(server.requests).toHaveLength(0) + + const human = await platform(() => + runCommand(`session hybrid-merge-candidates --project ${PROJECT}`), + ) + expect(human.error).toBeUndefined() + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + `GET /platform/projects/${PROJECT}/hybrid-merge-candidates`, + ]) + expect(human.stdout).toContain( + `${SURVIVOR} (support, linked_devices, connected)\t${ABSORBED} (support-cloud, official_api, connected)\teligible`, + ) + expect(human.stdout).toContain('ineligible: not_connected') + + const json = await platform(() => + runCommand(`session hybrid-merge-candidates --project ${PROJECT} --json`), + ) + expect(JSON.parse(json.stdout)).toMatchObject({data: candidates}) + }) + + async function platform(run: () => Promise): Promise { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + POLYMORFA_API_KEY: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: undefined, + }, + run, + ) + } +}) + +function conflict(code: string, message: string): ResponseFixture { + return { + status: 409, + headers: {'x-request-id': 'req_hybrid'}, + body: { + error: { + type: 'conflict_error', + code, + message, + param: null, + request_id: 'req_hybrid', + }, + data: null, + docs: `https://docs.polymorfa.com/api/errors#${code}`, + }, + } +} + +function exitCode(error: unknown): number | undefined { + return (error as {oclif?: {exit?: number}} | undefined)?.oclif?.exit +} From d71283748f171c80ef49240371573177c8b5ff0e Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 27 Sep 2026 19:34:44 +0200 Subject: [PATCH 2/3] Merge CLI dev and repin Hybrid tier transitions to API d831bf9c6 Merge candidates show whether each Number can be absorbed, print the merge command for eligible pairs, and explain each ineligible reason, including transition_in_progress, pairing_in_progress and hms_enabled. A merge that would absorb a hosted-storage Number explains which Number to keep. CI now builds SDK f89365c. --- .github/workflows/ci.yml | 2 +- README.md | 10 +++- .../session/hybrid-merge-candidates.ts | 2 +- src/commands/session/tier/quote.ts | 2 +- src/errors/present.ts | 6 +- src/services/call-analytics.ts | 2 + src/services/number-tiers.ts | 38 +++++++++--- test/commands/number-tiers.test.ts | 58 ++++++++++++++++++- 8 files changed, 104 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4aab81..6d85b4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: with: persist-credentials: false repository: polymorfa/sdks - ref: b4434ad571e98fdb9a85fa8f91ba9c364361eb20 + ref: f89365c07b87adf10e324e823859507f73d9b5f0 path: .sdk-source - run: npm ci working-directory: .sdk-source diff --git a/README.md b/README.md index 2da381a..33ef440 100644 --- a/README.md +++ b/README.md @@ -2059,6 +2059,14 @@ polymorfa session hybrid-merge-candidates --project PROJECT_UUID polymorfa session tier quote KEEP_UUID --tier pro --merge-with OTHER_UUID ``` +`hybrid-merge-candidates` marks each Number "can be absorbed" or "must be +kept". A Number that uses hosted message storage must be kept, so quote the +merge on it and pass the other Number to `--merge-with`; the output prints that +command for eligible pairs. An ineligible pair shows its reason, such as +`pairing_in_progress`, `transition_in_progress` or `hms_enabled`. Listing +candidates requires Hybrid Link access; without it the command exits with code 3 +(`feature_unavailable`). + `--keep`, `--split` and `--merge-with` are mutually exclusive, and invalid combinations exit with code 2 before any request. The quote, confirm and status output shows the Hybrid Link plan and its status (`scheduled`, `running`, @@ -2070,7 +2078,7 @@ Business Platform > Disconnect Account. An ineligible Number or pair fails with Merging requires Hybrid Link access, which the API enforces. These commands are tested against the companion SDK source, which pins API -contract `feaf0734a99e24124e06cc04af52b46fdc5158f1`. Update the registry SDK +contract `d831bf9c662f6fb1763a9de259612a4553ab5a02`. Update the registry SDK dependency after that SDK is published and before releasing this CLI. ### Hybrid Link controls diff --git a/src/commands/session/hybrid-merge-candidates.ts b/src/commands/session/hybrid-merge-candidates.ts index f14a829..74d043e 100644 --- a/src/commands/session/hybrid-merge-candidates.ts +++ b/src/commands/session/hybrid-merge-candidates.ts @@ -9,7 +9,7 @@ import { export default class SessionHybridMergeCandidates extends BaseCommand { static override description = `List same-number Number pairs that can merge into one Hybrid Link Number. -Each pair is one Linked Devices Number and one Official API Number that shares the number with the WhatsApp Business app. Merge an eligible pair with \`session tier quote KEEP_UUID --tier pro --merge-with OTHER_UUID\`.` +Each pair is one Linked Devices Number and one Official API Number that shares the number with the WhatsApp Business app. Merge an eligible pair with \`session tier quote KEEP_UUID --tier pro --merge-with OTHER_UUID\`. A Number marked "must be kept" uses hosted message storage and cannot be the one merged away. Requires Hybrid Link access.` static override examples = [ '<%= config.bin %> session hybrid-merge-candidates --project PROJECT_UUID', diff --git a/src/commands/session/tier/quote.ts b/src/commands/session/tier/quote.ts index df51a96..87f0af9 100644 --- a/src/commands/session/tier/quote.ts +++ b/src/commands/session/tier/quote.ts @@ -56,7 +56,7 @@ A Hybrid Link Number leaving Pro needs a choice: --keep one connection, or --spl }), 'merge-with': Flags.string({ description: - 'With --tier pro: UUID of the same-number Number to merge into this one. It is removed after its connection moves.', + 'With --tier pro: UUID of the same-number Number to merge into this one. It is removed after its connection moves, so it must not use hosted message storage.', exclusive: ['keep', 'split'], }), } diff --git a/src/errors/present.ts b/src/errors/present.ts index a404138..f0ed644 100644 --- a/src/errors/present.ts +++ b/src/errors/present.ts @@ -100,7 +100,11 @@ export function presentError( : error instanceof PolymorfaError && error.code === 'hybrid_resolution_required' ? `${message} Choose --keep linked_devices|official_api, or --split --stay linked_devices|official_api --new-number-name NAME, and quote again.` - : message, + : error instanceof PolymorfaError && + error.code === 'hybrid_transition_ineligible' && + /\bhms_enabled$/.test(error.message) + ? `${message} A Number with hosted message storage must be kept: quote the merge on it and pass the other Number to --merge-with.` + : message, ...(error instanceof PolymorfaError && error.metadata?.operationId ? {operationId: redact(error.metadata.operationId, secrets)} : {}), diff --git a/src/services/call-analytics.ts b/src/services/call-analytics.ts index 9f22323..b07aefa 100644 --- a/src/services/call-analytics.ts +++ b/src/services/call-analytics.ts @@ -133,6 +133,8 @@ export class CallAnalyticsApi { throw invalid( 'Call ID must contain 1 to 128 letters, digits, underscores or hyphens.', ) + // Newer SDK builds declare `retrieve`; the published pin may not yet. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion const calls = this.calls as typeof this.calls & { retrieve?: ( id: string, diff --git a/src/services/number-tiers.ts b/src/services/number-tiers.ts index 4697902..9ba8c86 100644 --- a/src/services/number-tiers.ts +++ b/src/services/number-tiers.ts @@ -173,22 +173,44 @@ function renderHybridTransition(transition: NumberHybridTransition): string[] { return lines } +/** Customer wording for each candidate `ineligibleReason`. */ +const MERGE_INELIGIBLE_REASONS: Readonly> = { + deletion_in_progress: 'a Number is being deleted', + transition_in_progress: 'a tier or Hybrid Link change is already running', + pairing_in_progress: 'a Number is still pairing', + hms_enabled: + 'both Numbers use hosted message storage; disable it on one Number', + not_coexistence: + 'the Official API Number does not share the number with the WhatsApp Business app', + different_customer: 'the Numbers belong to different Customers', + connection_disabled: 'a connection is disabled', + not_connected: 'both Numbers must be connected', +} + export function renderMergeCandidates( candidates: readonly HybridMergeCandidate[], ): string { if (candidates.length === 0) return 'No same-number pairs found.' return candidates - .map((candidate) => - [ + .map((candidate) => { + const reason = candidate.ineligibleReason + const verdict = candidate.eligible + ? 'eligible' + : `ineligible: ${reason === undefined ? 'unknown' : `${reason} (${MERGE_INELIGIBLE_REASONS[reason] ?? 'see the API reason'})`}` + const keep = candidate.numbers.filter((number) => !number.canBeAbsorbed) + return [ ...candidate.numbers.map( (number) => - `${number.id} (${number.name}, ${number.transport}, ${number.status})`, + `${number.id} (${number.name}, ${number.transport}, ${number.status}, ${number.canBeAbsorbed ? 'can be absorbed' : 'must be kept'})`, ), - candidate.eligible - ? 'eligible' - : `ineligible: ${candidate.ineligibleReason ?? 'unknown'}`, - ].join('\t'), - ) + verdict, + ...(candidate.eligible && keep.length === 1 + ? [ + `merge with: session tier quote ${keep[0]!.id} --tier pro --merge-with ${candidate.numbers.find((number) => number.canBeAbsorbed)!.id}`, + ] + : []), + ].join('\t') + }) .join('\n') } diff --git a/test/commands/number-tiers.test.ts b/test/commands/number-tiers.test.ts index 3cbcb14..d484766 100644 --- a/test/commands/number-tiers.test.ts +++ b/test/commands/number-tiers.test.ts @@ -322,12 +322,14 @@ describe('session tier commands', () => { name: 'support', transport: 'linked_devices', status: 'connected', + canBeAbsorbed: false, }, { id: ABSORBED, name: 'support-cloud', transport: 'official_api', status: 'connected', + canBeAbsorbed: true, }, ], eligible: true, @@ -339,16 +341,18 @@ describe('session tier commands', () => { 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', + ineligibleReason: 'pairing_in_progress', }, ] server = await startHttpSequenceServer([{body: {data: candidates}}]) @@ -367,9 +371,11 @@ describe('session tier commands', () => { `GET /platform/projects/${PROJECT}/hybrid-merge-candidates`, ]) expect(human.stdout).toContain( - `${SURVIVOR} (support, linked_devices, connected)\t${ABSORBED} (support-cloud, official_api, connected)\teligible`, + `${SURVIVOR} (support, linked_devices, connected, must be kept)\t${ABSORBED} (support-cloud, official_api, connected, can be absorbed)\teligible\tmerge with: session tier quote ${SURVIVOR} --tier pro --merge-with ${ABSORBED}`, + ) + expect(human.stdout).toContain( + 'ineligible: pairing_in_progress (a Number is still pairing)', ) - expect(human.stdout).toContain('ineligible: not_connected') const json = await platform(() => runCommand(`session hybrid-merge-candidates --project ${PROJECT} --json`), @@ -377,6 +383,52 @@ describe('session tier commands', () => { expect(JSON.parse(json.stdout)).toMatchObject({data: candidates}) }) + test('explains a merge that would absorb a hosted-storage Number', async () => { + server = await startHttpSequenceServer([ + conflict( + 'hybrid_transition_ineligible', + 'Hybrid Link change unavailable: hms_enabled', + ), + ]) + const result = await platform(() => + runCommand( + `session tier quote ${SURVIVOR} --tier pro --merge-with ${ABSORBED}`, + ), + ) + expect(exitCode(result.error)).toBe(4) + expect(result.stderr).toContain( + 'A Number with hosted message storage must be kept: quote the merge on it and pass the other Number to --merge-with.', + ) + }) + + test('candidate listing without Hybrid Link access exits as an authorization failure', async () => { + server = await startHttpSequenceServer([ + { + status: 403, + headers: {'x-request-id': 'req_forbidden'}, + body: { + 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 result = await platform(() => + runCommand(`session hybrid-merge-candidates --project ${PROJECT} --json`), + ) + expect(exitCode(result.error)).toBe(3) + expect(JSON.parse(result.stderr)).toMatchObject({ + error: {category: 'authorization', code: 'feature_unavailable'}, + }) + expect(server.requests).toHaveLength(1) + }) + async function platform(run: () => Promise): Promise { if (server === undefined) throw new Error('Test server was not started.') return withEnvironment( From b016505db790dcd2a7522d5aa954bad6885cefe5 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 1 Oct 2026 00:21:39 +0300 Subject: [PATCH 3/3] Repin Hybrid tier transitions to API 4be41e2b4 The missing-choice code is hybrid_choice_required. session tier status explains a queued change rejected for that reason. CI builds SDK aa64132. --- .github/workflows/ci.yml | 2 +- README.md | 8 +++++--- src/errors/present.ts | 2 +- src/services/number-tiers.ts | 7 +++++++ test/commands/number-tiers.test.ts | 24 +++++++++++++++++++++--- 5 files changed, 35 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d85b4c..d1ea36b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: with: persist-credentials: false repository: polymorfa/sdks - ref: f89365c07b87adf10e324e823859507f73d9b5f0 + ref: aa64132e6a41271c04714076718232cfd93c328d path: .sdk-source - run: npm ci working-directory: .sdk-source diff --git a/README.md b/README.md index 33ef440..40cea28 100644 --- a/README.md +++ b/README.md @@ -2039,7 +2039,7 @@ Number or spend credit. `confirm` requires `--yes` and accepts the change is applied or rejected. A Hybrid Link Number leaving Pro needs a choice. Without one, the quote fails -with `hybrid_resolution_required` (exit code 4) and the CLI names the flags to +with `hybrid_choice_required` (exit code 4) and the CLI names the flags to add. This includes `--tier inherit` when the project default lacks Hybrid Link. ```shell @@ -2050,7 +2050,9 @@ polymorfa session tier quote support --tier standard --keep linked_devices polymorfa session tier quote support --tier standard --split --stay official_api --new-number-name support-linked ``` -The choice runs when the paid Pro window ends. To merge two Numbers that are the +The choice runs when the paid Pro window ends. A queued change that is rejected +because no choice is recorded shows `rejected (hybrid_choice_required)` in +`session tier status`; quote again with a choice. To merge two Numbers that are the same WhatsApp Business number into one Hybrid Link Number, list the pairs, then quote Pro on the Number that keeps its ID: @@ -2078,7 +2080,7 @@ Business Platform > Disconnect Account. An ineligible Number or pair fails with Merging requires Hybrid Link access, which the API enforces. These commands are tested against the companion SDK source, which pins API -contract `d831bf9c662f6fb1763a9de259612a4553ab5a02`. Update the registry SDK +contract `4be41e2b467dc488980499d60076fcfb3d87c44d`. Update the registry SDK dependency after that SDK is published and before releasing this CLI. ### Hybrid Link controls diff --git a/src/errors/present.ts b/src/errors/present.ts index f0ed644..b24ef50 100644 --- a/src/errors/present.ts +++ b/src/errors/present.ts @@ -98,7 +98,7 @@ export function presentError( error instanceof PolymorfaError && error.code === 'send_outcome_unknown' ? `${message} Read message operation status; do not resend or change transport.` : error instanceof PolymorfaError && - error.code === 'hybrid_resolution_required' + error.code === 'hybrid_choice_required' ? `${message} Choose --keep linked_devices|official_api, or --split --stay linked_devices|official_api --new-number-name NAME, and quote again.` : error instanceof PolymorfaError && error.code === 'hybrid_transition_ineligible' && diff --git a/src/services/number-tiers.ts b/src/services/number-tiers.ts index 9ba8c86..33cb9ed 100644 --- a/src/services/number-tiers.ts +++ b/src/services/number-tiers.ts @@ -151,6 +151,13 @@ export function renderTierChange( lines.push(`Quote expires ${isoTime(change.expiresAtMs)}`) if (quote.hybridTransition !== undefined) lines.push(...renderHybridTransition(quote.hybridTransition)) + if ( + change.status === 'rejected' && + change.failureReason === 'hybrid_choice_required' + ) + lines.push( + 'This Hybrid Link Number needs a choice before leaving Pro. Quote again with --keep linked_devices|official_api, or --split --stay linked_devices|official_api --new-number-name NAME.', + ) if (next !== undefined) lines.push(next) return lines.join('\n') } diff --git a/test/commands/number-tiers.test.ts b/test/commands/number-tiers.test.ts index d484766..d16b61a 100644 --- a/test/commands/number-tiers.test.ts +++ b/test/commands/number-tiers.test.ts @@ -153,10 +153,10 @@ describe('session tier commands', () => { ) }) - test('explains hybrid_resolution_required with the flags to add', async () => { + test('explains hybrid_choice_required with the flags to add', async () => { server = await startHttpSequenceServer([ conflict( - 'hybrid_resolution_required', + 'hybrid_choice_required', 'choose which Hybrid Link connection to keep, or split the Number, before leaving Pro', ), ]) @@ -176,7 +176,7 @@ describe('session tier commands', () => { expect(JSON.parse(json.stderr)).toMatchObject({ error: { category: 'conflict', - code: 'hybrid_resolution_required', + code: 'hybrid_choice_required', retryable: false, }, }) @@ -286,6 +286,24 @@ describe('session tier commands', () => { ) }) + test('status explains a queued change rejected for a missing Hybrid Link choice', async () => { + const rejected = { + ...tierChange(undefined, 'rejected'), + failureReason: 'hybrid_choice_required', + } + server = await startHttpSequenceServer([{body: {data: rejected}}]) + const human = await platform(() => + runCommand(`session tier status support ${QUOTE}`), + ) + expect(human.error).toBeUndefined() + expect(human.stdout).toContain( + `Tier change ${QUOTE}: rejected (hybrid_choice_required)`, + ) + expect(human.stdout).toContain( + 'Quote again with --keep linked_devices|official_api, or --split', + ) + }) + test('status shows a failed transition reason in JSON and human output', async () => { const failed = tierChange( {