diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 144b7c9..f6dc69c 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: 6271f4734476c195b4a84d1863561222a8e515d1 + 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 e833f0e..1ad40be 100644 --- a/README.md +++ b/README.md @@ -2243,12 +2243,66 @@ 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_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 +# 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. 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: + +```shell +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`, +`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 `fcf7b21015dd185e987b5aa3580e8af5f4c7f1a4`. 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 b052f4d..cf1c9a7 100644 --- a/package.json +++ b/package.json @@ -258,6 +258,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..74d043e --- /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\`. 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', + ] + + 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..87f0af9 --- /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, so it must not use hosted message storage.', + 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..b24ef50 100644 --- a/src/errors/present.ts +++ b/src/errors/present.ts @@ -97,7 +97,14 @@ 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_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' && + /\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/number-tiers.ts b/src/services/number-tiers.ts new file mode 100644 index 0000000..33cb9ed --- /dev/null +++ b/src/services/number-tiers.ts @@ -0,0 +1,242 @@ +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 ( + 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') +} + +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 +} + +/** 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) => { + 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.canBeAbsorbed ? 'can be absorbed' : 'must be kept'})`, + ), + 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') +} + +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..d16b61a --- /dev/null +++ b/test/commands/number-tiers.test.ts @@ -0,0 +1,485 @@ +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_choice_required with the flags to add', async () => { + server = await startHttpSequenceServer([ + conflict( + 'hybrid_choice_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_choice_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 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( + { + 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', + canBeAbsorbed: false, + }, + { + id: ABSORBED, + name: 'support-cloud', + transport: 'official_api', + status: 'connected', + canBeAbsorbed: true, + }, + ], + eligible: true, + }, + { + numbers: [ + { + id: CREATED, + name: 'sales', + transport: 'linked_devices', + status: 'disconnected', + canBeAbsorbed: true, + }, + { + id: '01994234-0000-7000-8000-00000000000d', + name: 'sales-cloud', + transport: 'official_api', + status: 'connected', + canBeAbsorbed: true, + }, + ], + eligible: false, + ineligibleReason: 'pairing_in_progress', + }, + ] + 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, 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)', + ) + + const json = await platform(() => + runCommand(`session hybrid-merge-candidates --project ${PROJECT} --json`), + ) + 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( + { + 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 +}