Skip to content
Closed
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +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. 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
Expand Down
8 changes: 8 additions & 0 deletions bridge-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +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. 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
Expand Down
3 changes: 3 additions & 0 deletions bridge-cli/prompts/decisions/payments.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +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 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` |
Expand Down
7 changes: 5 additions & 2 deletions bridge-cli/prompts/mcp-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -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>/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",
Expand All @@ -22,6 +23,7 @@
"create_test_user",
"debug_auth_issue",
"delete_feature_flag",
"delete_plan",
"delete_privilege",
"delete_role",
"delete_tenant",
Expand Down Expand Up @@ -67,6 +69,7 @@
"set_plan_features",
"set_plan_price",
"set_plan_quota",
"set_tenant_plan",
"setup_communication",
"setup_payments",
"setup_sso",
Expand Down
261 changes: 261 additions & 0 deletions bridge-cli/src/__tests__/tbp806-plan-commands.test.ts
Original file line number Diff line number Diff line change
@@ -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']));
});
});
34 changes: 33 additions & 1 deletion bridge-cli/src/commands/plan.command.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -531,6 +532,37 @@ export function registerPlanCommands(program: Command): void {
} catch (err) { outputError(err); }
});

// 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('Delete a plan no workspace is on (refused, with the choices, when workspaces are on it)')
.requiredOption('--key <key>', 'Plan key')
.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)}`;
if (opts.dryRun) {
outputSuccess(await dryRunPreview(path));
return;
}
outputSuccess(await getManagementHttp().delete(path));
} 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 <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(
Expand Down
Loading
Loading