From 41519c25c0c7b3eb82e1eb8e3621e63028858560 Mon Sep 17 00:00:00 2001 From: Iman Pouya Date: Wed, 14 Oct 2026 13:19:10 +0200 Subject: [PATCH 1/4] feat(plan): bridge plan delete, with --dry-run (TBP-806) A plan could be created and reshaped from the CLI but never removed, so a scripted reset (a demo app back to clean) always ended with "delete these plans in the dashboard". `bridge plan delete --key ` removes it. The server decides what that means: a plan no workspace is on is deleted, one workspaces are on is archived (they keep it, nobody new can choose it), and the answer's `action` says which. `--dry-run` shows that decision and the workspaces on the plan, through the delete's read-only preview route, like `tenant delete` and `flag delete`. Needs delete authority (`auth login --admin`). Refs: TBP-806 Claude-Session: 9e06c000 --- README.md | 4 ++++ bridge-cli/README.md | 4 ++++ bridge-cli/prompts/decisions/payments.md | 1 + bridge-cli/src/commands/plan.command.ts | 21 ++++++++++++++++++++- 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 735abfa..edc4968 100644 --- a/README.md +++ b/README.md @@ -330,6 +330,10 @@ bridge plan list # A free tier is a zero-amount price: --amount 0 bridge plan create --key pro --name "Pro Plan" --amount 29 --interval month bridge plan update --key pro --name "Pro Plan v2" +# Remove a plan. Deleted when no workspace is on it; archived when some are +# (they keep it, nobody new can choose it). Needs `bridge auth login --admin`. +bridge plan delete --key pro --dry-run # delete or archive, and who is on it +bridge plan delete --key pro # Prices — a plan can have several (one per currency + interval). Idempotent. bridge plan price set pro --amount 290 --currency usd --interval year diff --git a/bridge-cli/README.md b/bridge-cli/README.md index 7f7370d..81fcfa7 100644 --- a/bridge-cli/README.md +++ b/bridge-cli/README.md @@ -330,6 +330,10 @@ bridge plan list # A free tier is a zero-amount price: --amount 0 bridge plan create --key pro --name "Pro Plan" --amount 29 --interval month bridge plan update --key pro --name "Pro Plan v2" +# Remove a plan. Deleted when no workspace is on it; archived when some are +# (they keep it, nobody new can choose it). Needs `bridge auth login --admin`. +bridge plan delete --key pro --dry-run # delete or archive, and who is on it +bridge plan delete --key pro # Prices — a plan can have several (one per currency + interval). Idempotent. bridge plan price set pro --amount 290 --currency usd --interval year diff --git a/bridge-cli/prompts/decisions/payments.md b/bridge-cli/prompts/decisions/payments.md index 9f9e367..04517b4 100644 --- a/bridge-cli/prompts/decisions/payments.md +++ b/bridge-cli/prompts/decisions/payments.md @@ -70,6 +70,7 @@ The flag and its rule are set up in the **feature-control** guide. | Or step by step: create a plan with its first price | `create_plan` | `bridge plan create --key pro --name Pro --amount 29 --interval month --currency usd --no-trial` | | Add another price | `set_plan_price` | `bridge plan price set pro --amount 290 --interval year` | | Add a limit | `set_plan_quota` | `bridge plan quota set pro --metric tickets --limit 100 --policy hard --kind gauge` | +| Remove a plan (archived instead when workspaces are on it) | `delete_plan` | `bridge plan delete --key pro` | | Add an on/off feature the plan sells | `add_plan_feature` | `bridge plan feature add pro analytics --name "Analytics"` | | Turn off the forced plan choice | `update_app` | `bridge app update --payments-auto-redirect false` | | Code for the framework | `get_integration_guide` (topic `billing`) | `bridge guide billing --framework svelte`, `bridge guide billing --framework nestjs` | diff --git a/bridge-cli/src/commands/plan.command.ts b/bridge-cli/src/commands/plan.command.ts index 438235d..4b8f898 100644 --- a/bridge-cli/src/commands/plan.command.ts +++ b/bridge-cli/src/commands/plan.command.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs'; import { Command } from 'commander'; -import { getManagementClient } from '../config.js'; +import { getManagementClient, getManagementHttp } from '../config.js'; +import { dryRunPreview } from '../preview.js'; import { outputSuccess, outputError } from '../output.js'; import { shapePlan } from '../shape.js'; import { DecisionNeededError, decisionField, type DecisionField } from '../product-decisions.js'; @@ -531,6 +532,24 @@ export function registerPlanCommands(program: Command): void { } catch (err) { outputError(err); } }); + // TBP-806 — the server decides: a plan no workspace is on is deleted, one + // that workspaces are on is archived (they keep it, nobody new can pick it). + // The answer's `action` says which happened. + plan.command('delete') + .description('Remove a plan: deleted when no workspace is on it, archived when some are (they keep it; nobody new can choose it)') + .requiredOption('--key ', 'Plan key') + .option('--dry-run', 'Show whether the plan would be deleted or archived, and which workspaces are on it, and change nothing') + .action(async (opts) => { + try { + const path = `/v1/account/payments/plan/${encodeURIComponent(opts.key)}`; + if (opts.dryRun) { + outputSuccess(await dryRunPreview(path)); + return; + } + outputSuccess(await getManagementHttp().delete(path)); + } catch (err) { outputError(err); } + }); + plan.command('apply') .description('Set up or reshape one plan in a single write: name, trial, prices and quotas together (creates it when the key is new)') .requiredOption( From 9c0638ed4eed5c7a3c85e30304219c1239f56186 Mon Sep 17 00:00:00 2001 From: Iman Pouya Date: Wed, 14 Oct 2026 13:25:36 +0200 Subject: [PATCH 2/4] feat(plan): plan delete is refused while workspaces are on the plan; plan archive and tenant set-plan (TBP-806) Owner decision: deleting a plan workspaces are on is refused, not turned into an archive. The server answers 409 PLAN_IN_USE with how many workspaces are on it and the choices, which the CLI prints as the fix: archive the plan (`bridge plan archive`, `--undo` brings it back), or move those workspaces (`bridge tenant set-plan`, the dashboard's plan picker on the same route, Stripe subscription included) or delete them, then delete. Refs: TBP-806 Claude-Session: 9e06c000 --- README.md | 10 ++++-- bridge-cli/README.md | 10 ++++-- bridge-cli/prompts/decisions/payments.md | 4 ++- bridge-cli/src/commands/plan.command.ts | 23 ++++++++++--- bridge-cli/src/commands/tenant.command.ts | 42 ++++++++++++++++++++++- 5 files changed, 76 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index edc4968..469cd80 100644 --- a/README.md +++ b/README.md @@ -330,10 +330,14 @@ bridge plan list # A free tier is a zero-amount price: --amount 0 bridge plan create --key pro --name "Pro Plan" --amount 29 --interval month bridge plan update --key pro --name "Pro Plan v2" -# Remove a plan. Deleted when no workspace is on it; archived when some are -# (they keep it, nobody new can choose it). Needs `bridge auth login --admin`. -bridge plan delete --key pro --dry-run # delete or archive, and who is on it +# Remove a plan. Only a plan no workspace is on is deleted (needs +# `bridge auth login --admin`); with workspaces on it the delete is refused. +bridge plan delete --key pro --dry-run # would it go through, and who is on it bridge plan delete --key pro +# Stop selling it instead: workspaces on it keep it, nobody new can choose it. +bridge plan archive --key pro # --undo brings it back +# Or move the workspaces off it first (a paid subscription moves too). +bridge tenant set-plan --name "Acme Corp" --plan free # Prices — a plan can have several (one per currency + interval). Idempotent. bridge plan price set pro --amount 290 --currency usd --interval year diff --git a/bridge-cli/README.md b/bridge-cli/README.md index 81fcfa7..8d5d6c6 100644 --- a/bridge-cli/README.md +++ b/bridge-cli/README.md @@ -330,10 +330,14 @@ bridge plan list # A free tier is a zero-amount price: --amount 0 bridge plan create --key pro --name "Pro Plan" --amount 29 --interval month bridge plan update --key pro --name "Pro Plan v2" -# Remove a plan. Deleted when no workspace is on it; archived when some are -# (they keep it, nobody new can choose it). Needs `bridge auth login --admin`. -bridge plan delete --key pro --dry-run # delete or archive, and who is on it +# Remove a plan. Only a plan no workspace is on is deleted (needs +# `bridge auth login --admin`); with workspaces on it the delete is refused. +bridge plan delete --key pro --dry-run # would it go through, and who is on it bridge plan delete --key pro +# Stop selling it instead: workspaces on it keep it, nobody new can choose it. +bridge plan archive --key pro # --undo brings it back +# Or move the workspaces off it first (a paid subscription moves too). +bridge tenant set-plan --name "Acme Corp" --plan free # Prices — a plan can have several (one per currency + interval). Idempotent. bridge plan price set pro --amount 290 --currency usd --interval year diff --git a/bridge-cli/prompts/decisions/payments.md b/bridge-cli/prompts/decisions/payments.md index 04517b4..b80b69a 100644 --- a/bridge-cli/prompts/decisions/payments.md +++ b/bridge-cli/prompts/decisions/payments.md @@ -70,7 +70,9 @@ The flag and its rule are set up in the **feature-control** guide. | Or step by step: create a plan with its first price | `create_plan` | `bridge plan create --key pro --name Pro --amount 29 --interval month --currency usd --no-trial` | | Add another price | `set_plan_price` | `bridge plan price set pro --amount 290 --interval year` | | Add a limit | `set_plan_quota` | `bridge plan quota set pro --metric tickets --limit 100 --policy hard --kind gauge` | -| Remove a plan (archived instead when workspaces are on it) | `delete_plan` | `bridge plan delete --key pro` | +| Remove a plan no workspace is on | `delete_plan` | `bridge plan delete --key pro` | +| Stop selling a plan workspaces are on (they keep it) | `archive_plan` | `bridge plan archive --key pro` | +| Move a workspace to another plan | `set_tenant_plan` | `bridge tenant set-plan --name Acme --plan free` | | Add an on/off feature the plan sells | `add_plan_feature` | `bridge plan feature add pro analytics --name "Analytics"` | | Turn off the forced plan choice | `update_app` | `bridge app update --payments-auto-redirect false` | | Code for the framework | `get_integration_guide` (topic `billing`) | `bridge guide billing --framework svelte`, `bridge guide billing --framework nestjs` | diff --git a/bridge-cli/src/commands/plan.command.ts b/bridge-cli/src/commands/plan.command.ts index 4b8f898..0ad78b4 100644 --- a/bridge-cli/src/commands/plan.command.ts +++ b/bridge-cli/src/commands/plan.command.ts @@ -532,13 +532,13 @@ export function registerPlanCommands(program: Command): void { } catch (err) { outputError(err); } }); - // TBP-806 — the server decides: a plan no workspace is on is deleted, one - // that workspaces are on is archived (they keep it, nobody new can pick it). - // The answer's `action` says which happened. + // TBP-806 — only a plan no workspace is on is deleted. One that workspaces + // are on is refused (409 PLAN_IN_USE) with what to do instead: archive it, + // or move (`tenant set-plan`) or delete those workspaces first. plan.command('delete') - .description('Remove a plan: deleted when no workspace is on it, archived when some are (they keep it; nobody new can choose it)') + .description('Delete a plan no workspace is on (refused, with the choices, when workspaces are on it)') .requiredOption('--key ', 'Plan key') - .option('--dry-run', 'Show whether the plan would be deleted or archived, and which workspaces are on it, and change nothing') + .option('--dry-run', 'Show whether the delete would go through, and which workspaces are on the plan, and change nothing') .action(async (opts) => { try { const path = `/v1/account/payments/plan/${encodeURIComponent(opts.key)}`; @@ -550,6 +550,19 @@ export function registerPlanCommands(program: Command): void { } catch (err) { outputError(err); } }); + plan.command('archive') + .description('Archive a plan: workspaces on it keep it, nobody new can choose it (--undo brings it back)') + .requiredOption('--key ', 'Plan key') + .option('--undo', 'Bring an archived plan back') + .action(async (opts) => { + try { + outputSuccess(await getManagementHttp().post( + `/v1/account/payments/plan/${encodeURIComponent(opts.key)}/archive`, + { archived: !opts.undo }, + )); + } catch (err) { outputError(err); } + }); + plan.command('apply') .description('Set up or reshape one plan in a single write: name, trial, prices and quotas together (creates it when the key is new)') .requiredOption( diff --git a/bridge-cli/src/commands/tenant.command.ts b/bridge-cli/src/commands/tenant.command.ts index dbe4692..3660a02 100644 --- a/bridge-cli/src/commands/tenant.command.ts +++ b/bridge-cli/src/commands/tenant.command.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import { getManagementClient } from '../config.js'; +import { getManagementClient, getManagementHttp } from '../config.js'; import { outputSuccess, outputError } from '../output.js'; import { resolveTenantIdByName } from '../resolve.js'; import { shapeTenant } from '../shape.js'; @@ -71,6 +71,46 @@ export function registerTenantCommands(program: Command): void { } catch (err) { outputError(err); } }); + // TBP-806 — the dashboard's plan picker on a workspace, from the terminal: + // the same route, which also moves a paid Stripe subscription. What clears a + // plan before `plan delete`. + tenant.command('set-plan') + .description('Move a tenant to another plan by --name or --id (a paid Stripe subscription moves with it)') + .option('--name ', 'Tenant name to address (alternative to --id; fails if not unique)') + .option('--id ', 'Tenant ID to address (alternative to --name)') + .requiredOption('--plan ', 'Plan key to move the tenant to') + .option('--interval ', "Which of the plan's prices: day | week | month | year (default: its first price)") + .option('--currency ', "Which of the plan's prices: its currency, e.g. usd") + .action(async (opts) => { + try { + const id = await resolveTenantIdByName({ id: opts.id, key: opts.name }); + const plans = (await getManagementClient().plans.list()) as unknown as Array<{ + key: string; + prices?: Array<{ currency: string; recurrenceInterval: string; amount: number }>; + }>; + const plan = plans.find((p) => p.key === opts.plan); + if (!plan) { + throw new Error(`Plan not found: ${opts.plan}. Existing plan keys: ${plans.map((p) => p.key).join(', ') || '(none)'}.`); + } + const currency = (opts.currency as string | undefined)?.toUpperCase(); + const prices = plan.prices ?? []; + const price = prices.find( + (p) => (!currency || p.currency.toUpperCase() === currency) && (!opts.interval || p.recurrenceInterval === opts.interval), + ); + if (!price) { + throw new Error( + `Plan ${opts.plan} has no matching price. Its prices: ` + + (prices.map((p) => `${p.currency} ${p.recurrenceInterval} ${p.amount}`).join(', ') || '(none)') + '.', + ); + } + await getManagementHttp().post(`/v1/account/tenant/${encodeURIComponent(id)}/planDetails`, { + planKey: opts.plan, + priceOffer: { currency: price.currency, recurrenceInterval: price.recurrenceInterval }, + }); + outputSuccess({ id, plan: opts.plan, price: { currency: price.currency, recurrenceInterval: price.recurrenceInterval, amount: price.amount } }); + } catch (err) { outputError(err); } + }); + tenant.command('delete') .description('Delete a tenant by --name or --id') .option('--name ', 'Tenant name to address (alternative to --id; fails if not unique)') From 08561c77d7fcb86546211666f94fc655c245949a Mon Sep 17 00:00:00 2001 From: Iman Pouya Date: Wed, 14 Oct 2026 13:38:37 +0200 Subject: [PATCH 3/4] test(plan): plan delete --dry-run, plan archive and tenant set-plan (TBP-806) - --dry-run calls only the deletion-preview route and never sends a DELETE. - The delete sends a DELETE to the encoded key path and prints the server's answer. - A 409 PLAN_IN_USE answer comes out as that code, with the server's fix as the hint and exit code 1. - plan archive sends { archived: true }; --undo sends false. - tenant set-plan picks the plan's price and posts to planDetails. An unknown plan, price or tenant, or an ambiguous tenant name, sends nothing. - The three commands are registered. Not run yet: no running container mounts bridge-cli. Refs: TBP-806 Claude-Session: 9e06c000 --- .../__tests__/tbp806-plan-commands.test.ts | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 bridge-cli/src/__tests__/tbp806-plan-commands.test.ts diff --git a/bridge-cli/src/__tests__/tbp806-plan-commands.test.ts b/bridge-cli/src/__tests__/tbp806-plan-commands.test.ts new file mode 100644 index 0000000..21dde58 --- /dev/null +++ b/bridge-cli/src/__tests__/tbp806-plan-commands.test.ts @@ -0,0 +1,261 @@ +/** + * TBP-806 — removing a plan from the command line. + * + * `bridge plan delete` only deletes a plan no workspace is on; the server + * refuses one that workspaces are on (409 PLAN_IN_USE) with the choices, and + * the command prints that refusal. `--dry-run` asks the delete's read-only + * preview route and never sends a DELETE. `bridge plan archive` archives (or, + * with --undo, brings back) a plan, and `bridge tenant set-plan` moves a + * workspace off a plan — the same route as the dashboard's plan picker. + * + * Mirrors the bridge-api MCP specs for delete_plan / archive_plan / + * set_tenant_plan (mcp/tools/__tests__/write-tools.spec.ts, tenants-crud.spec.ts). + */ + +jest.mock('../config.js', () => ({ + ConfigError: class ConfigError extends Error {}, + getManagementClient: jest.fn(), + getManagementHttp: jest.fn(), + resolveTenantId: (opts: { tenantId?: string }) => opts.tenantId ?? 'tenant-from-env', +})); + +import { Command } from 'commander'; +import { HttpError } from '@nebulr-group/bridge-auth-core'; +import { getManagementClient, getManagementHttp } from '../config'; +import { registerPlanCommands } from '../commands/plan.command'; +import { registerTenantCommands } from '../commands/tenant.command'; + +const mockClient = getManagementClient as jest.Mock; +const mockHttp = getManagementHttp as jest.Mock; + +async function runCli(...args: string[]) { + let stdout = ''; + let stderr = ''; + const out = jest.spyOn(process.stdout, 'write').mockImplementation((c: any) => { stdout += String(c); return true; }); + const err = jest.spyOn(process.stderr, 'write').mockImplementation((c: any) => { stderr += String(c); return true; }); + const prevExit = process.exitCode; + process.exitCode = undefined; + const program = new Command(); + program.exitOverride(); + registerPlanCommands(program); + registerTenantCommands(program); + try { + await program.parseAsync(['node', 'bridge', ...args]); + } finally { + out.mockRestore(); + err.mockRestore(); + } + const exitCode = process.exitCode; + process.exitCode = prevExit; + return { + exitCode, + data: stdout ? JSON.parse(stdout).data : undefined, + error: stderr ? JSON.parse(stderr).error : undefined, + }; +} + +function httpMock() { + const http = { + get: jest.fn(), + post: jest.fn(), + delete: jest.fn(), + }; + mockHttp.mockReturnValue(http); + return http; +} + +beforeEach(() => jest.resetAllMocks()); + +describe('bridge plan delete (TBP-806)', () => { + const REFUSE_PREVIEW = { + dryRun: true, + target: { id: 'plan_1', key: 'pro', name: 'Pro', archived: false }, + affects: { action: 'refuse', workspaceCount: 2, workspaces: [{ id: 't1', name: 'Acme' }, { id: 't2', name: null }] }, + sideEffects: ['The delete will be refused and nothing removed: 2 workspaces are on this plan. Archive the plan instead ...'], + }; + + it('--dry-run asks the read-only preview route and never sends a DELETE', async () => { + const http = httpMock(); + http.get.mockResolvedValue(REFUSE_PREVIEW); + const res = await runCli('plan', 'delete', '--key', 'pro', '--dry-run'); + expect(http.get).toHaveBeenCalledTimes(1); + expect(http.get).toHaveBeenCalledWith('/v1/account/payments/plan/pro/deletion-preview', undefined); + expect(http.delete).not.toHaveBeenCalled(); + expect(http.post).not.toHaveBeenCalled(); + expect(res.exitCode).toBeUndefined(); + expect(res.data).toEqual(expect.objectContaining(REFUSE_PREVIEW)); + expect(res.data.next).toContain('Nothing was changed'); + }); + + it('--dry-run against a server without the preview route (404) fails and deletes nothing', async () => { + const http = httpMock(); + http.get.mockRejectedValue(new HttpError('Cannot GET', 404, { message: 'Cannot GET /v1/x/deletion-preview', error: 'Not Found', statusCode: 404 })); + const res = await runCli('plan', 'delete', '--key', 'pro', '--dry-run'); + expect(http.delete).not.toHaveBeenCalled(); + expect(res.exitCode).toBe(1); + expect(res.error.code).toBe('PREVIEW_NOT_SUPPORTED'); + }); + + it('without --dry-run: DELETEs the key-addressed route and prints the server answer', async () => { + const http = httpMock(); + http.delete.mockResolvedValue({ action: 'deleted', key: 'pro' }); + const res = await runCli('plan', 'delete', '--key', 'pro'); + expect(http.delete).toHaveBeenCalledTimes(1); + expect(http.delete).toHaveBeenCalledWith('/v1/account/payments/plan/pro'); + expect(http.get).not.toHaveBeenCalled(); + expect(http.post).not.toHaveBeenCalled(); // never archives on its own + expect(res.data).toEqual({ action: 'deleted', key: 'pro' }); + }); + + it('URL-encodes the key on both routes', async () => { + const http = httpMock(); + http.get.mockResolvedValue(REFUSE_PREVIEW); + http.delete.mockResolvedValue({ action: 'deleted', key: 'pro plan/x' }); + await runCli('plan', 'delete', '--key', 'pro plan/x', '--dry-run'); + expect(http.get).toHaveBeenCalledWith('/v1/account/payments/plan/pro%20plan%2Fx/deletion-preview', undefined); + await runCli('plan', 'delete', '--key', 'pro plan/x'); + expect(http.delete).toHaveBeenCalledWith('/v1/account/payments/plan/pro%20plan%2Fx'); + }); + + it('a plan workspaces are on: prints the 409 PLAN_IN_USE refusal with the server fix, exit 1', async () => { + const http = httpMock(); + const fix = 'Archive the plan instead ... (`bridge plan archive --key pro`, or archive_plan).'; + http.delete.mockRejectedValue(new HttpError("Nothing was deleted: 2 workspaces are on plan 'pro'.", 409, { + statusCode: 409, nblocksCode: 'PLAN_IN_USE', message: "Nothing was deleted: 2 workspaces are on plan 'pro'.", fix, workspaceCount: 2, + })); + const res = await runCli('plan', 'delete', '--key', 'pro'); + expect(res.exitCode).toBe(1); + expect(res.error.code).toBe('PLAN_IN_USE'); + expect(res.error.message).toContain('Nothing was deleted'); + expect(res.error.hint).toBe(fix); + expect(res.error.details).toEqual(expect.objectContaining({ workspaceCount: 2 })); + expect(http.post).not.toHaveBeenCalled(); + }); + + it('requires --key', async () => { + httpMock(); + await expect(runCli('plan', 'delete')).rejects.toThrow(/--key/); + }); +}); + +describe('bridge plan archive (TBP-806)', () => { + it('archives: POSTs { archived: true } to the archive route and prints the answer', async () => { + const http = httpMock(); + http.post.mockResolvedValue({ action: 'archived', key: 'pro', workspaces: 2 }); + const res = await runCli('plan', 'archive', '--key', 'pro'); + expect(http.post).toHaveBeenCalledWith('/v1/account/payments/plan/pro/archive', { archived: true }); + expect(http.delete).not.toHaveBeenCalled(); + expect(res.data).toEqual({ action: 'archived', key: 'pro', workspaces: 2 }); + }); + + it('--undo brings an archived plan back', async () => { + const http = httpMock(); + http.post.mockResolvedValue({ action: 'unarchived', key: 'pro', workspaces: 2 }); + const res = await runCli('plan', 'archive', '--key', 'pro', '--undo'); + expect(http.post).toHaveBeenCalledWith('/v1/account/payments/plan/pro/archive', { archived: false }); + expect(res.data.action).toBe('unarchived'); + }); + + it('URL-encodes the key, and reports a server error', async () => { + const http = httpMock(); + http.post.mockRejectedValue(new HttpError('Plan not found', 404, { message: 'Plan not found', statusCode: 404 })); + const res = await runCli('plan', 'archive', '--key', 'a b'); + expect(http.post).toHaveBeenCalledWith('/v1/account/payments/plan/a%20b/archive', { archived: true }); + expect(res.exitCode).toBe(1); + expect(res.error.code).toBe('HTTP_404'); + }); +}); + +describe('bridge tenant set-plan (TBP-806)', () => { + const PLANS = [ + { key: 'free', prices: [{ currency: 'USD', recurrenceInterval: 'month', amount: 0 }] }, + { + key: 'pro', + prices: [ + { currency: 'USD', recurrenceInterval: 'month', amount: 10 }, + { currency: 'EUR', recurrenceInterval: 'year', amount: 100 }, + ], + }, + { key: 'empty', prices: [] }, + ]; + + function setup(tenants = [{ id: 't1', name: 'Acme' }]) { + mockClient.mockReturnValue({ + tenants: { list: jest.fn().mockResolvedValue(tenants) }, + plans: { list: jest.fn().mockResolvedValue(PLANS) }, + }); + const http = httpMock(); + http.post.mockResolvedValue({}); + return http; + } + + it('by --name, no price options: POSTs the plan with its FIRST price to planDetails', async () => { + const http = setup(); + const res = await runCli('tenant', 'set-plan', '--name', 'Acme', '--plan', 'pro'); + expect(http.post).toHaveBeenCalledTimes(1); + expect(http.post).toHaveBeenCalledWith('/v1/account/tenant/t1/planDetails', { + planKey: 'pro', priceOffer: { currency: 'USD', recurrenceInterval: 'month' }, + }); + expect(res.data).toEqual({ id: 't1', plan: 'pro', price: { currency: 'USD', recurrenceInterval: 'month', amount: 10 } }); + }); + + it('by --id, picking the price by --interval and lower-case --currency', async () => { + const http = setup(); + await runCli('tenant', 'set-plan', '--id', 't1', '--plan', 'pro', '--interval', 'year', '--currency', 'eur'); + expect(http.post).toHaveBeenCalledWith('/v1/account/tenant/t1/planDetails', { + planKey: 'pro', priceOffer: { currency: 'EUR', recurrenceInterval: 'year' }, + }); + }); + + it('an unknown plan fails with the existing keys and changes nothing', async () => { + const http = setup(); + const res = await runCli('tenant', 'set-plan', '--name', 'Acme', '--plan', 'ghost'); + expect(res.exitCode).toBe(1); + expect(res.error.message).toContain('Plan not found: ghost'); + expect(res.error.message).toContain('free, pro, empty'); + expect(http.post).not.toHaveBeenCalled(); + }); + + it('no matching price (or a plan without prices) fails and changes nothing', async () => { + const http = setup(); + const res = await runCli('tenant', 'set-plan', '--name', 'Acme', '--plan', 'pro', '--interval', 'year', '--currency', 'USD'); + expect(res.exitCode).toBe(1); + expect(res.error.message).toContain('USD month 10'); + const empty = await runCli('tenant', 'set-plan', '--name', 'Acme', '--plan', 'empty'); + expect(empty.exitCode).toBe(1); + expect(http.post).not.toHaveBeenCalled(); + }); + + it('an unknown or ambiguous tenant name fails and changes nothing', async () => { + let http = setup(); + const missing = await runCli('tenant', 'set-plan', '--name', 'Nope', '--plan', 'pro'); + expect(missing.exitCode).toBe(1); + expect(missing.error.code).toBe('TENANT_NOT_FOUND'); + expect(http.post).not.toHaveBeenCalled(); + + http = setup([{ id: 't1', name: 'Acme' }, { id: 't2', name: 'Acme' }]); + const ambiguous = await runCli('tenant', 'set-plan', '--name', 'Acme', '--plan', 'pro'); + expect(ambiguous.exitCode).toBe(1); + expect(ambiguous.error.code).toBe('TENANT_AMBIGUOUS'); + expect(http.post).not.toHaveBeenCalled(); + }); + + it('a server refusal (e.g. archived plan, 400) is reported', async () => { + const http = setup(); + http.post.mockRejectedValue(new HttpError("Plan 'pro' is no longer offered", 400, { message: "Plan 'pro' is no longer offered", statusCode: 400 })); + const res = await runCli('tenant', 'set-plan', '--name', 'Acme', '--plan', 'pro'); + expect(res.exitCode).toBe(1); + expect(res.error.code).toBe('HTTP_400'); + }); +}); + +describe('command registration (TBP-806)', () => { + it('registers plan delete, plan archive and tenant set-plan', () => { + const program = new Command(); + registerPlanCommands(program); + registerTenantCommands(program); + const sub = (parent: string) => program.commands.find((c) => c.name() === parent)!.commands.map((c) => c.name()); + expect(sub('plan')).toEqual(expect.arrayContaining(['delete', 'archive'])); + expect(sub('tenant')).toEqual(expect.arrayContaining(['set-plan'])); + }); +}); From f85cdf81bccaccbb6ee96f99ba3d7b1d835e29b9 Mon Sep 17 00:00:00 2001 From: Iman Pouya Date: Wed, 14 Oct 2026 13:43:22 +0200 Subject: [PATCH 4/4] chore(guides): regenerate the MCP tool list with delete_plan, archive_plan and set_tenant_plan (TBP-806) Refs: TBP-806 Claude-Session: 9e06c000 --- bridge-cli/prompts/mcp-tools.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bridge-cli/prompts/mcp-tools.json b/bridge-cli/prompts/mcp-tools.json index d85959b..d1b605a 100644 --- a/bridge-cli/prompts/mcp-tools.json +++ b/bridge-cli/prompts/mcp-tools.json @@ -3,13 +3,14 @@ "The names of the Bridge MCP server's tools, and nothing else. The bundled guides are tested against this list: a guide that names a tool not in it fails src/__tests__/guide-decisions.test.ts.", "Source: the `bridgeTools` registry in bridge-api, microservices/account/nebulr-api/mcp/tools/register.ts.", "Generated, never edited by hand: in bridge-api run `bun scripts/generate-mcp-tools-json.ts > /bridge-cli/prompts/mcp-tools.json` (in the bridge-api container).", - "The count must equal the number of entries in `bridgeTools` (76 at the source below). bridge-api's guide-currency.spec.ts fails when this file and the registry disagree." + "The count must equal the number of entries in `bridgeTools` (79 at the source below). bridge-api's guide-currency.spec.ts fails when this file and the registry disagree." ], - "source": "bridge-api feature/mcp-journey 75c0a1f", + "source": "bridge-api feature/tbp-806-plan-delete 1c622b17", "tools": [ "add_plan_feature", "add_redirect_uri", "apply_plan", + "archive_plan", "clear_flag_schedule", "connect_stripe", "create_api_token", @@ -22,6 +23,7 @@ "create_test_user", "debug_auth_issue", "delete_feature_flag", + "delete_plan", "delete_privilege", "delete_role", "delete_tenant", @@ -67,6 +69,7 @@ "set_plan_features", "set_plan_price", "set_plan_quota", + "set_tenant_plan", "setup_communication", "setup_payments", "setup_sso",