diff --git a/README.md b/README.md index d83f6f0b..49a22a44 100644 --- a/README.md +++ b/README.md @@ -2453,3 +2453,67 @@ project. Human output labels unmeasured duration as `unknown`. `calls view` reads one stored call. Its JSON output includes bounded event history, participant and connection lifetimes, and allowlisted diagnostics. It does not retrieve media or webhook deliveries. + +### Spending limits and funding priority + +With an organization key carrying `billing:read`, inspect current controls: + +```bash +polymorfa billing limits list --project --json +polymorfa billing priority list --json +``` + +Changes require `billing:manage` and Pay-As-You-Go. Supply the observed revision: + +```bash +polymorfa billing limits set --scope number --credits 1000 --expected-revision 0 +polymorfa billing limits set --scope project --unlimited --expected-revision 1 +polymorfa billing priority set --scope number --priority 10 --expected-revision 0 +polymorfa billing priority reorder --scope number --project --ids , --expected-revision 1 +``` + +A reorder includes every active project, customer or real number in the selected +project, highest first. A stale or incomplete order fails without partial +changes. Monthly limits reset at 00:00 UTC on the first day; zero blocks new +charges and null removes the limit. Existing paid service keeps its window. +Project and client credentials cannot use these financial controls. Commands +use the pinned SDK's authenticated raw transport; no unpublished SDK method is +required. CLI publication and API deployment remain separate release gates. +This source follows the financial-control contract at API commit +`ba09326dd3c6688f05320c1f7685aeb0290a0ed2`. + +Read or update one resource without loading the team directory: + +```bash +polymorfa billing controls get --scope customer --json +polymorfa billing controls set --scope customer --credits 1500 --priority 25 --expected-budget-revision 0 --expected-priority-revision 0 +polymorfa quicklink create --project --limit-credits 250 --priority 30 --json +``` + +The combined save is atomic and requires both observed revisions. Project +priority wins first. Numbers inherit customer priority; a higher number +priority overrides it within the project. A customer's assigned numbers share +its monthly cap. Creation controls apply to a real new +number before pairing admission; testing and supplementary connections reject +these fields. Omit them to keep the default unlimited cap and priority zero. + +Read one project's funding controls and save its combined customer and number +order: + +```bash +polymorfa billing priority list --project +polymorfa billing priority reorder --scope resource --project --ids number:,customer: --expected-revision 5 +``` + +Include every active customer and real number, highest priority first. For +thousands of resources, put the complete order in a JSON file: + +```json +["number:", "customer:"] +``` + +```bash +polymorfa billing priority reorder --scope resource --project --ids-file order.json --expected-revision 5 +``` + +Use either `--ids` or `--ids-file`. Funding order never bypasses a spending cap. diff --git a/src/commands/billing/controls/get.ts b/src/commands/billing/controls/get.ts new file mode 100644 index 00000000..8bd6d067 --- /dev/null +++ b/src/commands/billing/controls/get.ts @@ -0,0 +1,24 @@ +import {Args, Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {getResourceControls} from '../../../services/billing-controls.js' +export default class ControlsGet extends BaseCommand { + static override summary = 'Read one resource limit and priority.' + static override args = { + id: Args.string({required: true, description: 'Resource UUID.'}), + } + static override flags = { + scope: Flags.string({ + required: true, + options: ['project', 'customer', 'number'], + }), + } + async run() { + const {args, flags} = await this.parse(ControlsGet) + const result = await getResourceControls( + await this.platformClient(flags), + flags.scope as 'project' | 'customer' | 'number', + args.id, + ) + this.writeValue(result, JSON.stringify(result.data), flags) + } +} diff --git a/src/commands/billing/controls/set.ts b/src/commands/billing/controls/set.ts new file mode 100644 index 00000000..e1303ae2 --- /dev/null +++ b/src/commands/billing/controls/set.ts @@ -0,0 +1,38 @@ +import {Args, Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import { + setResourceControls, + limitValue, +} from '../../../services/billing-controls.js' +export default class ControlsSet extends BaseCommand { + static override summary = 'Atomically save one resource limit and priority.' + static override args = { + id: Args.string({required: true, description: 'Resource UUID.'}), + } + static override flags = { + scope: Flags.string({ + required: true, + options: ['project', 'customer', 'number'], + }), + credits: Flags.string(), + unlimited: Flags.boolean({exclusive: ['credits']}), + priority: Flags.integer({required: true, min: 0, max: 1000000}), + 'expected-budget-revision': Flags.integer({required: true, min: 0}), + 'expected-priority-revision': Flags.integer({required: true, min: 0}), + } + async run() { + const {args, flags} = await this.parse(ControlsSet) + const result = await setResourceControls( + await this.platformClient(flags), + flags.scope as 'project' | 'customer' | 'number', + args.id, + { + limitCredits: limitValue(flags.credits, flags.unlimited), + priority: flags.priority, + expectedBudgetRevision: flags['expected-budget-revision'], + expectedPriorityRevision: flags['expected-priority-revision'], + }, + ) + this.writeValue(result, 'Limits and priority saved.', flags) + } +} diff --git a/src/commands/billing/limits/list.ts b/src/commands/billing/limits/list.ts new file mode 100644 index 00000000..0e2a2f2b --- /dev/null +++ b/src/commands/billing/limits/list.ts @@ -0,0 +1,15 @@ +import {BaseCommand} from '../../../base-command.js' +import {getLimits, renderLimits} from '../../../services/billing-controls.js' +export default class LimitsList extends BaseCommand { + static override summary = 'Read monthly project and number spending limits.' + static override description = + 'Requires an organization key with billing:read. Use --project to include its number limits. Months reset on the first day at 00:00 UTC.' + async run(): Promise { + const {flags} = await this.parse(LimitsList) + const result = await getLimits( + await this.platformClient(flags), + flags.project, + ) + this.writeValue(result, renderLimits(result.data), flags) + } +} diff --git a/src/commands/billing/limits/set.ts b/src/commands/billing/limits/set.ts new file mode 100644 index 00000000..9e321787 --- /dev/null +++ b/src/commands/billing/limits/set.ts @@ -0,0 +1,41 @@ +import {Args, Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {setLimit, limitValue} from '../../../services/billing-controls.js' +export default class LimitsSet extends BaseCommand { + static override summary = 'Set or remove a monthly spending limit.' + static override description = + 'Requires Pay-As-You-Go and an organization key with billing:manage. Zero blocks new charges; already-paid service continues. A stale observed revision fails without overwriting newer settings.' + static override args = { + id: Args.string({required: true, description: 'Project or number UUID.'}), + } + static override flags = { + scope: Flags.string({ + options: ['project', 'customer', 'number'], + required: true, + }), + credits: Flags.string({ + description: 'Monthly Polymorfa credits, up to six decimal places.', + }), + unlimited: Flags.boolean({ + description: 'Remove the limit.', + exclusive: ['credits'], + }), + 'expected-revision': Flags.integer({ + required: true, + min: 0, + description: 'Revision from billing limits list.', + }), + } + async run(): Promise { + const {args, flags} = await this.parse(LimitsSet) + const value = limitValue(flags.credits, flags.unlimited) + const result = await setLimit( + await this.platformClient(flags), + flags.scope as 'project' | 'customer' | 'number', + args.id, + value, + flags['expected-revision'], + ) + this.writeValue(result, 'Spending limit saved.', flags) + } +} diff --git a/src/commands/billing/priority/list.ts b/src/commands/billing/priority/list.ts new file mode 100644 index 00000000..de5a123e --- /dev/null +++ b/src/commands/billing/priority/list.ts @@ -0,0 +1,19 @@ +import {BaseCommand} from '../../../base-command.js' +import { + getPriorities, + renderPriorities, +} from '../../../services/billing-controls.js' +export default class PriorityList extends BaseCommand { + static override summary = + 'Read project, customer and number funding priorities.' + static override description = + 'Requires an organization key with billing:read. Project priority takes precedence. Numbers inherit customer priority; a higher number priority overrides it. Use --project for its funding controls. Includes the revision used for changes.' + async run(): Promise { + const {flags} = await this.parse(PriorityList) + const result = await getPriorities( + await this.platformClient(flags), + flags.project, + ) + this.writeValue(result, renderPriorities(result.data), flags) + } +} diff --git a/src/commands/billing/priority/reorder.ts b/src/commands/billing/priority/reorder.ts new file mode 100644 index 00000000..7d9aff3f --- /dev/null +++ b/src/commands/billing/priority/reorder.ts @@ -0,0 +1,51 @@ +import {readFile} from 'node:fs/promises' +import {PolymorfaValidationError} from '@polymorfa/sdk' +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {reorderPriorities} from '../../../services/billing-controls.js' +export default class PriorityReorder extends BaseCommand { + static override summary = 'Save a complete funding order atomically.' + static override description = + 'Requires Pay-As-You-Go and an organization key with billing:manage. Pass every active project, customer or real number for its scope. Resource scope mixes all customers and numbers in --project, highest first. Incomplete or stale orders fail without partial changes.' + static override flags = { + scope: Flags.string({ + options: ['project', 'customer', 'number', 'resource'], + required: true, + }), + ids: Flags.string({ + exclusive: ['ids-file'], + description: + 'UUIDs, highest first. For resource scope use customer:UUID or number:UUID.', + }), + 'ids-file': Flags.string({ + exclusive: ['ids'], + description: 'JSON array of ordered IDs; supports large lists.', + }), + 'expected-revision': Flags.integer({required: true, min: 0}), + } + async run(): Promise { + const {flags} = await this.parse(PriorityReorder) + let ids: unknown = flags.ids?.split(',').map((id) => id.trim()) + if (flags['ids-file']) { + try { + ids = JSON.parse(await readFile(flags['ids-file'], 'utf8')) + } catch { + throw new PolymorfaValidationError( + 'Could not read an ordered JSON array from --ids-file.', + ) + } + } + if (!Array.isArray(ids) || !ids.every((id) => typeof id === 'string')) + throw new PolymorfaValidationError( + 'Use --ids or --ids-file with a complete ordered list.', + ) + const result = await reorderPriorities( + await this.platformClient(flags), + flags.scope as 'project' | 'customer' | 'number' | 'resource', + ids, + flags['expected-revision'], + flags.project, + ) + this.writeValue(result, 'Priority order saved.', flags) + } +} diff --git a/src/commands/billing/priority/set.ts b/src/commands/billing/priority/set.ts new file mode 100644 index 00000000..4058883e --- /dev/null +++ b/src/commands/billing/priority/set.ts @@ -0,0 +1,30 @@ +import {Args, Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {setPriority} from '../../../services/billing-controls.js' +export default class PrioritySet extends BaseCommand { + static override summary = 'Set a project or number funding priority.' + static override description = + 'Requires Pay-As-You-Go and an organization key with billing:manage. Changes future renewal protection and recovery; paid windows keep their expiry.' + static override args = { + id: Args.string({required: true, description: 'Project or number UUID.'}), + } + static override flags = { + scope: Flags.string({ + options: ['project', 'customer', 'number'], + required: true, + }), + priority: Flags.integer({required: true, min: 0, max: 1000000}), + 'expected-revision': Flags.integer({required: true, min: 0}), + } + async run(): Promise { + const {args, flags} = await this.parse(PrioritySet) + const result = await setPriority( + await this.platformClient(flags), + flags.scope as 'project' | 'customer' | 'number', + args.id, + flags.priority, + flags['expected-revision'], + ) + this.writeValue(result, 'Priority saved.', flags) + } +} diff --git a/src/commands/quicklink/create.ts b/src/commands/quicklink/create.ts index 345efe7e..a06f7768 100644 --- a/src/commands/quicklink/create.ts +++ b/src/commands/quicklink/create.ts @@ -2,6 +2,7 @@ import {PolymorfaConfigurationError} from '@polymorfa/sdk' import {Flags} from '@oclif/core' import {readJsonInput} from '../../api/request.js' import {BaseCommand} from '../../base-command.js' +import {limitValue} from '../../services/billing-controls.js' import {createQuickLink} from '../../services/quicklinks.js' export default class QuickLinkCreate extends BaseCommand { @@ -23,6 +24,15 @@ export default class QuickLinkCreate extends BaseCommand { options: ['linked_devices', 'official_api'] as const, description: 'Missing connection to add.', }), + 'limit-credits': Flags.string({ + description: + 'Initial monthly limit in credits; team billing:manage credential required.', + }), + priority: Flags.integer({ + min: 0, + max: 1000000, + description: 'Initial number funding priority.', + }), input: Flags.string({ description: 'JSON request file or - for stdin. Use configuration for connection, phone, history and simulation.', @@ -51,6 +61,20 @@ export default class QuickLinkCreate extends BaseCommand { ) body[key] = value } + if (flags['limit-credits'] !== undefined || flags.priority !== undefined) { + if (body.billingControls !== undefined) + throw new PolymorfaConfigurationError( + 'Use either input billingControls or financial flags.', + 'input', + ) + body.billingControls = { + limitCredits: + flags['limit-credits'] === undefined + ? null + : limitValue(flags['limit-credits'], false), + priority: flags.priority ?? 0, + } + } if ( body.purpose === 'add_connection' && (!body.session || !body.addConnection || body.connectionGoal === 'single') diff --git a/src/services/billing-controls.ts b/src/services/billing-controls.ts new file mode 100644 index 00000000..8062a6ec --- /dev/null +++ b/src/services/billing-controls.ts @@ -0,0 +1,280 @@ +import { + type ClientInstance, + PolymorfaValidationError, + PolymorfaServerError, +} from '@polymorfa/sdk' +import {z} from 'zod' + +const uuid = z.uuid() +const revision = z.number().int().min(0).max(2147483646) +const priority = z.number().int().min(0).max(1000000) +export const billingScope = z.enum(['project', 'customer', 'number']) +const row = z.object({id: uuid, name: z.string(), priority}) +const priorities = z.object({ + revision, + projects: z.array(row), + customers: z.array(row.extend({projectId: uuid})), + numbers: z.array(row.extend({projectId: uuid})), +}) +const credits = z.number().finite().nonnegative() +const limits = z.object({ + checkedAt: z.iso.datetime(), + periodStart: z.iso.datetime(), + periodEnd: z.iso.datetime(), + todayCredits: credits, + monthCredits: credits, + daily: z.array(z.object({date: z.iso.date(), credits})), + budgets: z.array( + z.object({ + scope: billingScope, + resourceId: uuid, + projectId: uuid, + name: z.string(), + limitCredits: credits.nullable(), + spentCredits: credits, + reservedCredits: credits, + revision, + }), + ), +}) +export type BillingPriorities = z.infer +export type BillingLimits = z.infer +export function validate(schema: z.ZodType, value: unknown): T { + const parsed = schema.safeParse(value) + if (!parsed.success) + throw new PolymorfaValidationError( + 'Invalid billing control. Use UUIDs and an observed revision.', + {code: 'invalid_billing_control'}, + ) + return parsed.data +} +export function observedRevision(value: number): number { + return validate(revision, value) +} +export function resourceId(value: string): string { + return validate(uuid, value).toLowerCase() +} +export function priorityValue(value: number): number { + return validate(priority, value) +} +export function limitValue( + value: string | undefined, + unlimited: boolean, +): number | null { + if (unlimited && value === undefined) return null + if ( + unlimited || + value === undefined || + !/^\d+(\.\d{1,6})?$/.test(value) || + Number(value) > 1000000 + ) + throw new PolymorfaValidationError( + 'Use --credits 0–1000000 (up to six decimal places) or --unlimited.', + {code: 'invalid_billing_control'}, + ) + return Number(value) +} +async function request( + client: ClientInstance<'organization'>, + method: 'GET' | 'PUT', + path: string, + schema: z.ZodType, + body?: unknown, + projectId?: string, +) { + const response = await client.raw.request<{data: unknown}>({ + method, + path, + ...(body === undefined ? {} : {body}), + ...(projectId === undefined + ? {} + : {query: {projectId: resourceId(projectId)}}), + }) + const parsed = schema.safeParse(response.data.data) + if (!parsed.success) + throw new PolymorfaServerError('Billing response is invalid.', { + code: 'invalid_response', + status: response.metadata.status, + }) + return {data: parsed.data, metadata: response.metadata} +} +export function getLimits( + client: ClientInstance<'organization'>, + projectId?: string, +) { + return request( + client, + 'GET', + '/platform/billing/limits', + limits, + undefined, + projectId, + ) +} +export function setLimit( + client: ClientInstance<'organization'>, + scope: 'project' | 'customer' | 'number', + id: string, + credits: number | null, + expectedRevision: number, +) { + return request( + client, + 'PUT', + `/platform/billing/limits/${validate(billingScope, scope)}/${resourceId(id)}`, + z.object({saved: z.boolean()}), + { + limitCredits: credits, + expectedRevision: observedRevision(expectedRevision), + }, + ) +} +export function getPriorities( + client: ClientInstance<'organization'>, + projectId?: string, +) { + return request( + client, + 'GET', + '/platform/billing/priorities', + priorities, + undefined, + projectId, + ) +} +export function setPriority( + client: ClientInstance<'organization'>, + scope: 'project' | 'customer' | 'number', + id: string, + value: number, + expectedRevision: number, +) { + return request( + client, + 'PUT', + `/platform/billing/priorities/${validate(billingScope, scope)}/${resourceId(id)}`, + priorities, + { + priority: priorityValue(value), + expectedRevision: observedRevision(expectedRevision), + }, + ) +} +export function reorderPriorities( + client: ClientInstance<'organization'>, + scope: 'project' | 'customer' | 'number' | 'resource', + ids: string[], + expectedRevision: number, + projectId?: string, +) { + if (scope === 'resource') { + if (!projectId) + throw new PolymorfaValidationError( + '--project is required for customer and number funding controls.', + ) + const resources = ids.map((value) => { + const [kind, id, ...extra] = value.split(':') + if ((kind !== 'customer' && kind !== 'number') || !id || extra.length) + throw new PolymorfaValidationError( + 'Use customer:UUID or number:UUID, highest first.', + ) + return {scope: kind, resourceId: resourceId(id)} + }) + if ( + resources.length > 1000000 || + new Set(resources.map((r) => `${r.scope}:${r.resourceId}`)).size !== + resources.length + ) + throw new PolymorfaValidationError('Use unique resources.') + return request(client, 'PUT', '/platform/billing/priorities', priorities, { + scope, + projectId: resourceId(projectId), + resources, + expectedRevision: observedRevision(expectedRevision), + }) + } + validate(billingScope, scope) + const resourceIds = ids.map(resourceId) + if ( + resourceIds.length > 1000000 || + new Set(resourceIds).size !== resourceIds.length || + (scope !== 'project' && !projectId) || + (scope === 'project' && projectId) + ) + throw new PolymorfaValidationError( + 'Use unique IDs; --project is required for customer or number order.', + {code: 'invalid_billing_control'}, + ) + return request(client, 'PUT', '/platform/billing/priorities', priorities, { + scope, + resourceIds, + expectedRevision: observedRevision(expectedRevision), + ...(projectId ? {projectId: resourceId(projectId)} : {}), + }) +} +export function renderLimits(data: BillingLimits): string { + return data.budgets + .map( + (b) => + `${b.scope}\t${b.resourceId}\t${b.name}\t${b.spentCredits + b.reservedCredits}/${b.limitCredits ?? 'unlimited'} credits\trevision ${b.revision}`, + ) + .join('\n') +} +export function renderPriorities(data: BillingPriorities): string { + return [ + `Revision: ${data.revision}`, + ...data.projects.map((p) => `project\t${p.id}\t${p.name}\t${p.priority}`), + ...data.customers.map( + (c) => + `customer\t${c.id}\t${c.name}\t${c.priority}\tproject ${c.projectId}`, + ), + ...data.numbers.map( + (n) => + `number\t${n.id}\t${n.name}\t${n.priority}\tproject ${n.projectId}`, + ), + ].join('\n') +} + +const resourceControls = z.object({ + budget: limits.shape.budgets.element, + priority, + priorityRevision: revision, +}) +export function getResourceControls( + client: ClientInstance<'organization'>, + scope: 'project' | 'customer' | 'number', + id: string, +) { + return request( + client, + 'GET', + `/platform/billing/controls/${validate(billingScope, scope)}/${resourceId(id)}`, + resourceControls, + ) +} +export function setResourceControls( + client: ClientInstance<'organization'>, + scope: 'project' | 'customer' | 'number', + id: string, + input: { + limitCredits: number | null + priority: number + expectedBudgetRevision: number + expectedPriorityRevision: number + }, +) { + return request( + client, + 'PUT', + `/platform/billing/controls/${validate(billingScope, scope)}/${resourceId(id)}`, + resourceControls, + { + limitCredits: input.limitCredits, + priority: priorityValue(input.priority), + expectedBudgetRevision: observedRevision(input.expectedBudgetRevision), + expectedPriorityRevision: observedRevision( + input.expectedPriorityRevision, + ), + }, + ) +} diff --git a/src/services/quicklinks.ts b/src/services/quicklinks.ts index 7b2c73c2..224c3039 100644 --- a/src/services/quicklinks.ts +++ b/src/services/quicklinks.ts @@ -12,6 +12,7 @@ export async function createQuickLink( ) const body = input as Record const allowed = new Set([ + 'billingControls', 'projectId', 'purpose', 'connectionGoal', @@ -31,6 +32,31 @@ export async function createQuickLink( 'Input projectId conflicts with --project.', 'project', ) + if (body.billingControls !== undefined) { + const financial = body.billingControls as Record + if ( + !financial || + typeof financial !== 'object' || + Array.isArray(financial) || + Object.keys(financial).some( + (key) => !['limitCredits', 'priority'].includes(key), + ) || + (financial.limitCredits !== null && + (typeof financial.limitCredits !== 'number' || + !/^\d+(\.\d{1,6})?$/.test(String(financial.limitCredits)) || + financial.limitCredits > 1000000)) || + typeof financial.priority !== 'number' || + !Number.isInteger(financial.priority) || + financial.priority < 0 || + financial.priority > 1000000 || + body.purpose === 'add_connection' || + (body.configuration as {testing?: unknown} | undefined)?.testing + ) + throw new PolymorfaConfigurationError( + 'Billing controls require a real new number, credits and numeric priority.', + 'input', + ) + } const response = await client.quickLinks.create({ ...body, ...(projectId ? {projectId} : {}), diff --git a/test/commands/billing-controls.test.ts b/test/commands/billing-controls.test.ts new file mode 100644 index 00000000..1841264b --- /dev/null +++ b/test/commands/billing-controls.test.ts @@ -0,0 +1,371 @@ +import {mkdir, mkdtemp, rm, writeFile} from 'node:fs/promises' +import {homedir} from 'node:os' +import {join} from 'node:path' +import {runCommand} from '@oclif/test' +import {afterEach, expect, test} from 'vitest' +import { + startHttpSequenceServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' +const key = + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const project = '22222222-2222-4222-8222-222222222222', + number = '33333333-3333-4333-8333-333333333333' +const priorities = { + revision: 5, + projects: [{id: project, name: 'Support', priority: 1}], + customers: [], + numbers: [{id: number, projectId: project, name: 'Desk', priority: 2}], +} +let server: TestHttpServer +let directory: string | undefined +async function orderFile(content: string) { + const parent = join( + homedir(), + '.polymorfa-agent-work/project-billing-dashboard/cli-test-artifacts', + ) + await mkdir(parent, {recursive: true}) + directory ??= await mkdtemp(join(parent, 'priority-order-')) + const path = join(directory, 'order.json') + await writeFile(path, content) + return path +} +async function run(args: string[]) { + return withEnvironment( + { + POLYMORFA_API_KEY: key, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: undefined, + }, + () => runCommand(args), + ) +} +afterEach(async () => { + await server?.close() + if (directory) { + await rm(directory, {recursive: true, force: true}) + directory = undefined + } +}) +test('reads priorities and saves a complete number order with the observed revision', async () => { + server = await startHttpSequenceServer([ + {body: {data: priorities}}, + {body: {data: priorities}}, + ]) + expect( + (await run(['billing', 'priority', 'list', '--json'])).error, + ).toBeUndefined() + expect( + ( + await run([ + 'billing', + 'priority', + 'reorder', + '--scope', + 'number', + '--project', + project, + '--ids', + number, + '--expected-revision', + '4', + '--json', + ]) + ).error, + ).toBeUndefined() + expect(server.requests[0]?.url).toBe('/platform/billing/priorities') + expect(JSON.parse(server.requests[1]!.body)).toEqual({ + scope: 'number', + projectId: project, + resourceIds: [number], + expectedRevision: 4, + }) +}) +test('removes a cap explicitly and blocks malformed controls before sending a request', async () => { + server = await startHttpSequenceServer([{body: {data: {saved: true}}}]) + expect( + ( + await run([ + 'billing', + 'limits', + 'set', + number, + '--scope', + 'number', + '--unlimited', + '--expected-revision', + '0', + '--json', + ]) + ).error, + ).toBeUndefined() + expect(JSON.parse(server.requests[0]!.body)).toEqual({ + limitCredits: null, + expectedRevision: 0, + }) + const invalid = await run([ + 'billing', + 'limits', + 'set', + number, + '--scope', + 'number', + '--credits', + '0.0000001', + '--expected-revision', + '0', + ]) + expect(invalid.error).toBeDefined() + expect(server.requests).toHaveLength(1) +}) +test('reports a revision conflict without retrying or claiming success', async () => { + server = await startHttpSequenceServer([ + { + status: 409, + body: { + success: false, + error: {code: 'conflict', message: 'priority changed; reload'}, + }, + }, + ]) + const result = await run([ + 'billing', + 'priority', + 'set', + project, + '--scope', + 'project', + '--priority', + '9', + '--expected-revision', + '0', + ]) + expect(result.error).toBeDefined() + expect(result.stdout).not.toContain('Priority saved') + expect(server.requests).toHaveLength(1) +}) + +test('reads one customer and saves both observed revisions', async () => { + const state = { + budget: { + scope: 'customer', + resourceId: number, + projectId: project, + name: 'Acme', + limitCredits: 100, + spentCredits: 0, + reservedCredits: 0, + revision: 4, + }, + priority: 10, + priorityRevision: 5, + } + server = await startHttpSequenceServer([ + {body: {data: state}}, + {body: {data: state}}, + ]) + expect( + ( + await run([ + 'billing', + 'controls', + 'get', + number, + '--scope', + 'customer', + '--json', + ]) + ).error, + ).toBeUndefined() + expect( + ( + await run([ + 'billing', + 'controls', + 'set', + number, + '--scope', + 'customer', + '--credits', + '200', + '--priority', + '40', + '--expected-budget-revision', + '4', + '--expected-priority-revision', + '5', + '--json', + ]) + ).error, + ).toBeUndefined() + expect(server.requests.map((r) => [r.method, r.url])).toEqual([ + ['GET', `/platform/billing/controls/customer/${number}`], + ['PUT', `/platform/billing/controls/customer/${number}`], + ]) + expect(JSON.parse(server.requests[1]!.body)).toEqual({ + limitCredits: 200, + priority: 40, + expectedBudgetRevision: 4, + expectedPriorityRevision: 5, + }) +}) + +test('reads a selected project and saves mixed customer/number funding controls', async () => { + server = await startHttpSequenceServer([ + {body: {data: priorities}}, + {body: {data: priorities}}, + ]) + expect( + (await run(['billing', 'priority', 'list', '--project', project, '--json'])) + .error, + ).toBeUndefined() + expect( + ( + await run([ + 'billing', + 'priority', + 'reorder', + '--scope', + 'resource', + '--project', + project, + '--ids', + `number:${number},customer:${project}`, + '--expected-revision', + '5', + '--json', + ]) + ).error, + ).toBeUndefined() + expect(server.requests[0]?.url).toBe( + `/platform/billing/priorities?projectId=${project}`, + ) + expect(JSON.parse(server.requests[1]!.body)).toEqual({ + scope: 'resource', + projectId: project, + resources: [ + {scope: 'number', resourceId: number}, + {scope: 'customer', resourceId: project}, + ], + expectedRevision: 5, + }) + expect( + ( + await run([ + 'billing', + 'priority', + 'reorder', + '--scope', + 'resource', + '--project', + project, + '--ids', + `number:${number},number:${number}`, + '--expected-revision', + '5', + ]) + ).error, + ).toBeDefined() + expect(server.requests).toHaveLength(2) +}) + +test('renders customers as well as projects and numbers in human output', async () => { + server = await startHttpSequenceServer([ + { + body: { + data: { + ...priorities, + customers: [ + {id: project, projectId: project, name: 'Acme', priority: 3}, + ], + }, + }, + }, + ]) + const result = await run([ + 'billing', + 'priority', + 'list', + '--project', + project, + ]) + expect(result.error).toBeUndefined() + expect(result.stdout).toContain('Revision: 5') + expect(result.stdout).toContain(`customer\t${project}\tAcme\t3`) + expect(result.stdout).toContain(`number\t${number}\tDesk\t2`) + expect(server.requests[0]?.url).toBe( + `/platform/billing/priorities?projectId=${project}`, + ) +}) + +test('loads a complete 2,000-resource order from a file into one revision-guarded request', async () => { + const resources = Array.from({length: 2000}, (_, index) => ({ + scope: index % 2 === 0 ? 'customer' : 'number', + resourceId: `00000000-0000-4000-8000-${(index + 1).toString(16).padStart(12, '0')}`, + })) + const file = await orderFile( + JSON.stringify(resources.map((row) => `${row.scope}:${row.resourceId}`)), + ) + server = await startHttpSequenceServer([{body: {data: priorities}}]) + const result = await run([ + 'billing', + 'priority', + 'reorder', + '--scope', + 'resource', + '--project', + project, + '--ids-file', + file, + '--expected-revision', + '5', + '--json', + ]) + expect(result.error).toBeUndefined() + expect(server.requests).toHaveLength(1) + expect(JSON.parse(server.requests[0]!.body)).toEqual({ + scope: 'resource', + projectId: project, + resources, + expectedRevision: 5, + }) +}) + +test('rejects malformed, missing, duplicate, or conflicting file inputs before HTTP', async () => { + server = await startHttpSequenceServer([{body: {data: priorities}}]) + const args = [ + 'billing', + 'priority', + 'reorder', + '--scope', + 'resource', + '--project', + project, + '--expected-revision', + '5', + ] + for (const content of [ + '{not json', + JSON.stringify({ids: [`number:${number}`]}), + JSON.stringify([5]), + JSON.stringify([`number:${number}`, `number:${number}`]), + JSON.stringify([`project:${project}`]), + ]) { + const file = await orderFile(content) + const result = await run([...args, '--ids-file', file]) + expect(result.error, content).toBeDefined() + expect(result.stdout).not.toContain('Priority order saved.') + } + const file = await orderFile(JSON.stringify([`number:${number}`])) + expect( + (await run([...args, '--ids-file', `${file}.missing`])).error, + ).toBeDefined() + expect((await run(args)).error).toBeDefined() + expect( + (await run([...args, '--ids-file', file, '--ids', `number:${number}`])) + .error, + ).toBeDefined() + expect(server.requests).toHaveLength(0) +}) diff --git a/test/commands/quicklinks.test.ts b/test/commands/quicklinks.test.ts index d47e77d8..c05894a5 100644 --- a/test/commands/quicklinks.test.ts +++ b/test/commands/quicklinks.test.ts @@ -196,3 +196,46 @@ test('reports completed QuickLink cancellation after a synchronous response', as function exitCode(error: unknown): number | undefined { return (error as {oclif?: {exit?: number}} | undefined)?.oclif?.exit } + +test('includes initial number financial controls and rejects invalid precision before transport', async () => { + const server = await startHttpSequenceServer([ + { + body: { + success: true, + data: { + id: 'ql_budget', + url: 'https://example.test/connect/budget', + session: 'reserved', + expiresAt: null, + }, + }, + }, + ]) + try { + await withEnvironment( + { + POLYMORFA_API_KEY: apiKey, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: undefined, + }, + async () => { + const result = await runCommand( + `quicklink create --project ${projectId} --limit-credits 250 --priority 30 --json`, + ) + expect(result.error, result.stderr).toBeUndefined() + expect(JSON.parse(server.requests[0]!.body)).toMatchObject({ + projectId, + billingControls: {limitCredits: 250, priority: 30}, + }) + const invalid = await runCommand( + `quicklink create --project ${projectId} --limit-credits 0.0000001 --priority 30 --json`, + ) + expect(invalid.error).toBeDefined() + expect(server.requests).toHaveLength(1) + }, + ) + } finally { + await server.close() + } +})