diff --git a/billing-client/package.json b/billing-client/package.json index 0a1c1c245..589e8987f 100644 --- a/billing-client/package.json +++ b/billing-client/package.json @@ -12,6 +12,8 @@ "type-check": "tsc --noEmit", "test": "jest", "clean": "rimraf dist", + "gen:types": "openapi-typescript ../services/billing-service/openapi.yaml -o src/schema.ts", + "lint:contract": "spectral lint ../services/billing-service/openapi.yaml --ruleset ../services/billing-service/.spectral.yaml", "prepublishOnly": "npm run clean && npm run build" }, "publishConfig": { @@ -29,9 +31,11 @@ "axios": "^1.7.0" }, "devDependencies": { + "@stoplight/spectral-cli": "^6.11.0", "@types/jest": "29.5.12", "@types/node": "18.19.0", "jest": "29.7.0", + "openapi-typescript": "^7.4.0", "rimraf": "^5.0.1", "ts-jest": "29.1.1", "typescript": "5.1.6" diff --git a/billing-client/src/contract.ts b/billing-client/src/contract.ts new file mode 100644 index 000000000..75041b468 --- /dev/null +++ b/billing-client/src/contract.ts @@ -0,0 +1,47 @@ +/** + * Contract alignment guard. + * + * The hand-authored public types in `./types` and the OpenAPI contract in + * `services/billing-service/openapi.yaml` (codegen'd into `./schema`) must stay + * in lockstep. This module statically asserts equivalence so that any drift — + * a field added to the spec, a type changed in either place — becomes a + * COMPILE ERROR in `tsc`, not a runtime integration surprise. + * + * It exports nothing at runtime; it is type-level only. Regenerate `schema.ts` + * with `npm run gen:types` after editing the contract. + */ +import type { components } from './schema'; +import type { + BillingSubscription, + Plan, + CreateSubscriptionRequest, + CreateSubscriptionResponse, + UpdateSubscriptionRequest, + EntityType, +} from './types'; + +type Schemas = components['schemas']; + +/** + * `Exact` resolves to `A` only when A and B are mutually assignable, + * otherwise to `never` — turning a mismatch into an unusable type. + */ +type Exact = [A] extends [B] ? ([B] extends [A] ? A : never) : never; + +// Each line fails to compile if the generated schema and the public type diverge. +type _Sub = Exact; +type _Plan = Exact; +type _CreateReq = Exact; +type _CreateRes = Exact; +type _UpdateReq = Exact; +type _Entity = Exact; + +// Reference the aliases so `noUnusedLocals`-style lints don't strip them. +export type ContractAlignment = { + subscription: _Sub; + plan: _Plan; + createRequest: _CreateReq; + createResponse: _CreateRes; + updateRequest: _UpdateReq; + entityType: _Entity; +}; diff --git a/billing-client/src/index.ts b/billing-client/src/index.ts index 42fae9519..db21d5e4c 100644 --- a/billing-client/src/index.ts +++ b/billing-client/src/index.ts @@ -1,4 +1,7 @@ export { BillingClient } from './client'; +// Generated OpenAPI surface (source of truth = services/billing-service/openapi.yaml). +export type { paths, components, operations } from './schema'; +export type { ContractAlignment } from './contract'; export type { BillingClientConfig, BillingSubscription, diff --git a/billing-client/src/schema.ts b/billing-client/src/schema.ts new file mode 100644 index 000000000..032bc4e2c --- /dev/null +++ b/billing-client/src/schema.ts @@ -0,0 +1,617 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/plans": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List active plans + * @description Public, cached list of active plans. No auth. Mirrors `createPlansRouter` -> `GET /plans`. + */ + get: operations["getPlans"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/subscriptions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create or upgrade a subscription + * @description Creates a Stripe subscription for the entity (creating the Stripe customer if needed) and mirrors it locally. Returns a `clientSecret` when Stripe needs SCA/3DS confirmation on the client. Mirrors `POST /subscriptions`. + */ + post: operations["createSubscription"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/subscriptions/{stripeSubscriptionId}": { + parameters: { + query?: never; + header?: never; + path: { + /** @description The Stripe subscription id (e.g. `sub_...`). */ + stripeSubscriptionId: components["parameters"]["StripeSubscriptionId"]; + }; + cookie?: never; + }; + /** + * Get the local mirror of a subscription + * @description Returns the locally-mirrored subscription by Stripe subscription id, or 404 if no mirror exists. Mirrors `GET /subscriptions/:id`. + */ + get: operations["getSubscription"]; + put?: never; + post?: never; + /** + * Cancel at period end (soft cancel) + * @description Sets `cancel_at_period_end=true`; Stripe keeps the subscription active until the period boundary. Mirrors `DELETE /subscriptions/:id`. + */ + delete: operations["cancelSubscription"]; + options?: never; + head?: never; + /** + * Change plan or seat quantity + * @description Upgrades prorate immediately (`create_prorations`); downgrades take effect at period end (`none`). Mirrors `PATCH /subscriptions/:id`. + */ + patch: operations["updateSubscription"]; + trace?: never; + }; + "/setup-intent": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a SetupIntent to collect a payment method + * @description Creates a Stripe SetupIntent (usage `off_session`) so the client can collect a payment method without an immediate charge (e.g. add a card during a no-card trial). Returns the `clientSecret` for the Stripe Payment Element. Mirrors `POST /setup-intent`. + */ + post: operations["createSetupIntent"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/credits": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Add a one-time customer balance adjustment (admin only) + * @description Adds a one-time customer balance adjustment (credit) via a Stripe customer balance transaction. A positive `amount` (in cents) credits the customer (reduces what they owe). Mirrors `POST /credits`. + */ + post: operations["addCredits"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/webhooks/stripe": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Stripe webhook receiver + * @description Receives Stripe events. The body MUST be the raw signed bytes (`application/json` consumed via `express.raw`) so signature verification works. Public (no internal token) — authenticity is established by the `Stripe-Signature` header. Idempotent: duplicate deliveries of the same event id return 200 without re-processing. Mirrors `POST /webhooks/stripe`. + */ + post: operations["receiveStripeWebhook"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Liveness / readiness probe + * @description Always available, even in degraded mode (no DB / no Stripe key). NOTE: mounted at the service root (`/health`), NOT under `/api/v1/billing` -- call it on the bare server origin. + */ + get: operations["getHealth"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** + * @description Which platform entity owns the billing relationship. + * @enum {string} + */ + EntityType: "user" | "organization"; + /** + * @description Plan tier name resolved from the Stripe price (free / starter / pro / enterprise, or any custom tier). Open string by design. + * @example free + * @example starter + * @example pro + * @example enterprise + */ + PlanTier: string; + /** + * @description Mirrors Stripe subscription status values. Open string by design. + * @example trialing + * @example active + * @example past_due + * @example canceled + * @example unpaid + * @example incomplete + * @example incomplete_expired + */ + SubscriptionStatus: string; + EntityRef: { + entityType: components["schemas"]["EntityType"]; + /** + * Format: uuid + * @description The id of the user or organization. + */ + entityId: string; + }; + BillingSubscription: { + /** + * Format: uuid + * @description Local billing.subscriptions primary key. + */ + id: string; + /** + * Format: uuid + * @description Local billing.customers primary key. + */ + customerId: string; + stripeSubscriptionId: string; + stripePriceId: string; + planTier: components["schemas"]["PlanTier"]; + status: components["schemas"]["SubscriptionStatus"]; + seatQuantity: number; + /** Format: date-time */ + trialStart: string | null; + /** Format: date-time */ + trialEnd: string | null; + /** Format: date-time */ + currentPeriodStart: string | null; + /** Format: date-time */ + currentPeriodEnd: string | null; + cancelAtPeriodEnd: boolean; + /** Format: date-time */ + canceledAt: string | null; + }; + Plan: { + stripePriceId: string; + stripeProductId: string; + tierName: components["schemas"]["PlanTier"]; + displayName: string; + /** + * @description Billing cadence (`month` / `year`, or any Stripe interval). + * @example month + * @example year + */ + billingInterval: string; + /** @description Unit amount in the currency's minor unit (cents). */ + unitAmount: number; + /** + * @description ISO 4217 currency code. + * @example usd + */ + currency: string; + seatBased: boolean; + meteredMeterName: string | null; + features: string[]; + isActive: boolean; + sortOrder: number; + }; + CreateSubscriptionRequest: { + entityType: components["schemas"]["EntityType"]; + /** Format: uuid */ + entityId: string; + priceId: string; + /** @description Start a no-card trial (Stripe trial_period_days). */ + trial?: boolean; + trialPeriodDays?: number; + /** @description Seat quantity for per-seat plans. Defaults to 1. */ + seatQuantity?: number; + /** @description Existing Stripe PaymentMethod to attach (omit for no-card trials). */ + paymentMethodId?: string; + }; + CreateSubscriptionResponse: { + subscription: components["schemas"]["BillingSubscription"]; + /** @description Set when Stripe needs SCA/3DS confirmation on the client. */ + clientSecret?: string; + /** @description True when the client must confirm a PaymentIntent/SetupIntent. */ + requiresAction: boolean; + }; + UpdateSubscriptionRequest: { + /** @description New price (plan change). */ + priceId?: string; + /** @description New seat quantity. */ + seatQuantity?: number; + }; + AddCreditsRequest: { + entityType: components["schemas"]["EntityType"]; + /** Format: uuid */ + entityId: string; + /** @description Amount in cents; a positive value credits the customer. */ + amount: number; + note?: string; + }; + Error: { + error: string; + }; + StripeErrorBody: { + /** @example stripe error */ + error: string; + message?: string; + }; + ValidationErrorBody: { + /** @example invalid request */ + error: string; + /** @description Zod flatten() output describing the field errors. */ + details?: unknown; + }; + }; + responses: { + /** @description Missing or invalid internal token. */ + Unauthorized: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Resource not found. */ + NotFound: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request body failed validation. */ + ValidationError: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ValidationErrorBody"]; + }; + }; + /** @description Upstream Stripe error (bad gateway). */ + StripeError: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StripeErrorBody"]; + }; + }; + /** @description Unexpected server error. */ + InternalError: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + parameters: { + /** @description The Stripe subscription id (e.g. `sub_...`). */ + StripeSubscriptionId: string; + }; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + getPlans: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The active plan catalogue. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + plans: components["schemas"]["Plan"][]; + }; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; + createSubscription: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateSubscriptionRequest"]; + }; + }; + responses: { + /** @description Subscription created (may require client-side action). */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateSubscriptionResponse"]; + }; + }; + 400: components["responses"]["ValidationError"]; + 401: components["responses"]["Unauthorized"]; + 502: components["responses"]["StripeError"]; + }; + }; + getSubscription: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The Stripe subscription id (e.g. `sub_...`). */ + stripeSubscriptionId: components["parameters"]["StripeSubscriptionId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The locally-mirrored subscription. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + subscription: components["schemas"]["BillingSubscription"]; + }; + }; + }; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + }; + }; + cancelSubscription: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The Stripe subscription id (e.g. `sub_...`). */ + stripeSubscriptionId: components["parameters"]["StripeSubscriptionId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The subscription mirror with cancellation scheduled. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + subscription: components["schemas"]["BillingSubscription"]; + }; + }; + }; + 401: components["responses"]["Unauthorized"]; + 502: components["responses"]["StripeError"]; + }; + }; + updateSubscription: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The Stripe subscription id (e.g. `sub_...`). */ + stripeSubscriptionId: components["parameters"]["StripeSubscriptionId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateSubscriptionRequest"]; + }; + }; + responses: { + /** @description The updated subscription mirror. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + subscription: components["schemas"]["BillingSubscription"]; + }; + }; + }; + 400: components["responses"]["ValidationError"]; + 401: components["responses"]["Unauthorized"]; + 502: components["responses"]["StripeError"]; + }; + }; + createSetupIntent: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["EntityRef"]; + }; + }; + responses: { + /** @description SetupIntent created. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Stripe SetupIntent client secret for the Payment Element. */ + clientSecret: string; + }; + }; + }; + 400: components["responses"]["ValidationError"]; + 401: components["responses"]["Unauthorized"]; + 502: components["responses"]["StripeError"]; + }; + }; + addCredits: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AddCreditsRequest"]; + }; + }; + responses: { + /** @description Balance transaction created. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Stripe customer balance transaction id. */ + id: string; + /** @description Resulting customer balance in cents (negative = credit). */ + endingBalance: number; + }; + }; + }; + 400: components["responses"]["ValidationError"]; + 401: components["responses"]["Unauthorized"]; + 502: components["responses"]["StripeError"]; + }; + }; + receiveStripeWebhook: { + parameters: { + query?: never; + header: { + /** @description Stripe webhook signature header used to verify the payload. */ + "Stripe-Signature": string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + responses: { + /** @description Event received (and processed, unless a duplicate). */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + received: boolean; + /** @description True when the event id was already processed. */ + duplicate?: boolean; + }; + }; + }; + /** @description Missing/invalid signature header or payload. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + }; + }; + getHealth: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Service is up. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example ok */ + status: string; + /** @example billing-service */ + service: string; + }; + }; + }; + }; + }; +} diff --git a/services/billing-service/.spectral.yaml b/services/billing-service/.spectral.yaml new file mode 100644 index 000000000..ebab36b9d --- /dev/null +++ b/services/billing-service/.spectral.yaml @@ -0,0 +1,16 @@ +# Spectral ruleset for the billing-service OpenAPI contract. +# Extends the recommended OpenAPI ruleset; relaxes a couple of rules that don't +# apply to an internal, cluster-only service. +extends: ['spectral:oas'] + +rules: + # Internal service: no externally-hosted contact/license URL required. + info-contact: off + # We use an UNLICENSED identifier (no SPDX URL) for a private service. + license-url: off + # operationId + summary + tags are present on every operation; descriptions + # are present on operations but we keep this as a warning, not an error. + operation-description: warn + # oas3-valid-media-example can false-positive on `examples` keyword arrays + # in 3.1; keep enabled but as a warning. + oas3-valid-schema-example: warn diff --git a/services/billing-service/openapi.yaml b/services/billing-service/openapi.yaml new file mode 100644 index 000000000..e300f4103 --- /dev/null +++ b/services/billing-service/openapi.yaml @@ -0,0 +1,649 @@ +openapi: 3.1.0 +info: + title: FuzeFront Billing Service API + version: 1.0.0 + description: >- + REST API for the FuzeFront billing-service. Bridges platform entities + (users / organizations, referenced by `(entityType, entityId)` only) to + Stripe customers, subscriptions, payment-method setup, usage metering, and + manual credits. + + This contract is **derived from the real route handlers** in + `services/billing-service/src/routes/*` and `src/app.ts` and is the single + source of truth for `@fuzefront/billing-client`. Changing the API means + amending this file first (see the `api-contract-first` skill). + + Card data never flows through this API — only Stripe ids and client secrets. + + ### Async surface (events) + Beyond HTTP, the service emits Kafka events whose Zod schemas live in + `shared/src/kafka/schemas/`: + * `billing.subscription.changed` — `billingSubscriptionChangedSchemaV1` + * `billing.usage.recorded` — `billingUsageRecordedSchemaV1` + * `billing.trial.ending` / `billing.payment.failed` (raw notify envelopes) + Consumers (e.g. the backend plan-state projection) read + `billing.subscription.changed` rather than reading the billing DB directly. + license: + name: UNLICENSED +servers: + - url: '{baseUrl}/api/v1/billing' + description: billing-service, mounted under /api/v1/billing + variables: + baseUrl: + default: http://fuzefront-billing-service:3006 + description: Cluster-internal base URL of the billing-service. + +tags: + - name: plans + description: Public plan catalogue (read cache of the Stripe product/price catalogue). + - name: subscriptions + description: Subscription lifecycle against Stripe + local mirror. + - name: payment-methods + description: SetupIntent creation for collecting a payment method without an immediate charge. + - name: credits + description: Admin-only manual customer balance adjustments. + - name: webhooks + description: Stripe webhook receiver (public, Stripe-signature verified). + - name: health + description: Liveness / readiness. + +paths: + /plans: + get: + operationId: getPlans + tags: [plans] + summary: List active plans + description: >- + Public, cached list of active plans. No auth. Mirrors + `createPlansRouter` -> `GET /plans`. + security: [] + responses: + '200': + description: The active plan catalogue. + content: + application/json: + schema: + type: object + additionalProperties: false + required: [plans] + properties: + plans: + type: array + items: + $ref: '#/components/schemas/Plan' + '500': + $ref: '#/components/responses/InternalError' + + /subscriptions: + post: + operationId: createSubscription + tags: [subscriptions] + summary: Create or upgrade a subscription + description: >- + Creates a Stripe subscription for the entity (creating the Stripe + customer if needed) and mirrors it locally. Returns a `clientSecret` + when Stripe needs SCA/3DS confirmation on the client. Mirrors + `POST /subscriptions`. + security: + - internalToken: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateSubscriptionRequest' + responses: + '201': + description: Subscription created (may require client-side action). + content: + application/json: + schema: + $ref: '#/components/schemas/CreateSubscriptionResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '502': + $ref: '#/components/responses/StripeError' + + /subscriptions/{stripeSubscriptionId}: + parameters: + - $ref: '#/components/parameters/StripeSubscriptionId' + get: + operationId: getSubscription + tags: [subscriptions] + summary: Get the local mirror of a subscription + description: >- + Returns the locally-mirrored subscription by Stripe subscription id, or + 404 if no mirror exists. Mirrors `GET /subscriptions/:id`. + security: + - internalToken: [] + responses: + '200': + description: The locally-mirrored subscription. + content: + application/json: + schema: + type: object + additionalProperties: false + required: [subscription] + properties: + subscription: + $ref: '#/components/schemas/BillingSubscription' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + patch: + operationId: updateSubscription + tags: [subscriptions] + summary: Change plan or seat quantity + description: >- + Upgrades prorate immediately (`create_prorations`); downgrades take + effect at period end (`none`). Mirrors `PATCH /subscriptions/:id`. + security: + - internalToken: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateSubscriptionRequest' + responses: + '200': + description: The updated subscription mirror. + content: + application/json: + schema: + type: object + additionalProperties: false + required: [subscription] + properties: + subscription: + $ref: '#/components/schemas/BillingSubscription' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '502': + $ref: '#/components/responses/StripeError' + delete: + operationId: cancelSubscription + tags: [subscriptions] + summary: Cancel at period end (soft cancel) + description: >- + Sets `cancel_at_period_end=true`; Stripe keeps the subscription active + until the period boundary. Mirrors `DELETE /subscriptions/:id`. + security: + - internalToken: [] + responses: + '200': + description: The subscription mirror with cancellation scheduled. + content: + application/json: + schema: + type: object + additionalProperties: false + required: [subscription] + properties: + subscription: + $ref: '#/components/schemas/BillingSubscription' + '401': + $ref: '#/components/responses/Unauthorized' + '502': + $ref: '#/components/responses/StripeError' + + /setup-intent: + post: + operationId: createSetupIntent + tags: [payment-methods] + summary: Create a SetupIntent to collect a payment method + description: >- + Creates a Stripe SetupIntent (usage `off_session`) so the client can + collect a payment method without an immediate charge (e.g. add a card + during a no-card trial). Returns the `clientSecret` for the Stripe + Payment Element. Mirrors `POST /setup-intent`. + security: + - internalToken: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EntityRef' + responses: + '200': + description: SetupIntent created. + content: + application/json: + schema: + type: object + additionalProperties: false + required: [clientSecret] + properties: + clientSecret: + type: string + description: Stripe SetupIntent client secret for the Payment Element. + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '502': + $ref: '#/components/responses/StripeError' + + /credits: + post: + operationId: addCredits + tags: [credits] + summary: Add a one-time customer balance adjustment (admin only) + description: >- + Adds a one-time customer balance adjustment (credit) via a Stripe + customer balance transaction. A positive `amount` (in cents) credits + the customer (reduces what they owe). Mirrors `POST /credits`. + security: + - internalToken: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AddCreditsRequest' + responses: + '201': + description: Balance transaction created. + content: + application/json: + schema: + type: object + additionalProperties: false + required: [id, endingBalance] + properties: + id: + type: string + description: Stripe customer balance transaction id. + endingBalance: + type: integer + description: Resulting customer balance in cents (negative = credit). + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '502': + $ref: '#/components/responses/StripeError' + + /webhooks/stripe: + post: + operationId: receiveStripeWebhook + tags: [webhooks] + summary: Stripe webhook receiver + description: >- + Receives Stripe events. The body MUST be the raw signed bytes + (`application/json` consumed via `express.raw`) so signature + verification works. Public (no internal token) — authenticity is + established by the `Stripe-Signature` header. Idempotent: duplicate + deliveries of the same event id return 200 without re-processing. + Mirrors `POST /webhooks/stripe`. + security: [] + parameters: + - name: Stripe-Signature + in: header + required: true + schema: + type: string + description: Stripe webhook signature header used to verify the payload. + requestBody: + required: true + content: + application/json: + schema: + type: object + description: Raw Stripe Event object (not parsed by this service before verification). + additionalProperties: true + responses: + '200': + description: Event received (and processed, unless a duplicate). + content: + application/json: + schema: + type: object + additionalProperties: false + required: [received] + properties: + received: + type: boolean + duplicate: + type: boolean + description: True when the event id was already processed. + '400': + description: Missing/invalid signature header or payload. + content: + text/plain: + schema: + type: string + + /health: + get: + operationId: getHealth + tags: [health] + summary: Liveness / readiness probe + description: >- + Always available, even in degraded mode (no DB / no Stripe key). + NOTE: mounted at the service root (`/health`), NOT under + `/api/v1/billing` -- call it on the bare server origin. + security: [] + servers: + - url: '{baseUrl}' + variables: + baseUrl: + default: http://fuzefront-billing-service:3006 + responses: + '200': + description: Service is up. + content: + application/json: + schema: + type: object + additionalProperties: false + required: [status, service] + properties: + status: + type: string + examples: [ok] + service: + type: string + examples: [billing-service] + +components: + securitySchemes: + internalToken: + type: http + scheme: bearer + description: >- + `BILLING_INTERNAL_TOKEN` presented as a Bearer token by the backend / + `@fuzefront/billing-client` on all non-public routes. Verified by + `requireInternalToken`. + + parameters: + StripeSubscriptionId: + name: stripeSubscriptionId + in: path + required: true + description: The Stripe subscription id (e.g. `sub_...`). + schema: + type: string + + responses: + Unauthorized: + description: Missing or invalid internal token. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + NotFound: + description: Resource not found. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + ValidationError: + description: Request body failed validation. + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorBody' + StripeError: + description: Upstream Stripe error (bad gateway). + content: + application/json: + schema: + $ref: '#/components/schemas/StripeErrorBody' + InternalError: + description: Unexpected server error. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + schemas: + EntityType: + type: string + enum: [user, organization] + description: Which platform entity owns the billing relationship. + + PlanTier: + type: string + description: >- + Plan tier name resolved from the Stripe price (free / starter / pro / + enterprise, or any custom tier). Open string by design. + examples: [free, starter, pro, enterprise] + + SubscriptionStatus: + type: string + description: Mirrors Stripe subscription status values. Open string by design. + examples: + - trialing + - active + - past_due + - canceled + - unpaid + - incomplete + - incomplete_expired + + EntityRef: + type: object + additionalProperties: false + required: [entityType, entityId] + properties: + entityType: + $ref: '#/components/schemas/EntityType' + entityId: + type: string + format: uuid + description: The id of the user or organization. + + BillingSubscription: + type: object + additionalProperties: false + required: + - id + - customerId + - stripeSubscriptionId + - stripePriceId + - planTier + - status + - seatQuantity + - trialStart + - trialEnd + - currentPeriodStart + - currentPeriodEnd + - cancelAtPeriodEnd + - canceledAt + properties: + id: + type: string + format: uuid + description: Local billing.subscriptions primary key. + customerId: + type: string + format: uuid + description: Local billing.customers primary key. + stripeSubscriptionId: + type: string + stripePriceId: + type: string + planTier: + $ref: '#/components/schemas/PlanTier' + status: + $ref: '#/components/schemas/SubscriptionStatus' + seatQuantity: + type: integer + minimum: 0 + trialStart: + type: [string, 'null'] + format: date-time + trialEnd: + type: [string, 'null'] + format: date-time + currentPeriodStart: + type: [string, 'null'] + format: date-time + currentPeriodEnd: + type: [string, 'null'] + format: date-time + cancelAtPeriodEnd: + type: boolean + canceledAt: + type: [string, 'null'] + format: date-time + + Plan: + type: object + additionalProperties: false + required: + - stripePriceId + - stripeProductId + - tierName + - displayName + - billingInterval + - unitAmount + - currency + - seatBased + - meteredMeterName + - features + - isActive + - sortOrder + properties: + stripePriceId: + type: string + stripeProductId: + type: string + tierName: + $ref: '#/components/schemas/PlanTier' + displayName: + type: string + billingInterval: + type: string + description: Billing cadence (`month` / `year`, or any Stripe interval). + examples: [month, year] + unitAmount: + type: integer + description: Unit amount in the currency's minor unit (cents). + minimum: 0 + currency: + type: string + description: ISO 4217 currency code. + examples: [usd] + seatBased: + type: boolean + meteredMeterName: + type: [string, 'null'] + features: + type: array + items: + type: string + isActive: + type: boolean + sortOrder: + type: integer + + CreateSubscriptionRequest: + type: object + additionalProperties: false + required: [entityType, entityId, priceId] + properties: + entityType: + $ref: '#/components/schemas/EntityType' + entityId: + type: string + format: uuid + priceId: + type: string + minLength: 1 + trial: + type: boolean + description: Start a no-card trial (Stripe trial_period_days). + trialPeriodDays: + type: integer + minimum: 1 + seatQuantity: + type: integer + minimum: 1 + description: Seat quantity for per-seat plans. Defaults to 1. + paymentMethodId: + type: string + description: Existing Stripe PaymentMethod to attach (omit for no-card trials). + + CreateSubscriptionResponse: + type: object + additionalProperties: false + required: [subscription, requiresAction] + properties: + subscription: + $ref: '#/components/schemas/BillingSubscription' + clientSecret: + type: string + description: Set when Stripe needs SCA/3DS confirmation on the client. + requiresAction: + type: boolean + description: True when the client must confirm a PaymentIntent/SetupIntent. + + UpdateSubscriptionRequest: + type: object + additionalProperties: false + minProperties: 1 + properties: + priceId: + type: string + minLength: 1 + description: New price (plan change). + seatQuantity: + type: integer + minimum: 1 + description: New seat quantity. + + AddCreditsRequest: + type: object + additionalProperties: false + required: [entityType, entityId, amount] + properties: + entityType: + $ref: '#/components/schemas/EntityType' + entityId: + type: string + format: uuid + amount: + type: integer + description: Amount in cents; a positive value credits the customer. + note: + type: string + maxLength: 500 + + Error: + type: object + additionalProperties: false + required: [error] + properties: + error: + type: string + + StripeErrorBody: + type: object + additionalProperties: false + required: [error] + properties: + error: + type: string + examples: ['stripe error'] + message: + type: string + + ValidationErrorBody: + type: object + additionalProperties: false + required: [error] + properties: + error: + type: string + examples: ['invalid request'] + details: + description: Zod flatten() output describing the field errors.