diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50ca2a16..17a3f2c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,9 @@ jobs: - name: Run ESLint run: npm run lint + - name: Verify OpenAPI specification (no drift) + run: pnpm run openapi:check + npm-audit: name: NPM Audit (High/Critical) runs-on: ubuntu-latest diff --git a/backend/server.ts b/backend/server.ts index 7fdf2ca8..255b7c82 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -16,6 +16,8 @@ */ import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; import { timingSafeEqual } from 'node:crypto'; import { URL } from 'node:url'; import { makeExecutableSchema } from '@graphql-tools/schema'; @@ -581,6 +583,49 @@ export async function startServer(options: StartServerOptions = {}): Promise + + + + SubTrackr API Explorer - OpenAPI 3.1 + + + +
+ + + +`); + return; + } + // ----------------------------------------------------------------- // Health Probes: Liveness & Readiness (bypass rate limiting) // ----------------------------------------------------------------- diff --git a/backend/server/createApiServer.ts b/backend/server/createApiServer.ts index 783255da..1b8c6fa7 100644 --- a/backend/server/createApiServer.ts +++ b/backend/server/createApiServer.ts @@ -32,6 +32,7 @@ import { createLoggerFor } from '../services/shared/logging'; import { createTracerFromEnv } from '../services/shared/tracing'; import { createRequestObservability } from '../shared/middleware/requestObservability'; import { createApiKeyRevocationRouter, createPasskeyRouter } from '../services/auth/router/authRouter'; +import { serveOpenApiSpec, serveSwaggerUI } from '../services/shared/swaggerUI'; // ── Zapier integration ──────────────────────────────────────────────────────── import { @@ -258,6 +259,11 @@ export function createApiServer(options: CreateApiServerOptions = {}): Express { // adds deprecation headers and rejects unknown or sunset versions. app.use(createVersionMiddleware(versionRegistry) as unknown as express.RequestHandler); + // ── OpenAPI & Swagger UI (Issue #1784) ────────────────────────────────────── + app.get('/api/docs/openapi.json', serveOpenApiSpec); + app.get('/api/docs', serveSwaggerUI); + app.get('/api/docs/', serveSwaggerUI); + // ── CDN-cached public routes ─────────────────────────────────────────────── app.use(cacheHeadersMiddleware()); app.use(createPublicApiRouter()); diff --git a/backend/services/shared/__tests__/generateOpenApi.test.ts b/backend/services/shared/__tests__/generateOpenApi.test.ts new file mode 100644 index 00000000..357685ce --- /dev/null +++ b/backend/services/shared/__tests__/generateOpenApi.test.ts @@ -0,0 +1,54 @@ +import { generateOpenApiSpec, generateSpecs } from '../../../../scripts/generate-openapi'; +import YAML from 'yaml'; + +describe('OpenAPI specification generator (issue #1784)', () => { + it('generates a valid OpenAPI 3.1.0 specification', () => { + const spec = generateOpenApiSpec() as any; + expect(spec.openapi).toBe('3.1.0'); + expect(spec.info.title).toBe('SubTrackr API'); + expect(spec.info.version).toBe('1.0.0'); + }); + + it('contains all required paths and tags', () => { + const spec = generateOpenApiSpec() as any; + expect(spec.paths['/plans']).toBeDefined(); + expect(spec.paths['/subscriptions']).toBeDefined(); + expect(spec.paths['/payments']).toBeDefined(); + expect(spec.paths['/webhooks']).toBeDefined(); + expect(spec.tags.length).toBeGreaterThan(0); + }); + + it('incorporates source schemas into components.schemas', () => { + const spec = generateOpenApiSpec() as any; + const schemas = spec.components.schemas; + expect(schemas.PlanInput).toBeDefined(); + expect(schemas.PlanUpdate).toBeDefined(); + expect(schemas.SubscriptionInput).toBeDefined(); + expect(schemas.SubscriptionUpdate).toBeDefined(); + expect(schemas.PaymentInput).toBeDefined(); + expect(schemas.WebhookInput).toBeDefined(); + expect(schemas.StandardErrorEnvelope).toBeDefined(); + }); + + it('includes cursor pagination and standard error envelopes in responses', () => { + const spec = generateOpenApiSpec() as any; + const plansGet = spec.paths['/plans'].get; + expect(plansGet.parameters.some((p: any) => p.name === 'cursor')).toBe(true); + + const validationResponse = spec.components.responses.ValidationError; + expect(validationResponse).toBeDefined(); + expect(validationResponse.content['application/json'].schema.$ref).toBe( + '#/components/schemas/StandardErrorEnvelope' + ); + }); + + it('generates valid JSON and YAML strings', () => { + const { json, yaml } = generateSpecs(); + const parsedJson = JSON.parse(json); + const parsedYaml = YAML.parse(yaml); + + expect(parsedJson.openapi).toBe('3.1.0'); + expect(parsedYaml.openapi).toBe('3.1.0'); + expect(parsedJson.info.title).toBe(parsedYaml.info.title); + }); +}); diff --git a/backend/services/shared/__tests__/validationMiddleware.test.ts b/backend/services/shared/__tests__/validationMiddleware.test.ts new file mode 100644 index 00000000..e169a78f --- /dev/null +++ b/backend/services/shared/__tests__/validationMiddleware.test.ts @@ -0,0 +1,212 @@ +import { z } from 'zod'; +import { + createValidationMiddleware, + validateRequest, + commonSchemas, +} from '../validationMiddleware'; + +describe('validationMiddleware (#1786 Request Validation Middleware)', () => { + const sampleBodySchema = z.object({ + name: z.string().min(2), + price: z.number().positive(), + currency: z.string().length(3), + }); + + const sampleQuerySchema = z.object({ + limit: z.coerce.number().min(1).max(100).default(20), + search: z.string().optional(), + }); + + const sampleParamsSchema = z.object({ + id: z.string().min(1), + }); + + describe('validateRequest function', () => { + it('succeeds with valid body, query, and params', () => { + const data = { + body: { name: 'Pro Plan', price: 29.99, currency: 'USD' }, + query: { limit: '10', search: 'pro' }, + params: { id: 'plan_123' }, + }; + + const result = validateRequest(data, { + body: sampleBodySchema, + query: sampleQuerySchema, + params: sampleParamsSchema, + }); + + expect(result.success).toBe(true); + expect(result.data?.body).toEqual({ name: 'Pro Plan', price: 29.99, currency: 'USD' }); + expect(result.data?.query).toEqual({ limit: 10, search: 'pro' }); + expect(result.data?.params).toEqual({ id: 'plan_123' }); + }); + + it('returns field-level errors when body fails validation', () => { + const data = { + body: { name: 'A', price: -5, currency: 'TOOLONG' }, + }; + + const result = validateRequest(data, { + body: sampleBodySchema, + }); + + expect(result.success).toBe(false); + expect(result.errors).toBeDefined(); + expect(result.errors?.length).toBeGreaterThanOrEqual(3); + const fields = result.errors?.map((e) => e.field); + expect(fields).toContain('body.name'); + expect(fields).toContain('body.price'); + expect(fields).toContain('body.currency'); + }); + + it('detects SQL injection and rejects', () => { + const data = { + body: { name: "admin'; DROP TABLE plans; --", price: 10, currency: 'USD' }, + }; + + const result = validateRequest( + data, + { body: sampleBodySchema }, + { detectSqlInjection: true } + ); + + expect(result.success).toBe(false); + expect(result.errors?.[0]?.code).toBe('SQL_INJECTION_DETECTED'); + }); + + it('sanitizes XSS strings by default', () => { + const data = { + body: { name: 'Plan', price: 10, currency: 'USD' }, + }; + + const result = validateRequest( + data, + { body: sampleBodySchema }, + { sanitizeXss: true } + ); + + expect(result.success).toBe(true); + expect((result.data?.body as any).name).toBe('Plan'); + }); + }); + + describe('createValidationMiddleware Express-style middleware', () => { + it('passes valid request through to next()', () => { + const middleware = createValidationMiddleware({ + body: sampleBodySchema, + }); + + const req: any = { + method: 'POST', + headers: { 'x-request-id': 'req-test-1' }, + body: { name: 'Enterprise', price: 99.99, currency: 'USD' }, + }; + const res: any = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + const next = jest.fn(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('returns 422 with standard error envelope when validation fails (Express style)', () => { + const middleware = createValidationMiddleware({ + body: sampleBodySchema, + }); + + const req: any = { + method: 'POST', + headers: { 'x-request-id': 'req-err-1' }, + body: { name: 'X', price: -10, currency: 'US' }, + }; + const res: any = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + const next = jest.fn(); + + middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(422); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ + code: 'VALIDATION_ERROR', + message: 'Request validation failed', + details: expect.any(Object), + }), + meta: expect.objectContaining({ + requestId: 'req-err-1', + apiVersion: 1, + }), + }) + ); + }); + + it('returns 422 using writeHead/end for raw Node http.ServerResponse', () => { + const middleware = createValidationMiddleware({ + body: sampleBodySchema, + }); + + const req: any = { + method: 'POST', + body: { name: 'X', price: -10, currency: 'US' }, + }; + let writtenStatus = 0; + let writtenBody = ''; + const res: any = { + writeHead: jest.fn((code: number) => { + writtenStatus = code; + }), + end: jest.fn((bodyStr: string) => { + writtenBody = bodyStr; + }), + }; + const next = jest.fn(); + + middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(writtenStatus).toBe(422); + const parsed = JSON.parse(writtenBody); + expect(parsed.success).toBe(false); + expect(parsed.error.code).toBe('VALIDATION_ERROR'); + }); + + it('returns 413 when body exceeds maxBodySize limit', () => { + const middleware = createValidationMiddleware( + { body: sampleBodySchema }, + { maxBodySize: 50 } + ); + + const req: any = { + method: 'POST', + body: { name: 'A very large plan name that is definitely way too long to fit', price: 10, currency: 'USD' }, + }; + const res: any = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + const next = jest.fn(); + + middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(413); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ + code: 'BAD_REQUEST', + }), + }) + ); + }); + }); +}); diff --git a/backend/services/shared/apiResponse.ts b/backend/services/shared/apiResponse.ts index eb5f95f2..1173d5d1 100644 --- a/backend/services/shared/apiResponse.ts +++ b/backend/services/shared/apiResponse.ts @@ -323,7 +323,21 @@ export function fail( requestId?: string, details?: Record | { version?: number }, ): ApiErrorResponse { - const errorPayload: ApiError = { code, message, ...details }; + const errorPayload: ApiError = { code, message }; + if (details) { + if ('version' in details && typeof (details as { version?: unknown }).version === 'number') { + errorPayload.version = (details as { version: number }).version; + } + const fieldDetails: Record = {}; + for (const [k, v] of Object.entries(details)) { + if (k !== 'version' && typeof v === 'string') { + fieldDetails[k] = v; + } + } + if (Object.keys(fieldDetails).length > 0) { + errorPayload.details = fieldDetails; + } + } return { success: false, error: errorPayload, diff --git a/backend/services/shared/errors.ts b/backend/services/shared/errors.ts index 1d413139..4c307401 100644 --- a/backend/services/shared/errors.ts +++ b/backend/services/shared/errors.ts @@ -1,4 +1,4 @@ -import { ErrorCode } from './apiResponse'; +import { ErrorCode, ERROR_HTTP_STATUS_MAP } from './apiResponse'; export interface StructuredErrorDetails { code: string; @@ -10,23 +10,88 @@ export interface StructuredErrorDetails { requestId?: string | null; } +export interface DomainErrorOptions { + userMessage?: string; + recovery?: string | null; + details?: Record | null; + requestId?: string | null; + cause?: unknown; +} + export class DomainError extends Error { readonly timestamp: string; + public readonly code: string; + public readonly statusCode: number; + public readonly details?: Record | null; + public readonly requestId?: string | null; + public readonly userMessage?: string; + public readonly recovery?: string | null; constructor( - public readonly code: ErrorCode | string, - message: string, - public readonly details?: Record, - public readonly statusCode: number = 400, - public readonly requestId?: string, - public readonly userMessage?: string, - public readonly recovery?: string, - public readonly cause?: unknown + arg1: string = 'Domain error', + arg2?: string, + arg3?: number | Record, + arg4?: DomainErrorOptions | Record, ) { + let message = arg1; + let code = 'INTERNAL_SERVER_ERROR'; + let statusCode = 400; + let details: Record | null | undefined; + let requestId: string | null | undefined; + let userMessage: string | undefined; + let recovery: string | null | undefined; + let cause: unknown; + + if (typeof arg3 === 'number') { + // Signature: (message, code, statusCode, options) + message = arg1; + code = arg2 ?? 'INTERNAL_SERVER_ERROR'; + statusCode = arg3; + if (arg4 && typeof arg4 === 'object') { + const opts = arg4 as DomainErrorOptions; + details = opts.details; + requestId = opts.requestId; + userMessage = opts.userMessage; + recovery = opts.recovery; + cause = opts.cause; + } + } else if (typeof arg2 === 'string') { + // Check if arg1 is an uppercase snake_case error code + const arg1IsCode = /^[A-Z0-9_]+$/.test(arg1); + if (arg1IsCode) { + code = arg1; + message = arg2; + details = (arg3 as Record) ?? undefined; + statusCode = ERROR_HTTP_STATUS_MAP[code as ErrorCode] ?? 400; + } else { + message = arg1; + code = arg2; + statusCode = typeof arg3 === 'number' ? arg3 : 400; + if (arg4 && typeof arg4 === 'object') { + const opts = arg4 as DomainErrorOptions; + details = opts.details; + requestId = opts.requestId; + userMessage = opts.userMessage; + recovery = opts.recovery; + } + } + } else { + // Single argument: (message) + message = arg1; + code = 'INTERNAL_SERVER_ERROR'; + statusCode = 400; + } + const fullMessage = cause instanceof Error ? `${message} (Caused by: ${cause.message})` : message; super(fullMessage); this.name = this.constructor.name; + this.code = code; + this.statusCode = statusCode; + this.details = details; + this.requestId = requestId; + this.userMessage = userMessage; + this.recovery = recovery; this.timestamp = new Date().toISOString(); Object.setPrototypeOf(this, new.target.prototype); @@ -53,13 +118,13 @@ export class DomainError extends Error { export class ValidationError extends DomainError { constructor( - message: string, + message = 'Validation failed', details?: Record, requestId?: string, userMessage?: string, recovery?: string ) { - super(ErrorCode.INVALID_INPUT, message, details, 400, requestId, userMessage, recovery); + super(message, 'VALIDATION_ERROR', 400, { details, requestId, userMessage, recovery }); } } @@ -71,7 +136,7 @@ export class UnauthorizedError extends DomainError { userMessage?: string, recovery?: string ) { - super(ErrorCode.UNAUTHORIZED, message, details, 401, requestId, userMessage, recovery); + super(message, 'UNAUTHORIZED', 401, { details, requestId, userMessage, recovery }); } } @@ -83,7 +148,7 @@ export class ForbiddenError extends DomainError { userMessage?: string, recovery?: string ) { - super(ErrorCode.FORBIDDEN, message, details, 403, requestId, userMessage, recovery); + super(message, 'FORBIDDEN', 403, { details, requestId, userMessage, recovery }); } } @@ -95,7 +160,7 @@ export class NotFoundError extends DomainError { userMessage?: string, recovery?: string ) { - super(ErrorCode.NOT_FOUND, message, details, 404, requestId, userMessage, recovery); + super(message, 'NOT_FOUND', 404, { details, requestId, userMessage, recovery }); } } @@ -107,7 +172,7 @@ export class ConflictError extends DomainError { userMessage?: string, recovery?: string ) { - super(ErrorCode.CONFLICT, message, details, 409, requestId, userMessage, recovery); + super(message, 'CONFLICT', 409, { details, requestId, userMessage, recovery }); } } @@ -119,7 +184,7 @@ export class UnprocessableEntityError extends DomainError { userMessage?: string, recovery?: string ) { - super('UNPROCESSABLE_ENTITY', message, details, 422, requestId, userMessage, recovery); + super(message, 'UNPROCESSABLE_ENTITY', 422, { details, requestId, userMessage, recovery }); } } @@ -131,7 +196,7 @@ export class RateLimitExceededError extends DomainError { userMessage?: string, recovery?: string ) { - super(ErrorCode.RATE_LIMIT_EXCEEDED, message, details, 429, requestId, userMessage, recovery); + super(message, 'RATE_LIMIT_EXCEEDED', 429, { details, requestId, userMessage, recovery }); } } @@ -143,7 +208,7 @@ export class InternalServerError extends DomainError { userMessage?: string, recovery?: string ) { - super(ErrorCode.INTERNAL_SERVER_ERROR, message, details, 500, requestId, userMessage, recovery); + super(message, 'INTERNAL_SERVER_ERROR', 500, { details, requestId, userMessage, recovery }); } } @@ -155,7 +220,7 @@ export class BadGatewayError extends DomainError { userMessage?: string, recovery?: string ) { - super('BAD_GATEWAY', message, details, 502, requestId, userMessage, recovery); + super(message, 'BAD_GATEWAY', 502, { details, requestId, userMessage, recovery }); } } @@ -167,7 +232,7 @@ export class ServiceUnavailableError extends DomainError { userMessage?: string, recovery?: string ) { - super(ErrorCode.SERVICE_UNAVAILABLE, message, details, 533, requestId, userMessage, recovery); + super(message, 'SERVICE_UNAVAILABLE', 503, { details, requestId, userMessage, recovery }); } } @@ -175,12 +240,15 @@ export function isDomainError(error: unknown): error is DomainError { return error instanceof DomainError; } -export function fromUnknownError(err: unknown, requestId?: string): DomainError { +export function fromUnknownError( + err: unknown, + code = 'INTERNAL_SERVER_ERROR', + statusCode = 500, + requestId?: string +): DomainError { if (isDomainError(err)) { return err; } - if (err instanceof Error) { - return new InternalServerError(err.message, undefined, requestId, undefined, undefined); - } - return new InternalServerError(String(err), undefined, requestId, undefined, undefined); + const message = err instanceof Error ? err.message : String(err); + return new DomainError(message, code, statusCode, { requestId }); } diff --git a/backend/services/shared/pagination.ts b/backend/services/shared/pagination.ts index ab418073..f245c513 100644 --- a/backend/services/shared/pagination.ts +++ b/backend/services/shared/pagination.ts @@ -91,7 +91,7 @@ export interface CursorPayload { } function sign(data: string): string { - return createHmac('sha256', CURSOR_SECRET).update(data).digest('base64url').slice(0, 16); + return createHmac('sha256', cursorSecret()).update(data).digest('base64url').slice(0, 16); } /** Encode a cursor payload into an opaque base64url string. */ diff --git a/backend/services/shared/validationMiddleware.ts b/backend/services/shared/validationMiddleware.ts index 5ba8515a..62e05b5f 100644 --- a/backend/services/shared/validationMiddleware.ts +++ b/backend/services/shared/validationMiddleware.ts @@ -12,7 +12,7 @@ import { z, type ZodSchema, type ZodError } from 'zod'; import type { IncomingMessage } from 'http'; -import { ErrorCode } from './apiResponse'; +import { fail, type ErrorCode } from './apiResponse'; // ── XSS Sanitization ───────────────────────────────────────────────────────── @@ -263,27 +263,42 @@ export function validateRequest( // ── Express-style Middleware Factory ────────────────────────────────────────── +function sendValidationResponse( + res: any, + status: number, + payload: any, +): void { + if (typeof res.status === 'function' && typeof res.json === 'function') { + res.status(status).json(payload); + } else if (typeof res.writeHead === 'function' && typeof res.end === 'function') { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(payload)); + } +} + export function createValidationMiddleware( schemas: ValidationSchemas, options: ValidationOptions = {} ) { return function validationMiddleware( - req: { body?: unknown; query?: unknown; params?: unknown; method?: string; url?: string }, - res: { writeHead: (status: number, headers?: Record) => void; end: (body: string) => void }, + req: { body?: unknown; query?: unknown; params?: unknown; method?: string; url?: string; headers?: Record }, + res: { writeHead?: (status: number, headers?: Record) => void; end?: (body: string) => void; status?: (code: number) => any; json?: (data: any) => any }, next: () => void ): void { + const reqHeaders = req.headers as Record | undefined; + const rawRequestId = reqHeaders?.['x-request-id']; + const requestId = typeof rawRequestId === 'string' ? rawRequestId : Array.isArray(rawRequestId) ? rawRequestId[0] : undefined; + // Check body size for POST/PUT/PATCH if (req.body && ['POST', 'PUT', 'PATCH'].includes(req.method ?? '')) { const bodyStr = JSON.stringify(req.body); if (bodyStr.length > (options.maxBodySize ?? BODY_SIZE_LIMITS.medium)) { - res.writeHead(413, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: { - code: 'PAYLOAD_TOO_LARGE', - message: `Request body exceeds maximum size of ${options.maxBodySize ?? BODY_SIZE_LIMITS.medium} bytes`, - }, - })); + const payload = fail( + 'BAD_REQUEST', + `Request body exceeds maximum size of ${options.maxBodySize ?? BODY_SIZE_LIMITS.medium} bytes`, + requestId, + ); + sendValidationResponse(res, 413, payload); return; } } @@ -295,18 +310,17 @@ export function createValidationMiddleware( ); if (!result.success) { - res.writeHead(422, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: { - code: ErrorCode.VALIDATION_ERROR, - message: 'Request validation failed', - details: result.errors?.reduce((acc, e) => { - acc[e.field] = e.message; - return acc; - }, {} as Record), - }, - })); + const details = result.errors?.reduce((acc, e) => { + acc[e.field] = e.message; + return acc; + }, {} as Record); + const isSqlInjection = result.errors?.some((e) => e.code === 'SQL_INJECTION_DETECTED'); + const errorCode: ErrorCode = isSqlInjection ? 'BAD_REQUEST' : 'VALIDATION_ERROR'; + const statusCode = isSqlInjection ? 400 : 422; + const message = isSqlInjection ? 'Invalid characters detected' : 'Request validation failed'; + const payload = fail(errorCode, message, requestId, details); + + sendValidationResponse(res, statusCode, payload); return; } diff --git a/backend/shared/middleware/index.ts b/backend/shared/middleware/index.ts index 200a77b8..9a784dd1 100644 --- a/backend/shared/middleware/index.ts +++ b/backend/shared/middleware/index.ts @@ -49,3 +49,15 @@ export { sanitizePath, } from './requestObservability'; export type { RequestObservabilityOptions } from './requestObservability'; +export { + createValidationMiddleware, + validateRequest, + commonSchemas, +} from '../../services/shared/validationMiddleware'; +export type { + ValidationSchemas, + ValidationOptions, + ValidationResult, + ValidationErrorDetail, +} from '../../services/shared/validationMiddleware'; + diff --git a/backend/subscription/controller/plansController.ts b/backend/subscription/controller/plansController.ts index 89d6acfa..fe271d22 100644 --- a/backend/subscription/controller/plansController.ts +++ b/backend/subscription/controller/plansController.ts @@ -3,6 +3,7 @@ */ import { ok } from '../../services/shared/apiResponse'; +import { buildPage, decodeCursor, type PageOptions } from '../../services/shared/pagination'; import { SURROGATE_KEY, scopedSurrogateKey } from '../../shared/cache/surrogateKeys'; import { publicDataStore } from '../store/publicDataStore'; import type { CacheableEndpointResult } from './types'; @@ -32,16 +33,54 @@ const DEFAULT_PLANS: PublicPlan[] = [ { id: 'enterprise', name: 'Enterprise', price: 29.99, currency: 'USD', billingCycle: 'monthly' }, ]; -/** GET /plans */ +export interface ListPlansOptions extends PageOptions { + page?: number; +} + +/** GET /plans with stable ordering and bounded cursor pagination */ export async function getPlans( provider: PlansDataProvider = storePlansProvider, requestId?: string, + options: ListPlansOptions = {}, ): Promise> { - const plans = await provider.listPlans(); - const surrogateKeys = [SURROGATE_KEY.PLAN, ...plans.map((p) => scopedSurrogateKey(SURROGATE_KEY.PLAN, p.id))]; + const allPlans = await provider.listPlans(); + const sorted = [...allPlans].sort((a, b) => a.id.localeCompare(b.id)); + + const rawLimit = options.limit ?? 20; + const limit = Math.min(Math.max(1, rawLimit), options.maxLimit ?? 100); + + let startIndex = 0; + if (options.cursor) { + const decoded = decodeCursor(options.cursor); + if (decoded) { + const idx = sorted.findIndex((p) => p.id === decoded.id); + if (idx !== -1) { + startIndex = idx + 1; + } + } + } else if (options.page && options.page > 1) { + startIndex = (options.page - 1) * limit; + } + + const pagedRows = sorted.slice(startIndex, startIndex + limit + 1); + const pageResult = buildPage( + pagedRows, + limit, + (row) => row.id, + (row) => row.id, + options.direction ?? 'asc', + 'id', + sorted.length, + ); + + const surrogateKeys = [SURROGATE_KEY.PLAN, ...pageResult.items.map((p) => scopedSurrogateKey(SURROGATE_KEY.PLAN, p.id))]; return { - response: ok(plans, requestId), + response: ok(pageResult.items, requestId, { + cursor: pageResult.nextCursor, + hasMore: pageResult.hasMore, + total: sorted.length, + }), surrogateKeys, }; } diff --git a/backend/subscription/router/publicApiRouter.ts b/backend/subscription/router/publicApiRouter.ts index c808a2fa..6318da3d 100644 --- a/backend/subscription/router/publicApiRouter.ts +++ b/backend/subscription/router/publicApiRouter.ts @@ -53,7 +53,10 @@ export function createPublicApiRouter(): Router { router.get( '/plans', asyncHandler(async (req, res) => { - const result = await getPlans(undefined, extractRequestId(req)); + const limit = req.query['limit'] ? Number(req.query['limit']) : undefined; + const cursor = typeof req.query['cursor'] === 'string' ? req.query['cursor'] : undefined; + const page = req.query['page'] ? Number(req.query['page']) : undefined; + const result = await getPlans(undefined, extractRequestId(req), { limit, cursor, page }); sendCacheableResponse(res, result, req); }), ); diff --git a/developer-portal/docs/openapi.json b/developer-portal/docs/openapi.json index b121ff71..9b27e0cd 100644 --- a/developer-portal/docs/openapi.json +++ b/developer-portal/docs/openapi.json @@ -27,21 +27,47 @@ } ], "tags": [ - { "name": "Auth", "description": "Authentication" }, - { "name": "Subscriptions", "description": "Manage subscriptions" }, - { "name": "Plans", "description": "Plans and pricing" }, - { "name": "Payments", "description": "Payment records" }, - { "name": "Invoices", "description": "Invoices and billing" }, - { "name": "Webhooks", "description": "Webhook configuration and delivery" }, - { "name": "Analytics", "description": "Usage and subscription analytics" }, - { "name": "Themes", "description": "Theme management for merchants" } + { + "name": "Auth", + "description": "Authentication" + }, + { + "name": "Subscriptions", + "description": "Manage subscriptions" + }, + { + "name": "Plans", + "description": "Plans and pricing" + }, + { + "name": "Payments", + "description": "Payment records" + }, + { + "name": "Invoices", + "description": "Invoices and billing" + }, + { + "name": "Webhooks", + "description": "Webhook configuration and delivery" + }, + { + "name": "Analytics", + "description": "Usage and subscription analytics" + }, + { + "name": "Themes", + "description": "Theme management for merchants" + } ], "paths": { "/auth/login": { "post": { "summary": "Authenticate user", "operationId": "loginUser", - "tags": ["Auth"], + "tags": [ + "Auth" + ], "requestBody": { "required": true, "content": { @@ -49,11 +75,22 @@ "schema": { "type": "object", "properties": { - "email": { "type": "string", "format": "email" }, - "password": { "type": "string" } + "email": { + "type": "string", + "format": "email" + }, + "password": { + "type": "string" + } }, - "required": ["email", "password"], - "example": { "email": "dev@example.com", "password": "secret" } + "required": [ + "email", + "password" + ], + "example": { + "email": "dev@example.com", + "password": "secret" + } } } } @@ -66,14 +103,20 @@ "schema": { "type": "object", "properties": { - "token": { "type": "string" }, - "expiresIn": { "type": "integer" } + "token": { + "type": "string" + }, + "expiresIn": { + "type": "integer" + } } } } } }, - "401": { "$ref": "#/components/responses/Unauthorized" } + "401": { + "$ref": "#/components/responses/Unauthorized" + } } } }, @@ -81,35 +124,103 @@ "get": { "summary": "List subscriptions", "operationId": "listSubscriptions", - "tags": ["Subscriptions"], - "security": [{ "BearerAuth": [] }], + "tags": [ + "Subscriptions" + ], + "security": [ + { + "BearerAuth": [] + } + ], "parameters": [ - { "name": "status", "in": "query", "schema": { "type": "string", "enum": ["active", "cancelled", "paused"] } }, - { "name": "category", "in": "query", "schema": { "type": "string" } }, - { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } }, - { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20, "maximum": 100 } } + { + "name": "status", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "active", + "paused", + "cancelled", + "past_due", + "trialing" + ] + } + }, + { + "name": "customerId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "planId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Opaque pagination cursor" + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "default": 20, + "maximum": 100 + } + } ], "responses": { "200": { "description": "List of subscriptions", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/SubscriptionList" } + "schema": { + "$ref": "#/components/schemas/SubscriptionList" + } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" } } }, "post": { "summary": "Create subscription", "operationId": "createSubscription", - "tags": ["Subscriptions"], - "security": [{ "BearerAuth": [] }], + "tags": [ + "Subscriptions" + ], + "security": [ + { + "BearerAuth": [] + } + ], "requestBody": { "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/SubscriptionInput" } + "schema": { + "$ref": "#/components/schemas/SubscriptionInput" + } } } }, @@ -118,11 +229,18 @@ "description": "Subscription created", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Subscription" } + "schema": { + "$ref": "#/components/schemas/Subscription" + } } } }, - "400": { "description": "Invalid request body" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } } } }, @@ -130,32 +248,68 @@ "get": { "summary": "Get subscription", "operationId": "getSubscription", - "tags": ["Subscriptions"], - "security": [{ "BearerAuth": [] }], - "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], + "tags": [ + "Subscriptions" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Subscription", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Subscription" } + "schema": { + "$ref": "#/components/schemas/Subscription" + } } } }, - "404": { "description": "Subscription not found" } + "404": { + "$ref": "#/components/responses/NotFound" + } } }, "put": { "summary": "Update subscription", "operationId": "updateSubscription", - "tags": ["Subscriptions"], - "security": [{ "BearerAuth": [] }], - "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], + "tags": [ + "Subscriptions" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "requestBody": { "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/SubscriptionInput" } + "schema": { + "$ref": "#/components/schemas/SubscriptionUpdate" + } } } }, @@ -164,20 +318,48 @@ "description": "Updated subscription", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Subscription" } + "schema": { + "$ref": "#/components/schemas/Subscription" + } } } + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "422": { + "$ref": "#/components/responses/ValidationError" } } }, "delete": { "summary": "Delete subscription", "operationId": "deleteSubscription", - "tags": ["Subscriptions"], - "security": [{ "BearerAuth": [] }], - "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], + "tags": [ + "Subscriptions" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { - "204": { "description": "Subscription deleted" } + "204": { + "description": "Subscription deleted" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } } } }, @@ -185,18 +367,29 @@ "post": { "summary": "Cancel subscription", "operationId": "cancelSubscription", - "tags": ["Subscriptions"], - "security": [{ "BearerAuth": [] }], - "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], + "tags": [ + "Subscriptions" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "reason": { "type": "string" }, - "atPeriodEnd": { "type": "boolean", "default": true } - } + "$ref": "#/components/schemas/SubscriptionCancel" } } } @@ -206,9 +399,14 @@ "description": "Subscription cancelled", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Subscription" } + "schema": { + "$ref": "#/components/schemas/Subscription" + } } } + }, + "404": { + "$ref": "#/components/responses/NotFound" } } } @@ -217,20 +415,80 @@ "get": { "summary": "List plans", "operationId": "listPlans", - "tags": ["Plans"], - "security": [{ "ApiKeyAuth": [] }], + "tags": [ + "Plans" + ], + "parameters": [ + { + "name": "active", + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "currency", + "in": "query", + "schema": { + "type": "string", + "minLength": 3, + "maxLength": 3 + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Opaque pagination cursor" + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "default": 20, + "maximum": 100 + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "default": 1 + } + } + ], "responses": { "200": { - "description": "List of plans", + "description": "List of plans with cursor pagination", "content": { "application/json": { "schema": { "type": "object", + "required": [ + "success", + "data", + "meta" + ], "properties": { - "success": { "type": "boolean" }, + "success": { + "type": "boolean", + "const": true + }, "data": { "type": "array", - "items": { "$ref": "#/components/schemas/Plan" } + "items": { + "$ref": "#/components/schemas/Plan" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + }, + "meta": { + "$ref": "#/components/schemas/Meta" } } } @@ -238,171 +496,452 @@ } } } + }, + "post": { + "summary": "Create plan", + "operationId": "createPlan", + "tags": [ + "Plans" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanInput" + } + } + } + }, + "responses": { + "201": { + "description": "Plan created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Plan" + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } } }, - "/payments": { + "/plans/{id}": { "get": { - "summary": "List payments", - "operationId": "listPayments", - "tags": ["Payments"], - "security": [{ "BearerAuth": [] }], + "summary": "Get plan by ID", + "operationId": "getPlanById", + "tags": [ + "Plans" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { - "description": "List of payments", + "description": "Plan details", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { "type": "boolean" }, - "data": { - "type": "array", - "items": { "$ref": "#/components/schemas/Payment" } - }, - "pagination": { "$ref": "#/components/schemas/Pagination" } - } + "$ref": "#/components/schemas/Plan" } } } + }, + "404": { + "$ref": "#/components/responses/NotFound" } } - } - }, - "/invoices": { - "get": { - "summary": "List invoices", - "operationId": "listInvoices", - "tags": ["Invoices"], - "security": [{ "BearerAuth": [] }], + }, + "patch": { + "summary": "Update plan", + "operationId": "updatePlan", + "tags": [ + "Plans" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanUpdate" + } + } + } + }, "responses": { "200": { - "description": "List of invoices", + "description": "Updated plan", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { "type": "boolean" }, - "data": { - "type": "array", - "items": { "$ref": "#/components/schemas/Invoice" } - } - } + "$ref": "#/components/schemas/Plan" } } } + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "422": { + "$ref": "#/components/responses/ValidationError" } } } }, - "/webhooks": { + "/payments": { "get": { - "summary": "List webhooks", - "operationId": "listWebhooks", - "tags": ["Webhooks"], - "security": [{ "BearerAuth": [] }], + "summary": "List payments", + "operationId": "listPayments", + "tags": [ + "Payments" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "customerId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "succeeded", + "failed", + "pending" + ] + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "default": 20, + "maximum": 100 + } + } + ], "responses": { "200": { - "description": "List of webhook endpoints", + "description": "List of payments", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { "type": "boolean" }, - "data": { "type": "array", "items": { "$ref": "#/components/schemas/Webhook" } } + "type": "array", + "items": { + "$ref": "#/components/schemas/Payment" } } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" } } }, "post": { - "summary": "Create webhook", - "operationId": "createWebhook", - "tags": ["Webhooks"], - "security": [{ "BearerAuth": [] }], + "summary": "Record or initiate payment", + "operationId": "recordPayment", + "tags": [ + "Payments" + ], + "security": [ + { + "BearerAuth": [] + } + ], "requestBody": { "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/WebhookInput" } + "schema": { + "$ref": "#/components/schemas/PaymentInput" + } } } }, "responses": { "201": { - "description": "Webhook created", + "description": "Payment recorded", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { "type": "boolean" }, - "data": { "$ref": "#/components/schemas/Webhook" } - } + "$ref": "#/components/schemas/Payment" } } } + }, + "422": { + "$ref": "#/components/responses/ValidationError" } } } }, - "/analytics/usage": { + "/invoices": { "get": { - "summary": "Get usage analytics", - "operationId": "getUsageAnalytics", - "tags": ["Analytics"], - "security": [{ "BearerAuth": [] }], - "responses": { - "200": { - "description": "Usage analytics", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { "type": "boolean" }, - "data": { - "type": "object", - "properties": { - "requests": { "type": "object" }, - "credits": { "type": "object" }, - "rateLimit": { "type": "object" } - } - } - } - } + "summary": "List invoices", + "operationId": "listInvoices", + "tags": [ + "Invoices" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "subscriptionId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "paid", + "open", + "void" + ] + } + } + ], + "responses": { + "200": { + "description": "List of invoices", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Invoice" + } + } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" } } } }, - "/themes": { + "/webhooks": { + "get": { + "summary": "List webhooks", + "operationId": "listWebhooks", + "tags": [ + "Webhooks" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "List of webhooks", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Webhook" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + }, + "post": { + "summary": "Create webhook endpoint", + "operationId": "createWebhook", + "tags": [ + "Webhooks" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookInput" + } + } + } + }, + "responses": { + "201": { + "description": "Webhook created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Webhook" + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/analytics/usage": { "get": { - "summary": "List themes", - "operationId": "listThemes", - "tags": ["Themes"], - "security": [{ "BearerAuth": [] }], + "summary": "Get usage analytics", + "operationId": "getUsageAnalytics", + "tags": [ + "Analytics" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "startDate", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "endDate", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "granularity", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "hour", + "day", + "week", + "month" + ], + "default": "day" + } + } + ], "responses": { "200": { - "description": "List of themes", + "description": "Usage analytics data", "content": { "application/json": { "schema": { "type": "object", "properties": { - "success": { "type": "boolean" }, - "data": { - "type": "array", - "items": { "$ref": "#/components/schemas/Theme" } + "period": { + "type": "string" + }, + "totalRequests": { + "type": "integer" + }, + "activeSubscriptions": { + "type": "integer" + }, + "revenue": { + "type": "number" } } } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/themes": { + "get": { + "summary": "Get theme settings", + "operationId": "getThemes", + "tags": [ + "Themes" + ], + "responses": { + "200": { + "description": "Theme settings", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Theme" + } + } + } + } } } } @@ -413,8 +952,7 @@ "BearerAuth": { "type": "http", "scheme": "bearer", - "bearerFormat": "JWT", - "description": "Bearer token (sk_test_... or sk_live_...)" + "bearerFormat": "JWT" }, "ApiKeyAuth": { "type": "apiKey", @@ -426,109 +964,906 @@ "Subscription": { "type": "object", "properties": { - "id": { "type": "string" }, - "name": { "type": "string" }, - "category": { "type": "string" }, - "price": { "type": "number" }, - "currency": { "type": "string" }, - "billingCycle": { "type": "string", "enum": ["monthly", "yearly"] }, - "status": { "type": "string", "enum": ["active", "paused", "cancelled"] }, - "nextBillingDate": { "type": "string", "format": "date-time" }, - "createdAt": { "type": "string", "format": "date-time" } + "id": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "planId": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "active", + "paused", + "cancelled", + "past_due", + "trialing" + ] + }, + "currentPeriodStart": { + "type": "string", + "format": "date-time" + }, + "currentPeriodEnd": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } } }, "SubscriptionInput": { "type": "object", - "required": ["name", "price", "currency", "billingCycle"], "properties": { - "name": { "type": "string" }, - "category": { "type": "string" }, - "price": { "type": "number", "exclusiveMinimum": 0 }, - "currency": { "type": "string" }, - "billingCycle": { "type": "string", "enum": ["monthly", "yearly"] }, - "startDate": { "type": "string", "format": "date-time" } - } + "customerId": { + "type": "string", + "pattern": "^0x[a-fA-F0-9]{40}$" + }, + "planId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "paymentMethodId": { + "type": "string", + "minLength": 1 + }, + "trialEnd": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "customerId", + "planId", + "paymentMethodId" + ], + "additionalProperties": false + }, + "SubscriptionUpdate": { + "type": "object", + "properties": { + "planId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "paymentMethodId": { + "type": "string", + "minLength": 1 + }, + "cancelAtPeriodEnd": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "SubscriptionCancel": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "maxLength": 500 + }, + "feedback": { + "type": "string", + "maxLength": 2000 + }, + "immediate": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "immediate" + ], + "additionalProperties": false + }, + "SubscriptionPause": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "maxLength": 500 + }, + "resumeAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "pauseDurationDays": { + "type": "integer", + "minimum": 1, + "maximum": 365 + } + }, + "additionalProperties": false }, "SubscriptionList": { "type": "object", "properties": { - "success": { "type": "boolean" }, - "data": { "type": "array", "items": { "$ref": "#/components/schemas/Subscription" } }, - "pagination": { "$ref": "#/components/schemas/Pagination" } + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Subscription" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } } }, "Pagination": { "type": "object", "properties": { - "page": { "type": "integer" }, - "limit": { "type": "integer" }, - "total": { "type": "integer" }, - "hasNext": { "type": "boolean" } + "nextCursor": { + "type": [ + "string", + "null" + ] + }, + "hasMore": { + "type": "boolean" + }, + "limit": { + "type": "integer" + } + } + }, + "Meta": { + "type": "object", + "required": [ + "timestamp", + "requestId", + "apiVersion" + ], + "properties": { + "timestamp": { + "type": "string", + "format": "date-time" + }, + "requestId": { + "type": "string" + }, + "apiVersion": { + "type": "string" + } + } + }, + "StandardErrorEnvelope": { + "type": "object", + "required": [ + "success", + "error", + "meta" + ], + "properties": { + "success": { + "type": "boolean", + "const": false + }, + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": true + }, + "version": { + "type": "string" + } + } + }, + "meta": { + "type": "object", + "required": [ + "timestamp", + "requestId", + "apiVersion" + ], + "properties": { + "timestamp": { + "type": "string", + "format": "date-time" + }, + "requestId": { + "type": "string" + }, + "apiVersion": { + "type": "string" + } + } + } } }, "Plan": { "type": "object", "properties": { - "id": { "type": "string" }, - "name": { "type": "string" }, - "price": { "type": "number" }, - "currency": { "type": "string" }, - "interval": { "type": "string", "enum": ["monthly", "yearly"] } + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "amount": { + "type": "number" + }, + "currency": { + "type": "string" + }, + "interval": { + "type": "string", + "enum": [ + "day", + "week", + "month", + "year" + ] + }, + "active": { + "type": "boolean" + } } }, + "PlanInput": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "description": { + "type": "string", + "maxLength": 500 + }, + "amount": { + "type": "number", + "exclusiveMinimum": 0 + }, + "currency": { + "type": "string", + "minLength": 3, + "maxLength": 3 + }, + "interval": { + "type": "string", + "enum": [ + "day", + "week", + "month", + "year" + ] + }, + "intervalCount": { + "default": 1, + "type": "integer", + "minimum": 1, + "maximum": 12 + }, + "trialPeriodDays": { + "type": "integer", + "minimum": 0, + "maximum": 365 + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "active": { + "default": true, + "type": "boolean" + } + }, + "required": [ + "name", + "amount", + "currency", + "interval", + "intervalCount", + "active" + ], + "additionalProperties": false + }, + "PlanUpdate": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "description": { + "type": "string", + "maxLength": 500 + }, + "amount": { + "type": "number", + "exclusiveMinimum": 0 + }, + "currency": { + "type": "string", + "minLength": 3, + "maxLength": 3 + }, + "interval": { + "type": "string", + "enum": [ + "day", + "week", + "month", + "year" + ] + }, + "intervalCount": { + "default": 1, + "type": "integer", + "minimum": 1, + "maximum": 12 + }, + "trialPeriodDays": { + "type": "integer", + "minimum": 0, + "maximum": 365 + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "active": { + "default": true, + "type": "boolean" + } + }, + "additionalProperties": false + }, "Payment": { "type": "object", "properties": { - "id": { "type": "string" }, - "subscriptionId": { "type": "string" }, - "amount": { "type": "number" }, - "currency": { "type": "string" }, - "status": { "type": "string" }, - "createdAt": { "type": "string", "format": "date-time" } + "id": { + "type": "string" + }, + "amount": { + "type": "number" + }, + "currency": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "succeeded", + "failed", + "pending" + ] + }, + "createdAt": { + "type": "string", + "format": "date-time" + } } }, + "PaymentInput": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "exclusiveMinimum": 0 + }, + "currency": { + "type": "string", + "minLength": 3, + "maxLength": 3 + }, + "customerId": { + "type": "string", + "pattern": "^0x[a-fA-F0-9]{40}$" + }, + "paymentMethodId": { + "type": "string", + "minLength": 1 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "gatewayPreference": { + "type": "string", + "enum": [ + "stripe", + "circle", + "stellar" + ] + } + }, + "required": [ + "amount", + "currency", + "customerId", + "paymentMethodId", + "idempotencyKey" + ], + "additionalProperties": false + }, + "RefundInput": { + "type": "object", + "properties": { + "chargeId": { + "type": "string", + "minLength": 1 + }, + "amount": { + "type": "number", + "exclusiveMinimum": 0 + }, + "reason": { + "type": "string", + "maxLength": 500 + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "chargeId" + ], + "additionalProperties": false + }, "Invoice": { "type": "object", "properties": { - "id": { "type": "string" }, - "subscriptionId": { "type": "string" }, - "total": { "type": "number" }, - "currency": { "type": "string" }, - "status": { "type": "string", "enum": ["draft", "open", "paid", "partial"] }, - "dueAt": { "type": "string", "format": "date-time" } + "id": { + "type": "string" + }, + "subscriptionId": { + "type": "string" + }, + "amount": { + "type": "number" + }, + "currency": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "paid", + "open", + "void" + ] + }, + "dueDate": { + "type": "string", + "format": "date-time" + } } }, "Webhook": { "type": "object", "properties": { - "id": { "type": "string" }, - "url": { "type": "string", "format": "uri" }, - "events": { "type": "array", "items": { "type": "string" } }, - "status": { "type": "string", "enum": ["enabled", "disabled"] }, - "createdAt": { "type": "string", "format": "date-time" } + "id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "events": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "createdAt": { + "type": "string", + "format": "date-time" + } } }, "WebhookInput": { "type": "object", - "required": ["url", "events"], "properties": { - "url": { "type": "string", "format": "uri" }, + "url": { + "type": "string", + "format": "uri" + }, "events": { + "minItems": 1, + "type": "array", + "items": { + "type": "string" + } + }, + "secret": { + "type": "string", + "minLength": 16, + "maxLength": 255 + }, + "active": { + "default": true, + "type": "boolean" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "url", + "events", + "active" + ], + "additionalProperties": false + }, + "ApiKeyInput": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "tier": { + "default": "free", + "type": "string", + "enum": [ + "free", + "basic", + "pro", + "enterprise" + ] + }, + "rateLimit": { + "type": "integer", + "minimum": 1, + "maximum": 100000 + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "name", + "tier" + ], + "additionalProperties": false + }, + "AnalyticsQuery": { + "type": "object", + "properties": { + "startDate": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "endDate": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "granularity": { + "default": "day", + "type": "string", + "enum": [ + "hour", + "day", + "week", + "month" + ] + }, + "metrics": { "type": "array", "items": { "type": "string", - "enum": ["subscription.created", "subscription.updated", "subscription.cancelled", "payment.completed", "payment.failed", "invoice.generated"] + "enum": [ + "revenue", + "subscribers", + "churn_rate", + "mrr", + "arr", + "ltv", + "conversion_rate" + ] } + }, + "groupBy": { + "type": "string", + "enum": [ + "plan", + "currency", + "gateway", + "country" + ] } - } + }, + "required": [ + "granularity" + ], + "additionalProperties": false + }, + "ForecastRequest": { + "type": "object", + "properties": { + "horizon": { + "default": 3, + "type": "integer", + "minimum": 1, + "maximum": 24 + }, + "granularity": { + "default": "month", + "type": "string", + "enum": [ + "day", + "week", + "month" + ] + }, + "includeConfidence": { + "default": true, + "type": "boolean" + }, + "confidenceLevel": { + "default": 0.95, + "type": "number", + "minimum": 0.5, + "maximum": 0.99 + } + }, + "required": [ + "horizon", + "granularity", + "includeConfidence", + "confidenceLevel" + ], + "additionalProperties": false + }, + "FallbackChain": { + "type": "object", + "properties": { + "merchantId": { + "type": "string", + "minLength": 1 + }, + "chain": { + "minItems": 2, + "maxItems": 5, + "type": "array", + "items": { + "type": "object", + "properties": { + "gateway": { + "type": "string", + "enum": [ + "stripe", + "circle", + "stellar" + ] + }, + "priority": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "enabled": { + "default": true, + "type": "boolean" + }, + "timeoutMs": { + "default": 5000, + "type": "integer", + "minimum": 100, + "maximum": 30000 + } + }, + "required": [ + "gateway", + "priority", + "enabled", + "timeoutMs" + ], + "additionalProperties": false + } + }, + "retryAttempts": { + "default": 1, + "type": "integer", + "minimum": 0, + "maximum": 5 + }, + "retryDelayMs": { + "default": 1000, + "type": "integer", + "minimum": 100, + "maximum": 10000 + } + }, + "required": [ + "merchantId", + "chain", + "retryAttempts", + "retryDelayMs" + ], + "additionalProperties": false + }, + "CorsPolicy": { + "type": "object", + "properties": { + "tenantId": { + "type": "string", + "minLength": 1 + }, + "allowedOrigins": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "format": "uri" + } + }, + "allowCredentials": { + "default": false, + "type": "boolean" + }, + "exposedHeaders": { + "type": "array", + "items": { + "type": "string" + } + }, + "maxAge": { + "default": 86400, + "type": "integer", + "minimum": 0, + "maximum": 86400 + }, + "allowMethods": { + "default": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ], + "type": "array", + "items": { + "type": "string", + "enum": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS" + ] + } + }, + "allowHeaders": { + "default": [ + "Content-Type", + "Authorization" + ], + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "tenantId", + "allowedOrigins", + "allowCredentials", + "maxAge", + "allowMethods", + "allowHeaders" + ], + "additionalProperties": false }, "Theme": { "type": "object", "properties": { - "id": { "type": "string" }, - "name": { "type": "string" }, - "primaryColor": { "type": "string" }, - "status": { "type": "string", "enum": ["active", "draft"] } + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "primaryColor": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "active", + "draft" + ] + } } } }, @@ -538,11 +1873,37 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { "type": "boolean", "const": false }, - "error": { "type": "object", "properties": { "code": { "type": "string" }, "message": { "type": "string" } } } - } + "$ref": "#/components/schemas/StandardErrorEnvelope" + } + } + } + }, + "NotFound": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardErrorEnvelope" + } + } + } + }, + "BadRequest": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardErrorEnvelope" + } + } + } + }, + "ValidationError": { + "description": "Request payload validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardErrorEnvelope" } } } diff --git a/docs/ERROR_HANDLING.md b/docs/ERROR_HANDLING.md index bc6bd8ea..13203004 100644 --- a/docs/ERROR_HANDLING.md +++ b/docs/ERROR_HANDLING.md @@ -28,26 +28,80 @@ All backend domain errors inherit from `DomainError`: - `ConflictError` (409 Conflict) - `RateLimitExceededError` (429 Too Many Requests) - `InternalServerError` (500 Internal Server Error) -- `ServiceUnavailableError` (533 Service Unavailable) +- `BadGatewayError` (502 Bad Gateway) +- `ServiceUnavailableError` (503 Service Unavailable) ## Standard API Error Response Payload -All API error responses follow the standard JSON format: +All API error responses follow the standard JSON envelope defined in `backend/services/shared/apiResponse.ts`: ```json { "success": false, "error": { - "code": "INVALID_INPUT", - "message": "The provided subscription ID format is invalid.", + "code": "VALIDATION_ERROR", + "message": "Invalid input provided.", "details": { - "field": "subscriptionId" - }, - "timestamp": "2026-07-27T11:00:00.000Z", - "requestId": "req_abc123" + "price": "must be > 0" + } + }, + "meta": { + "timestamp": "2026-10-17T15:00:00.000Z", + "requestId": "req_abc123", + "apiVersion": 1 } } ``` +## Canonical Error Codes & HTTP Status Mapping + +Error codes follow the `_` naming convention and map to HTTP status codes via `ERROR_HTTP_STATUS_MAP`: + +| Error Code | HTTP Status | Domain | Description | +|---|---|---|---| +| `INTERNAL_SERVER_ERROR` | 500 | Generic | Unhandled unexpected server failure | +| `VALIDATION_ERROR` | 422 | Generic | Request payload, query, or path validation failure | +| `BAD_REQUEST` | 400 | Generic | Malformed syntax or missing headers | +| `NOT_FOUND` | 404 | Generic | Requested resource does not exist | +| `UNAUTHORIZED` | 401 | Generic | Authentication credentials missing or invalid | +| `FORBIDDEN` | 403 | Generic | Insufficient permissions for requested action | +| `CONFLICT` | 409 | Generic | Resource state conflict | +| `CONFLICT_VERSION_MISMATCH` | 409 | Generic | Optimistic concurrency control version conflict | +| `SERVICE_UNAVAILABLE` | 503 | Generic | Upstream dependency or service down | +| `RATE_LIMIT_EXCEEDED` | 429 | Rate Limiting | Burst or general rate limit quota exceeded | +| `RATE_LIMIT_HOURLY_EXCEEDED`| 429 | Rate Limiting | Hourly request limit exceeded | +| `RATE_LIMIT_DAILY_EXCEEDED` | 429 | Rate Limiting | Daily request limit exceeded | +| `RATE_LIMIT_MONTHLY_EXCEEDED`| 429| Rate Limiting | Monthly tier limit exceeded | +| `SUBSCRIPTION_NOT_FOUND` | 404 | Subscription | Subscription ID does not exist | +| `SUBSCRIPTION_ALREADY_ACTIVE`| 409 | Subscription | Subscription already in active state | +| `SUBSCRIPTION_CANCELLED` | 409 | Subscription | Action cannot be performed on cancelled subscription | +| `SUBSCRIPTION_PAUSED` | 409 | Subscription | Subscription currently paused | +| `SUBSCRIPTION_CHARGE_FAILED`| 402 | Subscription | Subscription billing charge failed | +| `PLAN_NOT_FOUND` | 404 | Plan | Plan ID does not exist | +| `PLAN_INACTIVE` | 409 | Plan | Plan is not active | +| `PLAN_PRICE_INVALID` | 422 | Plan | Plan price specification invalid | +| `DUNNING_ENTRY_NOT_FOUND` | 404 | Dunning | Dunning record not found | +| `DUNNING_ALREADY_PAUSED` | 409 | Dunning | Dunning sequence already paused | +| `WEBHOOK_NOT_FOUND` | 404 | Webhook | Webhook endpoint not found | +| `WEBHOOK_DELIVERY_FAILED` | 502 | Webhook | Outbound webhook delivery attempt failed | +| `WEBHOOK_PAYLOAD_TOO_LARGE`| 413 | Webhook | Inbound webhook payload exceeds size limit | +| `CAMPAIGN_NOT_FOUND` | 404 | Promotion | Promotional campaign not found | +| `COUPON_INVALID` | 422 | Promotion | Coupon code invalid or not applicable | +| `COUPON_EXPIRED` | 410 | Promotion | Coupon validity period has expired | +| `COUPON_MAX_USES_REACHED` | 409 | Promotion | Coupon redemption limit reached | +| `TAX_CALCULATION_FAILED` | 500 | Tax | Automated tax computation failed | +| `TAX_JURISDICTION_NOT_FOUND`| 404| Tax | Tax jurisdiction unknown | +| `IDEMPOTENCY_KEY_COLLISION`| 422 | Idempotency | Idempotency key reused with mismatched body | +| `IDEMPOTENCY_REQUEST_IN_FLIGHT`| 409 | Idempotency | Concurrent request with same key in progress | +| `AUTH_API_KEY_NOT_FOUND` | 401 | Auth | API key not recognized | +| `AUTH_API_KEY_EXPIRED` | 401 | Auth | API key has expired | +| `AUTH_API_KEY_REVOKED` | 401 | Auth | API key has been revoked | +| `PAYMENT_GATEWAY_NOT_FOUND`| 404 | Payment | Payment gateway adapter not found | +| `PAYMENT_GATEWAY_ERROR` | 502 | Payment | Upstream payment gateway error | +| `PAYMENT_REFUND_PARTIAL_FAILED`| 422 | Payment | Partial refund execution failed | +| `RPC_TIMEOUT` | 504 | RPC | Blockchain RPC provider call timed out | +| `RPC_CIRCUIT_OPEN` | 503 | RPC | RPC circuit breaker open due to high failure rate | + ## Best Practices 1. **Never throw primitive strings or raw Error objects**. Always instantiate domain-specific error classes. 2. **Include Context and Request IDs** to allow trace correlation across client and server logs. 3. **Provide User Recovery Actions** in client error instances where actionable remediation is possible. +4. **Use `fail()` or `fromError()`** when constructing API responses directly in controllers. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 4b43b494..2f9a9e8e 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -130,6 +130,11 @@ Validation errors follow the standard API response envelope: "body.name": "String must contain at least 1 character(s)", "body.amount": "Amount must be positive" } + }, + "meta": { + "timestamp": "2026-10-17T15:00:00.000Z", + "requestId": "req_abc123", + "apiVersion": 1 } } ``` diff --git a/package.json b/package.json index 1f16e3cf..d51102f9 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,8 @@ "e2e:visual:update-ios": "UPDATE_VISUAL_BASELINE=true detox test -c ios.sim.release --testNamePattern \"Subscription Visual Regression\"", "e2e:visual:update-android": "UPDATE_VISUAL_BASELINE=true detox test -c android.emu.release --testNamePattern \"Subscription Visual Regression\"", "sdk:generate": "node scripts/generate-sdks.js", + "openapi:generate": "tsx scripts/generate-openapi.ts", + "openapi:check": "tsx scripts/generate-openapi.ts --check", "api:changelog": "node scripts/generate-api-changelog.js", "api:changelog:preview": "node scripts/generate-api-changelog.js --stdout", "api:changelog:ci": "node scripts/generate-api-changelog.js --breaking", diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts new file mode 100644 index 00000000..481d5ad9 --- /dev/null +++ b/scripts/generate-openapi.ts @@ -0,0 +1,857 @@ +#!/usr/bin/env node +/** + * Issue #1784 — OpenAPI specification generation from source schemas + * + * Generates developer-portal/docs/openapi.json and spec/openapi.yaml + * from source Zod validation schemas in backend/services/shared/schemas.ts. + * + * Usage: + * npx tsx scripts/generate-openapi.ts # Regenerate OpenAPI specs + * npx tsx scripts/generate-openapi.ts --check # Verify specs have no drift + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import YAML from 'yaml'; +import { z } from 'zod'; +import { + createPlanSchema, + updatePlanSchema, + planQuerySchema, + createSubscriptionSchema, + updateSubscriptionSchema, + cancelSubscriptionSchema, + pauseSubscriptionSchema, + subscriptionQuerySchema, + paymentRequestSchema, + refundRequestSchema, + paymentQuerySchema, + createWebhookSchema, + createApiKeySchema, + analyticsQuerySchema, + forecastRequestSchema, + customerQuerySchema, + fallbackChainSchema, + fallbackQuerySchema, + corsPolicySchema, + idParamSchema, + subscriptionIdParamSchema, + planIdParamSchema, +} from '../backend/services/shared/schemas'; + +const root = process.cwd(); +const jsonPath = path.join(root, 'developer-portal/docs/openapi.json'); +const yamlPath = path.join(root, 'spec/openapi.yaml'); + +function schemaToJsonSchema(schema: z.ZodTypeAny): Record { + const jsonSchema = z.toJSONSchema(schema) as Record; + delete jsonSchema['$schema']; + return jsonSchema; +} + +export function generateOpenApiSpec(): Record { + const planInput = schemaToJsonSchema(createPlanSchema); + const planUpdate = schemaToJsonSchema(updatePlanSchema); + const subInput = schemaToJsonSchema(createSubscriptionSchema); + const subUpdate = schemaToJsonSchema(updateSubscriptionSchema); + const subCancel = schemaToJsonSchema(cancelSubscriptionSchema); + const subPause = schemaToJsonSchema(pauseSubscriptionSchema); + const paymentRequest = schemaToJsonSchema(paymentRequestSchema); + const refundRequest = schemaToJsonSchema(refundRequestSchema); + const webhookInput = schemaToJsonSchema(createWebhookSchema); + const apiKeyInput = schemaToJsonSchema(createApiKeySchema); + const analyticsQuery = schemaToJsonSchema(analyticsQuerySchema); + const forecastRequest = schemaToJsonSchema(forecastRequestSchema); + const fallbackChain = schemaToJsonSchema(fallbackChainSchema); + const corsPolicy = schemaToJsonSchema(corsPolicySchema); + + const errorEnvelopeSchema = { + type: 'object', + required: ['success', 'error', 'meta'], + properties: { + success: { type: 'boolean', const: false }, + error: { + type: 'object', + required: ['code', 'message'], + properties: { + code: { type: 'string' }, + message: { type: 'string' }, + details: { type: 'object', additionalProperties: true }, + version: { type: 'string' }, + }, + }, + meta: { + type: 'object', + required: ['timestamp', 'requestId', 'apiVersion'], + properties: { + timestamp: { type: 'string', format: 'date-time' }, + requestId: { type: 'string' }, + apiVersion: { type: 'string' }, + }, + }, + }, + }; + + const metaSchema = { + type: 'object', + required: ['timestamp', 'requestId', 'apiVersion'], + properties: { + timestamp: { type: 'string', format: 'date-time' }, + requestId: { type: 'string' }, + apiVersion: { type: 'string' }, + }, + }; + + const paginationSchema = { + type: 'object', + properties: { + nextCursor: { type: ['string', 'null'] }, + hasMore: { type: 'boolean' }, + limit: { type: 'integer' }, + }, + }; + + return { + openapi: '3.1.0', + info: { + title: 'SubTrackr API', + version: '1.0.0', + description: + 'API specification for SubTrackr subscription management, billing, webhooks, and payments.\n\nBase paths are versioned under `/v1` and authenticated with a Bearer token (`sk_test_...` for sandbox, `sk_live_...` for production).', + contact: { + name: 'SubTrackr Support', + email: 'support@subtrackr.io', + }, + license: { + name: 'MIT', + }, + }, + servers: [ + { + url: 'https://api.subtrackr.io/v1', + description: 'Production Server', + }, + { + url: 'https://sandbox.api.subtrackr.io/v1', + description: 'Sandbox Server', + }, + { + url: 'http://localhost:3000/v1', + description: 'Local Development Server', + }, + ], + tags: [ + { name: 'Auth', description: 'Authentication' }, + { name: 'Subscriptions', description: 'Manage subscriptions' }, + { name: 'Plans', description: 'Plans and pricing' }, + { name: 'Payments', description: 'Payment records' }, + { name: 'Invoices', description: 'Invoices and billing' }, + { name: 'Webhooks', description: 'Webhook configuration and delivery' }, + { name: 'Analytics', description: 'Usage and subscription analytics' }, + { name: 'Themes', description: 'Theme management for merchants' }, + ], + paths: { + '/auth/login': { + post: { + summary: 'Authenticate user', + operationId: 'loginUser', + tags: ['Auth'], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + email: { type: 'string', format: 'email' }, + password: { type: 'string' }, + }, + required: ['email', 'password'], + example: { email: 'dev@example.com', password: 'secret' }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Authentication successful', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + token: { type: 'string' }, + expiresIn: { type: 'integer' }, + }, + }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + }, + }, + }, + '/subscriptions': { + get: { + summary: 'List subscriptions', + operationId: 'listSubscriptions', + tags: ['Subscriptions'], + security: [{ BearerAuth: [] }], + parameters: [ + { + name: 'status', + in: 'query', + schema: { + type: 'string', + enum: ['active', 'paused', 'cancelled', 'past_due', 'trialing'], + }, + }, + { name: 'customerId', in: 'query', schema: { type: 'string' } }, + { name: 'planId', in: 'query', schema: { type: 'string' } }, + { + name: 'cursor', + in: 'query', + schema: { type: 'string' }, + description: 'Opaque pagination cursor', + }, + { name: 'page', in: 'query', schema: { type: 'integer', default: 1 } }, + { name: 'limit', in: 'query', schema: { type: 'integer', default: 20, maximum: 100 } }, + ], + responses: { + '200': { + description: 'List of subscriptions', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/SubscriptionList' }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + }, + }, + post: { + summary: 'Create subscription', + operationId: 'createSubscription', + tags: ['Subscriptions'], + security: [{ BearerAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/SubscriptionInput' }, + }, + }, + }, + responses: { + '201': { + description: 'Subscription created', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Subscription' }, + }, + }, + }, + '400': { $ref: '#/components/responses/BadRequest' }, + '422': { $ref: '#/components/responses/ValidationError' }, + }, + }, + }, + '/subscriptions/{id}': { + get: { + summary: 'Get subscription', + operationId: 'getSubscription', + tags: ['Subscriptions'], + security: [{ BearerAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200': { + description: 'Subscription', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Subscription' }, + }, + }, + }, + '404': { $ref: '#/components/responses/NotFound' }, + }, + }, + put: { + summary: 'Update subscription', + operationId: 'updateSubscription', + tags: ['Subscriptions'], + security: [{ BearerAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/SubscriptionUpdate' }, + }, + }, + }, + responses: { + '200': { + description: 'Updated subscription', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Subscription' }, + }, + }, + }, + '404': { $ref: '#/components/responses/NotFound' }, + '422': { $ref: '#/components/responses/ValidationError' }, + }, + }, + delete: { + summary: 'Delete subscription', + operationId: 'deleteSubscription', + tags: ['Subscriptions'], + security: [{ BearerAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '204': { description: 'Subscription deleted' }, + '404': { $ref: '#/components/responses/NotFound' }, + }, + }, + }, + '/subscriptions/{id}/cancel': { + post: { + summary: 'Cancel subscription', + operationId: 'cancelSubscription', + tags: ['Subscriptions'], + security: [{ BearerAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/SubscriptionCancel' }, + }, + }, + }, + responses: { + '200': { + description: 'Subscription cancelled', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Subscription' }, + }, + }, + }, + '404': { $ref: '#/components/responses/NotFound' }, + }, + }, + }, + '/plans': { + get: { + summary: 'List plans', + operationId: 'listPlans', + tags: ['Plans'], + parameters: [ + { name: 'active', in: 'query', schema: { type: 'boolean' } }, + { + name: 'currency', + in: 'query', + schema: { type: 'string', minLength: 3, maxLength: 3 }, + }, + { + name: 'cursor', + in: 'query', + schema: { type: 'string' }, + description: 'Opaque pagination cursor', + }, + { name: 'limit', in: 'query', schema: { type: 'integer', default: 20, maximum: 100 } }, + { name: 'page', in: 'query', schema: { type: 'integer', default: 1 } }, + ], + responses: { + '200': { + description: 'List of plans with cursor pagination', + content: { + 'application/json': { + schema: { + type: 'object', + required: ['success', 'data', 'meta'], + properties: { + success: { type: 'boolean', const: true }, + data: { + type: 'array', + items: { $ref: '#/components/schemas/Plan' }, + }, + pagination: { $ref: '#/components/schemas/Pagination' }, + meta: { $ref: '#/components/schemas/Meta' }, + }, + }, + }, + }, + }, + }, + }, + post: { + summary: 'Create plan', + operationId: 'createPlan', + tags: ['Plans'], + security: [{ BearerAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/PlanInput' }, + }, + }, + }, + responses: { + '201': { + description: 'Plan created', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Plan' }, + }, + }, + }, + '422': { $ref: '#/components/responses/ValidationError' }, + }, + }, + }, + '/plans/{id}': { + get: { + summary: 'Get plan by ID', + operationId: 'getPlanById', + tags: ['Plans'], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200': { + description: 'Plan details', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Plan' }, + }, + }, + }, + '404': { $ref: '#/components/responses/NotFound' }, + }, + }, + patch: { + summary: 'Update plan', + operationId: 'updatePlan', + tags: ['Plans'], + security: [{ BearerAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/PlanUpdate' }, + }, + }, + }, + responses: { + '200': { + description: 'Updated plan', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Plan' }, + }, + }, + }, + '404': { $ref: '#/components/responses/NotFound' }, + '422': { $ref: '#/components/responses/ValidationError' }, + }, + }, + }, + '/payments': { + get: { + summary: 'List payments', + operationId: 'listPayments', + tags: ['Payments'], + security: [{ BearerAuth: [] }], + parameters: [ + { name: 'customerId', in: 'query', schema: { type: 'string' } }, + { + name: 'status', + in: 'query', + schema: { type: 'string', enum: ['succeeded', 'failed', 'pending'] }, + }, + { name: 'cursor', in: 'query', schema: { type: 'string' } }, + { name: 'limit', in: 'query', schema: { type: 'integer', default: 20, maximum: 100 } }, + ], + responses: { + '200': { + description: 'List of payments', + content: { + 'application/json': { + schema: { + type: 'array', + items: { $ref: '#/components/schemas/Payment' }, + }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + }, + }, + post: { + summary: 'Record or initiate payment', + operationId: 'recordPayment', + tags: ['Payments'], + security: [{ BearerAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/PaymentInput' }, + }, + }, + }, + responses: { + '201': { + description: 'Payment recorded', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Payment' }, + }, + }, + }, + '422': { $ref: '#/components/responses/ValidationError' }, + }, + }, + }, + '/invoices': { + get: { + summary: 'List invoices', + operationId: 'listInvoices', + tags: ['Invoices'], + security: [{ BearerAuth: [] }], + parameters: [ + { name: 'subscriptionId', in: 'query', schema: { type: 'string' } }, + { + name: 'status', + in: 'query', + schema: { type: 'string', enum: ['paid', 'open', 'void'] }, + }, + ], + responses: { + '200': { + description: 'List of invoices', + content: { + 'application/json': { + schema: { + type: 'array', + items: { $ref: '#/components/schemas/Invoice' }, + }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + }, + }, + }, + '/webhooks': { + get: { + summary: 'List webhooks', + operationId: 'listWebhooks', + tags: ['Webhooks'], + security: [{ BearerAuth: [] }], + responses: { + '200': { + description: 'List of webhooks', + content: { + 'application/json': { + schema: { + type: 'array', + items: { $ref: '#/components/schemas/Webhook' }, + }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + }, + }, + post: { + summary: 'Create webhook endpoint', + operationId: 'createWebhook', + tags: ['Webhooks'], + security: [{ BearerAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/WebhookInput' }, + }, + }, + }, + responses: { + '201': { + description: 'Webhook created', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Webhook' }, + }, + }, + }, + '422': { $ref: '#/components/responses/ValidationError' }, + }, + }, + }, + '/analytics/usage': { + get: { + summary: 'Get usage analytics', + operationId: 'getUsageAnalytics', + tags: ['Analytics'], + security: [{ BearerAuth: [] }], + parameters: [ + { name: 'startDate', in: 'query', schema: { type: 'string', format: 'date-time' } }, + { name: 'endDate', in: 'query', schema: { type: 'string', format: 'date-time' } }, + { + name: 'granularity', + in: 'query', + schema: { type: 'string', enum: ['hour', 'day', 'week', 'month'], default: 'day' }, + }, + ], + responses: { + '200': { + description: 'Usage analytics data', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + period: { type: 'string' }, + totalRequests: { type: 'integer' }, + activeSubscriptions: { type: 'integer' }, + revenue: { type: 'number' }, + }, + }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + }, + }, + }, + '/themes': { + get: { + summary: 'Get theme settings', + operationId: 'getThemes', + tags: ['Themes'], + responses: { + '200': { + description: 'Theme settings', + content: { + 'application/json': { + schema: { + type: 'array', + items: { $ref: '#/components/schemas/Theme' }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + securitySchemes: { + BearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + }, + ApiKeyAuth: { + type: 'apiKey', + in: 'header', + name: 'X-API-Key', + }, + }, + schemas: { + Subscription: { + type: 'object', + properties: { + id: { type: 'string' }, + userId: { type: 'string' }, + planId: { type: 'string' }, + status: { + type: 'string', + enum: ['active', 'paused', 'cancelled', 'past_due', 'trialing'], + }, + currentPeriodStart: { type: 'string', format: 'date-time' }, + currentPeriodEnd: { type: 'string', format: 'date-time' }, + createdAt: { type: 'string', format: 'date-time' }, + }, + }, + SubscriptionInput: subInput, + SubscriptionUpdate: subUpdate, + SubscriptionCancel: subCancel, + SubscriptionPause: subPause, + SubscriptionList: { + type: 'object', + properties: { + data: { + type: 'array', + items: { $ref: '#/components/schemas/Subscription' }, + }, + pagination: { $ref: '#/components/schemas/Pagination' }, + }, + }, + Pagination: paginationSchema, + Meta: metaSchema, + StandardErrorEnvelope: errorEnvelopeSchema, + Plan: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + description: { type: 'string' }, + amount: { type: 'number' }, + currency: { type: 'string' }, + interval: { type: 'string', enum: ['day', 'week', 'month', 'year'] }, + active: { type: 'boolean' }, + }, + }, + PlanInput: planInput, + PlanUpdate: planUpdate, + Payment: { + type: 'object', + properties: { + id: { type: 'string' }, + amount: { type: 'number' }, + currency: { type: 'string' }, + status: { type: 'string', enum: ['succeeded', 'failed', 'pending'] }, + createdAt: { type: 'string', format: 'date-time' }, + }, + }, + PaymentInput: paymentRequest, + RefundInput: refundRequest, + Invoice: { + type: 'object', + properties: { + id: { type: 'string' }, + subscriptionId: { type: 'string' }, + amount: { type: 'number' }, + currency: { type: 'string' }, + status: { type: 'string', enum: ['paid', 'open', 'void'] }, + dueDate: { type: 'string', format: 'date-time' }, + }, + }, + Webhook: { + type: 'object', + properties: { + id: { type: 'string' }, + url: { type: 'string', format: 'uri' }, + events: { type: 'array', items: { type: 'string' } }, + status: { type: 'string', enum: ['enabled', 'disabled'] }, + createdAt: { type: 'string', format: 'date-time' }, + }, + }, + WebhookInput: webhookInput, + ApiKeyInput: apiKeyInput, + AnalyticsQuery: analyticsQuery, + ForecastRequest: forecastRequest, + FallbackChain: fallbackChain, + CorsPolicy: corsPolicy, + Theme: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + primaryColor: { type: 'string' }, + status: { type: 'string', enum: ['active', 'draft'] }, + }, + }, + }, + responses: { + Unauthorized: { + description: 'Missing or invalid authorization', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/StandardErrorEnvelope' }, + }, + }, + }, + NotFound: { + description: 'Resource not found', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/StandardErrorEnvelope' }, + }, + }, + }, + BadRequest: { + description: 'Bad request', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/StandardErrorEnvelope' }, + }, + }, + }, + ValidationError: { + description: 'Request payload validation failed', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/StandardErrorEnvelope' }, + }, + }, + }, + }, + }, + }; +} + +export function generateSpecs(): { json: string; yaml: string } { + const specObj = generateOpenApiSpec(); + const jsonContent = JSON.stringify(specObj, null, 2) + '\n'; + const yamlContent = YAML.stringify(specObj); + return { json: jsonContent, yaml: yamlContent }; +} + +function main() { + const isCheck = process.argv.includes('--check'); + const { json: generatedJson, yaml: generatedYaml } = generateSpecs(); + + if (isCheck) { + let hasDrift = false; + if (!fs.existsSync(jsonPath)) { + console.error(`[openapi:check] Missing ${jsonPath}`); + hasDrift = true; + } else { + const currentJson = fs.readFileSync(jsonPath, 'utf8'); + if (currentJson.trim() !== generatedJson.trim()) { + console.error(`[openapi:check] Drift detected in ${jsonPath}`); + hasDrift = true; + } + } + + if (!fs.existsSync(yamlPath)) { + console.error(`[openapi:check] Missing ${yamlPath}`); + hasDrift = true; + } else { + const currentYaml = fs.readFileSync(yamlPath, 'utf8'); + if (currentYaml.trim() !== generatedYaml.trim()) { + console.error(`[openapi:check] Drift detected in ${yamlPath}`); + hasDrift = true; + } + } + + if (hasDrift) { + console.error('[openapi:check] Run "npm run openapi:generate" to synchronize OpenAPI specs.'); + process.exit(1); + } + + console.log('[openapi:check] OpenAPI specifications are up-to-date. No drift detected.'); + process.exit(0); + } + + fs.mkdirSync(path.dirname(jsonPath), { recursive: true }); + fs.mkdirSync(path.dirname(yamlPath), { recursive: true }); + + fs.writeFileSync(jsonPath, generatedJson); + fs.writeFileSync(yamlPath, generatedYaml); + + console.log(`[openapi:generate] Generated OpenAPI 3.1.0 specification:`); + console.log(` - ${path.relative(root, jsonPath)}`); + console.log(` - ${path.relative(root, yamlPath)}`); +} + +if (typeof require !== 'undefined' && typeof module !== 'undefined' && require.main === module) { + main(); +} else if (process.argv[1] && process.argv[1].endsWith('generate-openapi.ts')) { + main(); +} diff --git a/sdks/generated/endpoints.json b/sdks/generated/endpoints.json index fffbcb3e..663f6d7a 100644 --- a/sdks/generated/endpoints.json +++ b/sdks/generated/endpoints.json @@ -20,6 +20,96 @@ "method": "POST", "operation": "createSubscription", "summary": "Create subscription" + }, + { + "path": "/subscriptions/{id}", + "method": "GET", + "operation": "getSubscription", + "summary": "Get subscription" + }, + { + "path": "/subscriptions/{id}", + "method": "PUT", + "operation": "updateSubscription", + "summary": "Update subscription" + }, + { + "path": "/subscriptions/{id}", + "method": "DELETE", + "operation": "deleteSubscription", + "summary": "Delete subscription" + }, + { + "path": "/subscriptions/{id}/cancel", + "method": "POST", + "operation": "cancelSubscription", + "summary": "Cancel subscription" + }, + { + "path": "/plans", + "method": "GET", + "operation": "listPlans", + "summary": "List plans" + }, + { + "path": "/plans", + "method": "POST", + "operation": "createPlan", + "summary": "Create plan" + }, + { + "path": "/plans/{id}", + "method": "GET", + "operation": "getPlanById", + "summary": "Get plan by ID" + }, + { + "path": "/plans/{id}", + "method": "PATCH", + "operation": "updatePlan", + "summary": "Update plan" + }, + { + "path": "/payments", + "method": "GET", + "operation": "listPayments", + "summary": "List payments" + }, + { + "path": "/payments", + "method": "POST", + "operation": "recordPayment", + "summary": "Record or initiate payment" + }, + { + "path": "/invoices", + "method": "GET", + "operation": "listInvoices", + "summary": "List invoices" + }, + { + "path": "/webhooks", + "method": "GET", + "operation": "listWebhooks", + "summary": "List webhooks" + }, + { + "path": "/webhooks", + "method": "POST", + "operation": "createWebhook", + "summary": "Create webhook endpoint" + }, + { + "path": "/analytics/usage", + "method": "GET", + "operation": "getUsageAnalytics", + "summary": "Get usage analytics" + }, + { + "path": "/themes", + "method": "GET", + "operation": "getThemes", + "summary": "Get theme settings" } ] } diff --git a/spec/openapi.yaml b/spec/openapi.yaml index fe361bee..aaee9a4b 100644 --- a/spec/openapi.yaml +++ b/spec/openapi.yaml @@ -2,20 +2,49 @@ openapi: 3.1.0 info: title: SubTrackr API version: 1.0.0 - description: API specification for SubTrackr subscription management, notifications, and payments. + description: >- + API specification for SubTrackr subscription management, billing, webhooks, + and payments. + + + Base paths are versioned under `/v1` and authenticated with a Bearer token + (`sk_test_...` for sandbox, `sk_live_...` for production). contact: name: SubTrackr Support email: support@subtrackr.io + license: + name: MIT servers: - url: https://api.subtrackr.io/v1 description: Production Server + - url: https://sandbox.api.subtrackr.io/v1 + description: Sandbox Server - url: http://localhost:3000/v1 description: Local Development Server +tags: + - name: Auth + description: Authentication + - name: Subscriptions + description: Manage subscriptions + - name: Plans + description: Plans and pricing + - name: Payments + description: Payment records + - name: Invoices + description: Invoices and billing + - name: Webhooks + description: Webhook configuration and delivery + - name: Analytics + description: Usage and subscription analytics + - name: Themes + description: Theme management for merchants paths: /auth/login: post: summary: Authenticate user operationId: loginUser + tags: + - Auth requestBody: required: true content: @@ -31,8 +60,11 @@ paths: required: - email - password + example: + email: dev@example.com + password: secret responses: - '200': + "200": description: Authentication successful content: application/json: @@ -43,23 +75,511 @@ paths: type: string expiresIn: type: integer + "401": + $ref: "#/components/responses/Unauthorized" /subscriptions: get: summary: List subscriptions operationId: listSubscriptions + tags: + - Subscriptions security: - BearerAuth: [] + parameters: + - name: status + in: query + schema: + type: string + enum: + - active + - paused + - cancelled + - past_due + - trialing + - name: customerId + in: query + schema: + type: string + - name: planId + in: query + schema: + type: string + - name: cursor + in: query + schema: + type: string + description: Opaque pagination cursor + - name: page + in: query + schema: + type: integer + default: 1 + - name: limit + in: query + schema: + type: integer + default: 20 + maximum: 100 responses: - '200': - description: List of active user subscriptions + "200": + description: List of subscriptions + content: + application/json: + schema: + $ref: "#/components/schemas/SubscriptionList" + "401": + $ref: "#/components/responses/Unauthorized" post: summary: Create subscription operationId: createSubscription + tags: + - Subscriptions security: - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SubscriptionInput" responses: - '201': + "201": description: Subscription created + content: + application/json: + schema: + $ref: "#/components/schemas/Subscription" + "400": + $ref: "#/components/responses/BadRequest" + "422": + $ref: "#/components/responses/ValidationError" + /subscriptions/{id}: + get: + summary: Get subscription + operationId: getSubscription + tags: + - Subscriptions + security: + - BearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Subscription + content: + application/json: + schema: + $ref: "#/components/schemas/Subscription" + "404": + $ref: "#/components/responses/NotFound" + put: + summary: Update subscription + operationId: updateSubscription + tags: + - Subscriptions + security: + - BearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SubscriptionUpdate" + responses: + "200": + description: Updated subscription + content: + application/json: + schema: + $ref: "#/components/schemas/Subscription" + "404": + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/ValidationError" + delete: + summary: Delete subscription + operationId: deleteSubscription + tags: + - Subscriptions + security: + - BearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "204": + description: Subscription deleted + "404": + $ref: "#/components/responses/NotFound" + /subscriptions/{id}/cancel: + post: + summary: Cancel subscription + operationId: cancelSubscription + tags: + - Subscriptions + security: + - BearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/SubscriptionCancel" + responses: + "200": + description: Subscription cancelled + content: + application/json: + schema: + $ref: "#/components/schemas/Subscription" + "404": + $ref: "#/components/responses/NotFound" + /plans: + get: + summary: List plans + operationId: listPlans + tags: + - Plans + parameters: + - name: active + in: query + schema: + type: boolean + - name: currency + in: query + schema: + type: string + minLength: 3 + maxLength: 3 + - name: cursor + in: query + schema: + type: string + description: Opaque pagination cursor + - name: limit + in: query + schema: + type: integer + default: 20 + maximum: 100 + - name: page + in: query + schema: + type: integer + default: 1 + responses: + "200": + description: List of plans with cursor pagination + content: + application/json: + schema: + type: object + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + type: array + items: + $ref: "#/components/schemas/Plan" + pagination: + $ref: "#/components/schemas/Pagination" + meta: + $ref: "#/components/schemas/Meta" + post: + summary: Create plan + operationId: createPlan + tags: + - Plans + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PlanInput" + responses: + "201": + description: Plan created + content: + application/json: + schema: + $ref: "#/components/schemas/Plan" + "422": + $ref: "#/components/responses/ValidationError" + /plans/{id}: + get: + summary: Get plan by ID + operationId: getPlanById + tags: + - Plans + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Plan details + content: + application/json: + schema: + $ref: "#/components/schemas/Plan" + "404": + $ref: "#/components/responses/NotFound" + patch: + summary: Update plan + operationId: updatePlan + tags: + - Plans + security: + - BearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PlanUpdate" + responses: + "200": + description: Updated plan + content: + application/json: + schema: + $ref: "#/components/schemas/Plan" + "404": + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/ValidationError" + /payments: + get: + summary: List payments + operationId: listPayments + tags: + - Payments + security: + - BearerAuth: [] + parameters: + - name: customerId + in: query + schema: + type: string + - name: status + in: query + schema: + type: string + enum: + - succeeded + - failed + - pending + - name: cursor + in: query + schema: + type: string + - name: limit + in: query + schema: + type: integer + default: 20 + maximum: 100 + responses: + "200": + description: List of payments + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Payment" + "401": + $ref: "#/components/responses/Unauthorized" + post: + summary: Record or initiate payment + operationId: recordPayment + tags: + - Payments + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PaymentInput" + responses: + "201": + description: Payment recorded + content: + application/json: + schema: + $ref: "#/components/schemas/Payment" + "422": + $ref: "#/components/responses/ValidationError" + /invoices: + get: + summary: List invoices + operationId: listInvoices + tags: + - Invoices + security: + - BearerAuth: [] + parameters: + - name: subscriptionId + in: query + schema: + type: string + - name: status + in: query + schema: + type: string + enum: + - paid + - open + - void + responses: + "200": + description: List of invoices + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Invoice" + "401": + $ref: "#/components/responses/Unauthorized" + /webhooks: + get: + summary: List webhooks + operationId: listWebhooks + tags: + - Webhooks + security: + - BearerAuth: [] + responses: + "200": + description: List of webhooks + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Webhook" + "401": + $ref: "#/components/responses/Unauthorized" + post: + summary: Create webhook endpoint + operationId: createWebhook + tags: + - Webhooks + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/WebhookInput" + responses: + "201": + description: Webhook created + content: + application/json: + schema: + $ref: "#/components/schemas/Webhook" + "422": + $ref: "#/components/responses/ValidationError" + /analytics/usage: + get: + summary: Get usage analytics + operationId: getUsageAnalytics + tags: + - Analytics + security: + - BearerAuth: [] + parameters: + - name: startDate + in: query + schema: + type: string + format: date-time + - name: endDate + in: query + schema: + type: string + format: date-time + - name: granularity + in: query + schema: + type: string + enum: + - hour + - day + - week + - month + default: day + responses: + "200": + description: Usage analytics data + content: + application/json: + schema: + type: object + properties: + period: + type: string + totalRequests: + type: integer + activeSubscriptions: + type: integer + revenue: + type: number + "401": + $ref: "#/components/responses/Unauthorized" + /themes: + get: + summary: Get theme settings + operationId: getThemes + tags: + - Themes + responses: + "200": + description: Theme settings + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Theme" components: securitySchemes: BearerAuth: @@ -70,3 +590,693 @@ components: type: apiKey in: header name: X-API-Key + schemas: + Subscription: + type: object + properties: + id: + type: string + userId: + type: string + planId: + type: string + status: + type: string + enum: + - active + - paused + - cancelled + - past_due + - trialing + currentPeriodStart: + type: string + format: date-time + currentPeriodEnd: + type: string + format: date-time + createdAt: + type: string + format: date-time + SubscriptionInput: + type: object + properties: + customerId: + type: string + pattern: ^0x[a-fA-F0-9]{40}$ + planId: + type: string + minLength: 1 + maxLength: 255 + paymentMethodId: + type: string + minLength: 1 + trialEnd: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z))$ + metadata: + type: object + propertyNames: + type: string + additionalProperties: + type: string + required: + - customerId + - planId + - paymentMethodId + additionalProperties: false + SubscriptionUpdate: + type: object + properties: + planId: + type: string + minLength: 1 + maxLength: 255 + paymentMethodId: + type: string + minLength: 1 + cancelAtPeriodEnd: + type: boolean + metadata: + type: object + propertyNames: + type: string + additionalProperties: + type: string + additionalProperties: false + SubscriptionCancel: + type: object + properties: + reason: + type: string + maxLength: 500 + feedback: + type: string + maxLength: 2000 + immediate: + default: false + type: boolean + required: + - immediate + additionalProperties: false + SubscriptionPause: + type: object + properties: + reason: + type: string + maxLength: 500 + resumeAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z))$ + pauseDurationDays: + type: integer + minimum: 1 + maximum: 365 + additionalProperties: false + SubscriptionList: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/Subscription" + pagination: + $ref: "#/components/schemas/Pagination" + Pagination: + type: object + properties: + nextCursor: + type: + - string + - "null" + hasMore: + type: boolean + limit: + type: integer + Meta: + type: object + required: + - timestamp + - requestId + - apiVersion + properties: + timestamp: + type: string + format: date-time + requestId: + type: string + apiVersion: + type: string + StandardErrorEnvelope: + type: object + required: + - success + - error + - meta + properties: + success: + type: boolean + const: false + error: + type: object + required: + - code + - message + properties: + code: + type: string + message: + type: string + details: + type: object + additionalProperties: true + version: + type: string + meta: + type: object + required: + - timestamp + - requestId + - apiVersion + properties: + timestamp: + type: string + format: date-time + requestId: + type: string + apiVersion: + type: string + Plan: + type: object + properties: + id: + type: string + name: + type: string + description: + type: string + amount: + type: number + currency: + type: string + interval: + type: string + enum: + - day + - week + - month + - year + active: + type: boolean + PlanInput: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 100 + description: + type: string + maxLength: 500 + amount: + type: number + exclusiveMinimum: 0 + currency: + type: string + minLength: 3 + maxLength: 3 + interval: + type: string + enum: + - day + - week + - month + - year + intervalCount: + default: 1 + type: integer + minimum: 1 + maximum: 12 + trialPeriodDays: + type: integer + minimum: 0 + maximum: 365 + metadata: + type: object + propertyNames: + type: string + additionalProperties: + type: string + active: + default: true + type: boolean + required: + - name + - amount + - currency + - interval + - intervalCount + - active + additionalProperties: false + PlanUpdate: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 100 + description: + type: string + maxLength: 500 + amount: + type: number + exclusiveMinimum: 0 + currency: + type: string + minLength: 3 + maxLength: 3 + interval: + type: string + enum: + - day + - week + - month + - year + intervalCount: + default: 1 + type: integer + minimum: 1 + maximum: 12 + trialPeriodDays: + type: integer + minimum: 0 + maximum: 365 + metadata: + type: object + propertyNames: + type: string + additionalProperties: + type: string + active: + default: true + type: boolean + additionalProperties: false + Payment: + type: object + properties: + id: + type: string + amount: + type: number + currency: + type: string + status: + type: string + enum: + - succeeded + - failed + - pending + createdAt: + type: string + format: date-time + PaymentInput: + type: object + properties: + amount: + type: number + exclusiveMinimum: 0 + currency: + type: string + minLength: 3 + maxLength: 3 + customerId: + type: string + pattern: ^0x[a-fA-F0-9]{40}$ + paymentMethodId: + type: string + minLength: 1 + idempotencyKey: + type: string + minLength: 1 + maxLength: 255 + metadata: + type: object + propertyNames: + type: string + additionalProperties: + type: string + gatewayPreference: + type: string + enum: + - stripe + - circle + - stellar + required: + - amount + - currency + - customerId + - paymentMethodId + - idempotencyKey + additionalProperties: false + RefundInput: + type: object + properties: + chargeId: + type: string + minLength: 1 + amount: + type: number + exclusiveMinimum: 0 + reason: + type: string + maxLength: 500 + metadata: + type: object + propertyNames: + type: string + additionalProperties: + type: string + required: + - chargeId + additionalProperties: false + Invoice: + type: object + properties: + id: + type: string + subscriptionId: + type: string + amount: + type: number + currency: + type: string + status: + type: string + enum: + - paid + - open + - void + dueDate: + type: string + format: date-time + Webhook: + type: object + properties: + id: + type: string + url: + type: string + format: uri + events: + type: array + items: + type: string + status: + type: string + enum: + - enabled + - disabled + createdAt: + type: string + format: date-time + WebhookInput: + type: object + properties: + url: + type: string + format: uri + events: + minItems: 1 + type: array + items: + type: string + secret: + type: string + minLength: 16 + maxLength: 255 + active: + default: true + type: boolean + metadata: + type: object + propertyNames: + type: string + additionalProperties: + type: string + required: + - url + - events + - active + additionalProperties: false + ApiKeyInput: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 100 + tier: + default: free + type: string + enum: + - free + - basic + - pro + - enterprise + rateLimit: + type: integer + minimum: 1 + maximum: 100000 + expiresAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z))$ + metadata: + type: object + propertyNames: + type: string + additionalProperties: + type: string + required: + - name + - tier + additionalProperties: false + AnalyticsQuery: + type: object + properties: + startDate: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z))$ + endDate: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z))$ + granularity: + default: day + type: string + enum: + - hour + - day + - week + - month + metrics: + type: array + items: + type: string + enum: + - revenue + - subscribers + - churn_rate + - mrr + - arr + - ltv + - conversion_rate + groupBy: + type: string + enum: + - plan + - currency + - gateway + - country + required: + - granularity + additionalProperties: false + ForecastRequest: + type: object + properties: + horizon: + default: 3 + type: integer + minimum: 1 + maximum: 24 + granularity: + default: month + type: string + enum: + - day + - week + - month + includeConfidence: + default: true + type: boolean + confidenceLevel: + default: 0.95 + type: number + minimum: 0.5 + maximum: 0.99 + required: + - horizon + - granularity + - includeConfidence + - confidenceLevel + additionalProperties: false + FallbackChain: + type: object + properties: + merchantId: + type: string + minLength: 1 + chain: + minItems: 2 + maxItems: 5 + type: array + items: + type: object + properties: + gateway: + type: string + enum: + - stripe + - circle + - stellar + priority: + type: integer + minimum: 0 + maximum: 9007199254740991 + enabled: + default: true + type: boolean + timeoutMs: + default: 5000 + type: integer + minimum: 100 + maximum: 30000 + required: + - gateway + - priority + - enabled + - timeoutMs + additionalProperties: false + retryAttempts: + default: 1 + type: integer + minimum: 0 + maximum: 5 + retryDelayMs: + default: 1000 + type: integer + minimum: 100 + maximum: 10000 + required: + - merchantId + - chain + - retryAttempts + - retryDelayMs + additionalProperties: false + CorsPolicy: + type: object + properties: + tenantId: + type: string + minLength: 1 + allowedOrigins: + minItems: 1 + type: array + items: + type: string + format: uri + allowCredentials: + default: false + type: boolean + exposedHeaders: + type: array + items: + type: string + maxAge: + default: 86400 + type: integer + minimum: 0 + maximum: 86400 + allowMethods: + default: + - GET + - POST + - PUT + - PATCH + - DELETE + type: array + items: + type: string + enum: + - GET + - POST + - PUT + - PATCH + - DELETE + - OPTIONS + allowHeaders: + default: + - Content-Type + - Authorization + type: array + items: + type: string + required: + - tenantId + - allowedOrigins + - allowCredentials + - maxAge + - allowMethods + - allowHeaders + additionalProperties: false + Theme: + type: object + properties: + id: + type: string + name: + type: string + primaryColor: + type: string + status: + type: string + enum: + - active + - draft + responses: + Unauthorized: + description: Missing or invalid authorization + content: + application/json: + schema: + $ref: "#/components/schemas/StandardErrorEnvelope" + NotFound: + description: Resource not found + content: + application/json: + schema: + $ref: "#/components/schemas/StandardErrorEnvelope" + BadRequest: + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/StandardErrorEnvelope" + ValidationError: + description: Request payload validation failed + content: + application/json: + schema: + $ref: "#/components/schemas/StandardErrorEnvelope"