openapi: 3.1.0
info:
  title: Ledgox Billing API
  version: "1.0"
  summary: Subscription billing as a service. Multi-product, multi-currency, multi-provider.
  description: |
    This is the **integration API** for products embedding Ledgox billing
    (Download Cloud, fo.ru, and similar). It is the surface a developer
    needs to read once and then forget about — connect, subscribe a user,
    receive webhooks. An SDK on top of this spec is planned.

    Operations belonging to billing administrators (creating products,
    inviting team members, refunding payments, reading the audit log,
    dashboard KPIs, etc.) are **deliberately not part of this document**.
    They live behind the operator web console at `/console` and are not a
    public API surface — do not call those routes from product code.

    ## Authentication

    - **`Authorization: Bearer sk_live_<64hex>`** (or `sk_test_*`). One key
      per product, scoped to that product's tenant. Issued from the operator
      console. Plaintext is returned only at creation time; we store the
      SHA-256 hash.
    - **Checkout capability tokens** — `/api/v1/checkout/sessions/:id` (GET)
      and `/api/v1/checkout/sessions/:id/pay` (POST) authenticate via the
      unguessable session UUID itself. No bearer header. This is what the
      hosted checkout UI uses; products that build their own checkout don't
      need it.
    - **Provider webhooks** — `/webhooks/paymaster` and `/webhooks/stripe`
      verify HMAC signatures inside the body. Products never call these.

    ## Idempotency

    Idempotency is enforced at the database layer, not just the application:

    - `payments(provider, provider_transaction_id)` UNIQUE — webhook replay
      cannot create a duplicate payment.
    - `payments(checkout_session_id)` UNIQUE — calling `POST /pay` twice
      returns the existing `payment_url` without re-calling the provider.
    - Webhook handlers wrap mutations in `BEGIN SERIALIZABLE` +
      `pg_advisory_xact_lock(provider_event_id)`.

    ## Outbox + webhook delivery

    Every state change writes an event to the `events` table inside the same
    transaction. A background sender reads with `SELECT FOR UPDATE
    SKIP LOCKED` and POSTs to the product's webhook endpoint with
    `X-Billing-Signature: sha256=<hex>`. Retry curve: 1m / 5m / 30m / 2h / 12h.
    After 5 failed attempts the event stays in `delivered=false` — products
    catch up by polling `GET /api/v1/events?delivered=false`.

  contact:
    name: Ledgox support
    email: support@ledgox.local
  license:
    name: Proprietary

servers:
  - url: http://ledgox.dev.local
    description: Dev (local Docker stack)
  - url: https://billing.ledgox.com
    description: Production (placeholder — domain pending)

tags:
  - name: Plans
    description: Read the product's catalog of plans + period discounts.
  - name: Customers
    description: Create / read / update customers. Email-based identity, OTP verification.
  - name: Checkout
    description: Capability-token endpoints used by the hosted checkout UI. If you embed your own checkout, you'll call the protected endpoint to create a session and rely on webhooks for activation.
  - name: Events
    description: Catch-up endpoint to replay missed webhook events.
  - name: Webhooks (inbound)
    description: Provider → billing webhooks (PayMaster, Stripe). HMAC verified. Products never call these — listed only because they share the host.
  - name: Ops
    description: Liveness, readiness, Prometheus metrics. Operator surface.

security:
  - liveKey: []

