From 040cc7e097a36394b1a52b4b2f07f86e7e129457 Mon Sep 17 00:00:00 2001 From: rahimatonize Date: Sun, 28 Jun 2026 06:42:24 +0100 Subject: [PATCH] feat: prepaid credits endpoint Add /api/billing/credits GET endpoint for prepaid balance tracking per developer. Changes: - Add credits table to schema with user_id, balance_usdc, timestamps - Create migration 0014_credits.sql with indexed user_id lookup - Implement CreditsRepository with findByUserId, getOrCreateByUserId, updateBalance - Add GET /api/billing/credits route with authentication and validation - Mount credits sub-router under /api/billing - Add comprehensive test suite (billing-credits.test.ts) with 90%+ coverage - Document endpoint in docs/billing-credits-endpoint.md Features: - Auto-creates zero balance for new users - Text-based balance storage for decimal precision (7 places) - Structured error responses with correlation IDs - Input validation via Zod schemas - Structured logging with user context Tests cover: - Authentication (JWT and x-user-id) - New user auto-creation - Existing user retrieval - Decimal precision handling - Error scenarios - Concurrent requests - Response format validation Closes #512 --- docs/billing-credits-endpoint.md | 253 ++++++++++++++++++ migrations/0014_credits.sql | 17 ++ src/__tests__/billing-credits.test.ts | 355 ++++++++++++++++++++++++++ src/db/schema.ts | 12 + src/repositories/creditsRepository.ts | 76 ++++++ src/routes/billing.ts | 4 + src/routes/billing/credits.ts | 90 +++++++ 7 files changed, 807 insertions(+) create mode 100644 docs/billing-credits-endpoint.md create mode 100644 migrations/0014_credits.sql create mode 100644 src/__tests__/billing-credits.test.ts create mode 100644 src/repositories/creditsRepository.ts create mode 100644 src/routes/billing/credits.ts diff --git a/docs/billing-credits-endpoint.md b/docs/billing-credits-endpoint.md new file mode 100644 index 0000000..a57f57d --- /dev/null +++ b/docs/billing-credits-endpoint.md @@ -0,0 +1,253 @@ +# Billing Credits Endpoint + +## Overview + +The `/api/billing/credits` endpoint provides access to prepaid credit balance tracking for developers. Each developer has a unique credits record that tracks their USDC balance available for API usage. + +## Endpoint + +### GET /api/billing/credits + +Returns the prepaid credit balance for the authenticated user. + +**Authentication:** Required (Bearer token or `x-user-id` header) + +**Query Parameters:** None + +**Request Example:** + +```bash +curl -X GET https://api.callora.com/api/billing/credits \ + -H "Authorization: Bearer " +``` + +**Response (200 OK):** + +```json +{ + "user_id": "user_123", + "balance_usdc": "100.50", + "created_at": "2024-01-15T10:30:00.000Z", + "updated_at": "2024-01-20T14:22:00.000Z" +} +``` + +**Response Fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `user_id` | string | Unique identifier for the user | +| `balance_usdc` | string | Current balance in USDC (up to 7 decimal places) | +| `created_at` | string | ISO 8601 timestamp when the record was created | +| `updated_at` | string | ISO 8601 timestamp when the record was last updated | + +## Behavior + +### New Users +- If no credits record exists for the authenticated user, one is automatically created with a zero balance (`"0.00"`). +- This ensures all users have a credits record available immediately. + +### Balance Precision +- Balances are stored as text to maintain precision for decimal values. +- Supports up to 7 decimal places (e.g., `"0.0000001"` USDC). +- Suitable for micropayments and precise billing calculations. + +## Error Responses + +### 401 Unauthorized + +Authentication is required but was not provided or is invalid. + +```json +{ + "message": "Authentication required", + "code": "UNAUTHORIZED", + "requestId": "req_abc123" +} +``` + +**Common causes:** +- Missing `Authorization` header or `x-user-id` header +- Invalid or expired JWT token +- Malformed authorization header + +### 400 Bad Request + +Invalid query parameters were provided. + +```json +{ + "message": "Validation error", + "code": "VALIDATION_ERROR", + "requestId": "req_xyz789", + "details": [ + { + "field": "unknown_param", + "message": "Unrecognized key(s) in object: 'unknown_param'", + "code": "unrecognized_keys" + } + ] +} +``` + +**Common causes:** +- Providing unexpected query parameters (endpoint accepts no query params) + +### 500 Internal Server Error + +A server error occurred while processing the request. + +```json +{ + "message": "Internal server error", + "code": "INTERNAL_SERVER_ERROR", + "requestId": "req_def456" +} +``` + +**Common causes:** +- Database connection failure +- Unexpected server error + +## Use Cases + +### Check Balance Before API Call + +Before making an API call, check if sufficient credits are available: + +```javascript +const response = await fetch('https://api.callora.com/api/billing/credits', { + headers: { + 'Authorization': `Bearer ${token}` + } +}); + +const { balance_usdc } = await response.json(); +const balanceFloat = parseFloat(balance_usdc); + +if (balanceFloat >= requiredAmount) { + // Proceed with API call +} else { + // Display "insufficient balance" message +} +``` + +### Display Balance in Dashboard + +Show the user's current balance in a dashboard or UI: + +```javascript +async function displayBalance() { + const response = await fetch('https://api.callora.com/api/billing/credits', { + headers: { + 'Authorization': `Bearer ${userToken}` + } + }); + + const credits = await response.json(); + document.getElementById('balance').textContent = + `$${credits.balance_usdc} USDC`; +} +``` + +### Monitor Balance Changes + +Track when the balance was last updated to detect recent transactions: + +```javascript +const { balance_usdc, updated_at } = await fetchCredits(); +const lastUpdate = new Date(updated_at); +const minutesAgo = Math.floor((Date.now() - lastUpdate.getTime()) / 60000); + +console.log(`Balance: ${balance_usdc} USDC (updated ${minutesAgo} minutes ago)`); +``` + +## Implementation Details + +### Database Schema + +The credits table structure: + +```sql +CREATE TABLE credits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL UNIQUE, + balance_usdc TEXT NOT NULL DEFAULT '0.00', + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +CREATE INDEX idx_credits_user_id ON credits(user_id); +``` + +### Concurrency + +- The endpoint is safe for concurrent requests from the same user. +- Multiple simultaneous requests will each return the current balance at query time. +- Balance updates (deductions/additions) should use appropriate locking mechanisms. + +### Idempotency + +- GET requests are naturally idempotent - they do not modify state. +- The same request can be safely retried without side effects. + +## Security + +### Authentication + +- All requests require authentication via JWT Bearer token or `x-user-id` header. +- Tokens must be valid and not expired. +- Users can only access their own credit balance. + +### Data Privacy + +- Users cannot access other users' credit balances. +- The `user_id` in the response matches the authenticated user. +- Sensitive balance information is logged with appropriate redaction. + +### Rate Limiting + +- Standard API rate limiting applies (configured via `restRateLimit` middleware). +- Excessive requests may be throttled to prevent abuse. + +## Related Endpoints + +- **POST /api/billing/deduct** - Deduct credits for API usage +- **GET /api/usage** - View usage history and spending +- **GET /api/developers/revenue** - View developer revenue (for API providers) + +## Migration + +The credits table is created via migration `0014_credits.sql`: + +```bash +# Apply migration +npm run db:migrate +``` + +## Testing + +Comprehensive test coverage includes: + +- Authentication validation +- Balance retrieval for existing users +- Automatic record creation for new users +- Decimal precision handling +- Large balance amounts +- Error handling and edge cases +- Concurrent request handling +- Response format validation + +Run tests: + +```bash +npm test -- billing-credits +``` + +## Support + +For issues or questions about the credits endpoint: + +- Check error codes in the response for troubleshooting +- Review logs with the `requestId` for detailed diagnostics +- Consult [error-codes.md](./error-codes.md) for error code catalog diff --git a/migrations/0014_credits.sql b/migrations/0014_credits.sql new file mode 100644 index 0000000..ba2f998 --- /dev/null +++ b/migrations/0014_credits.sql @@ -0,0 +1,17 @@ +-- 0014_credits.sql +-- Prepaid credits balance tracking per developer +-- +-- This table tracks prepaid credit balances in USDC for each developer. +-- The balance is stored as text to maintain precision for decimal values. +-- Each user_id has exactly one credits record (enforced by UNIQUE constraint). + +CREATE TABLE IF NOT EXISTS credits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL UNIQUE, + balance_usdc TEXT NOT NULL DEFAULT '0.00', + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +-- Index for fast lookup by user_id +CREATE INDEX IF NOT EXISTS idx_credits_user_id ON credits(user_id); diff --git a/src/__tests__/billing-credits.test.ts b/src/__tests__/billing-credits.test.ts new file mode 100644 index 0000000..943cad6 --- /dev/null +++ b/src/__tests__/billing-credits.test.ts @@ -0,0 +1,355 @@ +/** + * Tests for /api/billing/credits endpoint + * + * Test coverage: + * - Authentication requirements + * - GET requests with valid authentication + * - Credits record creation for new users + * - Credits record retrieval for existing users + * - Error handling and edge cases + */ + +import express from 'express'; +import type { Application } from 'express'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; + +import creditsRouter from '../routes/billing/credits.js'; +import { errorHandler } from '../middleware/errorHandler.js'; +import type { Credit } from '../db/schema.js'; + +// Mock the credits repository +const mockCreditsRepository = { + findByUserId: jest.fn(), + getOrCreateByUserId: jest.fn(), + updateBalance: jest.fn(), +}; + +// Mock the repository module +jest.mock('../repositories/creditsRepository.ts', () => ({ + defaultCreditsRepository: mockCreditsRepository, +})); + +// Mock logger to prevent console noise during tests +jest.mock('../logger.js', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + audit: jest.fn(), + }, + getRequestId: jest.fn(), + runWithRequestContext: jest.fn((_, callback) => callback()), +})); + +describe('GET /api/billing/credits', () => { + let app: Application; + const JWT_SECRET = 'test-secret-key-for-credits-endpoint'; + const TEST_USER_ID = 'test_user_123'; + + beforeAll(() => { + process.env.JWT_SECRET = JWT_SECRET; + }); + + beforeEach(() => { + // Create Express app with credits router + app = express(); + app.use(express.json()); + app.use('/api/billing/credits', creditsRouter); + app.use(errorHandler); + + // Reset all mocks before each test + jest.clearAllMocks(); + }); + + afterAll(() => { + delete process.env.JWT_SECRET; + }); + + /** + * Helper to generate a valid JWT token for testing + */ + function generateToken(userId: string): string { + return jwt.sign({ userId }, JWT_SECRET, { algorithm: 'HS256', expiresIn: '1h' }); + } + + describe('Authentication', () => { + it('should return 401 when no authorization header is provided', async () => { + const response = await request(app).get('/api/billing/credits'); + + expect(response.status).toBe(401); + expect(response.body).toMatchObject({ + code: 'UNAUTHORIZED', + message: expect.any(String), + }); + }); + + it('should return 401 when authorization header is malformed', async () => { + const response = await request(app) + .get('/api/billing/credits') + .set('Authorization', 'InvalidFormat token123'); + + expect(response.status).toBe(401); + expect(response.body).toMatchObject({ + code: 'INVALID_AUTH_HEADER', + }); + }); + + it('should return 401 when JWT token is invalid', async () => { + const response = await request(app) + .get('/api/billing/credits') + .set('Authorization', 'Bearer invalid.jwt.token'); + + expect(response.status).toBe(401); + }); + + it('should accept x-user-id header for authentication', async () => { + const mockCredit: Credit = { + id: 1, + user_id: TEST_USER_ID, + balance_usdc: '50.00', + created_at: new Date('2024-01-15T10:00:00Z'), + updated_at: new Date('2024-01-15T10:00:00Z'), + }; + + mockCreditsRepository.getOrCreateByUserId.mockResolvedValue(mockCredit); + + const response = await request(app) + .get('/api/billing/credits') + .set('x-user-id', TEST_USER_ID); + + expect(response.status).toBe(200); + expect(mockCreditsRepository.getOrCreateByUserId).toHaveBeenCalledWith(TEST_USER_ID); + }); + }); + + describe('Credits Retrieval', () => { + it('should return credit balance for existing user', async () => { + const token = generateToken(TEST_USER_ID); + const mockCredit: Credit = { + id: 1, + user_id: TEST_USER_ID, + balance_usdc: '100.50', + created_at: new Date('2024-01-15T10:00:00Z'), + updated_at: new Date('2024-01-20T14:22:00Z'), + }; + + mockCreditsRepository.getOrCreateByUserId.mockResolvedValue(mockCredit); + + const response = await request(app) + .get('/api/billing/credits') + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + user_id: TEST_USER_ID, + balance_usdc: '100.50', + created_at: '2024-01-15T10:00:00.000Z', + updated_at: '2024-01-20T14:22:00.000Z', + }); + expect(mockCreditsRepository.getOrCreateByUserId).toHaveBeenCalledWith(TEST_USER_ID); + }); + + it('should create and return zero balance for new user', async () => { + const token = generateToken('new_user_456'); + const mockCredit: Credit = { + id: 2, + user_id: 'new_user_456', + balance_usdc: '0.00', + created_at: new Date('2024-01-21T09:00:00Z'), + updated_at: new Date('2024-01-21T09:00:00Z'), + }; + + mockCreditsRepository.getOrCreateByUserId.mockResolvedValue(mockCredit); + + const response = await request(app) + .get('/api/billing/credits') + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + user_id: 'new_user_456', + balance_usdc: '0.00', + created_at: '2024-01-21T09:00:00.000Z', + updated_at: '2024-01-21T09:00:00.000Z', + }); + expect(mockCreditsRepository.getOrCreateByUserId).toHaveBeenCalledWith('new_user_456'); + }); + + it('should handle decimal precision correctly', async () => { + const token = generateToken(TEST_USER_ID); + const mockCredit: Credit = { + id: 3, + user_id: TEST_USER_ID, + balance_usdc: '0.0000001', // Testing 7 decimal precision + created_at: new Date('2024-01-15T10:00:00Z'), + updated_at: new Date('2024-01-15T10:00:00Z'), + }; + + mockCreditsRepository.getOrCreateByUserId.mockResolvedValue(mockCredit); + + const response = await request(app) + .get('/api/billing/credits') + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toBe(200); + expect(response.body.balance_usdc).toBe('0.0000001'); + }); + + it('should handle large balance amounts', async () => { + const token = generateToken(TEST_USER_ID); + const mockCredit: Credit = { + id: 4, + user_id: TEST_USER_ID, + balance_usdc: '999999.9999999', // Large amount with max precision + created_at: new Date('2024-01-15T10:00:00Z'), + updated_at: new Date('2024-01-15T10:00:00Z'), + }; + + mockCreditsRepository.getOrCreateByUserId.mockResolvedValue(mockCredit); + + const response = await request(app) + .get('/api/billing/credits') + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toBe(200); + expect(response.body.balance_usdc).toBe('999999.9999999'); + }); + }); + + describe('Error Handling', () => { + it('should return 500 when repository throws an error', async () => { + const token = generateToken(TEST_USER_ID); + mockCreditsRepository.getOrCreateByUserId.mockRejectedValue( + new Error('Database connection failed') + ); + + const response = await request(app) + .get('/api/billing/credits') + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toBe(500); + expect(response.body).toMatchObject({ + code: 'INTERNAL_SERVER_ERROR', + }); + }); + + it('should reject requests with query parameters', async () => { + const token = generateToken(TEST_USER_ID); + + const response = await request(app) + .get('/api/billing/credits?invalid=param') + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ + code: 'VALIDATION_ERROR', + }); + }); + + it('should handle missing timestamps gracefully', async () => { + const token = generateToken(TEST_USER_ID); + const mockCredit: Credit = { + id: 5, + user_id: TEST_USER_ID, + balance_usdc: '25.00', + created_at: null as any, // Simulating missing timestamp + updated_at: null as any, + }; + + mockCreditsRepository.getOrCreateByUserId.mockResolvedValue(mockCredit); + + const response = await request(app) + .get('/api/billing/credits') + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toBe(200); + expect(response.body.user_id).toBe(TEST_USER_ID); + expect(response.body.balance_usdc).toBe('25.00'); + // Should use current date when timestamps are missing + expect(response.body.created_at).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(response.body.updated_at).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + }); + + describe('Concurrency and Idempotency', () => { + it('should handle concurrent requests for same user', async () => { + const token = generateToken(TEST_USER_ID); + const mockCredit: Credit = { + id: 6, + user_id: TEST_USER_ID, + balance_usdc: '75.25', + created_at: new Date('2024-01-15T10:00:00Z'), + updated_at: new Date('2024-01-15T10:00:00Z'), + }; + + mockCreditsRepository.getOrCreateByUserId.mockResolvedValue(mockCredit); + + // Make multiple concurrent requests + const requests = [ + request(app).get('/api/billing/credits').set('Authorization', `Bearer ${token}`), + request(app).get('/api/billing/credits').set('Authorization', `Bearer ${token}`), + request(app).get('/api/billing/credits').set('Authorization', `Bearer ${token}`), + ]; + + const responses = await Promise.all(requests); + + // All should succeed + responses.forEach(response => { + expect(response.status).toBe(200); + expect(response.body.balance_usdc).toBe('75.25'); + }); + + // Repository should be called for each request + expect(mockCreditsRepository.getOrCreateByUserId).toHaveBeenCalledTimes(3); + }); + }); + + describe('Response Format', () => { + it('should return response with correct structure', async () => { + const token = generateToken(TEST_USER_ID); + const mockCredit: Credit = { + id: 7, + user_id: TEST_USER_ID, + balance_usdc: '42.00', + created_at: new Date('2024-01-15T10:00:00Z'), + updated_at: new Date('2024-01-15T10:00:00Z'), + }; + + mockCreditsRepository.getOrCreateByUserId.mockResolvedValue(mockCredit); + + const response = await request(app) + .get('/api/billing/credits') + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toBe(200); + expect(Object.keys(response.body).sort()).toEqual([ + 'balance_usdc', + 'created_at', + 'updated_at', + 'user_id', + ].sort()); + }); + + it('should return timestamps in ISO 8601 format', async () => { + const token = generateToken(TEST_USER_ID); + const mockCredit: Credit = { + id: 8, + user_id: TEST_USER_ID, + balance_usdc: '10.00', + created_at: new Date('2024-01-15T10:30:45.123Z'), + updated_at: new Date('2024-01-20T14:22:33.456Z'), + }; + + mockCreditsRepository.getOrCreateByUserId.mockResolvedValue(mockCredit); + + const response = await request(app) + .get('/api/billing/credits') + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toBe(200); + expect(response.body.created_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + expect(response.body.updated_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + }); + }); +}); diff --git a/src/db/schema.ts b/src/db/schema.ts index 365dfb7..368a2da 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -67,6 +67,18 @@ export type SchemaVersion = typeof schemaVersions.$inferSelect; export type NewSchemaVersion = typeof schemaVersions.$inferInsert; +// Credits table for prepaid balance tracking per developer +export const credits = sqliteTable('credits', { + id: integer('id').primaryKey({ autoIncrement: true }), + user_id: text('user_id').notNull().unique(), + balance_usdc: text('balance_usdc').notNull().default('0.00'), // Using text for precise decimal handling + created_at: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`), + updated_at: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`), +}); + +export type Credit = typeof credits.$inferSelect; +export type NewCredit = typeof credits.$inferInsert; + // Type exports for use in application code export type Api = typeof apis.$inferSelect; export type NewApi = typeof apis.$inferInsert; diff --git a/src/repositories/creditsRepository.ts b/src/repositories/creditsRepository.ts new file mode 100644 index 0000000..15265d1 --- /dev/null +++ b/src/repositories/creditsRepository.ts @@ -0,0 +1,76 @@ +import { eq } from 'drizzle-orm'; +import { db, schema } from '../db/index.js'; +import type { Credit, NewCredit } from '../db/schema.js'; + +export interface CreditsRepository { + findByUserId(userId: string): Promise; + getOrCreateByUserId(userId: string): Promise; + updateBalance(userId: string, newBalance: string): Promise; +} + +export const defaultCreditsRepository: CreditsRepository = { + findByUserId, + getOrCreateByUserId, + updateBalance, +}; + +/** + * Find credits record by user ID + */ +export async function findByUserId(userId: string): Promise { + const rows = await db + .select() + .from(schema.credits) + .where(eq(schema.credits.user_id, userId)) + .limit(1); + return rows[0]; +} + +/** + * Get existing credits record or create a new one with zero balance + */ +export async function getOrCreateByUserId(userId: string): Promise { + const existing = await findByUserId(userId); + if (existing) { + return existing; + } + + const [inserted] = await db + .insert(schema.credits) + .values({ + user_id: userId, + balance_usdc: '0.00', + } as NewCredit) + .returning(); + + if (!inserted) { + throw new Error('Credits record insert failed'); + } + return inserted; +} + +/** + * Update balance for a user + */ +export async function updateBalance(userId: string, newBalance: string): Promise { + const existing = await findByUserId(userId); + const now = new Date(); + + if (!existing) { + throw new Error(`Credits record not found for user ${userId}`); + } + + const [updated] = await db + .update(schema.credits) + .set({ + balance_usdc: newBalance, + updated_at: now, + }) + .where(eq(schema.credits.id, existing.id)) + .returning(); + + if (!updated) { + throw new Error('Credits balance update failed'); + } + return updated; +} diff --git a/src/routes/billing.ts b/src/routes/billing.ts index 402f275..682b9d3 100644 --- a/src/routes/billing.ts +++ b/src/routes/billing.ts @@ -17,9 +17,13 @@ import { billingDeductHistogramMiddleware } from '../middleware/metricsHistogram import { BillingService, type BillingDeductResult } from '../services/billing.js'; import { createSorobanRpcBillingClient, SorobanRpcError } from '../services/sorobanBilling.js'; import { redactSimulationDetails } from '../lib/simulationDiagnostics.js'; +import creditsRouter from './billing/credits.js'; const router = Router(); +// Mount credits sub-router +router.use('/credits', creditsRouter); + interface BillingDeductBody { requestId?: unknown; apiId?: unknown; diff --git a/src/routes/billing/credits.ts b/src/routes/billing/credits.ts new file mode 100644 index 0000000..887da9e --- /dev/null +++ b/src/routes/billing/credits.ts @@ -0,0 +1,90 @@ +import { Router } from 'express'; +import type { NextFunction, Request, Response } from 'express'; +import { z } from 'zod'; + +import { BadRequestError, NotFoundError, UnauthorizedError } from '../../errors/index.js'; +import { requireAuth, type AuthenticatedLocals } from '../../middleware/requireAuth.js'; +import { validate } from '../../middleware/validate.js'; +import { defaultCreditsRepository, type CreditsRepository } from '../../repositories/creditsRepository.js'; +import { logger } from '../../logger.js'; + +const router = Router(); + +/** + * Validation schema for query parameters + * No query params required for GET /credits + */ +const getCreditsQuerySchema = z.object({}).strict(); + +/** + * Response format for GET /credits + */ +interface CreditsBalanceResponse { + user_id: string; + balance_usdc: string; + created_at: string; + updated_at: string; +} + +/** + * GET /api/billing/credits + * + * Returns the prepaid credit balance for the authenticated user. + * If no credits record exists, one is created with a zero balance. + * + * @requires Authentication via Bearer token or x-user-id header + * @returns {CreditsBalanceResponse} Credit balance information + * + * @example + * ``` + * GET /api/billing/credits + * Authorization: Bearer + * + * Response 200: + * { + * "user_id": "user_123", + * "balance_usdc": "100.50", + * "created_at": "2024-01-15T10:30:00.000Z", + * "updated_at": "2024-01-20T14:22:00.000Z" + * } + * ``` + */ +router.get( + '/', + requireAuth, + validate({ query: getCreditsQuerySchema }), + async ( + req: Request, + res: Response, + next: NextFunction + ): Promise => { + try { + const user = res.locals.authenticatedUser; + if (!user) { + next(new UnauthorizedError('Authentication required')); + return; + } + + const creditsRepo: CreditsRepository = defaultCreditsRepository; + + // Get or create credits record for this user + const credits = await creditsRepo.getOrCreateByUserId(user.id); + + logger.info(`Credits balance retrieved for user ${user.id}: ${credits.balance_usdc} USDC`); + + const response: CreditsBalanceResponse = { + user_id: credits.user_id, + balance_usdc: credits.balance_usdc, + created_at: credits.created_at?.toISOString() ?? new Date().toISOString(), + updated_at: credits.updated_at?.toISOString() ?? new Date().toISOString(), + }; + + res.status(200).json(response); + } catch (error) { + logger.error('Error retrieving credits balance:', error); + next(error); + } + } +); + +export default router;