Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
66 changes: 60 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
},
Expand Down
32 changes: 32 additions & 0 deletions src/commands/session/hybrid-merge-candidates.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
}
60 changes: 60 additions & 0 deletions src/commands/session/tier/confirm.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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,
)
}
}
89 changes: 89 additions & 0 deletions src/commands/session/tier/quote.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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,
)
}
}
38 changes: 38 additions & 0 deletions src/commands/session/tier/status.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
}
9 changes: 8 additions & 1 deletion src/errors/present.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
: {}),
Expand Down
Loading