diff --git a/packages/budget/src/__tests__/resource.test.ts b/packages/budget/src/__tests__/resource.test.ts new file mode 100644 index 0000000..b46ef6d --- /dev/null +++ b/packages/budget/src/__tests__/resource.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { BudgetResource } from '../index.js'; + +import type { HttpClient } from '@astroid/core'; +import type { Budget, BudgetSimulationResult, BudgetUtilization } from '@astroid/types'; + +/** + * Minimal HttpClient stand-in. Only the methods the resource uses on the wire + * are stubbed; each call records its arguments so tests can assert the exact + * path and query/body that were serialized. + */ +function makeClient() { + const calls: Array<{ method: string; path: string; query?: unknown; body?: unknown }> = []; + const handler = vi.fn(); + + const client = { + get: vi.fn(async (path: string, opts?: { query?: Record }) => { + calls.push({ method: 'get', path, query: opts?.query }); + const data = await handler('get', path, opts?.query); + return { data }; + }), + post: vi.fn(async (path: string, body?: unknown) => { + calls.push({ method: 'post', path, body }); + const data = await handler('post', path, undefined, body); + return { data }; + }), + patch: vi.fn(), + delete: vi.fn(), + } as unknown as HttpClient; + + return { client, calls, handler }; +} + +const BUDGET_ID = 'bud_123'; +const budget: Budget = { + id: BUDGET_ID, + organizationId: 'org_1', + name: 'Marketing', + currency: 'USDC', + period: 'MONTHLY', + periodStart: '2026-08-01T00:00:00.000Z', + limitAmount: '1000.00', + enabled: true, + rollover: false, + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', +}; + +describe('BudgetResource', () => { + it('getBudget requests /budgets/{id}', async () => { + const { client, calls, handler } = makeClient(); + handler.mockResolvedValueOnce(budget); + const resource = new BudgetResource(client); + + const result = await resource.getBudget(BUDGET_ID); + + expect(calls).toEqual([ + { method: 'get', path: `/budgets/${BUDGET_ID}`, query: undefined }, + ]); + expect(result).toEqual(budget); + }); + + it('listBudgets serializes filters and pagination into a GET querystring', async () => { + const { client, calls, handler } = makeClient(); + handler.mockResolvedValueOnce([budget]); + const resource = new BudgetResource(client); + + const result = await resource.listBudgets({ + period: 'MONTHLY', + enabled: true, + limit: 25, + page: 2, + order: 'desc', + sort: 'spent', + }); + + expect(calls).toEqual([ + { + method: 'get', + path: '/budgets', + query: { period: 'MONTHLY', enabled: true, limit: 25, page: 2, order: 'desc', sort: 'spent' }, + }, + ]); + expect(result.data).toEqual([budget]); + }); + + it('simulateBudgetCheck posts the draw to /budgets/{id}/simulate for an allowed spend', async () => { + const { client, calls, handler } = makeClient(); + const resultData: BudgetSimulationResult = { + budget, + allowed: true, + wouldExceed: false, + remainingAfter: '975.00', + restriction: null, + windowStart: '2026-08-01T00:00:00.000Z', + windowEnd: '2026-09-01T00:00:00.000Z', + }; + handler.mockResolvedValueOnce(resultData); + const resource = new BudgetResource(client); + + const result = await resource.simulateBudgetCheck(BUDGET_ID, { asset: 'USDC', amount: '25.00' }); + + expect(calls).toEqual([ + { + method: 'post', + path: `/budgets/${BUDGET_ID}/simulate`, + body: { asset: 'USDC', amount: '25.00' }, + }, + ]); + expect(result).toEqual(resultData); + }); + + it('simulateBudgetCheck surfaces a limit breach', async () => { + const { client, handler } = makeClient(); + const breach: BudgetSimulationResult = { + budget, + allowed: false, + wouldExceed: true, + remainingAfter: '10.00', + restriction: 'Spend of 9999.00 USDC would exceed the monthly budget limit of 1000.00 (remaining: 10.00).', + windowStart: '2026-08-01T00:00:00.000Z', + windowEnd: '2026-09-01T00:00:00.000Z', + }; + handler.mockResolvedValueOnce(breach); + const resource = new BudgetResource(client); + + const result = await resource.simulateBudgetCheck(BUDGET_ID, { asset: 'USDC', amount: '9999.00' }); + + expect(result.allowed).toBe(false); + expect(result.wouldExceed).toBe(true); + expect(result.restriction).toContain('would exceed'); + }); + + it('utilization fetches the utilization snapshot from /budgets/{id}/utilization', async () => { + const { client, calls, handler } = makeClient(); + const utilization: BudgetUtilization = { + budgetId: BUDGET_ID, + period: 'MONTHLY', + periodStart: '2026-08-01T00:00:00.000Z', + periodEnd: '2026-09-01T00:00:00.000Z', + limit: '1000.00', + spent: '400.00', + remaining: '600.00', + utilization: 0.4, + }; + handler.mockResolvedValueOnce(utilization); + const resource = new BudgetResource(client); + + const result = await resource.utilization(BUDGET_ID); + + expect(calls).toEqual([ + { method: 'get', path: `/budgets/${BUDGET_ID}/utilization`, query: undefined }, + ]); + expect(result.utilization).toBe(0.4); + expect(result.remaining).toBe('600.00'); + }); +}); \ No newline at end of file diff --git a/packages/budget/src/index.ts b/packages/budget/src/index.ts index 13a2990..fbf3af5 100644 --- a/packages/budget/src/index.ts +++ b/packages/budget/src/index.ts @@ -13,6 +13,152 @@ export { } from './metrics.js'; export { checkBudgetLimit, type BudgetValidationResult } from './validation.js'; -export * from './budget.js'; -export * from './alerts.js'; -export { BudgetResource, BudgetsResource, type BudgetListParams } from './resource.js'; +import { Resource } from '@astroid/core'; +import type { + Budget, + BudgetHistoryEntry, + BudgetPeriod, + BudgetSimulationInput, + BudgetSimulationResult, + BudgetUtilization, + ConsumeBudgetInput, + CreateBudgetInput, + Paginated, + PaginationParams, + UpdateBudgetInput, +} from '@astroid/types'; + +/** Filters accepted by {@link BudgetResource.list}. */ +export interface BudgetListParams extends PaginationParams { + period?: BudgetPeriod; + enabled?: boolean; + agentId?: string; + walletId?: string; +} + +/** + * The `budgets` namespace on the Astroid client. + * + * Budgets are created against an organization/agent, consumed as transactions + * settle, and can roll over between periods. {@link BudgetResource.consume} + * records a draw explicitly (the transaction pipeline normally does this for + * you); {@link BudgetResource.history} returns the audit trail. + */ +export class BudgetResource extends Resource { + /** Create a new budget. */ + async create(input: CreateBudgetInput): Promise { + const res = await this.client.post('/budgets', input); + return res.data; + } + + /** Fetch a single budget by id. */ + async get(budgetId: string): Promise { + return this.getData(`/budgets/${encodeURIComponent(budgetId)}`); + } + + /** List budgets, with optional period/scope filters and pagination. */ + async list(params: BudgetListParams = {}): Promise> { + return this.listData('/budgets', { ...params }); + } + + /** Iterate every budget across all pages. */ + iterate(params: BudgetListParams = {}): AsyncGenerator { + return this.iterateData('/budgets', { ...params }); + } + + /** Update a budget's limit, period, rollover, or enabled state. */ + async update(budgetId: string, input: UpdateBudgetInput): Promise { + const res = await this.client.patch(`/budgets/${encodeURIComponent(budgetId)}`, input); + return res.data; + } + + /** Permanently delete a budget. */ + async delete(budgetId: string): Promise { + await this.client.delete(`/budgets/${encodeURIComponent(budgetId)}`); + } + + /** + * Record a draw against a budget, returning the updated budget. Amounts are + * decimal strings; the API rejects a draw that would exceed the remaining + * balance unless the budget permits overage. + */ + async consume(budgetId: string, input: ConsumeBudgetInput): Promise { + const res = await this.client.post( + `/budgets/${encodeURIComponent(budgetId)}/consume`, + input, + ); + return res.data; + } + + /** Reset a budget's consumption for the current period back to zero. */ + async reset(budgetId: string): Promise { + const res = await this.client.post(`/budgets/${encodeURIComponent(budgetId)}/reset`); + return res.data; + } + + /** The budget's consumption history (one entry per draw). */ + async history( + budgetId: string, + params: PaginationParams = {}, + ): Promise> { + return this.listData( + `/budgets/${encodeURIComponent(budgetId)}/history`, + { ...params }, + ); + } + + /** + * Fetch a single budget by id. + * + * This is the fully-qualified alias of {@link BudgetResource.get} exposed for + * callers who prefer a `getBudget`-style resource API; behaviour is identical. + */ + async getBudget(budgetId: string): Promise { + return this.getData(`/budgets/${encodeURIComponent(budgetId)}`); + } + + /** + * List budgets, with optional period/scope filters and pagination. + * + * This is the fully-qualified alias of {@link BudgetResource.list} exposed for + * callers who prefer a `listBudgets`-style resource API; behaviour is identical. + */ + async listBudgets(params: BudgetListParams = {}): Promise> { + return this.listData('/budgets', { ...params }); + } + + /** + * Simulate a prospective spend draw against a budget **without committing it**. + * + * The API evaluates the request against the budget's active window, currency, + * and remaining allowance and returns a {@link BudgetSimulationResult}. This is + * the enforcement path agents / wallets use before executing a transaction. + * + * @param budgetId The budget to simulate against. + * @param input The prospective draw (`asset` + `amount`). + * @returns Whether the draw would be allowed and, if not, why. + */ + async simulateBudgetCheck( + budgetId: string, + input: BudgetSimulationInput, + ): Promise { + const res = await this.client.post( + `/budgets/${encodeURIComponent(budgetId)}/simulate`, + input, + ); + return res.data; + } + + /** + * Retrieve the current utilization snapshot for a budget. + * + * @param budgetId The budget to inspect. + * @returns Limit, spending, headroom, and the 0..1 utilization ratio + * for the active window (see {@link BudgetUtilization}). + */ + async utilization(budgetId: string): Promise { + return this.getData( + `/budgets/${encodeURIComponent(budgetId)}/utilization`, + ); + } +} diff --git a/packages/types/src/budget.ts b/packages/types/src/budget.ts index 1a3f483..8bf0a79 100644 --- a/packages/types/src/budget.ts +++ b/packages/types/src/budget.ts @@ -1,132 +1,54 @@ /** - * Budget allocation-tracking and threshold-alert types. + * Budget-related DTOs for simulation and utilization queries. * - * The {@link Budget} entity and {@link BudgetHistoryEntry} / {@link BudgetMetrics} - * shapes live in `./entities.ts`; this module adds the DTOs and value types used - * by `@astroid/budget` for allocation status checks and budget threshold alert - * subscriptions. + * These complement the {@link Budget} entity. The budget API lets agents + * simulate whether a prospective draw would breach their allocation before + * committing to it, and exposes a per-budget utilization snapshot. * * @module */ import type { DecimalString, IsoDateTime } from './entities.js'; -import type { PaginationParams } from './common.js'; - -/* -------------------------------------------------------------------------- */ -/* Allocation tracking */ -/* -------------------------------------------------------------------------- */ +import type { Budget, BudgetPeriod } from './entities.js'; + +/** A prospective spend draw to simulate against a budget. */ +export interface BudgetSimulationInput { + /** Asset identifier (e.g. `"XLM"`, `"USDC"`, `"USDC:G...Issuer"`). */ + asset: string; + /** Amount to draw (decimal string or number). */ + amount: DecimalString | number; +} -/** Health of a budget's current allocation. */ -export type BudgetAllocationState = 'healthy' | 'warning' | 'critical' | 'exhausted'; +/** The outcome of simulating a draw against a budget (nothing is committed). */ +export interface BudgetSimulationResult { + /** The budget the simulation ran against. */ + budget: Budget; + /** Whether the draw is permitted under the budget's rules. */ + allowed: boolean; + /** Whether the draw would breach the budget's remaining allowance. */ + wouldExceed: boolean; + /** Remaining headroom after applying the simulated draw (decimal string). */ + remainingAfter: DecimalString; + /** When `wouldExceed` is true, a human-readable description of the breach. */ + restriction: string | null; + /** The active window start the simulation was evaluated against (ISO-8601 UTC). */ + windowStart: IsoDateTime; + /** The active window end the simulation was evaluated against (ISO-8601 UTC). */ + windowEnd: IsoDateTime; +} -/** A point-in-time view of how much of a budget's allocation is consumed. */ -export interface BudgetAllocationStatus { - /** The budget this status describes. */ +/** A utilization snapshot for a single budget. */ +export interface BudgetUtilization { budgetId: string; - /** The active-window limit. */ + period: BudgetPeriod; + periodStart: IsoDateTime; + periodEnd: IsoDateTime; + /** Configured spend limit for the active window (decimal string). */ limit: DecimalString; - /** Amount consumed in the active window. */ + /** Total consumption so far in the active window (decimal string). */ spent: DecimalString; - /** `limit - spent`, clamped at 0. */ + /** Headroom left (limit minus spent), as a decimal string. */ remaining: DecimalString; - /** Fraction of the limit consumed, `0`–`1` (clamped). */ + /** Fraction consumed (0..1+), useful for progress bars. */ utilization: number; - /** {@link utilization} as a percentage, `0`–`100`, rounded to 2 dp. */ - percent: number; - /** Bucketed health derived from {@link percent} and the configured thresholds. */ - state: BudgetAllocationState; - /** Whether a prospective spend (when supplied) would push spending past the limit. */ - wouldExceed?: boolean; -} - -/** Thresholds (percent of limit) that bucket an allocation into a {@link BudgetAllocationState}. */ -export interface BudgetAllocationThresholds { - /** Percent at which the state becomes `warning`. Default `80`. */ - warnAt?: number; - /** Percent at which the state becomes `critical`. Default `95`. */ - criticalAt?: number; -} - -/* -------------------------------------------------------------------------- */ -/* Threshold alerts */ -/* -------------------------------------------------------------------------- */ - -/** Delivery channel for a budget threshold alert. */ -export type BudgetAlertChannel = 'EMAIL' | 'WEBHOOK' | 'SLACK' | 'DASHBOARD'; - -/** Lifecycle status of a budget alert subscription. */ -export type BudgetAlertStatus = 'ACTIVE' | 'PAUSED' | 'TRIGGERED'; - -/** The utilization percentages Astroid recommends configuring alerts at. */ -export const BUDGET_ALERT_THRESHOLDS = Object.freeze([50, 80, 100] as const); - -/** A configured budget threshold alert subscription. */ -export interface BudgetAlert { - id: string; - budgetId: string; - organizationId: string; - /** Utilization percentage (`1`–`1000`) at which the alert fires. */ - thresholdPercent: number; - /** Where the notification is delivered. */ - channel: BudgetAlertChannel; - /** - * Channel-specific destination: a URL for `WEBHOOK`, an email address for - * `EMAIL`, a channel id for `SLACK`. Ignored for `DASHBOARD`. - */ - target: string; - /** Current status. */ - status: BudgetAlertStatus; - /** Whether the alert re-arms after the budget period resets. */ - recurring: boolean; - /** Last time this alert fired, if ever. */ - lastTriggeredAt?: IsoDateTime | null; - createdAt: IsoDateTime; - updatedAt: IsoDateTime; -} - -/** Payload for creating a budget threshold alert. */ -export interface CreateBudgetAlertInput { - /** Utilization percentage at which to fire (e.g. `50`, `80`, `100`). */ - thresholdPercent: number; - /** Delivery channel. */ - channel: BudgetAlertChannel; - /** Channel-specific destination. Required for every channel except `DASHBOARD`. */ - target?: string; - /** Whether the alert re-arms each budget period. Defaults to `true` server-side. */ - recurring?: boolean; -} - -/** Payload for updating a budget threshold alert (all fields optional). */ -export interface UpdateBudgetAlertInput { - thresholdPercent?: number; - channel?: BudgetAlertChannel; - target?: string; - recurring?: boolean; - status?: Extract; -} - -/** Filter + pagination parameters for listing budget alerts. */ -export interface ListBudgetAlertsParams extends PaginationParams { - /** Only alerts in this status. */ - status?: BudgetAlertStatus; - /** Only alerts on this channel. */ - channel?: BudgetAlertChannel; -} - -/* -------------------------------------------------------------------------- */ -/* Budget history queries */ -/* -------------------------------------------------------------------------- */ - -/** Filter + pagination parameters for a budget's consumption history. */ -export interface BudgetHistoryQueryParams extends PaginationParams { - /** Only entries created at or after this instant (ISO-8601). */ - from?: IsoDateTime; - /** Only entries created at or before this instant (ISO-8601). */ - to?: IsoDateTime; - /** Only entries linked to this transaction. */ - transactionId?: string; - /** Only entries whose `amount` is at least this value. */ - minAmount?: DecimalString | number; - /** Only entries whose `amount` is at most this value. */ - maxAmount?: DecimalString | number; -} +} \ No newline at end of file diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index e4f1520..cbb8acf 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -4,6 +4,7 @@ export * from './entities.js'; export * from './dto.js'; export * from './policy.js'; export * from './analytics.js'; +export * from './budget.js'; export * from './webhooks.js'; export * from './ai.js'; export * from './client.js';