Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/billing-forecast-endpoint.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@

## Overview

The `/api/billing/forecast` endpoint allows authenticated developers to forecast their upcoming billing amount based on their actual current run rate over a configurable historical lookback window.
The `/api/billing/forecast` endpoint (mounted via `createBillingForecastRouter` in `src/routes/index.ts` alongside `/api/forecast` from `createForecastRouter`) allows authenticated developers to forecast their upcoming billing amount based on their actual current run rate over a configurable historical lookback window.

## Authentication

Requires Bearer JWT token or `x-user-id` header (via `requireAuth` middleware). Unauthenticated requests return `401 Unauthorized`.
Requires Bearer JWT token (via `requireAuth` middleware). Unauthenticated requests return `401 Unauthorized`.

## Query Parameters

Expand Down
2 changes: 1 addition & 1 deletion src/routes/billing/forecast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export interface BillingForecastRouterDeps {
}

/**
* Creates the router for /api/billing/forecast.
* Creates the router for /api/billing/forecast (mounted by createApiRouter in src/routes/index.ts).
*/
export function createBillingForecastRouter(deps: BillingForecastRouterDeps = {}): Router {
const router = Router();
Expand Down
46 changes: 45 additions & 1 deletion src/routes/forecast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ import { forecastLogger } from '../middleware/forecastAccessLog.js';
import jwt from 'jsonwebtoken';

// Mock auditService to capture audit calls
jest.mock('../services/auditService.js');
jest.mock('../services/auditService.js', () => ({
defaultAuditService: {
record: jest.fn(),
},
appendAuditRow: jest.fn(),
}));
const mockAuditService = defaultAuditService as jest.Mocked<typeof defaultAuditService>;

// Mock JWT to simulate authenticated users
Expand Down Expand Up @@ -823,3 +828,42 @@ describe('Forecast access log integration', () => {
expect(payload.status).toBe(500);
});
});

// =============================================================================
// API Index Router Mount Integration (Issue #1215)
// =============================================================================

describe('createApiRouter forecast mounting (#1215)', () => {
it('mounts both /api/forecast and /api/billing/forecast on createApiRouter', async () => {
const { createApiRouter } = await import('./index.js');
const apiApp = express();
apiApp.use(express.json());
apiApp.use(requestIdMiddleware);
apiApp.use(auditEnrichMiddleware);
apiApp.use('/api', createApiRouter());
apiApp.use(errorHandler);

const listRes = await request(apiApp).get('/api/forecast?limit=5');
expect(listRes.status).toBe(200);
expect(listRes.body.success).toBe(true);
expect(listRes.body.data.items).toHaveLength(5);
expect(listRes.body.data.total).toBe(24);

const createRes = await request(apiApp)
.post('/api/forecast')
.set('Authorization', 'Bearer mock-token-dev-index-1')
.send({ name: 'Mounted Forecast', description: 'Created via createApiRouter' });
expect(createRes.status).toBe(201);
expect(createRes.body.data.name).toBe('Mounted Forecast');

const billingRes = await request(apiApp)
.get('/api/billing/forecast?lookbackDays=15&period=week')
.set('Authorization', 'Bearer mock-token-dev-index-1');
expect(billingRes.status).toBe(200);
expect(billingRes.body.userId).toBe('dev-index-1');
expect(billingRes.body.lookbackDays).toBe(15);
expect(billingRes.body.forecastPeriod).toBe('week');
expect(billingRes.body.forecastDays).toBe(7);
});
});

4 changes: 4 additions & 0 deletions src/routes/forecast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ export interface Forecast {
// In-memory store for forecast data (simulated persistence)
const forecastStore = new Map<string, Forecast>();

export function resetForecastStoreForTests(): void {
forecastStore.clear();
}

// -----------------------------------------------------------------------
// Helper functions
// -----------------------------------------------------------------------
Expand Down
8 changes: 8 additions & 0 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import type { SubscriptionRepository } from "../repositories/subscriptionReposit
import type { DeveloperRepository } from "../repositories/developerRepository.js";
import type { ApiRepository } from "../repositories/apiRepository.js";
import { createForecastRouter } from "./forecast.js";
import { createBillingForecastRouter } from "./billing/forecast.js";
import { createPlansRouter } from "./plans.js";
import { createCreditsRouter } from "./credits.js";
import type { CreditsRepository } from "../repositories/creditsRepository.js";
Expand Down Expand Up @@ -76,6 +77,7 @@ export function createApiRouter(deps: ApiRouterDeps = {}): Router {
const router = Router();

router.use("/health", healthRouter);
router.use("/forecast", createForecastRouter());
router.use("/plans", createPlansRouter());
// Hot-path credits lookup (idx_credits_lookup_hot) — see migrations/credits_index.sql
router.use(
Expand Down Expand Up @@ -258,13 +260,19 @@ export function createApiRouter(deps: ApiRouterDeps = {}): Router {
}

if (billingMiddlewares.length > 0) {
router.use(
"/billing/forecast",
...billingMiddlewares,
createBillingForecastRouter(),
);
router.use("/billing", ...billingMiddlewares, billingRouter);
router.use(
"/billing/portal",
...billingMiddlewares,
createBillingPortalRouter(),
);
} else {
router.use("/billing/forecast", createBillingForecastRouter());
router.use("/billing", billingRouter);
router.use("/billing/portal", createBillingPortalRouter());
}
Expand Down