From 61647d1ac5fe58fb02e71aa2b9fe0270d855a326 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Tue, 6 Oct 2026 14:56:46 +0200 Subject: [PATCH] feat(cli): show monthly usage and charges Co-Authored-By: Claude Fable 5.1 Assisted-by: Claude Fable 5.1 via Claude Code --- .changeset/cli-usage.md | 5 + .changeset/iam-usage.md | 5 + packages/cli/README.md | 23 ++ packages/cli/src/lib/usage.ts | 197 +++++++++++++++++ packages/cli/src/specs.yaml | 27 +++ packages/cli/src/utils/format.ts | 20 +- packages/cli/test/lib/usage.test.ts | 293 +++++++++++++++++++++++++ packages/cli/test/utils/format.test.ts | 19 ++ packages/iam/src/index.ts | 8 + packages/iam/src/lib/http-client.ts | 2 + packages/iam/src/lib/usage/get.test.ts | 191 ++++++++++++++++ packages/iam/src/lib/usage/get.ts | 185 ++++++++++++++++ 12 files changed, 970 insertions(+), 5 deletions(-) create mode 100644 .changeset/cli-usage.md create mode 100644 .changeset/iam-usage.md create mode 100644 packages/cli/src/lib/usage.ts create mode 100644 packages/cli/test/lib/usage.test.ts create mode 100644 packages/iam/src/lib/usage/get.test.ts create mode 100644 packages/iam/src/lib/usage/get.ts diff --git a/.changeset/cli-usage.md b/.changeset/cli-usage.md new file mode 100644 index 00000000..5c04c921 --- /dev/null +++ b/.changeset/cli-usage.md @@ -0,0 +1,5 @@ +--- +"@tigrisdata/cli": minor +--- + +`tigris usage` shows the active organization's usage and charges for the current month, or any month with `--month YYYY-MM`. Line items with zero usage are hidden unless `--all` is given; `--format json` returns the full invoice. Requires OAuth login. diff --git a/.changeset/iam-usage.md b/.changeset/iam-usage.md new file mode 100644 index 00000000..c346b325 --- /dev/null +++ b/.changeset/iam-usage.md @@ -0,0 +1,5 @@ +--- +"@tigrisdata/iam": minor +--- + +`getUsage({ month })` fetches the organization's usage and charges for a billing month from the management API — the data behind the console's Usage page: every charge with its quantity, billed quantity, tiers and amount, plus subtotal, credits, total, plan and invoice state. Defaults to the current month; needs a session token. diff --git a/packages/cli/README.md b/packages/cli/README.md index 22353716..fba035c4 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -37,6 +37,7 @@ Run `tigris help` to see all available commands, or `tigris help` for | `tigris configure` (c) | Save access-key credentials to ~/.tigris/config.json for persistent use across all commands | | `tigris login` (l) | Start a session via OAuth (default) or temporary credentials. Session state is cleared on logout | | `tigris whoami` (w) | Print the currently authenticated user, organization, and auth method | +| `tigris usage` | Show the active organization's usage and charges for a billing month, as on the console's Usage page. Requires OAuth login | | `tigris update` | Update the CLI to the latest version | | `tigris logout` | End the current session and clear login state. Credentials saved via 'configure' are kept | | `tigris credentials` (creds) | Test whether your current credentials can reach Tigris and optionally verify access to a specific bucket | @@ -161,6 +162,28 @@ tigris whoami tigris whoami ``` +### `tigris usage` + +Show the active organization's usage and charges for a billing month, as on the console's Usage page. Requires OAuth login + +``` +tigris usage [flags] +``` + +| Flag | Description | +|------|-------------| +| `--month` | Billing month as YYYY-MM (default is the current month) | +| `--all` | Show every line item, even those with zero usage | +| `--format` | Output format (default: table) | + +**Examples:** +```bash +tigris usage +tigris usage --month 2026-09 +tigris usage --all +tigris usage --format json +``` + ### `tigris update` Update the CLI to the latest version diff --git a/packages/cli/src/lib/usage.ts b/packages/cli/src/lib/usage.ts new file mode 100644 index 00000000..c4a730c8 --- /dev/null +++ b/packages/cli/src/lib/usage.ts @@ -0,0 +1,197 @@ +import { getOAuthIAMConfig } from '@auth/iam.js'; +import { + getUsage, + type UsageCharge, + type UsageResponse, +} from '@tigrisdata/iam'; +import { failWithError } from '@utils/exit.js'; +import { + formatJson, + formatTable, + formatXml, + formatXmlObject, + type TableColumn, +} from '@utils/format.js'; +import { + msg, + printEmpty, + printHint, + printStart, + printSuccess, +} from '@utils/messages.js'; +import { getFormat, getOption } from '@utils/options.js'; + +const context = msg('usage'); + +// The console's own formatters, so the two views agree. +const quantityFormat = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 2, +}); +const amountFormat = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', +}); + +/** Short unit labels: "826 Requests", "43.7 GiB-Month". */ +const UNIT_LABELS: Record = { + 'Number of Requests': 'Requests', + 'Number of events published': 'Events', +}; + +type Row = { type: string; used: string; billed: string; amount: string }; + +const columns: TableColumn[] = [ + { key: 'type', header: 'Type' }, + { key: 'used', header: 'Used', align: 'right' }, + { key: 'billed', header: 'Billed', align: 'right' }, + { key: 'amount', header: 'Amount', align: 'right' }, +]; + +function quantity(value: number, charge: UsageCharge): string { + return `${quantityFormat.format(value)} ${UNIT_LABELS[charge.unit] ?? charge.unit}`; +} + +function monthLabel(usage: UsageResponse): string { + return new Date(usage.startingOn).toLocaleDateString('en-US', { + month: 'long', + year: 'numeric', + timeZone: 'UTC', + }); +} + +/** + * The invoice amounts in the console's order. The commitment is a floor, + * total = max(subtotal, minimumCommit) - credits, which the note printed + * under the table spells out. + */ +function footerRows(usage: UsageResponse): Row[] { + const rows: Row[] = []; + if (usage.minimumCommit) { + rows.push({ + type: 'Minimum Commitment', + used: '', + billed: '', + amount: amountFormat.format(usage.minimumCommit), + }); + } + rows.push({ + type: 'Subtotal', + used: '', + billed: '', + amount: amountFormat.format(usage.subtotal), + }); + for (const grant of usage.creditGrants ?? []) { + rows.push({ + type: grant.name, + used: '', + billed: '', + amount: `-${amountFormat.format(grant.amount)}`, + }); + } + rows.push({ + type: 'Total', + used: '', + billed: '', + amount: amountFormat.format(usage.total), + }); + return rows; +} + +/** Charges as raw values, then the invoice-level amounts. */ +function usageXml(charges: UsageCharge[], usage: UsageResponse): string { + const nested = (block: string) => ` ${block.replace(/\n/g, '\n ')}`; + const lines = ['']; + lines.push( + nested( + formatXml( + charges.map((charge) => ({ + id: charge.id, + type: charge.name, + unit: charge.unit, + used: charge.quantity, + billed: charge.chargedQuantity, + amount: charge.total, + })), + 'charges', + 'charge' + ) + ) + ); + const totals: Record = { subtotal: usage.subtotal }; + if (usage.minimumCommit) totals.minimumCommit = usage.minimumCommit; + lines.push(formatXmlObject(totals, ' ')); + if (usage.creditGrants?.length) { + lines.push(nested(formatXml(usage.creditGrants, 'credits', 'credit'))); + } + lines.push(formatXmlObject({ total: usage.total }, ' ')); + lines.push(''); + return lines.join('\n'); +} + +export default async function usage(options: Record) { + printStart(context); + + const format = getFormat(options); + const month = getOption(options, ['month']); + const all = getOption(options, ['all']) ?? false; + + const config = await getOAuthIAMConfig(context); + + const { data, error } = await getUsage({ month, config }); + + if (error) { + failWithError(context, error); + } + + if (format === 'json') { + console.log(formatJson(data)); + return; + } + + const charges = all + ? data.charges + : data.charges.filter((charge) => charge.quantity > 0 || charge.total > 0); + + const url = data.checkoutUrl ?? data.receiptUrl; + // A commitment or credit can leave an amount owed with nothing metered. + const settles = + data.total !== 0 || + Boolean(data.minimumCommit) || + (data.creditGrants?.length ?? 0) > 0 || + Boolean(url); + + if (charges.length === 0 && !settles) { + printEmpty(context, { month: monthLabel(data) }); + return; + } + + if (format === 'xml') { + console.log(usageXml(charges, data)); + } else { + const rows: Row[] = charges.map((charge) => ({ + type: charge.name, + used: quantity(charge.quantity, charge), + billed: quantity(charge.chargedQuantity, charge), + amount: amountFormat.format(charge.total), + })); + // ending_before is the exclusive end of the period: the last refresh. + console.log( + `${monthLabel(data)} · ${data.planName} · as of ${data.endingBefore.slice(0, 10)}` + ); + console.log(formatTable(rows, columns, footerRows(data))); + if (data.minimumCommit) { + console.log( + `Minimum commitment: your plan bills at least ${amountFormat.format(data.minimumCommit)} a month, before credits.` + ); + } + } + + if (url) { + printHint(context, { + action: data.checkoutUrl ? 'Make a payment' : 'View the receipt', + url, + }); + } + + printSuccess(context); +} diff --git a/packages/cli/src/specs.yaml b/packages/cli/src/specs.yaml index c4617e7e..5f04fc0c 100644 --- a/packages/cli/src/specs.yaml +++ b/packages/cli/src/specs.yaml @@ -276,6 +276,33 @@ commands: onSuccess: '' onFailure: 'Failed to get user information' onAlreadyDone: "Not authenticated\nRun \"tigris login\" to authenticate" + + # usage + - name: usage + group: Get started + description: Show the active organization's usage and charges for a billing month, as on the console's Usage page. Requires OAuth login + helpText: Show this month's usage and charges + examples: + - "tigris usage" + - "tigris usage --month 2026-09" + - "tigris usage --all" + - "tigris usage --format json" + messages: + onStart: '' + onSuccess: '' + onFailure: 'Failed to get usage' + onEmpty: 'No usage recorded for {{month}}' + hint: '{{action}}: {{url}}' + arguments: + - name: month + description: Billing month as YYYY-MM (default is the current month) + - name: all + description: Show every line item, even those with zero usage + type: flag + - name: format + description: Output format + options: [json, table, xml] + default: table # update - name: update group: CLI diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index 674482b2..6e796c43 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -183,12 +183,14 @@ function calculateColumnWidths>( */ export function formatTable>( items: T[], - columns: TableColumn[] + columns: TableColumn[], + /** Summary rows (totals and the like), set apart from the data by a rule. */ + footer: T[] = [] ): string { const lines: string[] = []; // Calculate widths based on content - const widths = calculateColumnWidths(items, columns); + const widths = calculateColumnWidths([...items, ...footer], columns); // Build header separator const topBorder = `┌${widths.map((w) => '─'.repeat(w + 2)).join('┬')}┐`; @@ -210,8 +212,7 @@ export function formatTable>( // Middle border lines.push(middleBorder); - // Data rows - items.forEach((item) => { + const renderRow = (item: T) => { const cells = columns.map((col, i) => { const value = truncate(formatCellValue(item[col.key]), widths[i]); return col.align === 'right' @@ -219,7 +220,16 @@ export function formatTable>( : value.padEnd(widths[i]); }); lines.push(`│ ${cells.join(' │ ')} │`); - }); + }; + + // Data rows + items.forEach(renderRow); + + if (footer.length > 0) { + // With no data rows the header rule already sets the footer apart. + if (items.length > 0) lines.push(middleBorder); + footer.forEach(renderRow); + } // Bottom border lines.push(`${bottomBorder}\n`); diff --git a/packages/cli/test/lib/usage.test.ts b/packages/cli/test/lib/usage.test.ts new file mode 100644 index 00000000..744fbeea --- /dev/null +++ b/packages/cli/test/lib/usage.test.ts @@ -0,0 +1,293 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as YAML from 'yaml'; + +vi.mock('@auth/iam.js', () => ({ + getOAuthIAMConfig: vi.fn(async () => ({ + sessionToken: 'tok', + organizationId: 'org-a', + })), +})); +vi.mock('@tigrisdata/iam', () => ({ + getUsage: vi.fn(), +})); + +import { getUsage, type UsageResponse } from '@tigrisdata/iam'; +import usage from '../../src/lib/usage.js'; +import type { Specs } from '../../src/types.js'; +import { setSpecs } from '../../src/utils/specs.js'; + +setSpecs( + YAML.parse(readFileSync(join(process.cwd(), 'src/specs.yaml'), 'utf8'), { + schema: 'core', + }) as Specs +); + +class ExitSignal extends Error {} + +function charge( + id: string, + name: string, + unit: string, + quantity: number, + total: number, + chargedQuantity = quantity +): UsageResponse['charges'][number] { + return { + id, + name, + unit, + quantity, + chargedQuantity, + total, + tiers: [], + }; +} + +const invoice: UsageResponse = { + customerId: 'org-a', + planId: 'default_plan', + planName: 'Default plan with free tier', + status: 'DRAFT', + startingOn: '2026-10-01T00:00:00Z', + endingBefore: '2026-10-06T00:00:00Z', + charges: [ + charge( + 'requests_class_A_v1', + 'PUT, COPY, POST, LIST Requests', + 'Number of Requests', + 16318, + 0.03 + ), + charge( + 'requests_class_B_v1', + 'GET, SELECT, and all other Requests', + 'Number of Requests', + 4826, + 0 + ), + charge( + 'storage_size_bytes_v1', + 'Storage used - Standard', + 'GiB-Month', + 0, + 0 + ), + charge('bandwidth_out_bytes_v1', 'Data transfer Out', 'GiB', 0, 0), + ], + subtotal: 0.03, + total: 0.03, + createdAt: '2026-10-06T05:33:03Z', + lastModified: '2026-10-06T05:33:03Z', +}; + +// Nothing metered: only the all-zero lines remain. +const unused: UsageResponse = { + ...invoice, + charges: invoice.charges.slice(2), + subtotal: 0, + total: 0, +}; + +let stdout: string[]; +let stderr: string[]; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getUsage).mockResolvedValue({ data: invoice }); + stdout = []; + stderr = []; + vi.spyOn(console, 'log').mockImplementation((...args) => { + stdout.push(args.join(' ')); + }); + vi.spyOn(console, 'error').mockImplementation((...args) => { + stderr.push(args.join(' ')); + }); + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new ExitSignal(); + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('usage', () => { + it('lists the charges with usage, used and billed quantities with their unit', async () => { + await usage({}); + + const output = stdout.join('\n'); + expect(output).toContain( + 'October 2026 · Default plan with free tier · as of 2026-10-06' + ); + expect(output).toMatch(/Type\s+│\s+Used\s+│\s+Billed\s+│\s+Amount/); + expect(output).toMatch( + /PUT, COPY, POST, LIST Requests\s+│\s+16,318 Requests\s+│\s+16,318 Requests\s+│\s+\$0\.03/ + ); + expect(output).toContain('4,826 Requests'); + // Subtotal and total sit below a rule. + const lines = output.split('\n'); + const subtotal = lines.findIndex((line) => line.includes('Subtotal')); + expect(lines[subtotal - 1]).toMatch(/^├/); + expect(lines[subtotal + 1]).toContain('Total'); + // Nothing used, so not shown. + expect(output).not.toContain('Storage used - Standard'); + expect(output).not.toContain('Data transfer Out'); + }); + + it('shows the billed quantity when a minimum raises it above the used one', async () => { + vi.mocked(getUsage).mockResolvedValueOnce({ + data: { + ...invoice, + charges: [ + charge( + 'std_archive_storage_v1', + 'Storage used - Archive', + 'GiB-Month', + 0.5, + 0.02, + 5 + ), + ], + }, + }); + + await usage({}); + + expect(stdout.join('\n')).toMatch( + /Storage used - Archive\s+│\s+0\.5 GiB-Month\s+│\s+5 GiB-Month\s+│\s+\$0\.02/ + ); + }); + + it('includes the line items without usage with --all', async () => { + await usage({ all: true }); + + const output = stdout.join('\n'); + expect(output).toContain('Storage used - Standard'); + expect(output).toContain('0 GiB-Month'); + expect(output).toContain('Data transfer Out'); + }); + + it('asks for the requested month', async () => { + await usage({ month: '2026-09' }); + + expect(getUsage).toHaveBeenCalledWith({ + month: '2026-09', + config: { sessionToken: 'tok', organizationId: 'org-a' }, + }); + }); + + it('lists the commitment above the subtotal and explains it under the table', async () => { + vi.mocked(getUsage).mockResolvedValueOnce({ + data: { + ...invoice, + minimumCommit: 10, + creditGrants: [{ name: 'Startup credit', amount: 5 }], + total: 5, + }, + }); + + await usage({}); + + const output = stdout.join('\n'); + const footer = output + .split('\n') + .filter((line) => + /Subtotal|Minimum Commitment|Startup credit|Total/.test(line) + ) + .map((line) => line.replace(/\s*│\s*/g, '|').replace(/^\||\|$/g, '')); + expect(footer).toEqual([ + 'Minimum Commitment|||$10.00', + 'Subtotal|||$0.03', + 'Startup credit|||-$5.00', + 'Total|||$5.00', + ]); + expect(output).toMatch( + /┘\n\nMinimum commitment: your plan bills at least \$10\.00 a month, before credits\.$/ + ); + }); + + it('keeps the commitment visible when the subtotal exceeds it', async () => { + vi.mocked(getUsage).mockResolvedValueOnce({ + data: { ...invoice, subtotal: 12, minimumCommit: 10, total: 12 }, + }); + + await usage({}); + + const output = stdout.join('\n'); + expect(output).toMatch(/Minimum Commitment\s+│\s+│\s+│\s+\$10\.00/); + expect(output).toMatch(/Subtotal\s+│\s+│\s+│\s+\$12\.00/); + expect(output).toContain('bills at least $10.00 a month'); + }); + + it('prints no commitment note without one', async () => { + await usage({}); + + expect(stdout.join('\n')).not.toContain('Minimum commitment'); + }); + + it('prints the whole invoice with --json', async () => { + await usage({ json: true }); + + expect(stdout).toHaveLength(1); + expect(JSON.parse(stdout[0])).toEqual(invoice); + }); + + it('prints the charges and invoice amounts as xml', async () => { + await usage({ format: 'xml' }); + + const output = stdout.join('\n'); + expect(output).toMatch(/^\n {2}\n {4}/); + expect(output).toContain('16318'); + expect(output).toContain('16318'); + expect(output).toContain('Number of Requests'); + expect(output).toContain('0.03'); + expect(output).toMatch(/0.03<\/total>\n<\/usage>$/); + expect(output).not.toContain('Storage used - Standard'); + }); + + it('still prints the amounts owed when nothing was metered', async () => { + vi.mocked(getUsage).mockResolvedValueOnce({ + data: { ...unused, minimumCommit: 10, total: 10 }, + }); + + await usage({}); + + const output = stdout.join('\n'); + expect(output).toContain('October 2026'); + expect(output).toMatch(/Minimum Commitment\s+│\s+│\s+│\s+\$10\.00/); + expect(output).toMatch(/Total\s+│\s+│\s+│\s+\$10\.00/); + // A footer-only table: the header rule is the only one. + expect( + output.split('\n').filter((line) => line.startsWith('├')) + ).toHaveLength(1); + }); + + it('still prints the table when there is a payment link', async () => { + vi.mocked(getUsage).mockResolvedValueOnce({ + data: { ...unused, checkoutUrl: 'https://pay.example/inv' }, + }); + + await usage({}); + + expect(stdout.join('\n')).toMatch(/Total\s+│\s+│\s+│\s+\$0\.00/); + }); + + it('prints no table when nothing was used and nothing is owed', async () => { + vi.mocked(getUsage).mockResolvedValueOnce({ data: unused }); + + await usage({}); + + expect(stdout.join('\n')).not.toContain('│'); + }); + + it('fails with the gateway error', async () => { + vi.mocked(getUsage).mockResolvedValueOnce({ + error: new Error('Forbidden'), + }); + + await expect(usage({})).rejects.toThrow(ExitSignal); + expect(stderr.join('\n')).toContain('Forbidden'); + }); +}); diff --git a/packages/cli/test/utils/format.test.ts b/packages/cli/test/utils/format.test.ts index 38302e61..f34d2bed 100644 --- a/packages/cli/test/utils/format.test.ts +++ b/packages/cli/test/utils/format.test.ts @@ -103,6 +103,25 @@ describe('formatTable', () => { expect(result).toContain('42 MB'); }); + it('sets footer rows apart with a rule and sizes columns to fit them', () => { + const items = [{ name: 'a', size: '1 KB' }]; + const footer = [{ name: 'Grand total', size: '1 KB' }]; + const lines = formatTable(items, columns, footer).trim().split('\n'); + const totalLine = lines.findIndex((line) => line.includes('Grand total')); + expect(totalLine).toBeGreaterThan(0); + expect(lines[totalLine - 1]).toMatch(/^├[─┼]+┤$/); + expect(lines[totalLine - 2]).toContain('│ a '); + // Only one rule between data and footer, plus the one under the header. + expect(lines.filter((line) => line.startsWith('├'))).toHaveLength(2); + }); + + it('renders a footer-only table with a single rule', () => { + const footer = [{ name: 'Grand total', size: '0 B' }]; + const lines = formatTable([], columns, footer).trim().split('\n'); + expect(lines.filter((line) => line.startsWith('├'))).toHaveLength(1); + expect(lines[3]).toContain('Grand total'); + }); + it('right-aligns when specified', () => { const cols: TableColumn[] = [ { key: 'name', header: 'Name' }, diff --git a/packages/iam/src/index.ts b/packages/iam/src/index.ts index a9814a2c..93f7fd9c 100644 --- a/packages/iam/src/index.ts +++ b/packages/iam/src/index.ts @@ -110,6 +110,14 @@ export { listTeams, type Team, } from './lib/team/list'; +export { + type GetUsageOptions, + getUsage, + type UsageCharge, + type UsageChargeTier, + type UsageCreditGrant, + type UsageResponse, +} from './lib/usage/get'; export { type InviteUserOptions, inviteUser } from './lib/users/invite'; export { type ListUsersOptions, diff --git a/packages/iam/src/lib/http-client.ts b/packages/iam/src/lib/http-client.ts index 33b555be..ec36aa01 100644 --- a/packages/iam/src/lib/http-client.ts +++ b/packages/iam/src/lib/http-client.ts @@ -7,6 +7,8 @@ export const IAM_ENDPOINTS = { teams: '/tigris-iam/teams', // Whoami whoami: '/users/whoami', + // Usage + usage: '/usage/invoice', // Users revokeInvitation: '/tigris-iam/invitations', removeUser: '/tigris-iam/namespaces', diff --git a/packages/iam/src/lib/usage/get.test.ts b/packages/iam/src/lib/usage/get.test.ts new file mode 100644 index 00000000..26a272b0 --- /dev/null +++ b/packages/iam/src/lib/usage/get.test.ts @@ -0,0 +1,191 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../http-client', async (importOriginal) => ({ + ...(await importOriginal()), + createIAMClient: vi.fn(), +})); + +import { createIAMClient, IAM_ENDPOINTS } from '../http-client'; +import { getUsage } from './get'; + +const request = vi.fn(); +const session = { sessionToken: 'tok', organizationId: 'org-selected' }; + +// Trimmed from a real DRAFT invoice. +const invoice = { + customer_id: 'org-selected', + payer_id: 'flyio_payer', + plan_id: 'default_plan', + plan_name: 'Default plan with free tier', + status: 'DRAFT', + starting_on: '2026-10-01T00:00:00Z', + ending_before: '2026-10-06T00:00:00Z', + charges: [ + { + id: 'requests_class_A_v1', + name: 'PUT, COPY, POST, LIST Requests', + unit: 'Number of Requests', + quantity: 16318, + charged_quantity: 16318, + total: 0.03, + tiers: [ + { + name: 'Tier 1 (1 - 10,000)', + starting_after: 0, + quantity: 10000, + price: 0, + subtotal: 0, + }, + { + name: 'Tier 2 (> 10,000)', + starting_after: 10000, + quantity: 6318, + price: 0.000005, + subtotal: 0.03, + }, + ], + }, + ], + subtotal: 0.03, + total: 0.03, + created_at: '2026-10-06T05:33:03.721350399Z', + last_modified: '2026-10-06T05:33:03.75558167Z', + submitted_at: '0001-01-01T00:00:00Z', + scheduled_at: '0001-01-01T00:00:00Z', + paid_at: '0001-01-01T00:00:00Z', +}; + +beforeEach(() => { + vi.clearAllMocks(); + request.mockResolvedValue({ data: invoice }); + vi.mocked(createIAMClient).mockReturnValue({ + data: { request }, + } as unknown as ReturnType); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('getUsage', () => { + it('fetches the month from the management endpoint and maps the invoice', async () => { + const result = await getUsage({ month: '2026-09', config: session }); + + expect(createIAMClient).toHaveBeenCalledWith(session, true); + expect(request).toHaveBeenCalledWith({ + method: 'GET', + path: IAM_ENDPOINTS.usage, + query: { month: '2026-09' }, + }); + expect(result.data).toMatchObject({ + customerId: 'org-selected', + payerId: 'flyio_payer', + planId: 'default_plan', + planName: 'Default plan with free tier', + status: 'DRAFT', + startingOn: '2026-10-01T00:00:00Z', + endingBefore: '2026-10-06T00:00:00Z', + subtotal: 0.03, + total: 0.03, + createdAt: '2026-10-06T05:33:03.721350399Z', + lastModified: '2026-10-06T05:33:03.75558167Z', + }); + expect(result.data?.charges).toEqual([ + { + id: 'requests_class_A_v1', + name: 'PUT, COPY, POST, LIST Requests', + unit: 'Number of Requests', + quantity: 16318, + chargedQuantity: 16318, + total: 0.03, + minimumQuantity: undefined, + tiers: [ + { + name: 'Tier 1 (1 - 10,000)', + startingAfter: 0, + quantity: 10000, + price: 0, + subtotal: 0, + }, + { + name: 'Tier 2 (> 10,000)', + startingAfter: 10000, + quantity: 6318, + price: 0.000005, + subtotal: 0.03, + }, + ], + }, + ]); + }); + + it('defaults to the current month', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-06T12:00:00Z')); + + await getUsage({ config: session }); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ query: { month: '2026-10' } }) + ); + }); + + it('leaves states the invoice has not reached undefined instead of the zero time', async () => { + const draft = await getUsage({ month: '2026-10', config: session }); + expect(draft.data?.submittedAt).toBeUndefined(); + expect(draft.data?.scheduledAt).toBeUndefined(); + expect(draft.data?.paidAt).toBeUndefined(); + + request.mockResolvedValueOnce({ + data: { ...invoice, status: 'PAID', paid_at: '2026-11-02T00:00:00Z' }, + }); + const paid = await getUsage({ month: '2026-10', config: session }); + expect(paid.data?.paidAt).toBe('2026-11-02T00:00:00Z'); + }); + + it('tolerates missing charges and tiers', async () => { + request.mockResolvedValueOnce({ data: { ...invoice, charges: null } }); + const none = await getUsage({ month: '2026-10', config: session }); + expect(none.data?.charges).toEqual([]); + + request.mockResolvedValueOnce({ + data: { + ...invoice, + charges: [{ ...invoice.charges[0], tiers: undefined }], + }, + }); + const flat = await getUsage({ month: '2026-10', config: session }); + expect(flat.data?.charges[0].tiers).toEqual([]); + }); + + it('rejects a month that is not YYYY-MM before any request', async () => { + for (const month of ['2026-13', '2026-1', '10-2026', 'october']) { + const result = await getUsage({ month, config: session }); + expect(result.error?.message).toBe( + `Month must be in YYYY-MM format, got "${month}"` + ); + } + expect(createIAMClient).not.toHaveBeenCalled(); + }); + + it("returns the client's error when it cannot be created", async () => { + vi.mocked(createIAMClient).mockReturnValueOnce({ + error: new Error('Organization ID is required'), + }); + const result = await getUsage({ month: '2026-10', config: session }); + expect(result.error?.message).toBe('Organization ID is required'); + expect(request).not.toHaveBeenCalled(); + }); + + it("returns the gateway's error", async () => { + request.mockResolvedValueOnce({ error: new Error('Forbidden') }); + const result = await getUsage({ month: '2026-10', config: session }); + expect(result.error?.message).toBe('Forbidden'); + }); + + it('returns a transport failure as { error } instead of throwing', async () => { + request.mockRejectedValueOnce(new TypeError('fetch failed')); + const result = await getUsage({ month: '2026-10', config: session }); + expect(result.error?.message).toBe('fetch failed'); + }); +}); diff --git a/packages/iam/src/lib/usage/get.ts b/packages/iam/src/lib/usage/get.ts new file mode 100644 index 00000000..a46c854e --- /dev/null +++ b/packages/iam/src/lib/usage/get.ts @@ -0,0 +1,185 @@ +import { createIAMClient, IAM_ENDPOINTS } from '../http-client'; +import type { TigrisIAMConfig, TigrisIAMResponse } from '../types'; + +export type GetUsageOptions = { + /** Billing month as `YYYY-MM`. Defaults to the current month (UTC). */ + month?: string; + config?: TigrisIAMConfig; +}; + +export type UsageChargeTier = { + name: string; + startingAfter: number; + quantity: number; + price: number; + subtotal: number; +}; + +export type UsageCharge = { + id: string; + name: string; + unit: string; + quantity: number; + /** The quantity billed: `max(quantity, minimumQuantity)`. */ + chargedQuantity: number; + total: number; + minimumQuantity?: number; + tiers: UsageChargeTier[]; +}; + +export type UsageCreditGrant = { + name: string; + amount: number; +}; + +export type UsageResponse = { + customerId: string; + payerId?: string; + planId: string; + planName: string; + /** `DRAFT` while the month is open; settled invoices carry the billing state. */ + status: string; + startingOn: string; + /** Exclusive end of the period covered, i.e. when the usage was last refreshed. */ + endingBefore: string; + charges: UsageCharge[]; + /** Sum of the charges, before any minimum commitment or credits. */ + subtotal: number; + /** `max(subtotal, minimumCommit) - credits`. */ + total: number; + /** Floor for the amount charged, in dollars. */ + minimumCommit?: number; + creditGrants?: UsageCreditGrant[]; + checkoutUrl?: string; + receiptUrl?: string; + memo?: string; + createdAt: string; + lastModified: string; + submittedAt?: string; + scheduledAt?: string; + paidAt?: string; +}; + +type UsageApiResponse = { + customer_id: string; + payer_id?: string; + plan_id: string; + plan_name: string; + status: string; + starting_on: string; + ending_before: string; + charges?: Array<{ + id: string; + name: string; + unit: string; + quantity: number; + charged_quantity: number; + total: number; + minimum_quantity?: number; + tiers?: Array<{ + name: string; + starting_after: number; + quantity: number; + price: number; + subtotal: number; + }>; + }> | null; + subtotal: number; + total: number; + minimum_commit?: number; + credit_grants?: Array<{ name: string; amount: number }> | null; + checkout_url?: string; + receipt_url?: string; + memo?: string; + created_at: string; + last_modified: string; + submitted_at?: string; + scheduled_at?: string; + paid_at?: string; +}; + +const MONTH_FORMAT = /^\d{4}-(0[1-9]|1[0-2])$/; + +/** The gateway sends Go's zero time for a state the invoice has not reached. */ +function timestamp(value: string | undefined): string | undefined { + return value && !value.startsWith('0001-01-01') ? value : undefined; +} + +/** + * Fetch the organization's usage and charges for a billing month — the data + * behind the console's Usage page. Requires a session token. + */ +export async function getUsage( + options?: GetUsageOptions +): Promise> { + const month = options?.month ?? new Date().toISOString().slice(0, 7); + if (!MONTH_FORMAT.test(month)) { + return { + error: new Error(`Month must be in YYYY-MM format, got "${month}"`), + }; + } + + const { data: client, error } = createIAMClient(options?.config, true); + + if (error) { + return { error }; + } + + try { + const response = await client.request({ + method: 'GET', + path: IAM_ENDPOINTS.usage, + query: { month }, + }); + + if (response.error) { + return { error: response.error }; + } + + const invoice = response.data; + + return { + data: { + customerId: invoice.customer_id, + payerId: invoice.payer_id, + planId: invoice.plan_id, + planName: invoice.plan_name, + status: invoice.status, + startingOn: invoice.starting_on, + endingBefore: invoice.ending_before, + charges: (invoice.charges ?? []).map((charge) => ({ + id: charge.id, + name: charge.name, + unit: charge.unit, + quantity: charge.quantity, + chargedQuantity: charge.charged_quantity, + total: charge.total, + minimumQuantity: charge.minimum_quantity, + tiers: (charge.tiers ?? []).map((tier) => ({ + name: tier.name, + startingAfter: tier.starting_after, + quantity: tier.quantity, + price: tier.price, + subtotal: tier.subtotal, + })), + })), + subtotal: invoice.subtotal, + total: invoice.total, + minimumCommit: invoice.minimum_commit, + creditGrants: invoice.credit_grants ?? undefined, + checkoutUrl: invoice.checkout_url, + receiptUrl: invoice.receipt_url, + memo: invoice.memo, + createdAt: invoice.created_at, + lastModified: invoice.last_modified, + submittedAt: timestamp(invoice.submitted_at), + scheduledAt: timestamp(invoice.scheduled_at), + paidAt: timestamp(invoice.paid_at), + }, + }; + } catch (error) { + return { + error: error instanceof Error ? error : new Error(String(error)), + }; + } +}