paths:
  # =========================================================================
  # Ops
  # =========================================================================
  /healthz:
    get:
      tags: [Ops]
      summary: Liveness probe
      security: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: "ok" }

  /readyz:
    get:
      tags: [Ops]
      summary: Readiness probe
      description: Pings the database with a 1-second timeout. Returns 503 if Postgres unreachable.
      security: []
      responses:
        "200":
          description: Ready
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: "ok" }
        "503":
          description: Database unreachable
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: "unavailable" }
                  checks:
                    type: object
                    properties:
                      db: { type: string, example: "down" }

  /metrics:
    get:
      tags: [Ops]
      summary: Prometheus metrics
      security: []
      responses:
        "200":
          description: Prometheus exposition format
          content:
            text/plain: {}

  # =========================================================================
  # Public Plans
  # =========================================================================
  /api/v1/plans:
    get:
      tags: [Plans]
      summary: List active plans for the tenant
      description: |
        Returns plans that belong to the product associated with the API key.
        Archived and private (`is_public=false`) plans are filtered out.
      responses:
        "200":
          description: Plans
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Plan" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # =========================================================================
  # Public Customers (5 endpoints — protected + 2 public for OTP flow)
  # =========================================================================
  /api/v1/customers:
    post:
      tags: [Customers]
      summary: Get or create a customer (idempotent on email)
      description: |
        Tenant-scoped via `external_ids[product.slug]`. Returns 200 if a customer
        with this email already exists; 201 if newly created. The product's slug
        is silently added to the customer's external_ids map.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
                name: { type: string, nullable: true }
                external_id:
                  type: string
                  nullable: true
                  description: Product-side identifier (e.g. user uuid in product DB).
      responses:
        "200":
          description: Existing customer
          content: { application/json: { schema: { $ref: "#/components/schemas/CustomerResponse" } } }
        "201":
          description: Created
          content: { application/json: { schema: { $ref: "#/components/schemas/CustomerResponse" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/customers/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Customers]
      summary: Get a customer by id (tenant scoped)
      responses:
        "200":
          description: Customer
          content: { application/json: { schema: { $ref: "#/components/schemas/CustomerResponse" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Customers]
      summary: Update a customer
      description: |
        `{"external_id": null}` explicitly removes the binding to this product.
        Changing `email` resets `email_verified` to false.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                email: { type: string, format: email, nullable: true }
                name: { type: string, nullable: true }
                external_id: { type: string, nullable: true }
      responses:
        "200":
          description: Updated
          content: { application/json: { schema: { $ref: "#/components/schemas/CustomerResponse" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/customers/by-email/{email}:
    parameters:
      - { name: email, in: path, required: true, schema: { type: string, format: email } }
    get:
      tags: [Customers]
      summary: Get a customer by email (tenant scoped)
      responses:
        "200":
          description: Customer
          content: { application/json: { schema: { $ref: "#/components/schemas/CustomerResponse" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/customers/email/send-code:
    post:
      tags: [Customers]
      summary: Send a 6-digit email verification code (public, rate-limited)
      description: |
        No API key required — the hosted checkout UI calls this directly. Anti-enumeration:
        always returns 200 for a valid email format, regardless of whether an
        account exists. Cooldown 30s, hourly limit 5. 429 sets `Retry-After`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "200":
          description: Sent (or silently no-op for anti-enum)
          content:
            application/json:
              schema:
                type: object
                properties:
                  sent_to: { type: string, description: "Masked email", example: "p***@acme.io" }
                  expires_in: { type: integer, example: 600 }
                  next_resend_in: { type: integer, example: 30 }
          headers:
            Retry-After:
              schema: { type: integer }
              description: Present on 429 only.
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/v1/customers/email/verify:
    post:
      tags: [Customers]
      summary: Verify a 6-digit code → get-or-create customer with email_verified=true
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, code]
              properties:
                email: { type: string, format: email }
                code: { type: string, pattern: "^[0-9]{6}$" }
      responses:
        "200":
          description: Verified
          content:
            application/json:
              schema:
                type: object
                properties:
                  customer:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      email: { type: string, format: email }
                      email_verified: { type: boolean, example: true }
        "422":
          description: Wrong / expired code, with remaining attempts in details
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Error"
                  - properties:
                      error:
                        properties:
                          details:
                            type: object
                            properties:
                              remaining_attempts: { type: integer }

  # =========================================================================
  # Checkout sessions
  # =========================================================================
  /api/v1/checkout/sessions:
    post:
      tags: [Checkout]
      summary: Create a checkout session
      description: |
        Snapshots `price * months * (1 - period_discount / 100)` integer math at
        creation time. `return_url` / `cancel_url` must be HTTPS in production
        (override with `BILLING_ALLOW_INSECURE_RETURN_URL=true` for dev).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [plan_id, customer_email, months]
              properties:
                plan_id: { type: string, format: uuid }
                customer_email: { type: string, format: email }
                months: { type: integer, enum: [1, 3, 6, 12] }
                return_url: { type: string, format: uri }
                cancel_url: { type: string, format: uri }
                promo_code: { type: string, nullable: true }
      responses:
        "201":
          description: Session created
          content:
            application/json:
              schema:
                type: object
                properties:
                  session_id: { type: string, format: uuid }
                  checkout_url: { type: string, format: uri, example: "http://ledgox.dev.local/c/0d29ac66-..." }
                  expires_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/Validation" }

  /api/v1/checkout/sessions/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Checkout]
      summary: Public read of a session (capability token)
      description: |
        Reads via the session UUID itself — no `sk_*` key. Used by the hosted
        checkout UI to render plan/price + poll status. Returns 410 once expired
        or cancelled. `status` ∈ `pending` / `paid` / `expired` / `cancelled` /
        `failed`.
      security: []
      responses:
        "200":
          description: Session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PublicCheckoutSession" }
        "404":
          description: Session not found (anti-enumeration)
        "410":
          description: Session expired

  /api/v1/checkout/sessions/{id}/pay:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Checkout]
      summary: Initialise payment with the selected provider
      description: |
        Capability-token authenticated. Double-POST returns the existing
        payment_url without re-calling the provider (UNIQUE
        `payments(checkout_session_id)`).
      security: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                preferred_provider: { type: string, enum: [paymaster, stripe], nullable: true }
                payment_method_id: { type: string, format: uuid, nullable: true, description: "Stripe on-session confirm with saved card." }
      responses:
        "200":
          description: Payment initialised — redirect user to payment_url
          content:
            application/json:
              schema:
                type: object
                properties:
                  payment_url: { type: string, format: uri }
                  payment_id: { type: string, format: uuid }
                  provider: { type: string, example: "paymaster" }
        "409":
          description: Session already paid or cancelled
        "502":
          description: Provider unavailable

  # =========================================================================
  # Outbox catch-up
  # =========================================================================
  /api/v1/events:
    get:
      tags: [Events]
      summary: Replay missed webhook events (catch-up)
      description: |
        Returns events tenant-scoped to the API key's product. Useful when the
        product's webhook endpoint was down and missed deliveries. Cursor
        pagination via `next_since`.
      parameters:
        - { name: since, in: query, required: false, schema: { type: string }, description: "RFC3339 timestamp OR `sequence` cursor BIGSERIAL." }
        - { name: limit, in: query, required: false, schema: { type: integer, default: 100, maximum: 500 } }
        - { name: delivered, in: query, required: false, schema: { type: boolean }, description: "Filter by delivery status." }
      responses:
        "200":
          description: Events
          content:
            application/json:
              schema:
                type: object
                properties:
                  events:
                    type: array
                    items: { $ref: "#/components/schemas/Event" }
                  next_since: { type: string, nullable: true }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # =========================================================================
  # Webhooks (inbound from providers — products never call these)
  # =========================================================================
  /webhooks/paymaster:
    post:
      tags: [Webhooks (inbound)]
      summary: PayMaster → billing webhook
      description: |
        HMAC-SHA256 signature verified against `BILLING_PAYMASTER_WEBHOOK_SECRET`.
        Idempotent via `payments(provider, provider_transaction_id)` UNIQUE +
        `pg_advisory_xact_lock(provider_event_id)`.
      security: []
      responses:
        "200": { description: Accepted (or already processed) }
        "400": { description: Bad signature or malformed payload }

  /webhooks/stripe:
    post:
      tags: [Webhooks (inbound)]
      summary: Stripe → billing webhook
      description: |
        `Stripe-Signature` header verified via `webhook.ConstructEvent`. Handles
        `payment_intent.succeeded` / `payment_intent.payment_failed` /
        `charge.refunded` / `setup_intent.succeeded` / `customer.deleted`.
      security: []
      responses:
        "200": { description: Accepted }
        "400": { description: Bad signature }

# =========================================================================
# Components
# =========================================================================
components:
  securitySchemes:
    liveKey:
      type: http
      scheme: bearer
      bearerFormat: sk_live_<64hex> | sk_test_<64hex>
      description: |
        Product API key. Created in the operator console (`/console` →
        Settings → API keys). Plaintext returned only at creation time;
        stored as SHA-256 hash on our side.

  responses:
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Resource not found (or anti-enum 404 for cross-tenant access)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Validation:
      description: Validation error
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: Too many requests
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
      headers:
        Retry-After:
          schema: { type: integer }

  schemas:
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code: { type: string, example: "unauthorized" }
            message: { type: string }
            request_id: { type: string, format: uuid }
            details: { type: object, additionalProperties: true }

    Plan:
      type: object
      properties:
        id: { type: string, format: uuid }
        product_id: { type: string, format: uuid }
        name: { type: string, example: "Pro" }
        slug: { type: string, example: "pro" }
        description: { type: string }
        price_monthly:
          type: integer
          description: Amount in minor units (kopecks for RUB, cents for USD).
        currency: { type: string, enum: [RUB, USD] }
        is_public: { type: boolean }
        is_archived: { type: boolean }
        period_discounts:
          type: array
          items:
            type: object
            properties:
              months: { type: integer, enum: [3, 6, 12] }
              discount_percent: { type: integer, minimum: 1, maximum: 99 }
        metadata:
          type: object
          additionalProperties: true
          description: Free-form product-side payload (limits, quotas, feature flags). Ledgox stores it but does not interpret it.

    CustomerResponse:
      type: object
      properties:
        customer:
          type: object
          properties:
            id: { type: string, format: uuid }
            email: { type: string, format: email }
            email_verified: { type: boolean }
            external_id: { type: string, nullable: true }
            name: { type: string, nullable: true }
            created_at: { type: string, format: date-time }
            updated_at: { type: string, format: date-time }

    PublicCheckoutSession:
      type: object
      properties:
        id: { type: string, format: uuid }
        status: { type: string, enum: [pending, paid, expired, cancelled, failed] }
        plan:
          type: object
          properties:
            id: { type: string, format: uuid }
            name: { type: string }
            price_monthly: { type: integer }
            currency: { type: string }
            period_discounts:
              type: array
              items:
                type: object
                properties:
                  months: { type: integer }
                  discount_percent: { type: integer }
        amount_minor: { type: integer }
        currency: { type: string }
        customer_email: { type: string, format: email }
        return_url: { type: string, format: uri }
        cancel_url: { type: string, format: uri }
        available_providers:
          type: array
          items: { type: string, enum: [paymaster, stripe] }
        stripe_publishable_key: { type: string, nullable: true }
        expires_at: { type: string, format: date-time }
        next_billing_date: { type: string, format: date-time, nullable: true }

    Event:
      type: object
      properties:
        id: { type: string, format: uuid }
        sequence: { type: integer }
        type:
          type: string
          example: "subscription.created"
          enum:
            - subscription.created
            - subscription.cancelled
            - subscription.expired
            - subscription.renewed
            - subscription.past_due
            - subscription.change_plan
            - payment.succeeded
            - payment.failed
            - refund.created
            - webhook.test
        created_at: { type: string, format: date-time }
        delivered: { type: boolean }
        delivery_attempts: { type: integer }
        data: { type: object, additionalProperties: true }
