Skip to content
Merged
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
158 changes: 158 additions & 0 deletions packages/budget/src/__tests__/resource.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }) => {
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');
});
});
152 changes: 149 additions & 3 deletions packages/budget/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Budget> {
const res = await this.client.post<Budget>('/budgets', input);
return res.data;
}

/** Fetch a single budget by id. */
async get(budgetId: string): Promise<Budget> {
return this.getData<Budget>(`/budgets/${encodeURIComponent(budgetId)}`);
}

/** List budgets, with optional period/scope filters and pagination. */
async list(params: BudgetListParams = {}): Promise<Paginated<Budget>> {
return this.listData<Budget>('/budgets', { ...params });
}

/** Iterate every budget across all pages. */
iterate(params: BudgetListParams = {}): AsyncGenerator<Budget, void, void> {
return this.iterateData<Budget>('/budgets', { ...params });
}

/** Update a budget's limit, period, rollover, or enabled state. */
async update(budgetId: string, input: UpdateBudgetInput): Promise<Budget> {
const res = await this.client.patch<Budget>(`/budgets/${encodeURIComponent(budgetId)}`, input);
return res.data;
}

/** Permanently delete a budget. */
async delete(budgetId: string): Promise<void> {
await this.client.delete<void>(`/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<Budget> {
const res = await this.client.post<Budget>(
`/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<Budget> {
const res = await this.client.post<Budget>(`/budgets/${encodeURIComponent(budgetId)}/reset`);
return res.data;
}

/** The budget's consumption history (one entry per draw). */
async history(
budgetId: string,
params: PaginationParams = {},
): Promise<Paginated<BudgetHistoryEntry>> {
return this.listData<BudgetHistoryEntry>(
`/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<Budget> {
return this.getData<Budget>(`/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<Paginated<Budget>> {
return this.listData<Budget>('/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<BudgetSimulationResult> {
const res = await this.client.post<BudgetSimulationResult>(
`/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<BudgetUtilization> {
return this.getData<BudgetUtilization>(
`/budgets/${encodeURIComponent(budgetId)}/utilization`,
);
}
}
Loading
Loading