openapi: 3.1.0
info:
  title: Lurus Platform API
  version: "1.1.0"
  summary: Single canonical spec for the lurus-platform identity / billing / messaging backplane.
  description: |
    Enterprise Identity, Billing & Communication Platform.

    This is the **single canonical** OpenAPI 3.1 spec for `lurus-platform`. It
    merges and supersedes the previously-split `openapi.yaml` (account /
    billing / wallet) and `openapi-zita.yaml` (tenant lifecycle) — there is
    now exactly one wire contract.

    ## Surfaces

    | Prefix         | Audience                              | Auth                                       |
    |----------------|---------------------------------------|--------------------------------------------|
    | `/api/v1/*`    | End-user products on `*.lurus.cn`     | `bearerAuth` (Zita JWT) or `cookieAuth`    |
    | `/internal/v1/*` | Other Lurus services                | `internalKey` (`Authorization: Bearer`)    |
    | `/admin/v1/*`  | Operator dashboards / MCP / CLI       | `bearerAuth` (operator JWT, admin role)    |
    | `/webhook/*`   | Payment providers (Epay, Stripe, …)   | Provider signature verification (no app auth) |
    | `/health`, `/readyz`, `/sli`, `/docs/*` | Anyone | None                            |

    ## Error envelope

    Every non-2xx response shares the unified shape `{ "error": "<code>",
    "message": "<english>" }` (see `Error` schema). `error` is a
    machine-readable code (`tenant_already_exists`, `not_found`,
    `unauthorized`, `forbidden`, `rate_limited`, …); `message` is a
    free-form English string for operators. Some 429 / 503 responses also
    carry a numeric `retry_after` field hinting how many seconds to wait.

    ## Conventions

    - Slugs are lowercase ASCII `^[a-z][a-z0-9-]{1,38}[a-z0-9]$`. Once
      assigned, a slug is immutable for the tenant's lifetime.
    - Async jobs (today: data-export) return `202 Accepted` with a `job_id`.
      Poll the status endpoint until `status` is terminal.
    - Money is CNY decimal (`number/double`) unless otherwise noted; LLM
      usage cost is `cost_micros` (1e-6 USD, BIGINT) to avoid float drift.
    - Pagination uses `page` (1-indexed) + `page_size` (server-clamped to
      100 unless documented otherwise).
    - All endpoints under `/api/v1` accept either an `Authorization: Bearer
      <jwt>` header (Zita JWT) or a `lurus_session` cookie issued by the
      platform-core login flow. Mix freely per-call.

    ## Live API browser

    A RapiDoc-powered explorer is served at <https://identity.lurus.cn/docs/api>.
    The raw spec is at `/docs/openapi.yaml` (this file).
  contact:
    name: Lurus Platform Team
  license:
    name: Internal
  x-audience-doc: |
    Each operation carries an `x-audience` extension declaring who is
    expected to call it. DX-5 (Redocly filter) splits the dev-portal
    publish target on this field — only `public` ops reach
    docs.lurus.cn/developer/.

      - `public`   — `/api/v1/*`, `/health`, `/readyz`, `/sli`, `/docs/*`.
                     Authenticated via Zita JWT (`bearerAuth`) or parent
                     `lurus_session` cookie (`cookieAuth`); exposed to
                     end users + 3rd-party SDK consumers.
      - `internal` — `/internal/v1/*` and `/webhook/*`. Service-to-service:
                     `internalKey` — a scoped service key or the legacy
                     `INTERNAL_API_KEY`, always as `Authorization: Bearer`;
                     payment-provider webhooks verify their own signatures.
      - `admin`    — `/admin/v1/*`. Operator JWT (`bearerAuth` with admin
                     role on the platform org); never published externally.

servers:
  - url: https://identity.lurus.cn
    description: Production (R6, dev-mode in 2026-Q2)
  - url: http://platform-core.lurus-platform.svc:18104
    description: In-cluster (K8s service)

# ─────────────────────────────────────────────────────────────────────
# Tag groups (for grouping in the docs UI)
# ─────────────────────────────────────────────────────────────────────
tags:
  - name: Identity
    description: |
      Account self-read, login, logout, drop-in identity (`/whoami`).
      Surfaces both the public end-user contract and the auth dance
      consumed by the Zita login UI.
  - name: Wallet
    description: |
      End-user wallet endpoints — balance, transactions, topup, redeem,
      orders. Internal counterparts (debit / credit / pre-auth) live
      under the `Internal` tag.
  - name: Subscription
    description: |
      Product catalogue, plan listing, subscribe / cancel, checkout
      orchestration. Cross-service checkout reuses these primitives via
      the `Internal` surface.
  - name: Organization
    description: |
      Tenant-level org CRUD + membership + per-org wallet + per-org API
      keys. Distinct from the platform-level Zita Tenants surface (which
      is admin-only).
  - name: Identity Recovery
    description: |
      Registration, password reset, SMS / email codes, phone
      verification. Sits under `/api/v1/auth/*`.
  - name: PIPL / DSAR
    description: |
      Data-portability + delete-request endpoints. PIPL §45 (export) and
      §47 (delete) for end users; the operator-driven version sits under
      Admin and the org-scoped DSAR sits under `Zita Tenants`.
  - name: Health
    description: Liveness, readiness, SLI snapshot, public docs.
  - name: Internal
    description: |
      Service-to-service endpoints under `/internal/v1/*`. Authenticated with
      `Authorization: Bearer <key>` — a scoped service key, or the legacy
      `INTERNAL_API_KEY` which carries the full scope set as break-glass. See
      the `internalKey` security scheme; there is no `X-API-Key` header on this
      surface. Each operation lists its `x-required-scope` for the per-key gate.
  - name: Kova Provisioning
    description: |
      Platform→Kova R6 provisioning bridge (F2 revenue path). Both
      directions: provision / view (org-facing) and per-run usage
      ingestion (worker-facing).
  - name: Admin
    description: |
      Operator-only endpoints under `/admin/v1/*`. Account search /
      grant / wallet adjust, refund approval, redemption code batch,
      financial reports, settings, organizations, app registry,
      api-keys, service-keys, ops catalogue, hook DLQ, audit events.
  - name: Zita Tenants
    description: |
      Tenant CRUD + lifecycle (create, list, get, suspend, reactivate)
      under `/admin/v1/zita/tenants/*`. Every state transition emits a
      `zita.tenant.*` NATS event for downstream services to react
      idempotently.
  - name: Zita DSAR
    description: |
      Data Subject Access Request — async export of all data tied to a
      tenant. Today the job is a stub (auto-completes synchronously
      with a placeholder URL); real impl tracked as P2-11.
  - name: Distributor
    description: |
      Distributor self-service endpoints under `/api/v1/distributor/*` +
      admin under `/admin/v1/distributors/*`. Covers RES-1 (signup+KYC) +
      RES-2 (me API: customers/payouts/aggregate) + topup + admin
      grant/tier ops.
  - name: Webhooks
    description: |
      Inbound payment-provider notifications. Each handler verifies the
      provider's signature on its own; there is no platform-level
      `Authorization` header.

security: []

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        Zita-issued JWT. Used for end-user `/api/v1/*` traffic and for
        operator `/admin/v1/*` traffic — the latter additionally requires
        the `admin` role on the platform org.
    cookieAuth:
      type: apiKey
      in: cookie
      name: lurus_session
      description: |
        Parent-domain session cookie issued by the platform-core login
        flow. Equivalent to `bearerAuth` for `/api/v1/*` paths so SPAs
        on `*.lurus.cn` work without juggling an Authorization header.
    internalKey:
      type: http
      scheme: bearer
      description: |
        Service-to-service API key issued via `/admin/v1/api-keys`, sent as
        `Authorization: Bearer <key>`. Scopes are enforced per-endpoint; see
        each operation's `x-required-scope` for the gate.

        🔴 It is NOT an `X-API-Key` header, whatever this scheme used to say.
        `internalKeyAuth` (internal/adapter/handler/router/router.go) reads
        `Authorization` and nothing else; anything without a `Bearer ` prefix
        is rejected before the key is ever looked up. Measured against
        production 2026-09-01: `X-API-Key` on
        `POST /internal/v1/admin/platform-admins/:account_id` returns
        `401 {"error":"unauthorized","message":"Missing or malformed
        Authorization header"}`, and the identical call with
        `Authorization: Bearer` returns 200. That endpoint is the break-glass
        path for seeding the very first platform admin, so a spec that names
        the wrong header costs an operator their first attempt at exactly the
        moment nobody can log in.

  parameters:
    PageQuery:
      name: page
      in: query
      schema: { type: integer, minimum: 1, default: 1 }
      description: 1-based page index.
    PageSizeQuery:
      name: page_size
      in: query
      schema: { type: integer, minimum: 1, maximum: 200, default: 20 }
      description: Page size (server-clamped, default 20).
    SlugPath:
      name: slug
      in: path
      required: true
      schema:
        type: string
        pattern: "^[a-z][a-z0-9-]{1,38}[a-z0-9]$"
      description: Tenant slug (immutable identifier).
    JobIDPath:
      name: job_id
      in: path
      required: true
      schema:
        type: string
        format: uuid
      description: Async job UUID.
    AccountIDPath:
      name: id
      in: path
      required: true
      schema: { type: integer, format: int64 }
      description: Numeric account ID.
    ProductIDPath:
      name: product_id
      in: path
      required: true
      schema: { type: string }
      description: Stable product code (e.g. `lutu`, `tally`, `lucrum`).

  responses:
    Unauthorized:
      description: Missing or invalid credential.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            missingBearer:
              value: { error: "unauthorized", message: "Authentication required" }
    Forbidden:
      description: Authenticated but lacks the required role / scope.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Conflict:
      description: Idempotency or state conflict.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ValidationError:
      description: Input failed schema validation.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: |
        Per-IP or per-user rate limit hit. Carries `Retry-After` (seconds)
        in the response header.
      headers:
        Retry-After:
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Error:
      type: object
      description: Unified error envelope. All non-2xx responses share this shape.
      properties:
        error:
          type: string
          description: Machine-readable error code.
          examples: ["tenant_already_exists", "not_found", "unauthorized"]
        message:
          type: string
          description: Human-readable English message for operators.
        retry_after:
          type: integer
          description: Seconds the caller should wait before retrying.
      required: [error, message]

    OrgSettingOverride:
      type: object
      description: A single per-org config override row. Secret values are masked.
      properties:
        key: { type: string }
        value:
          type: string
          description: Stored value, or the bullet mask if the key is a secret.
        is_secret: { type: boolean }
        category:
          type: string
          description: Catalog category (branding | auth | payment | issuer | billing); empty for unrecognized legacy keys.
        updated_by: { type: string }
        updated_at: { type: string, format: date-time }
      required: [key, value, is_secret]

    OrgSettingEffective:
      type: object
      description: Resolved effective value for a catalog key, with its provenance.
      properties:
        key: { type: string }
        category: { type: string }
        value:
          type: string
          description: Effective value (masked if the key is a secret).
        is_secret: { type: boolean }
        source:
          type: string
          enum: [override, global, default]
          description: Where the effective value came from.
      required: [key, category, value, is_secret, source]

    OrgSettingPutBody:
      type: object
      description: Body for setting a per-org config override. Empty value clears/inherits.
      properties:
        value: { type: string }
      required: [value]

    OverageView:
      type: object
      description: >
        Per-(account, product) overage / usage-credits policy. All amounts are
        int64 minor units (分). Rate and cap apply only to `auto_payg`.
      properties:
        product_id: { type: string }
        policy:
          type: string
          enum: [hard_stop, auto_payg]
          description: hard_stop = stop at allotment; auto_payg = pay-as-you-go overage.
        rate_cents_per_credit: { type: integer, format: int64, description: "auto_payg only." }
        max_cents_per_period:
          type: integer
          format: int64
          description: >
            auto_payg only; 0 = unlimited. "Period" = the current billing
            calendar month in Asia/Shanghai (resets at 00:00 Beijing time on
            the 1st, the same window the wallet "this month" by-product view
            renders) — NOT the subscription's own
            billing anniversary. The ceiling is scoped to this (account,
            product) pair: a second product's auto_payg spend does not consume
            it. It caps ONLY the cash charged when the credits allotment runs
            dry under auto_payg; direct wallet debits (internal wallet/debit,
            purchases, subscription charges) are unaffected by it.
        has_live_subscription: { type: boolean }
      required: [product_id, policy, rate_cents_per_credit, max_cents_per_period, has_live_subscription]

    SetOverageBody:
      type: object
      description: Request body for setting an overage policy. All amounts are int64 minor units (分).
      properties:
        product_id: { type: string }
        policy:
          type: string
          enum: [hard_stop, auto_payg]
        rate_cents_per_credit: { type: integer, format: int64 }
        max_cents_per_period:
          type: integer
          format: int64
          description: >
            auto_payg only; 0 = unlimited. Same semantics as
            OverageView.max_cents_per_period — the window is the Asia/Shanghai
            billing calendar month and the ceiling applies to this (account,
            product) pair alone.
      required: [product_id, policy]

    MeterView:
      type: object
      description: >
        Account-global credits allotment roll-up (the "X% used, resets in N days"
        meter). All amounts are int64 minor units (分); the credits pool is
        account-global so this surface is not product-scoped.
      properties:
        granted_cents: { type: integer, format: int64 }
        used_cents: { type: integer, format: int64 }
        remaining_cents: { type: integer, format: int64 }
        percent_used: { type: integer, minimum: 0, maximum: 100 }
        reset_at:
          type: string
          format: date-time
          nullable: true
          description: RFC3339; null = no expiry (forever plan / gift-only).
      required: [granted_cents, used_cents, remaining_cents, percent_used]

    ModelQuotaView:
      type: object
      description: >
        Per-model weekly quota bucket for the current ISO-week. All amounts are
        credits 分.
      properties:
        model_name: { type: string }
        limit_cents: { type: integer, format: int64, description: "Weekly cap; 0 = uncapped (tracked)." }
        used_cents: { type: integer, format: int64 }
        remaining_cents: { type: integer, format: int64, description: "-1 = unlimited." }
        week_start_at: { type: string, format: date-time, description: "RFC3339; ISO-week Monday UTC." }
      required: [model_name, limit_cents, used_cents, remaining_cents, week_start_at]

    ModelQuotaList:
      type: object
      description: Current-week per-model quota buckets.
      properties:
        quotas:
          type: array
          items: { $ref: '#/components/schemas/ModelQuotaView' }
      required: [quotas]

    SetModelQuotaBody:
      type: object
      description: Request body for setting a weekly per-model cap. All amounts are credits 分.
      properties:
        model_name: { type: string }
        limit_cents: { type: integer, format: int64, description: "Weekly cap; 0 = lift the cap (tracked, uncapped)." }
      required: [model_name]

    AutoReloadView:
      type: object
      description: >
        Wallet auto-reload config. When enabled and the cash balance falls below
        `threshold_cents`, the default saved card is charged for `amount_cents`
        and the wallet credited. Amounts are int64 minor units (分). The
        engine-owned fields report the last outcome.
      properties:
        enabled: { type: boolean }
        threshold_cents: { type: integer, format: int64, description: "Reload when the cash balance falls below this (分)." }
        amount_cents: { type: integer, format: int64, description: "Top-up amount per reload (分)." }
        last_reload_at:
          type: string
          format: date-time
          nullable: true
          description: Last successful reload; null when never reloaded.
        consecutive_failures: { type: integer, description: "Consecutive failed attempts; the engine stops past an internal cap." }
        last_error: { type: string, description: "Reason for the most recent failure, if any." }
      required: [enabled, threshold_cents, amount_cents, consecutive_failures, last_error]

    SetAutoReloadBody:
      type: object
      description: >
        Request body for setting the wallet auto-reload policy. An enabled config
        must set a positive `amount_cents`. Amounts are int64 minor units (分).
      properties:
        enabled: { type: boolean }
        threshold_cents: { type: integer, format: int64 }
        amount_cents: { type: integer, format: int64 }
      required: [enabled, threshold_cents, amount_cents]

    PlanChangePreview:
      type: object
      description: >
        Computed projection of a plan change (Feature H). `delta_cents` is the
        prorated price difference for the unused portion of the current cycle;
        `charge_cents` (upgrade) or `credit_cents` (downgrade) is what moves
        against the cash wallet now. `applied` is false for a preview, true once
        the change is settled. All amounts are int64 minor units (分).
      properties:
        product_id: { type: string }
        current_plan_code: { type: string }
        new_plan_code: { type: string }
        billing_cycle: { type: string }
        current_price_cents: { type: integer, format: int64 }
        new_price_cents: { type: integer, format: int64 }
        direction: { type: string, enum: [upgrade, downgrade, noop] }
        delta_cents: { type: integer, format: int64, description: "Prorated diff magnitude (分, >= 0)." }
        charge_cents: { type: integer, format: int64, description: "Debited now on upgrade, else 0 (分)." }
        credit_cents: { type: integer, format: int64, description: "Credited now on downgrade, else 0 (分)." }
        expires_at:
          type: string
          format: date-time
          nullable: true
          description: Cycle end — unchanged by the plan change.
        applied: { type: boolean }
      required: [product_id, current_plan_code, new_plan_code, billing_cycle, current_price_cents, new_price_cents, direction, delta_cents, charge_cents, credit_cents, applied]

    ChangePlanBody:
      type: object
      description: Request body to apply a plan change (Feature H).
      properties:
        product_id: { type: string }
        new_plan_code: { type: string }
      required: [product_id, new_plan_code]

    PaymentMethodView:
      type: object
      description: >
        A saved payment method — display fields only; vault handles never leave
        the server.
      properties:
        id: { type: integer, format: int64 }
        brand: { type: string }
        last4: { type: string }
        exp_month: { type: integer }
        exp_year: { type: integer }
        is_default: { type: boolean }
      required: [id, brand, last4, exp_month, exp_year, is_default]

    PaymentMethodList:
      type: object
      description: The account's saved payment methods.
      properties:
        payment_methods:
          type: array
          items: { $ref: '#/components/schemas/PaymentMethodView' }
      required: [payment_methods]

    StartSetupBody:
      type: object
      description: Request body for starting a hosted save-card flow. All fields optional.
      properties:
        return_url:
          type: string
          format: uri
          description: Where the provider redirects after the save-card flow; falls back to the deployment default when omitted.

    OKResult:
      type: object
      description: Minimal acknowledgement envelope for mutating operations.
      properties:
        ok: { type: boolean }
      required: [ok]

    ConvergenceStats:
      type: object
      description: Unified-currency readiness counts. platform is authoritative; newhub is best-effort.
      properties:
        platform:
          type: object
          properties:
            accounts_total: { type: integer, format: int64 }
            wallets_total: { type: integer, format: int64 }
            wallets_balance_nonzero: { type: integer, format: int64 }
            wallets_credits_nonzero: { type: integer, format: int64 }
            accounts_newapi_linked: { type: integer, format: int64 }
          required: [accounts_total, wallets_total, wallets_balance_nonzero, wallets_credits_nonzero, accounts_newapi_linked]
        newhub:
          type: object
          properties:
            users_total: { type: integer, format: int64 }
            users_linked: { type: integer, format: int64 }
            tokens_linked: { type: integer, format: int64 }
            tokens_unlimited: { type: integer, format: int64 }
            available:
              type: boolean
              description: false when newhub is unset/unreachable (counts then zero).
          required: [available]
      required: [platform, newhub]

    Pagination:
      type: object
      description: Pagination metadata for list endpoints.
      properties:
        page: { type: integer, minimum: 1 }
        page_size: { type: integer, minimum: 1, maximum: 200 }
        total: { type: integer, minimum: 0 }
      required: [page, page_size, total]

    Account:
      type: object
      description: Full account row (admin / internal view).
      properties:
        id: { type: integer, format: int64 }
        lurus_id: { type: string }
        zitadel_sub:
          type: string
          deprecated: true
          x-deprecation-target: "v2.0"
          description: Vendor-leaking alias for `idp_subject`. Will be removed in v2.0; consumers should migrate to `idp_subject`.
        idp_subject:
          type: string
          description: OIDC `sub` claim from the underlying identity provider. Vendor-neutral canonical name; mirrored under `zitadel_sub` during the deprecation cycle.
        email: { type: string, format: email }
        email_verified: { type: boolean }
        phone: { type: string }
        phone_verified: { type: boolean }
        display_name: { type: string }
        avatar_url: { type: string, format: uri }
        status:
          type: integer
          description: "1=active · 2=suspended · 3=deleted"
        locale: { type: string }
        aff_code: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    AccountSummary:
      type: object
      description: Trimmed account view safe for end-user surfaces.
      properties:
        id: { type: integer, format: int64 }
        lurus_id: { type: string }
        display_name: { type: string }
        avatar_url: { type: string }

    AccountOverview:
      type: object
      description: Aggregated read for product dashboards (account + VIP + wallet + subscription).
      properties:
        account: { $ref: '#/components/schemas/AccountSummary' }
        vip: { $ref: '#/components/schemas/VIPSummary' }
        wallet: { $ref: '#/components/schemas/WalletSummary' }
        subscription: { $ref: '#/components/schemas/SubscriptionSummary' }
        topup_url: { type: string }

    Whoami:
      type: object
      description: Drop-in identity contract for `*.lurus.cn` products. Phone is masked.
      properties:
        account_id: { type: integer, format: int64 }
        lurus_id: { type: string }
        display_name: { type: string }
        email: { type: string, format: email }
        phone: { type: string, description: Masked (`+86138****2222`). }
      required: [account_id, lurus_id]

    LLMToken:
      type: object
      description: NewAPI bearer issued for the calling user.
      properties:
        key: { type: string, description: '`sk-…` bearer; treat as a secret.' }
        base_url: { type: string, format: uri, examples: ["https://newapi.lurus.cn/v1"] }
        name: { type: string }
        unlimited_quota: { type: boolean }
      required: [key, base_url, name]

    VIPSummary:
      type: object
      properties:
        level: { type: integer }
        level_name: { type: string }
        level_en: { type: string }
        points: { type: integer, format: int64 }
        level_expires_at: { type: string, format: date-time }

    Entitlements:
      type: object
      description: Map of entitlement key to value.
      additionalProperties: { type: string }

    AuthInfo:
      type: object
      description: Login UI bootstrapping payload.
      properties:
        org_name: { type: string }
        login_method: { type: string, examples: ["password", "wechat", "sms"] }
        wechat_enabled: { type: boolean }
        sms_enabled: { type: boolean }
        legal_links:
          type: object
          properties:
            tos: { type: string, format: uri }
            privacy: { type: string, format: uri }

    LoginRequest:
      type: object
      required: [identifier, password]
      properties:
        identifier: { type: string, description: Email / phone / username — server resolves which. }
        password: { type: string, writeOnly: true }
        return_url: { type: string, format: uri }

    LoginResponse:
      type: object
      properties:
        session_token: { type: string }
        account: { $ref: '#/components/schemas/AccountSummary' }
        expires_at: { type: string, format: date-time }
      required: [session_token, account]

    WechatLoginRequest:
      type: object
      required: [code]
      properties:
        code: { type: string, description: WeChat OAuth authorization code. }
        state: { type: string }

    LogoutResponse:
      type: object
      properties:
        ok: { type: boolean }
      required: [ok]

    Wallet:
      type: object
      properties:
        balance: { type: number, format: double }
        frozen: { type: number, format: double }
        currency: { type: string, default: CNY }
        updated_at: { type: string, format: date-time }

    WalletSummary:
      type: object
      properties:
        balance: { type: number, format: double }
        frozen: { type: number, format: double }
        credits_cents:
          type: integer
          format: int64
          description: Q币 (credits) pool balance, in 分.
        discount_rate: { type: number, format: double }
        discount_tier: { type: string }

    # 🔴 2026-08-27 新增。此前 GET /api/v1/wallet 与
    # GET /api/v1/organizations/{id}/wallet 都 $ref 上面那个 WalletSummary,
    # 而它们实发的是 entity.Wallet / entity.OrgWallet —— 两边只有 balance/frozen
    # 是重合的:
    #   声明了却从不发:discount_rate、discount_tier
    #     (那两个只存在于 AccountOverview 的 app.WalletSummary 里,
    #      所以 WalletSummary 这个 schema 本身没错,错的是这两个端点在引用它)
    #   发了却没声明:id、account_id、lifetime_topup、lifetime_spend
    #     ——其中 lifetime_topup **web 已经在读**(web/src/pages/Wallet/index.jsx:234
    #     的「累计充值」),即契约消费者做不出这一格。
    # 症状是「折扣时有时无」(账户总览有、钱包页没有),排查会先怀疑折扣逻辑
    # 而不是接口形状。
    # 各起各的 schema,不复用 Wallet —— 那一个正被数据导出端点引用,
    # 且它把 balance/frozen 声明成 number 而导出实发的是字符串。
    AccountWallet:
      type: object
      description: GET /api/v1/wallet 的响应,逐字段对应 entity.Wallet。
      properties:
        id: { type: integer, format: int64 }
        account_id: { type: integer, format: int64 }
        balance: { type: number, format: double, description: 可用余额(元) }
        frozen: { type: number, format: double, description: 冻结中(元) }
        credits_cents: { type: integer, format: int64, description: Q币池余额(分) }
        lifetime_topup: { type: number, format: double, description: 累计充值(元) }
        lifetime_spend: { type: number, format: double, description: 累计消费(元) }

    OrgWallet:
      type: object
      description: GET /api/v1/organizations/{id}/wallet 的响应,逐字段对应 entity.OrgWallet。
      properties:
        org_id: { type: integer, format: int64 }
        balance: { type: number, format: double }
        frozen: { type: number, format: double }
        lifetime_topup: { type: number, format: double }
        lifetime_spend: { type: number, format: double }
        updated_at: { type: string, format: date-time }

    Transaction:
      type: object
      properties:
        id: { type: integer, format: int64 }
        account_id: { type: integer, format: int64 }
        type: { type: string }
        amount: { type: number, format: double }
        balance_after: { type: number, format: double }
        product_id: { type: string }
        order_no: { type: string }
        description: { type: string }
        created_at: { type: string, format: date-time }

    TransactionList:
      type: object
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/Transaction' }
        total: { type: integer, format: int64 }
      required: [data, total]

    WalletByProductRow:
      type: object
      properties:
        product_id:
          type: string
          nullable: true
          description: |
            Product identifier (e.g. "lucrum", "switch"). Null when the
            originating debit had no product tag — surfaced as a separate
            "uncategorised" bucket rather than collapsed into another row.
        spent:
          type: string
          description: |
            Absolute spend in the wallet currency, formatted with two decimal
            places (e.g. "12.34"). String-typed to avoid float rounding on
            the client.
        txn_count:
          type: integer
          format: int64
          description: Number of debit transactions contributing to this row.
      required: [product_id, spent, txn_count]

    WalletByProductResponse:
      type: object
      properties:
        data:
          type: object
          properties:
            period:
              type: string
              enum: [month]
            from:
              type: string
              format: date-time
              description: Inclusive start of the aggregation window (UTC).
            to:
              type: string
              format: date-time
              description: Exclusive end of the aggregation window (UTC).
            currency:
              type: string
              description: Wallet currency label (e.g. "LB").
            total_spent:
              type: string
              description: Sum of all `by_product[].spent`, two-decimal string.
            by_product:
              type: array
              items: { $ref: '#/components/schemas/WalletByProductRow' }
          required: [period, from, to, currency, total_spent, by_product]
      required: [data]

    WalletOperationResult:
      type: object
      properties:
        success: { type: boolean }
        balance_after: { type: number, format: double }
      required: [success]

    CreditsGrant:
      type: object
      description: One credits (Q币) injection — its source, amount, remaining, expiry.
      properties:
        id: { type: integer, format: int64 }
        account_id: { type: integer, format: int64 }
        source_kind:
          type: string
          description: subscription | trial | gift | refund_reverse | admin
        source_ref:
          type: string
          description: Stable id of the source row (sub id / gift code / trial id).
        amount_cents: { type: integer, format: int64 }
        remaining_cents: { type: integer, format: int64 }
        granted_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time, nullable: true }
        revoked_at: { type: string, format: date-time, nullable: true }

    WalletStatement:
      type: object
      description: |
        Cross-product currency statement (unified currency): cash + Q币 pools,
        active grants, current-month spend by product, lifetime earn by source.
      properties:
        cash:
          type: object
          properties:
            balance: { type: number, format: double }
            frozen: { type: number, format: double }
            available: { type: number, format: double }
            currency: { type: string, description: 'e.g. "CNY"' }
          required: [balance, frozen, available, currency]
        qbi:
          type: object
          description: The Q币 (credits) pool — fungible balance plus its active grants.
          properties:
            credits_cents: { type: integer, format: int64 }
            active_grants:
              type: array
              items:
                type: object
                properties:
                  source_kind: { type: string }
                  source_ref: { type: string }
                  remaining_cents: { type: integer, format: int64 }
                  expires_at: { type: string, format: date-time, nullable: true }
          required: [credits_cents, active_grants]
        spend_by_product:
          type: array
          description: Per-product debit aggregation for the current calendar month.
          items: { $ref: '#/components/schemas/ProductSpendRow' }
        earn_by_source:
          type: array
          description: Per-source_kind credits granted (lifetime) vs still remaining.
          items:
            type: object
            properties:
              source_kind: { type: string }
              granted_cents: { type: integer, format: int64 }
              remaining_cents: { type: integer, format: int64 }
      required: [cash, qbi, spend_by_product, earn_by_source]

    ProductSpendRow:
      type: object
      properties:
        product_id: { type: string, nullable: true }
        spent: { type: number, format: double }
        txn_count: { type: integer, format: int64 }
      required: [product_id, spent, txn_count]

    PreAuthRequest:
      type: object
      required: [amount, product_id]
      properties:
        amount: { type: number, format: double }
        product_id: { type: string }
        reference_id: { type: string }
        description: { type: string }
        ttl_seconds: { type: integer }

    PreAuthResponse:
      type: object
      properties:
        preauth_id: { type: string }
        amount: { type: number, format: double }
        expires_at: { type: string, format: date-time }
        status: { type: string }

    BillingSummary:
      type: object
      properties:
        balance: { type: number, format: double }
        frozen: { type: number, format: double }
        pre_auths: { type: integer }
        pending_orders: { type: integer }

    CheckoutRequest:
      type: object
      required: [account_id, amount_cny, payment_method, source_service]
      properties:
        account_id: { type: integer, format: int64 }
        amount_cny: { type: number, format: double }
        payment_method:
          type: string
          examples: ["wallet", "alipay", "wechat", "stripe", "creem"]
        source_service: { type: string }
        idempotency_key: { type: string }
        return_url: { type: string }
        ttl_seconds: { type: integer }

    CheckoutResponse:
      type: object
      properties:
        order_no: { type: string }
        pay_url: { type: string }
        status: { type: string }
        expires_at: { type: string, format: date-time }

    InternalSubscriptionCheckoutRequest:
      type: object
      required: [account_id, product_id, plan_code, billing_cycle, payment_method]
      properties:
        account_id: { type: integer, format: int64 }
        product_id: { type: string }
        plan_code: { type: string }
        billing_cycle: { type: string, examples: ["monthly", "annual"] }
        payment_method: { type: string, examples: ["wallet", "stripe"] }
        return_url: { type: string }

    Product:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        description: { type: string }
        status: { type: string }
        billing_mode:
          type: string
          enum: [off, shadow, enforce]
          description: Per-product spend gate (migration 094).

    Plan:
      type: object
      # 🔴 2026-08-27 全面订正。这个 schema 此前与运行时(entity.ProductPlan)
      # 几乎处处对不上,而它**同时被当响应体和请求体用**(3 处 RESP + 2 处 REQ:
      # adminCreatePlan / adminUpdatePlan),所以两个方向都在坏:
      #
      # 读方向:`id` 声明成 string,运行时是 int64 ⇒ 强类型生成客户端在解析
      #   **套餐列表**那一步就死(cannot unmarshal number into string),
      #   整个货架取不出来;而 plan_code / duration_days 运行时从来没发过。
      # 写方向(更危险):契约让管理员发 `plan_code`,而 AdminCreatePlan 绑的是
      #   entity.ProductPlan(json tag 是 `code`)⇒ 那个值被 ShouldBindJSON
      #   **静默丢弃**,建出一个 code 为空的套餐 —— 而 code 正是全链下游判档位的键,
      #   买了它的人会在每一处都表现得像免费用户。
      #   服务端那道底已在同一轮补上(app.ErrPlanCodeRequired → 400)。
      #
      # 现在逐字段照 entity.ProductPlan(internal/domain/entity/product.go)。
      # price_cny_cents 是 json:"-" 的分影子,不上线,故不声明。
      properties:
        id: { type: integer, format: int64 }
        product_id: { type: string }
        code:
          type: string
          description: >
            套餐标识符(如 free / pro / team)。**下游判档位就靠它**;
            创建/更新时必填且不能为空。注意:2026-08-27 之前本契约错误地把这个
            字段叫 plan_code,而服务端从来只认 `code`。
        name: { type: string }
        billing_cycle:
          type: string
          enum: [forever, weekly, monthly, quarterly, yearly, one_time]
        price_cny: { type: number, format: double }
        price_usd: { type: number, format: double }
        is_default: { type: boolean }
        sort_order: { type: integer }
        status: { type: integer, format: int32, description: 1=上架, 其它=下架 }
        features: { type: object, description: 该档位解锁的权益键值对(jsonb) }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Subscription:
      type: object
      properties:
        id: { type: integer, format: int64 }
        account_id: { type: integer, format: int64 }
        product_id: { type: string }
        plan_code: { type: string }
        status: { type: string, examples: ["active", "expired", "cancelled"] }
        expires_at: { type: string, format: date-time }
        auto_renew: { type: boolean }

    SubscriptionSummary:
      type: object
      properties:
        product_id: { type: string }
        plan_code: { type: string }
        status: { type: string }
        expires_at: { type: string, format: date-time }
        auto_renew: { type: boolean }

    SubscribeRequest:
      type: object
      required: [product_id, plan_id, payment_method]
      properties:
        product_id: { type: string }
        # int64, matching the handler's binding — was mis-declared as string
        # (2026-07-13 acceptance: SDKs generated from the spec could not call
        # checkout at all).
        plan_id: { type: integer, format: int64 }
        payment_method: { type: string, description: '"wallet" activates immediately; anything else creates an external payment order.' }
        return_url: { type: string }
        auto_renew:
          type: boolean
          default: false
          description: >
            Buyer's EXPLICIT wallet auto-renewal opt-in (wallet path only;
            ignored for external payment orders). Defaults to false — 网络交易
            监督管理办法 §18 forbids default-on renewal until the 5-day
            reminder email path is live.

    Invoice:
      type: object
      # 🔴 2026-08-27 订正:原来这里声明的是 `amount`,而 entity.Invoice **没有
      # 这个字段** —— 它发的是 subtotal_cny + total_cny(amount 只存在于**明细行**
      # LineItem 上)。发票是对公客户的报销凭证,金额字段撒谎属于钱路对外文件出错:
      # 照契约做发票页的集成方,金额那一格恒为空白或 NaN。
      properties:
        invoice_no: { type: string }
        order_no: { type: string }
        account_id: { type: integer, format: int64 }
        issue_date: { type: string, format: date-time }
        line_items:
          type: array
          description: 明细行。每行 { description, quantity, unit_price, amount }。
          items: { type: object }
        subtotal_cny: { type: number, format: double, description: 税前小计(元) }
        total_cny: { type: number, format: double, description: 价税合计(元)—— 报销凭证上的金额是这一个 }
        currency: { type: string }
        status: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        # Fapiao (税务发票) loop — migration 121.
        buyer_title: { type: string, description: 发票抬头 (company legal name) }
        buyer_tax_number: { type: string, description: 纳税人识别号 }
        fapiao_status: { type: string, enum: [none, requested, issued] }
        fapiao_no: { type: string, description: Registered 数电票号 }
        fapiao_issued_at: { type: string, format: date-time, nullable: true }

    BankTransferInstructions:
      type: object
      description: >
        Remittance sheet returned by checkout/topup for the offline
        bank_transfer method — the receiving corporate account plus the
        remark code (= order_no) the buyer must quote in the wire so the
        operator can match the incoming payment. The order stays pending for
        7 days; an operator confirm (POST
        /admin/v1/payment-orders/{order_no}/confirm-received) marks it paid.
      properties:
        bank_name: { type: string, description: 开户银行 }
        account_name: { type: string, description: 收款户名 }
        account_number: { type: string, description: 银行账号 }
        amount_cny: { type: number, format: double }
        remark_code: { type: string, description: 转账附言码 (= order_no) }
        expires_at: { type: string, format: date-time }

    Refund:
      type: object
      description: >-
        A customer refund request. Money is never returned to the original
        payment instrument (there is deliberately no provider-level refund
        path). Fulfilment depends on the order type: subscription/one_time
        refunds CREDIT the account's platform wallet
        (refund_destination=wallet_balance); topup refunds REVERSE the
        payment-time wallet credit (refund_destination=topup_reversal) and any
        real-money return is an out-of-band operator action.
      properties:
        refund_no: { type: string }
        order_no: { type: string }
        amount_cny: { type: number, format: double }
        reason: { type: string }
        status: { type: string }
        created_at: { type: string, format: date-time }

    Organization:
      type: object
      properties:
        id: { type: integer, format: int64 }
        name: { type: string }
        slug: { type: string }
        status: { type: string }

    OrgService:
      type: object
      description: "Provisioned downstream service for an org (today: kova)."
      properties:
        org_id: { type: integer, format: int64 }
        service: { type: string, examples: ["kova"] }
        status: { type: string, enum: [pending, active, failed, revoked] }
        base_url: { type: string }
        key_prefix: { type: string }
        tester_name: { type: string }
        port: { type: integer }
        provisioned_at: { type: string, format: date-time }
        mock_mode: { type: boolean }
        admin_key:
          type: string
          description: Returned ONLY on the first successful provision.

    UsageReportKovaRequest:
      type: object
      required: [org_id]
      properties:
        org_id: { type: integer, format: int64 }
        service: { type: string, default: kova }
        tester_name: { type: string }
        agent_id: { type: string }
        tokens_in: { type: integer, format: int64, minimum: 0 }
        tokens_out: { type: integer, format: int64, minimum: 0 }
        cost_micros: { type: integer, format: int64, minimum: 0 }
        occurred_at: { type: string, format: date-time }

    CreateTenantInput:
      type: object
      description: Genesis payload for a brand-new tenant.
      properties:
        slug:
          type: string
          pattern: "^[a-z][a-z0-9-]{1,38}[a-z0-9]$"
          minLength: 3
          maxLength: 40
          examples: ["acme-corp", "globex"]
        display_name: { type: string, minLength: 1, maxLength: 200 }
        admin_email: { type: string, format: email }
        admin_password: { type: string, minLength: 12, writeOnly: true }
        starter_plan: { type: string, examples: ["pro_monthly", "free"] }
        initial_credit_cny: { type: number, format: double, minimum: 0 }
      required: [slug, display_name, admin_email, admin_password, starter_plan]

    Tenant:
      type: object
      properties:
        id: { type: integer, format: int64 }
        slug: { type: string }
        display_name: { type: string }
        status:
          type: string
          enum: [active, suspended, archived]
        starter_plan: { type: string }
        admin_account_id: { type: integer, format: int64 }
        created_at: { type: string, format: date-time }
        suspended_at: { type: string, format: date-time, nullable: true }
        suspended_reason: { type: string, nullable: true }
      required: [id, slug, display_name, status, admin_account_id, created_at]

    TenantView:
      $ref: '#/components/schemas/Tenant'

    CreateTenantResult:
      type: object
      properties:
        tenant: { $ref: '#/components/schemas/Tenant' }
        admin_account_id: { type: integer, format: int64 }
        oidc_login_url: { type: string, format: uri }
        admin_password_acknowledged: { type: boolean }
      required: [tenant, admin_account_id, oidc_login_url]

    TenantList:
      type: object
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/Tenant' }
        pagination: { $ref: '#/components/schemas/Pagination' }
      required: [data, pagination]

    SuspendTenantInput:
      type: object
      required: [reason]
      properties:
        reason: { type: string, maxLength: 500 }

    DataExportJob:
      type: object
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: integer, format: int64 }
        slug: { type: string }
        status: { type: string, enum: [queued, running, completed, failed] }
        download_url: { type: string, format: uri, nullable: true }
        error: { type: string, nullable: true }
        requested_at: { type: string, format: date-time }
        completed_at: { type: string, format: date-time, nullable: true }
        expires_at: { type: string, format: date-time, nullable: true }
      required: [id, slug, status, requested_at]

    AuditEvent:
      type: object
      properties:
        id: { type: integer, format: int64 }
        op: { type: string, examples: ["delete_account", "rotate_secret"] }
        actor_id: { type: integer, format: int64 }
        actor_kind: { type: string, examples: ["operator", "service", "user"] }
        target_kind: { type: string, examples: ["account", "app", "tenant"] }
        target_id: { type: string }
        result: { type: string, examples: ["success", "failure"] }
        metadata: { type: object, additionalProperties: true }
        occurred_at: { type: string, format: date-time }

    HookFailure:
      type: object
      properties:
        id: { type: integer, format: int64 }
        event:
          type: string
          examples: [account_created, account_deleted, plan_changed, checkin, referral_signup, reconciliation_issue]
        hook_name: { type: string, examples: ["mail", "notification", "newapi_sync"] }
        account_id: { type: integer, format: int64 }
        attempts: { type: integer }
        last_error: { type: string }
        last_failed_at: { type: string, format: date-time }
        replayed_at: { type: string, format: date-time, nullable: true }
        payload: { type: object, additionalProperties: true }

    NewAPIQuotaSyncFailure:
      type: object
      description: >
        One failed topup→NewAPI quota sync, recorded durably because the NewAPI
        increment is non-idempotent and cannot be safely auto-retried.
      properties:
        id: { type: integer, format: int64 }
        event_id: { type: string, description: Upstream envelope id (unique). }
        account_id: { type: integer, format: int64 }
        newapi_user_id: { type: integer }
        delta_quota: { type: integer, format: int64, description: Quota units that failed to apply (¥1 = 500000). }
        amount_cny: { type: number, format: double }
        attempts: { type: integer }
        last_error: { type: string }
        status: { type: string, enum: [failed, resolved] }
        resolution: { type: string, enum: ["", replayed, manual], description: How a resolved row was closed. }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        resolved_at: { type: string, format: date-time, nullable: true }

    AppEntry:
      type: object
      description: Single app from the declarative `apps.yaml` joined with live Zitadel state.
      properties:
        name: { type: string, examples: ["tally", "lucrum"] }
        display_name: { type: string }
        enabled: { type: boolean }
        auth_method: { type: string, examples: ["pkce", "client_secret"] }
        app_type: { type: string, examples: ["web", "native"] }
        environments:
          type: array
          items: { $ref: '#/components/schemas/AppEnvironment' }

    AppEnvironment:
      type: object
      properties:
        env: { type: string, examples: ["dev", "stage", "prod"] }
        domain: { type: string }
        redirect_uri: { type: string }
        secret_namespace: { type: string }
        secret_name: { type: string }
        zitadel_app_id:
          type: string
          deprecated: true
          x-deprecation-target: "v2.0"
          description: Vendor-leaking alias for `idp_app_id`. Will be removed in v2.0; consumers should migrate to `idp_app_id`.
        idp_app_id:
          type: string
          description: Identity-provider-side application ID. Vendor-neutral canonical name; mirrored under `zitadel_app_id` during the deprecation cycle.
        client_id_preview: { type: string }
        tombstoned: { type: boolean }

    OpsCatalogEntry:
      type: object
      properties:
        type: { type: string, examples: ["delete_account", "delete_oidc_app", "rotate_secret"] }
        description: { type: string }
        risk_level:
          type: string
          enum: [info, warn, destructive]
        destructive: { type: boolean }
        delegate: { type: boolean }

    APIKeyCreateRequest:
      type: object
      required: [name, display_name, purpose]
      properties:
        name: { type: string, description: Stable machine identifier (kebab-case). }
        display_name: { type: string }
        purpose:
          type: string
          enum: [login_ui, mcp, external, admin]
        expiration_days: { type: integer, description: 0 = never expires. }

    APIKeyCreateResponse:
      type: object
      properties:
        id: { type: integer, format: int64 }
        name: { type: string }
        display_name: { type: string }
        purpose: { type: string }
        status: { type: string }
        token: { type: string, description: Returned **once** — at create or rotate time. }
        expires_at: { type: string, format: date-time }
      required: [id, name, status]

    APIKey:
      type: object
      properties:
        id: { type: integer, format: int64 }
        name: { type: string }
        display_name: { type: string }
        purpose: { type: string }
        status: { type: string }
        created_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
      required: [id, name, status]

    SMSRelayRequest:
      type: object
      description: Zita SMS webhook payload (forwarded to Aliyun Dysms).
      properties:
        contextInfo:
          type: object
          properties:
            recipient: { type: string }
            eventType: { type: string }
        templateData:
          type: object
          properties:
            code: { type: string }
            minutes: { type: string }
        messageContent: { type: string }
      required: [contextInfo, templateData]

    # ─── Distributor (Wave R / RES-1 + RES-2) ─────────────────────────
    Distributor:
      type: object
      description: |
        A distributor org — same shape as `Organization` with `org_type`
        fixed to `distributor` (enforced in DB via migration 047 +
        `app.enforce_org_type_hierarchy()` trigger). Owned by the
        signup-account; `parent_org_id` points to the enterprise that
        granted distribution rights.
      properties:
        id: { type: integer, format: int64 }
        name: { type: string }
        slug: { type: string, pattern: "^[a-z][a-z0-9-]{1,38}[a-z0-9]$" }
        owner_account_id: { type: integer, format: int64 }
        status: { type: string, enum: [active, suspended], default: active }
        plan: { type: string, examples: ["free", "team", "enterprise"] }
        parent_org_id:
          type: integer
          format: int64
          nullable: true
          description: Enterprise org that granted this distributor.
        org_type: { type: string, enum: [distributor] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
      required: [id, name, slug, owner_account_id, status, org_type]

    DistributorKYC:
      type: object
      description: |
        Per-distributor KYC row (`identity.distributor_kyc`, migration 070).
        `business_license_no` is globally UNIQUE — same business license
        cannot register two distributors. `status=active` is required
        before any distributor business API (grant/tier/topup/dashboard)
        will execute against this org.
      properties:
        org_id: { type: integer, format: int64 }
        business_license_no:
          type: string
          description: Business license registration number (营业执照号). Globally unique.
        legal_representative: { type: string, description: 法定代表人 full name. }
        contact_email: { type: string, format: email }
        contact_phone: { type: string }
        status:
          type: string
          enum: [pending, active, rejected]
          description: |
            `pending` after self-service signup (waiting admin review);
            `active` after `/admin/v1/distributors/{id}/kyc/approve`;
            `rejected` after `/admin/v1/distributors/{id}/kyc/reject`
            (notes carries the rejection reason).
        notes:
          type: string
          nullable: true
          description: Free-form admin note. Required on reject.
        submitted_at: { type: string, format: date-time }
        reviewed_at: { type: string, format: date-time, nullable: true }
        reviewed_by:
          type: integer
          format: int64
          nullable: true
          description: Reviewing admin's account_id.
      required: [org_id, business_license_no, legal_representative, contact_email, status, submitted_at]

    DistributorSignupRequest:
      type: object
      description: Body of `POST /api/v1/distributor/signup`.
      properties:
        parent_org_id:
          type: integer
          format: int64
          description: >
            Enterprise org that will own this distributor. The caller must
            already be a member of it — a non-member gets 403, whether or not
            the org exists.
        name: { type: string, description: Display name. }
        slug:
          type: string
          pattern: "^[a-z][a-z0-9-]{1,38}[a-z0-9]$"
          description: Lowercase slug — immutable once assigned.
        business_license_no:
          type: string
          description: 营业执照号. Must be unique across all distributors.
        legal_representative: { type: string, description: 法定代表人. }
        contact_email: { type: string, format: email }
        contact_phone: { type: string }
      required: [parent_org_id, name, slug, business_license_no, legal_representative, contact_email]

    DistributorSignupResponse:
      type: object
      description: |
        Returned on successful `POST /api/v1/distributor/signup` (201)
        and on admin `POST /admin/v1/distributors` (201). The KYC row
        is in `status=pending` for self-service signup and
        `status=active` for admin-created distributors.
      properties:
        org: { $ref: '#/components/schemas/Distributor' }
        kyc: { $ref: '#/components/schemas/DistributorKYC' }
      required: [org, kyc]

    DistributorCustomer:
      type: object
      description: |
        A customer account belonging to this distributor — currently
        returned as the full `Account` row (the handler forwards
        `repo.ListCustomerAccounts → []entity.Account` verbatim). Fields
        below are the stable subset SDKs should code against.
      properties:
        id: { type: integer, format: int64 }
        email: { type: string, format: email }
        display_name: { type: string }
        phone: { type: string }
        status:
          type: integer
          description: "1=active · 2=suspended · 3=deleted"
        created_at: { type: string, format: date-time }
      required: [id, created_at]

    DistributorPayout:
      type: object
      description: |
        One T+1 settlement run row (`billing.daily_settlement_runs`).
        Returned by `GET /api/v1/distributor/{id}/payouts`. Amounts are
        BIGINT cents (1 cent = 0.01 CNY) to dodge float drift —
        `total_retail_cents` ≡ `total_wholesale_cents` + `total_margin_cents`.
      properties:
        id: { type: integer, format: int64 }
        run_date: { type: string, format: date, description: T (date being settled — invoice_lines.occurred_at). }
        distributor_org_id: { type: integer, format: int64 }
        total_retail_cents: { type: integer, format: int64, description: Sum customer-paid. }
        total_wholesale_cents: { type: integer, format: int64, description: Sum distributor-paid-upstream. }
        total_margin_cents: { type: integer, format: int64, description: retail − wholesale. }
        rows_count: { type: integer, description: Number of invoice_lines aggregated. }
        status:
          type: string
          enum: [pending, completed, failed]
        error_msg:
          type: string
          nullable: true
          description: Populated only when `status=failed`.
        started_at: { type: string, format: date-time }
        completed_at: { type: string, format: date-time, nullable: true }
      required: [id, run_date, distributor_org_id, total_retail_cents, total_wholesale_cents, total_margin_cents, rows_count, status, started_at]

    OpsCoverage:
      type: object
      description: >-
        A verdict plus the census it rests on. `unknown` contributors are
        excluded from the worst-of rather than counted as healthy (which is
        how an ops surface lies by omission) or as down (which would page on
        every transient count-query blip), so the verdict answers the
        narrower question "of what I could measure, the worst is X" and
        hands the reader the size of the blind spot. A consumer must never
        render `status` without also rendering `unmeasured`.
      required: [status, measured, unmeasured, excluded]
      properties:
        status:
          type: string
          enum: [healthy, degraded, down, unknown]
        measured: { type: integer, description: Contributors that produced a real verdict. }
        unmeasured: { type: integer, description: Contributors that reported unknown — lookup failed, probe unwired, or never computed. }
        excluded: { type: integer, description: Contributors skipped by design (a disabled module) — absence by choice, not absence of data. }

    OpsSignal:
      type: object
      description: >-
        One operator-facing number in the single shape every surface
        publishes it in. `current` is null when the value could not be
        determined and MUST render distinctly from 0.
      required: [key, label, domain, current, unit, direction, status, reason, as_of, freshness]
      properties:
        key:
          type: string
          description: Stable; doubles as the deep-link anchor. Renaming one breaks bookmarks and every runbook that cites it.
        label: { type: string }
        domain: { type: string, enum: [money, people, machine, duty] }
        current:
          type: number
          nullable: true
          description: Null means "could not determine" — never substitute 0.
        unit: { type: string, enum: [count, cny, ratio, ms, bool] }
        target: { type: number, nullable: true, description: Absent for an informational signal that is merely reported. }
        direction: { type: string, enum: [higher_better, lower_better, info] }
        status: { type: string, enum: [healthy, degraded, down, unknown] }
        reason:
          type: string
          description: Prose shown beside the status. Never empty — a coloured dot that will not say why gets guessed at, and readers guess "fine".
        as_of: { type: string, format: date-time }
        freshness:
          type: string
          description: '`live`, `worker:<go-duration>`, or `cumulative`. "0 right now" and "0 as of a sweep an hour ago" call for different actions.'
          examples: ["live", "worker:5m0s", "cumulative"]
        drill:
          type: string
          description: In-app path to the rows behind this number, validated against the SPA's real route table by a build gate. Absent means no drill-down exists — an honest absence, unlike a link that resolves to nothing.
        runbook: { type: string }
        _lookup_error: { type: string, description: Why `current` is null. }

paths:
  # ─── Health ────────────────────────────────────────────────────────
  /health:
    get:
      tags: [Health]
      x-audience: public
      operationId: healthCheck
      summary: Liveness probe
      responses:
        '200':
          description: Service is up.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, examples: ["ok"] }
                  service: { type: string, examples: ["lurus-platform"] }

  /healthz:
    get:
      tags: [Health]
      x-audience: public
      operationId: healthCheckZ
      summary: Liveness probe (k8s-conventional spelling of /health)
      description: >
        Identical response to /health. Both spellings are served because
        /healthz is the Kubernetes convention and was already published in
        the integration docs; it went unregistered until 2026-08-31, during
        which the SPA fallback answered it with 200 + index.html and every
        status-code-only probe read that as healthy.
      responses:
        '200':
          description: Service is up.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, examples: ["ok"] }
                  service: { type: string, examples: ["lurus-platform"] }

  /readyz:
    get:
      tags: [Health]
      x-audience: public
      operationId: readinessCheck
      summary: Readiness probe (DB / Redis / NATS / Zita health)
      responses:
        '200': { description: All registered dependencies healthy. }
        '503':
          description: One or more dependencies unhealthy.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /sli:
    get:
      tags: [Health]
      x-audience: public
      operationId: sliSnapshot
      summary: SLI / SLO snapshot
      responses:
        '200':
          description: SLI snapshot.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true

  /docs/api:
    get:
      tags: [Health]
      x-audience: public
      operationId: getApiDocsBrowser
      summary: Live API browser (RapiDoc)
      responses:
        '200':
          description: HTML doc browser.
          content:
            text/html:
              schema: { type: string }

  /docs/openapi.yaml:
    get:
      tags: [Health]
      x-audience: public
      operationId: getApiSpec
      summary: Raw OpenAPI 3.1 spec
      responses:
        '200':
          description: OpenAPI YAML.
          content:
            application/yaml:
              schema: { type: string }

  # ─── Identity (public /api/v1/*) ───────────────────────────────────
  /api/v1/whoami:
    get:
      tags: [Identity]
      x-audience: public
      operationId: whoami
      summary: Drop-in identity contract
      description: |
        Single endpoint a downstream `*.lurus.cn` product calls to learn
        "who is this user". Reads `lurus_session` cookie OR
        `Authorization: Bearer <jwt>`. Phone is masked. 401 on
        missing/invalid auth — never reveals whether the account exists.
        503 if the deployment lacks a session-validating secret.
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      responses:
        '200':
          description: Authenticated user.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Whoami' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/auth/login:
    post:
      tags: [Identity]
      x-audience: public
      operationId: directLogin
      summary: Direct credential login (zlogin)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/LoginRequest' }
      responses:
        '200':
          description: Logged in.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LoginResponse' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '503':
          description: Login backend not configured (PAT missing).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/auth/mfa/verify:
    post:
      tags: [Identity]
      x-audience: public
      operationId: mfaVerify
      summary: Exchange an MFA challenge token + TOTP/recovery code for a session
      description: >
        Second half of an MFA-gated DirectLogin. The mfa_token is the
        verify-purpose challenge returned by POST /api/v1/auth/login when the
        account is enrolled. code is a 6-digit TOTP code or a single-use
        recovery code (consumed on use).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mfa_token, code]
              properties:
                mfa_token: { type: string }
                code: { type: string }
      responses:
        '200':
          description: Verified — session issued.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LoginResponse' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429':
          description: Too many failed attempts; see Retry-After.
        '503':
          description: MFA not configured on this deployment.

  /api/v1/auth/mfa/enroll/begin:
    post:
      tags: [Identity]
      x-audience: public
      operationId: mfaEnrollBegin
      summary: Begin TOTP enrollment — returns a secret + provisioning URI
      description: >
        Authenticated by EITHER the lurus_session cookie / Bearer token
        (logged-in opt-in) OR an enroll-purpose challenge token in mfa_token
        (forced enrollment mid-login). The returned secret is shown once for
        manual entry; otpauth_url encodes the QR code.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                mfa_token: { type: string }
      responses:
        '200':
          description: Pending enrollment created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  secret: { type: string }
                  otpauth_url: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409':
          description: Already enrolled — disable first.
        '503':
          description: MFA not configured on this deployment.

  /api/v1/auth/mfa/enroll/confirm:
    post:
      tags: [Identity]
      x-audience: public
      operationId: mfaEnrollConfirm
      summary: Confirm TOTP enrollment — enables MFA + returns one-time recovery codes
      description: >
        Verifies the first TOTP code against the pending secret, enables MFA,
        and returns single-use recovery codes ONCE. When the caller
        authenticated via an enroll-purpose challenge token (forced enrollment),
        the response also carries a session token + account_id so the
        interrupted login completes.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                mfa_token: { type: string }
                code: { type: string }
      responses:
        '200':
          description: Enrolled. recovery_codes returned once; token present only on forced-enroll completion.
          content:
            application/json:
              schema:
                type: object
                properties:
                  recovery_codes:
                    type: array
                    items: { type: string }
                  token: { type: string }
                  account_id: { type: integer, format: int64 }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409':
          description: Already enrolled.
        '503':
          description: MFA not configured on this deployment.

  /api/v1/auth/mfa/status:
    get:
      tags: [Identity]
      x-audience: public
      operationId: mfaStatus
      summary: Current account MFA posture
      security: [{ cookieAuth: [] }, { bearerAuth: [] }]
      responses:
        '200':
          description: MFA posture.
          content:
            application/json:
              schema:
                type: object
                properties:
                  enabled: { type: boolean }
                  confirmed_at: { type: string, format: date-time, nullable: true }
                  recovery_codes_left: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '503':
          description: MFA not configured on this deployment.

  /api/v1/auth/mfa/disable:
    post:
      tags: [Identity]
      x-audience: public
      operationId: mfaDisable
      summary: Disable MFA (requires a current TOTP / recovery code)
      security: [{ cookieAuth: [] }, { bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string }
      responses:
        '204':
          description: Disabled.
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '503':
          description: MFA not configured on this deployment.

  /api/v1/auth/info:
    get:
      tags: [Identity]
      x-audience: public
      operationId: getAuthInfo
      summary: Login UI bootstrap
      responses:
        '200':
          description: Login UI bootstrap payload.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthInfo' }

  # NOTE (2026-08-05): POST /api/v1/auth/wechat/login was declared here but
  # never routed — the WeChat flow is the OAuth redirect pair below. Removed
  # rather than implemented: a published path that answers route_not_found is
  # worse than an absent one, and no consumer in the fleet called it.
  /api/v1/auth/wechat:
    get:
      tags: [Identity]
      x-audience: public
      operationId: wechatInitiate
      summary: Browser WeChat OAuth redirect
      responses:
        '302': { description: Redirect to WeChat OAuth URL. }

  /api/v1/auth/wechat/callback:
    get:
      tags: [Identity]
      x-audience: public
      operationId: wechatCallback
      summary: WeChat OAuth callback
      parameters:
        - { name: code, in: query, required: true, schema: { type: string } }
        - { name: state, in: query, schema: { type: string } }
      responses:
        '302': { description: Redirect to product post-login URL. }
        '400': { $ref: '#/components/responses/ValidationError' }

  /api/v1/auth/logout:
    post:
      tags: [Identity]
      x-audience: public
      operationId: logout
      summary: Server-side session revocation
      description: Idempotent. Clears the `lurus_session` cookie and adds the token to the revoke list.
      responses:
        '200':
          description: Logged out (or already was).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LogoutResponse' }

  /api/v1/account/me:
    get:
      tags: [Identity]
      x-audience: public
      operationId: getMe
      summary: Account self-read
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      responses:
        '200':
          description: User profile + VIP + subscriptions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  account: { $ref: '#/components/schemas/Account' }
                  vip: { $ref: '#/components/schemas/VIPSummary' }
                  subscriptions:
                    type: array
                    items: { $ref: '#/components/schemas/Subscription' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    put:
      tags: [Identity]
      x-audience: public
      operationId: updateMe
      summary: Update self profile
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                display_name: { type: string }
                avatar_url: { type: string }
                locale: { type: string }
      responses:
        '200':
          description: Updated account.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Account' }

  /api/v1/account/me/overview:
    get:
      tags: [Identity]
      x-audience: public
      operationId: getMyOverview
      summary: Aggregated overview for current user
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - { name: product_id, in: query, schema: { type: string } }
      responses:
        '200':
          description: Account overview.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AccountOverview' }

  /api/v1/account/me/services:
    get:
      tags: [Identity]
      x-audience: public
      operationId: getMyServices
      summary: List products the user has active subscriptions for
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      responses:
        '200':
          description: Services list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  services:
                    type: array
                    items: { $ref: '#/components/schemas/Subscription' }

  /api/v1/account/me/vip-discount:
    get:
      tags: [Identity]
      x-audience: public
      operationId: getMyVIPDiscount
      summary: Real checkout price multiplier (VIP)
      description: >
        The exact multiplier wallet checkout applies (VIPService.EffectiveDiscount) —
        purchase UIs must render THIS, never a client-side fabrication.
        1.0 = full price.
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      responses:
        '200':
          description: Effective discount.
          content:
            application/json:
              schema:
                type: object
                properties:
                  multiplier: { type: number, format: double, description: "Price multiplier in (0, 1]; 1.0 = no discount." }
                  vip_level: { type: integer }

  /api/v1/account/me/referral:
    get:
      tags: [Identity]
      x-audience: public
      operationId: getMyReferral
      summary: Referral code + stats
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      responses:
        '200':
          description: Referral info.
          content:
            application/json:
              schema:
                type: object
                properties:
                  aff_code: { type: string }
                  referral_url: { type: string }
                  stats: { type: object, additionalProperties: true }

  /api/v1/account/me/llm-token:
    get:
      tags: [Identity]
      x-audience: public
      operationId: getLLMToken
      summary: Issue a NewAPI bearer for the current user
      description: |
        Drop-in endpoint. Idempotent — repeated calls return the same key.
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - { name: name, in: query, schema: { type: string }, description: Optional token name. }
      responses:
        '200':
          description: Token issued or reused.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LLMToken' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '502':
          description: NewAPI returned an unexpected error.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          description: NewAPI sync unwired or account not yet provisioned.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/account/me/delete-request:
    post:
      tags: [PIPL / DSAR]
      x-audience: public
      operationId: requestSelfAccountDeletion
      summary: User-initiated account deletion (PIPL §47, 30-day cooling-off)
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  enum: [no_longer_using, privacy_concern, experience_issue, found_alternative, other]
                reason_text: { type: string, maxLength: 500 }
      responses:
        '200':
          description: Request registered (or pending row surfaced idempotently).
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id: { type: string }
                  status: { type: string, enum: [pending, already_deleted] }
                  cooling_off_until: { type: string, format: date-time }
                  already_deleted: { type: boolean }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409':
          description: Account has an active subscription that must be cancelled first.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/account/me/export:
    post:
      tags: [PIPL / DSAR]
      x-audience: public
      operationId: exportSelfAccountData
      summary: PIPL §45 portable data export
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      responses:
        '200':
          description: Export payload (account + wallet + subscriptions + recent transactions).
          content:
            application/json:
              schema:
                type: object
                properties:
                  account: { type: object, additionalProperties: true }
                  wallet: { $ref: '#/components/schemas/Wallet' }
                  subscriptions:
                    type: array
                    items: { $ref: '#/components/schemas/Subscription' }
                  wallet_transactions:
                    type: array
                    items: { $ref: '#/components/schemas/Transaction' }
                  truncated: { type: boolean }
                  exported_at: { type: string, format: date-time }
                  scope_note: { type: string }

  # ─── Wallet ────────────────────────────────────────────────────────
  # NOTE (2026-08-05): the /api/v1/account/me/wallet{,/transactions} aliases
  # were declared here but never routed. A customer coding against the
  # published contract hit route_not_found on the wallet — i.e. on the money
  # path. Removed rather than implemented: nothing in the fleet called them,
  # and the canonical /api/v1/wallet* below has always been the real surface.
  /api/v1/wallet:
    get:
      tags: [Wallet]
      x-audience: public
      operationId: getMyWallet
      summary: Wallet balance for current user
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      responses:
        '200':
          description: Wallet info.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AccountWallet' }

  /api/v1/wallet/transactions:
    get:
      tags: [Wallet]
      x-audience: public
      operationId: listMyTransactions
      summary: Paginated wallet transaction history
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/PageQuery'
        - $ref: '#/components/parameters/PageSizeQuery'
      responses:
        '200':
          description: Transactions page.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TransactionList' }

  /api/v1/wallet/transactions/by-product:
    get:
      tags: [Wallet]
      x-audience: public
      operationId: getMyTransactionsByProduct
      summary: Per-product debit spend summary for the current period
      description: |
        Returns the calling user's debit transactions aggregated by `product_id`
        over a calendar period. The window is computed in `Asia/Shanghai` so
        "this month" matches the user's wall clock. Transactions whose
        `product_id` is NULL/empty are grouped under a `null` bucket so the
        client can render an "uncategorised" row.

        Response is a single aggregate (no pagination): the row count is
        bounded by the number of distinct products (~10).
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - in: query
          name: period
          required: false
          schema:
            type: string
            enum: [month]
            default: month
          description: |
            Aggregation window. Only `month` is implemented today; other
            values return 400. The parameter is forward-compatible for
            `week` / `quarter` / `year` once a real consumer asks.
      responses:
        '200':
          description: Per-product spend summary.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WalletByProductResponse' }
        '400':
          description: Invalid `period` value.

  /api/v1/wallet/statement:
    get:
      tags: [Wallet]
      x-audience: public
      operationId: getMyWalletStatement
      summary: Cross-product currency statement (cash + Q币 pools)
      description: |
        Returns the calling user's unified "currency statement": the cash pool
        (现金, DECIMAL 元) and the Q币 credits pool (BIGINT 分) on one read, with
        where credits were EARNED (by source_kind) and SPENT (by product_id this
        calendar month). Proves the single fungible credits pool spans products —
        credits earned via product A's subscription are spendable on product B.
        Read-only over the money tables; no money moves.
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      responses:
        '200':
          description: Wallet statement.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/WalletStatement' }

  /api/v1/wallet/credits/grants:
    get:
      tags: [Wallet]
      x-audience: public
      operationId: listMyCreditsGrants
      summary: Active credits (Q币) grants for the current user
      description: |
        Returns the calling user's un-revoked, un-drained credits grants — the
        building blocks of the Q币 pool, with their source, remaining amount, and
        expiry.
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      responses:
        '200':
          description: Active credits grants.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/CreditsGrant' }

  /api/v1/wallet/topup:
    post:
      tags: [Wallet]
      x-audience: public
      operationId: createTopup
      summary: Create wallet topup order
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount_cny, payment_method]
              properties:
                amount_cny: { type: number, format: double }
                payment_method: { type: string }
                return_url: { type: string }
      responses:
        '201':
          description: >
            Topup order created — pay_url for online methods, bank_transfer
            remittance instructions for the offline corporate method.
          content:
            application/json:
              schema:
                type: object
                properties:
                  order_no: { type: string }
                  pay_url: { type: string }
                  bank_transfer: { $ref: '#/components/schemas/BankTransferInstructions' }

  /api/v1/wallet/topup/info:
    get:
      tags: [Wallet]
      x-audience: public
      operationId: getTopupInfo
      summary: Topup methods + suggested amounts
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      responses:
        '200':
          description: Topup info.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true

  /api/v1/wallet/redeem:
    post:
      tags: [Wallet]
      x-audience: public
      operationId: redeemCode
      summary: Redeem a gift / promo code
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string }
      responses:
        '200':
          description: Code redeemed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  redeemed: { type: boolean }

  /api/v1/wallet/orders:
    get:
      tags: [Wallet]
      x-audience: public
      operationId: listOrders
      summary: List wallet orders
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/PageQuery'
        - $ref: '#/components/parameters/PageSizeQuery'
      responses:
        '200':
          description: Orders page.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true

  /api/v1/wallet/orders/{order_no}:
    get:
      tags: [Wallet]
      x-audience: public
      operationId: getOrder
      summary: Get order detail
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - { name: order_no, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Order detail.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '404': { $ref: '#/components/responses/NotFound' }

  /api/v1/wallet/orders/{order_no}/cancel:
    post:
      tags: [Wallet]
      x-audience: public
      operationId: cancelOrder
      summary: Cancel an own pending payment order
      description: >
        Abandons the caller's OWN pending order (pending → cancelled). Added
        with the offline bank-transfer method, whose 7-day pending window
        would otherwise leave a mistaken order lingering until the expiry
        sweep. No provider interaction ever happened at pending, so this is a
        pure status flip; a repeat cancel replays 200. Paid orders are refund
        territory — 409.
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - { name: order_no, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Order cancelled (or already cancelled — idempotent replay).
          content:
            application/json:
              schema:
                type: object
                properties:
                  order_no: { type: string }
                  status: { type: string }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { description: Order is not pending (e.g. already paid). }

  # ─── Subscription ──────────────────────────────────────────────────
  /api/v1/products:
    get:
      tags: [Subscription]
      x-audience: public
      operationId: listProducts
      summary: List active products
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      responses:
        '200':
          description: Product list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  products:
                    type: array
                    items: { $ref: '#/components/schemas/Product' }

  /api/v1/products/{id}/plans:
    get:
      tags: [Subscription]
      x-audience: public
      operationId: listPlansForProduct
      summary: List plans for a product
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/ProductIDPath'
      responses:
        '200':
          description: Plan list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  plans:
                    type: array
                    items: { $ref: '#/components/schemas/Plan' }

  /api/v1/public/products/{id}/plans:
    get:
      tags: [Subscription]
      x-audience: public
      operationId: listPublicPlansForProduct
      summary: List a product's on-sale plans (unauthenticated)
      description: >
        Same active-only plan list as the authed route, on the public door —
        pricing/landing pages render the shelf to anonymous visitors before
        asking them to log in.
      security: []
      parameters:
        - $ref: '#/components/parameters/ProductIDPath'
      responses:
        '200':
          description: Plan list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  plans:
                    type: array
                    items: { $ref: '#/components/schemas/Plan' }

  # NOTE (2026-08-05): POST /api/v1/products/{id}/checkout was declared here
  # as a sibling alias of /api/v1/subscriptions/checkout but never routed.
  # Removed for the same reason as the wallet aliases above — a checkout path
  # that 404s is the worst possible thing to publish. Use
  # POST /api/v1/subscriptions/checkout.
  /api/v1/entitlements/{product_id}:
    get:
      tags: [Subscription]
      x-audience: public
      operationId: getEntitlementToken
      summary: Get a signed entitlement token for a product
      description: |
        Returns the caller's MERGED entitlements for the product (personal +
        org-inherited, with the ENT-4 suspended-subscription suppression) as
        both a plain map and an RS256-signed compact JWS a desktop client can
        verify OFFLINE against `/api/v1/entitlements/jwks`. Claims:
        `iss` (platform PublicURL) / `sub` (account id, string) /
        `aud` (canonical product id) / `iat` / `exp` (24h) / `ent`
        (entitlement map) / `pricing` (optional per-model credit price table
        from `products.config->'pricing'`). Client freshness policy: honor
        `exp`, allow a further 72h offline grace, ±5min clock skew. There is
        no revocation list — a downgrade propagates on the next refresh.
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/ProductIDPath'
      responses:
        '200':
          description: Signed entitlement token + plain entitlement map.
          content:
            application/json:
              schema:
                type: object
                required: [entitlement_token, entitlements, expires_at]
                properties:
                  entitlement_token:
                    type: string
                    description: RS256 compact JWS (header.payload.signature).
                  entitlements:
                    type: object
                    additionalProperties: { type: string }
                  expires_at: { type: string, format: date-time }
                  kid:
                    type: string
                    description: Active signing key id (matches the JWKS entry).
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/v1/entitlements/jwks:
    get:
      tags: [Subscription]
      x-audience: public
      operationId: getEntitlementJWKS
      summary: Public keys for entitlement-token verification (JWKS)
      description: |
        Unauthenticated RFC 7517 key set for offline verification of
        entitlement tokens. Cacheable (max-age 3600). During a signing-key
        rotation the set carries BOTH kids for a 96h overlap window.
      security: []
      responses:
        '200':
          description: JSON Web Key Set.
          content:
            application/json:
              schema:
                type: object
                required: [keys]
                properties:
                  keys:
                    type: array
                    items:
                      type: object
                      properties:
                        kty: { type: string }
                        use: { type: string }
                        alg: { type: string }
                        kid: { type: string }
                        n: { type: string }
                        e: { type: string }

  /api/v1/subscriptions:
    get:
      tags: [Subscription]
      x-audience: public
      operationId: listMySubscriptions
      summary: List my subscriptions
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      responses:
        '200':
          description: Subscription list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  subscriptions:
                    type: array
                    items: { $ref: '#/components/schemas/Subscription' }

  /api/v1/subscriptions/{product_id}:
    get:
      tags: [Subscription]
      x-audience: public
      operationId: getMySubscription
      summary: Get my active subscription for a product
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/ProductIDPath'
      responses:
        '200':
          description: Active subscription.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Subscription' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/v1/subscriptions/checkout:
    post:
      tags: [Subscription]
      x-audience: public
      operationId: checkoutSubscription
      summary: Subscribe to a product plan
      description: >
        plan_id must name a plan that product_id currently sells — the pair is
        resolved through the same catalogue query
        GET /api/v1/public/products/{id}/plans serves, so any plan a client can
        legitimately show the buyer is purchasable and nothing else is.
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SubscribeRequest' }
      responses:
        '201':
          description: >
            Subscription activated (wallet), payment order created (external —
            pay_url), or offline bank-transfer order created (payment_method
            bank_transfer — remittance instructions instead of a pay_url; the
            order stays pending until an operator confirms receipt).
          content:
            application/json:
              schema:
                type: object
                properties:
                  subscription: { $ref: '#/components/schemas/Subscription' }
                  order_no: { type: string }
                  pay_url: { type: string }
                  bank_transfer: { $ref: '#/components/schemas/BankTransferInstructions' }
        '404':
          description: >
            plan_id is unknown, belongs to a different product_id, or is no
            longer on sale. The three are deliberately indistinguishable: a
            distinguishable response would let plan_id probing map every
            product's catalogue, including plans withdrawn from sale.

  /api/v1/flows/subscriptions/{id}/timeline:
    get:
      tags: [Subscription]
      x-audience: public
      operationId: getMySubscriptionTimeline
      summary: Buyer-facing subscription timeline (下单→支付→开通)
      description: >
        Ownership-fenced self-read of the subscription's state-transition
        history (the same rows the admin flow timeline serves, minus
        operator_actions). A subscription that does not exist OR belongs to
        another account is a uniform 404.
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
        - { name: limit, in: query, required: false, schema: { type: integer } }
      responses:
        '200':
          description: Timeline (current + transitions).
          content:
            application/json:
              schema:
                type: object
                properties:
                  entity_kind: { type: string }
                  entity_id: { type: string }
                  current: { type: string }
                  current_known: { type: boolean }
                  transitions:
                    type: array
                    items: { type: object, additionalProperties: true }
        '404': { description: Subscription not found (or not yours). }

  /api/v1/subscriptions/{product_id}/cancel:
    post:
      tags: [Subscription]
      x-audience: public
      operationId: cancelSubscription
      summary: Cancel active subscription
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/ProductIDPath'
      responses:
        '200':
          # 2026-08-26 订正:这里原来声明返回 Subscription,而运行时实发的是
          # {"cancelled": true}(internal/adapter/handler/subscription.go 的
          # CancelSubscription)。照契约「取消后直接用返回对象刷新本地状态」的
          # 客户端会拿到一个 status/expires_at/auto_renew 全 undefined 的对象,
          # 把已经取消成功的订阅渲染成空态 —— 操作成功但界面说失败。
          # 改契约不改运行时:后者会变更既有 wire shape,而 web 端并不读 body。
          description: Cancelled (auto-renew off; the subscription runs to expiry).
          content:
            application/json:
              schema:
                type: object
                required: [cancelled]
                properties:
                  cancelled:
                    type: boolean
                    description: Always true on 200. 取消后的订阅对象请重新 GET /api/v1/subscriptions/{product_id}。

  /api/v1/invoices:
    get:
      tags: [Subscription]
      x-audience: public
      operationId: listMyInvoices
      summary: List invoices for current user
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      responses:
        '200':
          description: Invoice list.
          content:
            application/json:
              # 🔴 2026-08-27 订正:原来声明的信封是 `{ invoices: [...] }`,
              # 而运行时发的是 `{ data: [...], total: N }`
              # (internal/adapter/handler/invoice.go 的 c.JSON)。
              # 照契约做发票页的客户端读 resp.invoices → undefined →
              # 即使该账户有已开票记录也渲染成「暂无发票」。
              # 改契约不改运行时:web 控制台已经在读 data/total,换信封会打断它。
              schema:
                type: object
                required: [data, total]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Invoice' }
                  total:
                    type: integer
                    format: int64
                    description: 该账户的发票总数(分页用),不是本页条数。
    post:
      tags: [Subscription]
      x-audience: public
      operationId: generateInvoice
      summary: Generate an invoice for an order (optionally requesting a fapiao)
      description: >
        Idempotent per order_no. A non-empty buyer_title (发票抬头) marks the
        invoice fapiao_status=requested and stores the title/tax number;
        repeat calls update the title until the fapiao is issued (409
        afterwards).
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [order_no]
              properties:
                order_no: { type: string }
                buyer_title: { type: string, description: 发票抬头 (company legal name) }
                buyer_tax_number: { type: string, description: 纳税人识别号 }
      responses:
        '200':
          description: Generated (or idempotently returned/updated) invoice.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Invoice' }
        '409': { description: Fapiao already issued — buyer title locked. }

  /api/v1/invoices/{invoice_no}:
    get:
      tags: [Subscription]
      x-audience: public
      operationId: getInvoice
      summary: Get invoice detail
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - { name: invoice_no, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Invoice detail.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Invoice' }

  /api/v1/invoices/{invoice_no}/view:
    get:
      tags: [Subscription]
      x-audience: public
      operationId: renderInvoiceHTML
      summary: Render an invoice — print-ready HTML (default) or binary PDF (format=pdf)
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - { name: invoice_no, in: path, required: true, schema: { type: string } }
        - name: format
          in: query
          required: false
          description: "'pdf' streams a server-rendered binary PDF; default is print-ready HTML."
          schema: { type: string, enum: [html, pdf], default: html }
      responses:
        '200':
          description: Invoice document — print-ready HTML, or a binary PDF when format=pdf.
          content:
            text/html:
              schema: { type: string }
            application/pdf:
              schema: { type: string, format: binary }
        '404':
          description: Invoice not found.

  /api/v1/refunds:
    get:
      tags: [Subscription]
      x-audience: public
      operationId: listMyRefunds
      summary: List refund requests
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      responses:
        '200':
          description: Refund list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Refund' }
                  total: { type: integer }
    post:
      tags: [Subscription]
      x-audience: public
      operationId: requestRefund
      summary: Request a refund
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [order_no, reason]
              properties:
                order_no: { type: string }
                reason: { type: string }
      responses:
        '200':
          description: Refund recorded.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Refund' }

  /api/v1/refunds/{refund_no}:
    get:
      tags: [Subscription]
      x-audience: public
      operationId: getRefund
      summary: Get refund detail
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - { name: refund_no, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Refund detail.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Refund' }

  /api/v1/refunds/{refund_no}/credit-note:
    get:
      tags: [Subscription]
      x-audience: public
      operationId: renderCreditNoteHTML
      summary: Render a credit note (approved/completed refund) — HTML (default) or binary PDF (format=pdf)
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - { name: refund_no, in: path, required: true, schema: { type: string } }
        - name: format
          in: query
          required: false
          description: "'pdf' streams a server-rendered binary PDF; default is print-ready HTML."
          schema: { type: string, enum: [html, pdf], default: html }
      responses:
        '200':
          description: Credit-note document — print-ready HTML, or a binary PDF when format=pdf.
          content:
            text/html:
              schema: { type: string }
            application/pdf:
              schema: { type: string, format: binary }
        '404':
          description: Refund not found.
        '409':
          description: Refund is not approved/completed — no credit note available.

  /api/v1/checkin/status:
    get:
      tags: [Subscription]
      x-audience: public
      operationId: getCheckinStatus
      summary: Daily check-in status
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      responses:
        '200':
          description: Check-in status.
          content:
            application/json:
              schema:
                type: object
                properties:
                  checked_in: { type: boolean }
                  streak: { type: integer }
                  reward: { type: number, format: double }

  /api/v1/checkin:
    post:
      tags: [Subscription]
      x-audience: public
      operationId: doCheckin
      summary: Perform daily check-in
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      responses:
        '200':
          description: Check-in successful.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  reward: { type: number, format: double }

  # ─── Distributor (Wave R / RES-1 + RES-2) ──────────────────────────
  /api/v1/distributor/signup:
    post:
      tags: [Distributor]
      x-audience: public
      operationId: distributorSignup
      summary: Self-service distributor onboarding (RES-1)
      description: |
        Creates a distributor child-org under `parent_org_id` (which must be
        `org_type=enterprise`) and an `identity.distributor_kyc` row in
        `status=pending`. The caller (JWT subject) becomes the new org's
        owner. KYC must be `approve`-d by an admin before any distributor
        business API (grant / tier / topup / dashboard) will execute.

        **The caller must already be a member of `parent_org_id`.** "Self
        service" here means the applicant completes their own KYC, not that
        anyone may attach to any enterprise: the enterprise endorses the
        applicant by adding them as an org member first (`POST
        /api/v1/organizations/{id}/members`), and only then can they sign up
        underneath it. Without that step `parent_org_id` would be a write
        target chosen entirely by the request body.

        `Idempotency-Key` is required — at-most-once guard against
        double-tap signup that would otherwise hit the
        `business_license_no` UNIQUE and return 409.
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - { name: Idempotency-Key, in: header, required: true, schema: { type: string }, description: "Stripe-style at-most-once key." }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DistributorSignupRequest' }
      responses:
        '201':
          description: Distributor org + KYC row created. Returned KYC.status=`pending`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DistributorSignupResponse' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: |
            The caller is not a member of `parent_org_id` (`forbidden`).
            Deliberately indistinguishable from "no such org": splitting the
            two would turn this endpoint into an existence-and-type oracle
            over every org id.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: |
            `business_license_no` already registered to another
            distributor (`license_taken`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422':
          description: |
            `parent_org_id` is not `org_type=enterprise` — the org-type
            hierarchy trigger rejects the insert.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/distributor/{id}/aggregate:
    get:
      tags: [Distributor]
      x-audience: public
      operationId: getDistributorAggregate
      summary: This-month + last-month aggregate snapshot (RES-2)
      description: |
        Returns two side-by-side period summaries (`this_month`,
        `last_month`) — retail / wholesale / margin totals plus
        `customers_total` (cumulative) and `customers_new_this_period`.
        Caller must be a member of the distributor org; RLS returns
        empty totals to non-members today (explicit membership check
        comes in Wave R Day 5).
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 }, description: Distributor org id. }
      responses:
        '200':
          description: Aggregate snapshot.
          content:
            application/json:
              schema:
                type: object
                properties:
                  distributor_org_id: { type: integer, format: int64 }
                  generated_at: { type: string, format: date-time }
                  this_month:
                    type: object
                    properties:
                      period_start: { type: string, format: date-time }
                      period_end: { type: string, format: date-time }
                      retail_cents: { type: integer, format: int64 }
                      wholesale_cents: { type: integer, format: int64 }
                      margin_cents: { type: integer, format: int64 }
                      customers_total: { type: integer }
                      customers_new_this_period: { type: integer }
                  last_month:
                    type: object
                    properties:
                      period_start: { type: string, format: date-time }
                      period_end: { type: string, format: date-time }
                      retail_cents: { type: integer, format: int64 }
                      wholesale_cents: { type: integer, format: int64 }
                      margin_cents: { type: integer, format: int64 }
                      customers_total: { type: integer }
                      customers_new_this_period: { type: integer }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/v1/distributor/{id}/customers:
    get:
      tags: [Distributor]
      x-audience: public
      operationId: listDistributorCustomers
      summary: List customer accounts under this distributor (RES-2)
      description: |
        Returns the customer-accounts whose `via_distributor_org_id`
        points at `{id}`. Today returns `entity.Account` rows verbatim;
        the `DistributorCustomer` schema documents the stable subset.
        Caller must be an org member — RLS returns an empty `data` list
        to non-members.
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 }, description: Distributor org id. }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
          description: Server-clamped to 200.
      responses:
        '200':
          description: Customer list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/DistributorCustomer' }
                  limit: { type: integer }
                required: [data, limit]
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/v1/distributor/{id}/customers/{account_id}/topup:
    post:
      tags: [Distributor]
      x-audience: public
      operationId: distributorTopupCustomer
      summary: Distributor-funded customer wallet topup
      description: |
        Atomic two-leg transfer: distributor `org_wallet` → customer
        `wallet`. Writes a `billing.wallet_transfers` row with the
        caller-supplied `Idempotency-Key` as the dedup column. Failed
        with `insufficient_balance` (409) if the distributor's
        `org_wallet.balance` cannot cover `amount_cents`.

        `Idempotency-Key` is REQUIRED — money-moving endpoint, retries
        must collapse to one transfer.
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 }, description: Distributor org id. }
        - { name: account_id, in: path, required: true, schema: { type: integer, format: int64 }, description: Receiving customer account id. }
        - { name: Idempotency-Key, in: header, required: true, schema: { type: string }, description: "Stripe-style at-most-once key." }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount_cents]
              properties:
                amount_cents:
                  type: integer
                  format: int64
                  minimum: 1
                  description: Transfer amount in cents (1 = ¥0.01).
                notes: { type: string, description: Free-form note attached to the transfer row. }
      responses:
        '200':
          description: Transfer recorded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: integer, format: int64 }
                  from_kind: { type: string, enum: [org] }
                  from_id: { type: integer, format: int64 }
                  to_kind: { type: string, enum: [account] }
                  to_id: { type: integer, format: int64 }
                  amount_cents: { type: integer, format: int64 }
                  reason: { type: string, examples: ["distributor_topup_customer"] }
                  idempotency_key: { type: string }
                  initiated_by: { type: integer, format: int64 }
                  notes: { type: string, nullable: true }
                  created_at: { type: string, format: date-time }
        '400':
          description: "Validation error — missing `Idempotency-Key`, invalid amount, or invalid id."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409':
          description: |
            Distributor `org_wallet.balance` is insufficient
            (`insufficient_balance`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/distributor/{id}/card-batches:
    post:
      tags: [Distributor]
      x-audience: public
      operationId: distributorMintCardBatch
      summary: 发卡 — mint a prepaid batch of hub redemption codes
      description: |
        预付进货一批兑换码: 按当前阶梯批发价 (wholesale = retail ×
        (10000−discount_bps)/10000) 原子扣 org_wallet 并请求 hub 发码 + 同步
        注池. 权威报价在响应 batch 里 — 客户端不自算价格. hub 幂等键
        `card-batch-<Idempotency-Key>`; 同 key 重放返回同一批次 (200), 绝不
        二次扣款. hub 失败自动原路退款 (batch.status=failed).

        `Idempotency-Key` is REQUIRED — money-moving endpoint.
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 }, description: Distributor org id. }
        - { name: Idempotency-Key, in: header, required: true, schema: { type: string }, description: "Stripe-style at-most-once key." }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_id, plan_code, qty]
              properties:
                product_id: { type: string, examples: ["llm-api"] }
                plan_code: { type: string, examples: ["cc_standard"] }
                qty: { type: integer, minimum: 1, maximum: 500 }
      responses:
        '201':
          description: Batch minted (fresh). `batch.codes` carries the redemption codes.
        '200':
          description: Replay of an existing batch (`replayed=true`).
        '409':
          description: Distributor org_wallet balance insufficient (`insufficient_balance`).
        '422':
          description: No active grant / plan not card-capable / qty out of bounds.
        '503':
          description: Card minting not configured on this deployment (`card_minting_unavailable`).
    get:
      tags: [Distributor]
      x-audience: public
      operationId: distributorListCardBatches
      summary: List card batches (JSON or CSV export)
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
        - { name: limit, in: query, required: false, schema: { type: integer, default: 50, maximum: 200 } }
        - { name: format, in: query, required: false, schema: { type: string, enum: [csv] }, description: "format=csv streams one row per code for reconciliation." }
      responses:
        '200':
          description: Batch list (JSON) or CSV attachment.

  /api/v1/distributor/{id}/card-batches/{batch_id}/revoke:
    post:
      tags: [Distributor]
      x-audience: public
      operationId: distributorRevokeCardBatch
      summary: Revoke a batch's UNUSED codes (leak response)
      description: >
        hub 禁用该批未兑换的码 (已用码不动), 行置 revoked. 不退款 — 库存处置.
        天然幂等 (再撤 revoked_count=0).
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
        - { name: batch_id, in: path, required: true, schema: { type: integer, format: int64 } }
      responses:
        '200':
          description: Revoked. `revoked_count` = codes disabled this call.
        '422':
          description: Batch not found for this distributor, or not in a revocable state.
        '503':
          description: Card minting not configured (`card_minting_unavailable`).

  /api/v1/distributor/{id}/dashboard:
    get:
      tags: [Distributor]
      x-audience: public
      operationId: getDistributorDashboard
      summary: Distributor self-service dashboard snapshot (RES-1)
      description: |
        Returns the distributor's current `yyyymm` snapshot — granted
        products, per-product net sales, current discount tier, and
        unsettled-line observability fields (RES-6). Switch B consumes
        this at the `/distributor/me` alias. Caller must be an org
        member; RLS returns empty grants + zero volumes to non-members.
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 }, description: Distributor org id. }
      responses:
        '200':
          description: Dashboard snapshot.
          content:
            application/json:
              schema:
                type: object
                properties:
                  distributor_org_id: { type: integer, format: int64 }
                  yyyymm: { type: integer, description: "YYYYMM (e.g. 202605)." }
                  grants:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer, format: int64 }
                        distributor_org_id: { type: integer, format: int64 }
                        product_id: { type: string }
                        status: { type: string, enum: [active, revoked, suspended] }
                        granted_at: { type: string, format: date-time }
                        granted_by: { type: integer, format: int64 }
                        notes: { type: string, nullable: true }
                  current_volumes:
                    type: array
                    items:
                      type: object
                      properties:
                        product_id: { type: string }
                        sales_cents: { type: integer, format: int64 }
                        refund_cents: { type: integer, format: int64 }
                        net_sales_cents: { type: integer, format: int64 }
                        current_discount_bps: { type: integer }
                        next_tier_threshold_cents: { type: integer, format: int64 }
                        next_tier_discount_bps: { type: integer }
                  unsettled_lines_count: { type: integer }
                  unsettled_total_cents: { type: integer, format: int64 }
                  last_settlement:
                    nullable: true
                    allOf:
                      - $ref: '#/components/schemas/DistributorPayout'
                  month_margin_cents: { type: integer, format: int64 }
                  month_wholesale_cents: { type: integer, format: int64 }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/v1/distributor/{id}/payouts:
    get:
      tags: [Distributor]
      x-audience: public
      operationId: listDistributorPayouts
      summary: Recent T+1 settlement runs for this distributor (RES-2)
      description: |
        Returns the most-recent N daily settlement runs (DESC by
        `run_date`). Caller must be an org member; RLS returns empty
        list to non-members.
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 }, description: Distributor org id. }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 30 }
          description: Server-clamped to 100.
      responses:
        '200':
          description: Settlement-run page.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/DistributorPayout' }
                  limit: { type: integer }
                required: [data, limit]
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/v1/distributor/{id}/statement:
    get:
      tags: [Distributor]
      x-audience: public
      operationId: exportDistributorStatement
      summary: CSV statement export over a date range (RES-6)
      description: |
        Streams a CSV of every `billing.distributor_invoice_lines` row
        in `[from, to)`. Default range is the current calendar month.
        Server caps the window at 92 days to avoid OOM on large
        statements. Response is `text/csv` with a download-disposition
        header keyed on `<orgId>_<from>_<to>.csv`.
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 }, description: Distributor org id. }
        - { name: from, in: query, required: false, schema: { type: string, format: date }, description: "Inclusive (defaults to current-month 1st)." }
        - { name: to, in: query, required: false, schema: { type: string, format: date }, description: "Exclusive (defaults to from + 1 month)." }
      responses:
        '200':
          description: CSV statement (streamed).
          content:
            text/csv:
              schema: { type: string }
        '400':
          description: "Invalid date range — `to < from`, malformed date, or range > 92 days."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  # ─── Identity Recovery ─────────────────────────────────────────────
  /api/v1/auth/register:
    post:
      tags: [Identity Recovery]
      x-audience: public
      operationId: register
      summary: Self-service registration
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [identifier, password]
              properties:
                identifier: { type: string, description: Email or phone. }
                password: { type: string, writeOnly: true }
                code: { type: string }
                referrer_aff_code: { type: string }
      responses:
        '200': { description: Registered. }
        '400': { $ref: '#/components/responses/ValidationError' }
        '409': { $ref: '#/components/responses/Conflict' }

  /api/v1/auth/forgot-password:
    post:
      tags: [Identity Recovery]
      x-audience: public
      operationId: forgotPassword
      summary: Trigger password-reset link
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [identifier]
              properties:
                identifier: { type: string }
      description: |
        永远返回 200,且**响应体逐字节相同** —— 标识符解析得到账号与否、
        走邮件还是短信,`message` 与 `channel` 都是同一组固定值。2026-09 之前
        只有状态码是恒定的,响应体仍按分支返回四种不同文案,等于把"这个账号
        存不存在"直接写在 body 里;`channel` 字段现在保留但恒为空串,是为了
        不让既有前端解构出 undefined。
        走了哪条通道只进服务端结构化日志。
        按 (客户端 IP, identifier) 限流:超限返回 429 + `Retry-After`,
        且该判定发生在解析标识符**之前**,所以它不会变成第二个枚举 oracle。
        ⚠️ 残留:发信与不发信之间仍有耗时差(时间侧信道),本次未消除。
      responses:
        '200': { description: Reset link dispatched (always 200 — never reveals account existence). }
        '429': { description: 'Throttled per (client IP, identifier). Carries Retry-After.' }

  /api/v1/auth/reset-password:
    post:
      tags: [Identity Recovery]
      x-audience: public
      operationId: resetPassword
      summary: Complete a password reset
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, new_password]
              properties:
                token: { type: string }
                new_password: { type: string, writeOnly: true }
      responses:
        '200': { description: Password updated. }
        '400': { $ref: '#/components/responses/ValidationError' }

  /api/v1/auth/send-sms:
    post:
      tags: [Identity Recovery]
      x-audience: public
      operationId: sendSMSCode
      summary: Send a verification SMS
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phone]
              properties:
                phone: { type: string }
                purpose: { type: string, enum: [register, reset, login] }
      responses:
        '200': { description: SMS dispatched. }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/account/me/send-phone-code:
    post:
      tags: [Identity Recovery]
      x-audience: public
      operationId: sendPhoneCode
      summary: Send phone-bind verification code
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phone]
              properties:
                phone: { type: string }
      responses:
        '200': { description: Code dispatched. }

  /api/v1/account/me/verify-phone:
    post:
      tags: [Identity Recovery]
      x-audience: public
      operationId: verifyPhone
      summary: Confirm phone code + bind to account
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phone, code]
              properties:
                phone: { type: string }
                code: { type: string }
      responses:
        '200': { description: Phone bound. }
        '400': { $ref: '#/components/responses/ValidationError' }

  # ─── Organization ──────────────────────────────────────────────────
  /api/v1/organizations:
    post:
      tags: [Organization]
      x-audience: public
      operationId: createOrganization
      summary: Create an organization owned by current user
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                slug: { type: string }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Organization' }
        '409': { $ref: '#/components/responses/Conflict' }
    get:
      tags: [Organization]
      x-audience: public
      operationId: listMyOrganizations
      summary: List organizations the current user is a member of
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      responses:
        '200':
          description: Organizations.
          content:
            application/json:
              schema:
                type: object
                properties:
                  organizations:
                    type: array
                    items: { $ref: '#/components/schemas/Organization' }

  /api/v1/organizations/{id}:
    get:
      tags: [Organization]
      x-audience: public
      operationId: getOrganization
      summary: Get organization detail
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/AccountIDPath'
      responses:
        '200':
          description: Organization detail.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Organization' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/v1/organizations/{id}/members:
    get:
      tags: [Organization]
      x-audience: public
      operationId: listOrgMembers
      summary: List members of an organization
      description: Caller must be a member of the organization.
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/AccountIDPath'
      responses:
        '200':
          description: Member list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        org_id: { type: integer, format: int64 }
                        account_id: { type: integer, format: int64 }
                        role: { type: string, enum: [owner, admin, member] }
                        joined_at: { type: string, format: date-time }
        '403': { $ref: '#/components/responses/Forbidden' }
    post:
      tags: [Organization]
      x-audience: public
      operationId: addOrgMember
      summary: Add member to organization
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/AccountIDPath'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [account_id]
              properties:
                account_id: { type: integer, format: int64 }
                role: { type: string }
      responses:
        '200': { description: Member added. }

  /api/v1/organizations/{id}/members/{uid}:
    delete:
      tags: [Organization]
      x-audience: public
      operationId: removeOrgMember
      summary: Remove member from organization
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/AccountIDPath'
        - { name: uid, in: path, required: true, schema: { type: integer, format: int64 } }
      responses:
        '200': { description: Removed. }

  /api/v1/organizations/{id}/api-keys:
    get:
      tags: [Organization]
      x-audience: public
      operationId: listOrgApiKeys
      summary: List API keys for organization
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/AccountIDPath'
      responses:
        '200': { description: API keys. }
    post:
      tags: [Organization]
      x-audience: public
      operationId: createOrgApiKey
      summary: Mint a new org-scoped API key
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/AccountIDPath'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                description: { type: string }
      responses:
        '201': { description: API key minted (token returned once). }

  /api/v1/organizations/{id}/api-keys/{kid}:
    delete:
      tags: [Organization]
      x-audience: public
      operationId: revokeOrgApiKey
      summary: Revoke org API key
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/AccountIDPath'
        - { name: kid, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Revoked. }

  /api/v1/organizations/{id}/wallet:
    get:
      tags: [Organization]
      x-audience: public
      operationId: getOrgWallet
      summary: Org-scoped wallet balance
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/AccountIDPath'
      responses:
        '200':
          description: Wallet info.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrgWallet' }

  /api/v1/orgs/{id}/services/kova:
    get:
      tags: [Kova Provisioning]
      x-audience: public
      operationId: getOrgKovaService
      summary: Tenant view of the org's provisioned kova workspace
      description: Full admin key is NEVER returned — emitted only at provision time.
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/AccountIDPath'
      responses:
        '200':
          description: Provisioned service info.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrgService' }
        '403': { description: Caller is not a member of the organization. }
        '404':
          description: |
            Either the organization does not exist, or it has not yet
            been provisioned. Error code `service_not_provisioned`
            distinguishes the latter so the dashboard can render a
            "click to provision" CTA.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/orgs/{id}/settings/effective:
    get:
      tags: [Organization]
      x-audience: public
      operationId: getOrgSettingsEffectiveForTenant
      summary: Tenant self-service resolved effective config for an org.
      description: |
        Twin of the operator-only `/admin/v1/orgs/{id}/settings/effective`:
        the same masked catalogue read, but the caller must be an owner or
        admin of the org named in the path — cross-tenant access is denied
        with 403 before the handler runs, and per-org RLS (`is_org_member`)
        remains the defense-in-depth backstop. Secret values are masked.
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 }, description: Organization ID. }
      responses:
        '200':
          description: Effective config across the catalog (masked).
          content:
            application/json:
              schema:
                type: object
                required: [settings]
                properties:
                  settings:
                    type: array
                    items: { $ref: '#/components/schemas/OrgSettingEffective' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Caller is not an owner or admin of this organization. }
        '500':
          description: Repository error resolving effective config.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ═══════════════════════════════════════════════════════════════════
  # Internal API
  # ═══════════════════════════════════════════════════════════════════
  /internal/v1/accounts/by-zitadel-sub/{sub}:
    get:
      tags: [Internal]
      x-audience: internal
      operationId: getAccountByZitadelSub
      summary: Lookup account by IDP `sub` claim (deprecated path — use /by-idp-sub)
      deprecated: true
      x-deprecation-target: "v2.0"
      description: |
        Vendor-leaking path; identical handler to
        `/internal/v1/accounts/by-idp-sub/{sub}`. Both URLs serve the
        same account lookup during the deprecation cycle. New consumers
        should use the vendor-neutral `/by-idp-sub/` URL.
      security: [{ internalKey: [] }]
      x-required-scope: account:read
      parameters:
        - { name: sub, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Account.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Account' }
        '404': { $ref: '#/components/responses/NotFound' }

  /internal/v1/accounts/by-idp-sub/{sub}:
    get:
      tags: [Internal]
      x-audience: internal
      operationId: getAccountByIDPSub
      summary: Lookup account by IDP `sub` claim
      description: |
        Vendor-neutral canonical path. Mirrors
        `/internal/v1/accounts/by-zitadel-sub/{sub}` (deprecated) for
        the duration of the migration window. The `sub` value is the
        OIDC `sub` claim issued by the underlying identity provider.
      security: [{ internalKey: [] }]
      x-required-scope: account:read
      parameters:
        - { name: sub, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Account.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Account' }
        '404': { $ref: '#/components/responses/NotFound' }

  /internal/v1/accounts/by-id/{id}:
    get:
      tags: [Internal]
      x-audience: internal
      operationId: getAccountByID
      summary: Lookup account by numeric ID
      security: [{ internalKey: [] }]
      x-required-scope: account:read
      parameters: [{ $ref: '#/components/parameters/AccountIDPath' }]
      responses:
        '200':
          description: Account.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Account' }
        '404': { $ref: '#/components/responses/NotFound' }

  /internal/v1/accounts/by-email/{email}:
    get:
      tags: [Internal]
      x-audience: internal
      operationId: getAccountByEmail
      summary: Lookup account by email
      security: [{ internalKey: [] }]
      x-required-scope: account:read
      parameters:
        - { name: email, in: path, required: true, schema: { type: string, format: email } }
      responses:
        '200':
          description: Account.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Account' }
        '404': { $ref: '#/components/responses/NotFound' }

  /internal/v1/accounts/by-phone/{phone}:
    get:
      tags: [Internal]
      x-audience: internal
      operationId: getAccountByPhone
      summary: Lookup account by phone
      security: [{ internalKey: [] }]
      x-required-scope: account:read
      parameters:
        - { name: phone, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Account.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Account' }
        '404': { $ref: '#/components/responses/NotFound' }

  /internal/v1/accounts/by-oauth/{provider}/{provider_id}:
    get:
      tags: [Internal]
      x-audience: internal
      operationId: getAccountByOAuth
      summary: Lookup account by 3rd-party OAuth binding
      security: [{ internalKey: [] }]
      x-required-scope: account:read
      parameters:
        - { name: provider, in: path, required: true, schema: { type: string, examples: ["wechat", "feishu"] } }
        - { name: provider_id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Account.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Account' }
        '404': { $ref: '#/components/responses/NotFound' }

  /internal/v1/accounts/upsert:
    post:
      tags: [Internal]
      x-audience: internal
      operationId: upsertAccount
      summary: Create or update account from OIDC login
      description: |
        Exactly one of `idp_subject` (canonical) or `zitadel_sub`
        (deprecated alias) must be supplied; supplying both gives
        precedence to `idp_subject`. Email is always required.
      security: [{ internalKey: [] }]
      x-required-scope: account:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                zitadel_sub:
                  type: string
                  deprecated: true
                  x-deprecation-target: "v2.0"
                  description: Vendor-leaking alias for `idp_subject`. Will be removed in v2.0.
                idp_subject:
                  type: string
                  description: OIDC `sub` claim from the underlying identity provider. Vendor-neutral canonical name.
                email: { type: string, format: email }
                display_name: { type: string }
                avatar_url: { type: string }
                referrer_aff_code: { type: string }
      responses:
        '200':
          description: Account upserted.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Account' }

  /internal/v1/accounts/validate-session:
    post:
      tags: [Internal]
      x-audience: internal
      operationId: validateSession
      summary: Validate a session token
      security: [{ internalKey: [] }]
      x-required-scope: account:read
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
      responses:
        '200':
          description: Session valid.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Account' }
        '401': { description: Invalid session. }

  /internal/v1/accounts/{id}/overview:
    get:
      tags: [Internal]
      x-audience: internal
      operationId: getAccountOverviewInternal
      summary: Aggregated account overview
      security: [{ internalKey: [] }]
      x-required-scope: account:read
      parameters:
        - $ref: '#/components/parameters/AccountIDPath'
        - { name: product_id, in: query, schema: { type: string } }
      responses:
        '200':
          description: Overview.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AccountOverview' }

  /internal/v1/accounts/{id}/billing-summary:
    get:
      tags: [Internal]
      x-audience: internal
      operationId: getBillingSummary
      summary: Billing aggregation
      security: [{ internalKey: [] }]
      # 2026-08-26 订正:这里原来写 `billing:read`,而平台从来不认这个 scope
      # ——它不在 entity.AllScopes(),也不在任何 requireScope 里。照契约申领
      # service key 会在签发那一步就被 400 拒掉(Unknown scope)。
      # 运行时真值:internal_api.go GetBillingSummary → requireScope("wallet:read")。
      x-required-scope: wallet:read
      parameters: [{ $ref: '#/components/parameters/AccountIDPath' }]
      responses:
        '200':
          description: Billing summary.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BillingSummary' }

  /internal/v1/accounts/{id}/entitlements/{product_id}:
    get:
      tags: [Internal]
      x-audience: internal
      operationId: getEntitlements
      summary: Get product entitlements (Redis-cached)
      security: [{ internalKey: [] }]
      # 2026-08-26 订正,同 getBillingSummary:`billing:read` 是个不存在的 scope。
      # 运行时真值:GetEntitlements → requireScope("entitlement")。
      x-required-scope: entitlement
      parameters:
        - $ref: '#/components/parameters/AccountIDPath'
        - $ref: '#/components/parameters/ProductIDPath'
      responses:
        '200':
          description: Entitlements map.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Entitlements' }

  /internal/v1/accounts/{id}/subscription/{product_id}:
    get:
      tags: [Internal]
      x-audience: internal
      operationId: getSubscriptionInternal
      summary: Get active subscription for a product
      security: [{ internalKey: [] }]
      # 2026-08-26 订正,同上。运行时真值:GetSubscription → requireScope("entitlement")。
      x-required-scope: entitlement
      parameters:
        - $ref: '#/components/parameters/AccountIDPath'
        - $ref: '#/components/parameters/ProductIDPath'
      responses:
        '200':
          description: Active subscription.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Subscription' }
        '404': { $ref: '#/components/responses/NotFound' }

  /internal/v1/accounts/{id}/wallet/balance:
    get:
      tags: [Internal]
      x-audience: internal
      operationId: getWalletBalance
      summary: Quick wallet balance lookup
      security: [{ internalKey: [] }]
      x-required-scope: wallet:read
      parameters: [{ $ref: '#/components/parameters/AccountIDPath' }]
      responses:
        '200':
          description: Balance info.
          content:
            application/json:
              schema:
                type: object
                properties:
                  balance: { type: number, format: double }
                  frozen: { type: number, format: double }

  /internal/v1/accounts/{id}/wallet/debit:
    post:
      tags: [Internal]
      x-audience: internal
      operationId: walletDebit
      summary: Debit (deduct) from account wallet
      security: [{ internalKey: [] }]
      # 🔴 2026-08-26 订正:这里(以及 pre-authorize / settle / release /
      # currency-exchange 共 5 处)原来写 `wallet:write` —— 平台**不认这个 scope**。
      # entity.AllScopes() 里钱包只有三个:wallet:read / wallet:debit / wallet:credit。
      # 照契约申领 service key 会在签发那一步被 400 拒掉(Unknown scope),
      # 而且报错文本里的词和契约里的词对不上,排查会先怀疑签发流程 ——
      # 于是「新产品接入钱路」的第一步就卡死,却看不出卡在哪。
      # 运行时真值:internal_api.go 这五个 handler 全是 requireScope("wallet:debit")。
      # 防复发的闸:internal/adapter/handler/money_wire_shape_test.go 的
      # TestContractDeclaresNoPhantomScope —— 拿 AllScopes() 给整份契约点名。
      # (`wallet:write` 就是那条闸抓出来的,不是人翻出来的。)
      x-required-scope: wallet:debit
      parameters: [{ $ref: '#/components/parameters/AccountIDPath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount, type]
              properties:
                amount: { type: number, format: double }
                type: { type: string }
                product_id: { type: string }
                description: { type: string }
      responses:
        '200':
          description: Debit result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WalletOperationResult' }
        '402':
          description: org_billing_suspended — calling service key's bound organization has billing cut off (T1 org-billing gate).
        '409':
          description: account_not_active — target account is suspended or deleted (T1 account-active gate).

  /internal/v1/accounts/{id}/wallet/credit:
    post:
      tags: [Internal]
      x-audience: internal
      operationId: internalWalletCredit
      summary: Credit (add) to account wallet (ADR D4 — lucrum author revenue)
      description: >
        Service-to-service twin of the admin credit endpoint. Requires the
        wallet:credit scope and an Idempotency-Key header (fail-closed replay
        cache); the key additionally rides into the ledger reference so a
        replayed delivery — e.g. lucrum's outbox-drain CronJob re-posting a
        stored key — is a no-op at the ledger even if the HTTP cache missed.
      security: [{ internalKey: [] }]
      x-required-scope: wallet:credit
      parameters: [{ $ref: '#/components/parameters/AccountIDPath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount, type]
              properties:
                amount: { type: number, format: double }
                type: { type: string }
                product_id: { type: string }
                description: { type: string }
      responses:
        '200':
          description: Credit result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WalletOperationResult' }
        '400':
          description: idempotency_key_required — the Idempotency-Key header is missing.

  /internal/v1/accounts/{id}/wallet/pre-authorize:
    post:
      tags: [Internal]
      x-audience: internal
      operationId: walletPreAuthorize
      summary: Create pre-authorization hold
      security: [{ internalKey: [] }]
      x-required-scope: wallet:debit
      parameters: [{ $ref: '#/components/parameters/AccountIDPath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PreAuthRequest' }
      responses:
        '201':
          description: Pre-auth created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PreAuthResponse' }
        '402':
          description: org_billing_suspended — calling service key's bound organization has billing cut off (T1 org-billing gate).
        '409':
          description: account_not_active — target account is suspended or deleted (T1 account-active gate).

  /internal/v1/wallet/pre-auth/{id}/settle:
    post:
      tags: [Internal]
      x-audience: internal
      operationId: settlePreAuth
      summary: Settle a pre-authorization (capture actual amount)
      security: [{ internalKey: [] }]
      x-required-scope: wallet:debit
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [actual_amount]
              properties:
                actual_amount: { type: number, format: double }
      responses:
        '200': { description: Pre-auth settled. }
        '409':
          description: account_not_active — target account is suspended or deleted (T1 account-active gate; the org-billing gate never applies here, D3).

  /internal/v1/wallet/pre-auth/{id}/release:
    post:
      tags: [Internal]
      x-audience: internal
      operationId: releasePreAuth
      summary: Release a pre-authorization hold
      security: [{ internalKey: [] }]
      x-required-scope: wallet:debit
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Pre-auth released. }
        '409':
          description: account_not_active — target account is suspended or deleted (T1 account-active gate; the org-billing gate never applies here, D3).

  /internal/v1/wallet/credits-debit:
    post:
      tags: [Internal]
      x-audience: internal
      operationId: debitCreditsForExternal
      summary: Debit credits-pool cents on behalf of an external service (Wave SG-C)
      description: |
        Used by 2b-svc-search-gw and any future per-call credits biller.
        Idempotency-Key header recommended for retry safety. Overage policy is
        resolved per (account, product) — falls back to hard_stop for products
        without a live subscription.
      security: [{ internalKey: [] }]
      x-required-scope: wallet:debit
      parameters:
        - { name: X-Idempotency-Key, in: header, required: false, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [account_id, credits_cents, product_id]
              properties:
                account_id:     { type: integer, format: int64 }
                credits_cents:  { type: integer, format: int64, minimum: 1 }
                product_id:     { type: string, example: "search" }
                model_name:     { type: string, example: "brave" }
                reference_type: { type: string }
                reference_id:   { type: string }
                description:    { type: string }
      responses:
        '200':
          description: Credits debited.
          content:
            application/json:
              schema:
                type: object
                properties:
                  tx_id:           { type: integer, format: int64 }
                  credits_debited: { type: integer, format: int64 }
                  balance_debited: { type: number, format: double }
                  policy:          { type: string }
                  burned_grants:   { type: array, items: { type: integer, format: int64 } }
        '402':
          description: Insufficient credits / payg cap / insufficient balance / calling service key's bound organization has billing cut off (T1 org-billing gate).
          content:
            application/json:
              schema:
                type: object
                properties:
                  code:    { type: string, enum: [insufficient_credits, payg_cap_exceeded, insufficient_balance, org_billing_suspended] }
                  message: { type: string }
        '409':
          description: Target account is suspended or deleted (T1 account-active gate).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:   { type: string, enum: [account_not_active] }
                  message: { type: string }

  /internal/v1/wallet/credits-refund:
    post:
      tags: [Internal]
      x-audience: internal
      operationId: refundCreditsForExternal
      summary: Refund credits via reverse grant (Wave SG-C)
      description: |
        Use when an external service successfully debited credits then the
        downstream operation failed (e.g. search-gateway debited 5 credits,
        Brave returned 500). The refund must reference a REAL prior debit:
        idempotency_key is matched against the original debit's reference_id
        and the refunded cents are capped at that debit's credits portion
        (404 / 409 otherwise — a refund is a reversal, not a grant).
        The refund creates a credits_grant row with
        source_kind=refund_reverse, idempotent on (account_id, source_kind,
        idempotency_key) so repeat calls with the same key are no-ops.
        Refund credits NEVER expire (no expires_at).
      security: [{ internalKey: [] }]
      x-required-scope: wallet:debit
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [account_id, idempotency_key, credits_cents]
              properties:
                account_id:      { type: integer, format: int64 }
                idempotency_key: { type: string, description: "The original debit's request_id; UNIQUE per account" }
                credits_cents:   { type: integer, format: int64, minimum: 1 }
                reason:          { type: string, example: "upstream_failure" }
                product_id:      { type: string }
      responses:
        '200':
          description: Credits refunded (or no-op on repeat).
          content:
            application/json:
              schema:
                type: object
                properties:
                  grant_id:         { type: integer, format: int64 }
                  tx_id:            { type: integer, format: int64 }
                  credits_refunded: { type: integer, format: int64 }
                  idempotency_key:  { type: string }
        '404':
          description: No credits debit with this idempotency_key exists for the account (refund_original_not_found).
        '409':
          description: Refund exceeds the original debit's credits portion (refund_exceeds_debit).

  /internal/v1/accounts/{id}/wallet/transactions:
    post:
      tags: [Internal]
      x-audience: internal
      operationId: listWalletTransactionsInternal
      summary: Wallet transaction history (service-to-service)
      security: [{ internalKey: [] }]
      x-required-scope: wallet:read
      parameters:
        - $ref: '#/components/parameters/AccountIDPath'
        - $ref: '#/components/parameters/PageQuery'
        - $ref: '#/components/parameters/PageSizeQuery'
      responses:
        '200':
          description: Transactions page.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TransactionList' }

  /internal/v1/accounts/{id}/currency/exchange:
    post:
      tags: [Internal]
      x-audience: internal
      operationId: exchangeLucToLut
      summary: Exchange LUC → LUT
      security: [{ internalKey: [] }]
      x-required-scope: wallet:debit
      parameters: [{ $ref: '#/components/parameters/AccountIDPath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                amount: { type: number, format: double }
      responses:
        '200': { description: Exchanged. }

  /internal/v1/currency/info:
    get:
      tags: [Internal]
      x-audience: internal
      operationId: getCurrencyInfo
      summary: Current LUC ↔ LUT exchange rate
      security: [{ internalKey: [] }]
      # 2026-08-26 订正,同上。运行时真值:GetCurrencyInfo → requireScope("wallet:read")。
      x-required-scope: wallet:read
      responses:
        '200':
          description: Rate info.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true

  /internal/v1/usage/report:
    post:
      tags: [Internal]
      x-audience: internal
      operationId: reportUsage
      summary: Report LLM usage for VIP accumulation (legacy lurus-api flow)
      description: Distinct from `/internal/v1/usage/report/kova`.
      security: [{ internalKey: [] }]
      x-required-scope: usage:report
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [account_id, amount_cny]
              properties:
                account_id: { type: integer, format: int64 }
                amount_cny: { type: number, format: double }
      responses:
        '200':
          description: Usage accepted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  accepted: { type: boolean }

  /internal/v1/usage/report/kova:
    post:
      tags: [Kova Provisioning]
      x-audience: internal
      operationId: reportKovaUsage
      summary: Per-run kova usage ingestion
      description: |
        Append-only metering. `cost_micros` uses 1e-6 USD units.
      security: [{ internalKey: [] }]
      x-required-scope: usage:report
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UsageReportKovaRequest' }
      responses:
        '202':
          description: Usage event accepted and persisted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  accepted: { type: boolean }
                  id: { type: integer, format: int64 }
        '400': { $ref: '#/components/responses/ValidationError' }
        '403': { description: Service key missing required scope `usage:report`. }

  /internal/v1/checkout/create:
    post:
      tags: [Internal]
      x-audience: internal
      operationId: createCheckout
      summary: Create cross-service checkout order
      security: [{ internalKey: [] }]
      x-required-scope: checkout
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CheckoutRequest' }
      responses:
        '201':
          description: Checkout created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CheckoutResponse' }

  /internal/v1/checkout/{order_no}/status:
    get:
      tags: [Internal]
      x-audience: internal
      operationId: getCheckoutStatus
      summary: Query checkout order status
      security: [{ internalKey: [] }]
      x-required-scope: checkout
      parameters:
        - { name: order_no, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Checkout status. }

  /internal/v1/payment-methods:
    get:
      tags: [Internal]
      x-audience: internal
      operationId: getPaymentMethods
      summary: List available payment methods
      security: [{ internalKey: [] }]
      x-required-scope: checkout
      responses:
        '200': { description: Payment methods. }

  /internal/v1/payment/providers:
    get:
      tags: [Internal]
      x-audience: internal
      operationId: getPaymentProviderStatus
      summary: Health of each payment provider
      security: [{ internalKey: [] }]
      x-required-scope: checkout
      responses:
        '200': { description: Provider status map. }

  /internal/v1/subscriptions/checkout:
    post:
      tags: [Internal]
      x-audience: internal
      operationId: internalSubscriptionCheckout
      summary: Subscription checkout on behalf of a user
      security: [{ internalKey: [] }]
      x-required-scope: checkout
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/InternalSubscriptionCheckoutRequest' }
      responses:
        '201':
          description: Activated (wallet) or order created (external).
          content:
            application/json:
              schema:
                type: object
                properties:
                  subscription: { $ref: '#/components/schemas/Subscription' }
                  order_no: { type: string }
                  pay_url: { type: string }

  /internal/v1/orgs/{id}/subscriptions/issue:
    post:
      tags: [Internal]
      x-audience: internal
      operationId: issueOrgSubscription
      summary: Issue an org-scoped subscription (ADR 0007)
      description: >
        Records a subscription whose entitlements attach to the organization;
        any active member inherits them. Does NOT move money — the charge
        happens out-of-band (enterprise procurement / distributor allocation).
        Idempotency-Key required.
      security: [{ internalKey: [] }]
      x-required-scope: checkout
      parameters: [{ $ref: '#/components/parameters/AccountIDPath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_id, plan_code, billing_cycle, purchaser_account_id]
              properties:
                product_id: { type: string }
                plan_code: { type: string }
                billing_cycle: { type: string }
                purchaser_account_id: { type: integer, format: int64 }
                payment_method: { type: string }
                external_sub_id: { type: string }
      responses:
        '201':
          description: Org subscription issued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  subscription: { $ref: '#/components/schemas/Subscription' }
        '404': { $ref: '#/components/responses/NotFound' }

  /internal/v1/orgs/resolve-api-key:
    post:
      tags: [Internal]
      x-audience: internal
      operationId: resolveOrgApiKey
      summary: Resolve organization by API key
      security: [{ internalKey: [] }]
      x-required-scope: account:read
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [api_key]
              properties:
                api_key: { type: string }
      responses:
        '200':
          description: Organization resolved.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Organization' }
        '404': { $ref: '#/components/responses/NotFound' }

  /internal/v1/orgs/{id}/services/kova-tester:
    post:
      tags: [Kova Provisioning]
      x-audience: internal
      operationId: provisionKovaTester
      summary: Provision (or refresh) a kova-rest tester for the organization
      description: Idempotent on (org_id, "kova"). Admin key returned exactly once.
      security: [{ internalKey: [] }]
      x-required-scope: org:provision
      parameters: [{ $ref: '#/components/parameters/AccountIDPath' }]
      responses:
        '200':
          description: Provision attempted; row persisted.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrgService' }
        '404': { $ref: '#/components/responses/NotFound' }
        '502':
          description: Upstream R6 provisioning RPC failed after retries.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /internal/v1/preferences/sync:
    post:
      tags: [Internal]
      x-audience: internal
      operationId: syncPreferences
      summary: Cross-device user preferences sync
      security: [{ internalKey: [] }]
      x-required-scope: account:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
      responses:
        '200': { description: Synced. }

  /internal/v1/preferences/{account_id}:
    get:
      tags: [Internal]
      x-audience: internal
      operationId: getPreferences
      summary: Get cross-device preferences blob
      security: [{ internalKey: [] }]
      x-required-scope: account:read
      parameters:
        - { name: account_id, in: path, required: true, schema: { type: integer, format: int64 } }
      responses:
        '200': { description: Preferences blob. }

  /internal/v1/sms/relay:
    post:
      tags: [Internal]
      x-audience: internal
      operationId: smsRelay
      summary: Zita SMS webhook → Aliyun Dysms relay
      description: "On rate-limit returns 429 with `Retry-After: 60`."
      security: [{ internalKey: [] }]
      x-required-scope: sms:relay
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SMSRelayRequest' }
      responses:
        '200': { description: SMS sent (or noop sender accepted it). }
        '400': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500':
          description: Provider failed after retry budget exhausted.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ═══════════════════════════════════════════════════════════════════
  # Admin
  # ═══════════════════════════════════════════════════════════════════
  /admin/v1/accounts:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminListAccounts
      summary: Paginated account search
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: q, in: query, schema: { type: string } }
        - $ref: '#/components/parameters/PageQuery'
        - $ref: '#/components/parameters/PageSizeQuery'
      responses:
        '200':
          description: Account list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Account' }
                  total: { type: integer }

  /admin/v1/accounts/{id}:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminGetAccount
      summary: Full account details
      security: [{ bearerAuth: [] }]
      parameters: [{ $ref: '#/components/parameters/AccountIDPath' }]
      responses:
        '200':
          description: Full account.
          content:
            application/json:
              schema:
                type: object
                properties:
                  account: { $ref: '#/components/schemas/Account' }
                  vip: { $ref: '#/components/schemas/VIPSummary' }
                  subscriptions:
                    type: array
                    items: { $ref: '#/components/schemas/Subscription' }

  /admin/v1/accounts/{id}/grant:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminGrantEntitlement
      summary: Grant entitlement to account
      security: [{ bearerAuth: [] }]
      parameters: [{ $ref: '#/components/parameters/AccountIDPath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_id, key, value]
              properties:
                product_id: { type: string }
                key: { type: string }
                value: { type: string }
      responses:
        '200': { description: Granted. }

  /admin/v1/accounts/{id}/mfa/disable:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminDisableMFA
      summary: Break-glass — clear a locked-out user's MFA factor
      description: >
        Operator anti-lockout path. Removes the account's TOTP factor + recovery
        codes outright (no code required — that is the point of break-glass).
        Idempotent. Audit-logged server-side.
      security: [{ bearerAuth: [] }]
      parameters: [{ $ref: '#/components/parameters/AccountIDPath' }]
      responses:
        '200':
          description: MFA disabled for the account.
          content:
            application/json:
              schema:
                type: object
                properties:
                  disabled: { type: boolean }
                  account_id: { type: integer, format: int64 }
        '400': { $ref: '#/components/responses/ValidationError' }
        '503':
          description: MFA not configured on this deployment.

  /admin/v1/accounts/{id}/wallet/adjust:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminAdjustWallet
      summary: Operator wallet balance adjustment
      security: [{ bearerAuth: [] }]
      parameters: [{ $ref: '#/components/parameters/AccountIDPath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount, type]
              properties:
                amount: { type: number, format: double }
                type: { type: string }
                description: { type: string }
      responses:
        '200': { description: Adjustment applied. }

  /admin/v1/accounts/{id}/wallet/credit:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCreditWallet
      summary: Credit wallet (operator-issued top-up)
      security: [{ bearerAuth: [] }]
      parameters: [{ $ref: '#/components/parameters/AccountIDPath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                amount: { type: number, format: double }
                description: { type: string }
      responses:
        '200': { description: Credit applied. }

  /admin/v1/accounts/{id}/delete-request:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminRequestAccountDeletion
      summary: Operator-driven account deletion (QR-delegate)
      description: Mints a QR session — cascade only runs after biometric confirm.
      security: [{ bearerAuth: [] }]
      parameters: [{ $ref: '#/components/parameters/AccountIDPath' }]
      responses:
        '200':
          description: QR delegate session minted.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '404': { $ref: '#/components/responses/NotFound' }

  /admin/v1/accounts/{id}/billing/overage:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminGetOverage
      summary: Read an account's overage / usage-credits policy
      description: >
        Operator view of the per-(account, product) overage policy
        (`hard_stop` | `auto_payg`). All amounts are int64 minor units (分).
      security: [{ bearerAuth: [] }]
      parameters:
        - { $ref: '#/components/parameters/AccountIDPath' }
        - name: product_id
          in: query
          required: true
          schema: { type: string }
          description: Product whose subscription policy to read.
      responses:
        '200':
          description: Current overage policy.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OverageView' }
        '400': { $ref: '#/components/responses/ValidationError' }
    put:
      tags: [Admin]
      x-audience: admin
      operationId: adminSetOverage
      summary: Set an account's overage / usage-credits policy
      description: >
        Writes the per-(account, product) overage policy on behalf of the
        account. `hard_stop` normalises the rate / cap to zero. All amounts
        are int64 minor units (分).
      security: [{ bearerAuth: [] }]
      parameters:
        - { $ref: '#/components/parameters/AccountIDPath' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SetOverageBody' }
      responses:
        '200':
          description: Updated overage policy.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OverageView' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '409': { $ref: '#/components/responses/Conflict' }

  /admin/v1/accounts/{id}/billing/meter:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminGetUsageMeter
      summary: Read an account's usage meter (% used + reset window)
      description: >
        Operator view of the account-global credits allotment roll-up
        (granted / used / remaining, percent used, optional reset time).
        All amounts are int64 minor units (分).
      security: [{ bearerAuth: [] }]
      parameters:
        - { $ref: '#/components/parameters/AccountIDPath' }
      responses:
        '200':
          description: Usage meter snapshot.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MeterView' }
        '503':
          description: Usage metering is not configured on this deployment.

  /admin/v1/accounts/{id}/billing/model-quotas:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminListModelQuotas
      summary: List an account's current-week per-model quota buckets
      description: >
        Operator view of this ISO-week's per-model weekly quota buckets.
        `remaining_cents` is -1 when the bucket is uncapped. All amounts are
        credits 分.
      security: [{ bearerAuth: [] }]
      parameters:
        - { $ref: '#/components/parameters/AccountIDPath' }
      responses:
        '200':
          description: Current-week per-model quota buckets.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ModelQuotaList' }
        '503':
          description: Per-model quotas are not configured on this deployment.
    put:
      tags: [Admin]
      x-audience: admin
      operationId: adminSetModelQuota
      summary: Set an account's current-week weekly cap for one model
      description: >
        Sets the current-week weekly cap for one (account, model); it carries
        forward to future weeks until changed. `limit_cents` of 0 lifts the
        cap (tracked, uncapped). All amounts are credits 分.
      security: [{ bearerAuth: [] }]
      parameters:
        - { $ref: '#/components/parameters/AccountIDPath' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SetModelQuotaBody' }
      responses:
        '200':
          description: Updated quota bucket.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ModelQuotaView' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '503':
          description: Per-model quotas are not configured on this deployment.

  /admin/v1/accounts/{id}/billing/auto-reload:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminGetAutoReload
      summary: Read an account's wallet auto-reload policy
      description: >
        Operator view of an account's wallet auto-reload config, including the
        engine-owned outcome fields (consecutive failures, last error). Amounts
        are int64 minor units (分).
      security: [{ bearerAuth: [] }]
      parameters:
        - { $ref: '#/components/parameters/AccountIDPath' }
      responses:
        '200':
          description: Current auto-reload config.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AutoReloadView' }
        '503':
          description: Wallet auto-reload is not configured on this deployment.
    put:
      tags: [Admin]
      x-audience: admin
      operationId: adminSetAutoReload
      summary: Set an account's wallet auto-reload policy
      description: >
        Operator write of an account's wallet auto-reload config. An enabled
        config must set a positive `amount_cents`. Amounts are int64 minor units
        (分).
      security: [{ bearerAuth: [] }]
      parameters:
        - { $ref: '#/components/parameters/AccountIDPath' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SetAutoReloadBody' }
      responses:
        '200':
          description: Updated auto-reload config.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AutoReloadView' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '503':
          description: Wallet auto-reload is not configured on this deployment.

  /admin/v1/accounts/{id}/billing/plan-change/preview:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminPreviewPlanChange
      summary: Preview a plan change for an account
      description: >
        Operator preview of a customer's prorated plan-change diff (same
        semantics as the self endpoint). All amounts are int64 分.
      security: [{ bearerAuth: [] }]
      parameters:
        - { $ref: '#/components/parameters/AccountIDPath' }
        - { name: product_id, in: query, required: true, schema: { type: string } }
        - { name: new_plan_code, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: Plan-change projection (applied=false).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlanChangePreview' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '404':
          description: The target plan_code does not exist for this product.
        '409':
          description: No active subscription, or already on this plan.

  /admin/v1/accounts/{id}/billing/plan-change:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminChangePlan
      summary: Change an account's plan (settle prorated diff)
      description: >
        Operator applies a customer's plan change, settling the prorated diff
        against their cash wallet (upgrade debits — 402 if short; downgrade
        credits). All amounts are int64 分.
      security: [{ bearerAuth: [] }]
      parameters:
        - { $ref: '#/components/parameters/AccountIDPath' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ChangePlanBody' }
      responses:
        '200':
          description: Plan changed and diff settled (applied=true).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlanChangePreview' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '402':
          description: Insufficient wallet balance for the prorated upgrade charge.
        '404':
          description: The target plan_code does not exist for this product.
        '409':
          description: No active subscription, or already on this plan.
        '503':
          description: Plan change is not available on this deployment.

  # ─── Distributor — admin (Wave R / RES-1) ──────────────────────────
  /admin/v1/distributors/kyc/pending:
    get:
      tags: [Distributor]
      x-audience: admin
      operationId: adminListPendingDistributorKYC
      summary: Pending KYC review queue
      description: |
        Returns `distributor_kyc` rows in `status=pending` so admins can
        triage incoming self-service signups.
      security: [{ bearerAuth: [] }]
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        '200':
          description: Pending-KYC list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/DistributorKYC' }
                  limit: { type: integer }
                required: [data, limit]
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/distributors/{id}/grants:
    post:
      tags: [Distributor]
      x-audience: admin
      operationId: adminGrantDistributorProduct
      summary: Grant a product to a distributor
      description: |
        Authorises this distributor to sell the given `product_id`.
        Idempotent on `(distributor_org_id, product_id)` — the unique
        index on `billing.distributor_grants` rejects duplicates;
        re-issuing an existing active grant returns the existing row.
        `Idempotency-Key` is REQUIRED (state-mutating admin op).
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 }, description: Distributor org id. }
        - { name: Idempotency-Key, in: header, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_id]
              properties:
                product_id: { type: string, description: 'Stable product code (e.g. "lutu", "kova-tester").' }
                notes: { type: string, description: Free-form admin note. }
      responses:
        '200':
          description: Grant row (newly created or existing).
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: integer, format: int64 }
                  distributor_org_id: { type: integer, format: int64 }
                  product_id: { type: string }
                  status: { type: string, enum: [active, revoked, suspended] }
                  granted_at: { type: string, format: date-time }
                  granted_by: { type: integer, format: int64 }
                  notes: { type: string, nullable: true }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500':
          description: Underlying `service.Grant` failed (`grant_failed`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /admin/v1/distributors/{id}/kyc/approve:
    post:
      tags: [Distributor]
      x-audience: admin
      operationId: adminApproveDistributorKYC
      summary: Approve a pending distributor KYC
      description: |
        Flips `distributor_kyc.status` `pending → active`, records
        `reviewed_at` + `reviewed_by`, and unblocks downstream business
        APIs (grant / tier / topup / dashboard) for this distributor.
        `Idempotency-Key` is REQUIRED.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 }, description: Distributor org id. }
        - { name: Idempotency-Key, in: header, required: true, schema: { type: string } }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                notes: { type: string, description: Optional approval note. }
      responses:
        '200':
          description: Approved.
          content:
            application/json:
              schema:
                type: object
                properties:
                  approved: { type: boolean }
                  org_id: { type: integer, format: int64 }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500':
          description: Underlying `service.ApproveKYC` failed (`kyc_approve_failed`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /admin/v1/distributors/{id}/pricing-tiers:
    post:
      tags: [Distributor]
      x-audience: admin
      operationId: adminCreateDistributorPricingTier
      summary: Create a stepped wholesale-discount tier
      description: |
        Adds a `billing.distributor_pricing_tiers` row for the given
        `(distributor, product)` pair. `discount_bps` is in basis points
        (800 = 8% off the retail price; service layer rejects values
        outside `[0, 10000]`). `tier_threshold_cents` is the net-sales
        floor (after refunds) at which this tier engages.
        `Idempotency-Key` is REQUIRED.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 }, description: Distributor org id. }
        - { name: Idempotency-Key, in: header, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_id, tier_threshold_cents, discount_bps]
              properties:
                product_id: { type: string }
                tier_threshold_cents: { type: integer, format: int64, minimum: 0 }
                discount_bps:
                  type: integer
                  minimum: 0
                  maximum: 10000
                  description: Basis points off retail (800 = 8%).
                effective_from:
                  type: string
                  format: date-time
                  description: Defaults to server-now if omitted.
      responses:
        '200':
          description: Created tier.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: integer, format: int64 }
                  distributor_org_id: { type: integer, format: int64 }
                  product_id: { type: string }
                  tier_threshold_cents: { type: integer, format: int64 }
                  discount_bps: { type: integer }
                  effective_from: { type: string, format: date-time }
                  effective_to: { type: string, format: date-time, nullable: true }
                  created_at: { type: string, format: date-time }
                  created_by: { type: integer, format: int64 }
        '400':
          description: |
            Validation error — invalid `discount_bps` (must be 0-10000),
            missing `product_id`, or invalid distributor id.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/products:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminListProducts
      summary: List every product (any status) with its billing_mode spend gate
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Product catalogue.
          content:
            application/json:
              schema:
                type: object
                properties:
                  products:
                    type: array
                    items: { $ref: '#/components/schemas/Product' }
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCreateProduct
      summary: Create a product
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Product' }
      responses:
        '200':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Product' }

  /admin/v1/products/{id}:
    put:
      tags: [Admin]
      x-audience: admin
      operationId: adminUpdateProduct
      summary: Update a product
      security: [{ bearerAuth: [] }]
      parameters: [{ $ref: '#/components/parameters/ProductIDPath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Product' }
      responses:
        '200': { description: Updated. }

  /admin/v1/products/{id}/billing-mode:
    put:
      tags: [Admin]
      x-audience: admin
      operationId: adminSetProductBillingMode
      summary: Flip a product's spend gate (off / shadow / enforce)
      description: >-
        Platform-side enforcement dial for unified billing (migration 094).
        off and shadow reject usage-spend calls (wallet debit, pre-authorize,
        credits-debit and their gRPC twins) for the product; enforce allows
        real debits. Audited; takes effect within the policy cache TTL
        (seconds) on every pod.
      security: [{ bearerAuth: [] }]
      parameters: [{ $ref: '#/components/parameters/ProductIDPath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mode]
              properties:
                mode: { type: string, enum: [off, shadow, enforce] }
      responses:
        '200':
          description: Updated product.
          content:
            application/json:
              schema:
                type: object
                properties:
                  product: { $ref: '#/components/schemas/Product' }
        '400': { description: Invalid mode. }
        '404': { description: Product not found. }

  /admin/v1/products/{id}/plans:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminListPlans
      summary: List every plan for a product (any status)
      security: [{ bearerAuth: [] }]
      parameters: [{ $ref: '#/components/parameters/ProductIDPath' }]
      responses:
        '200':
          description: Plans.
          content:
            application/json:
              schema:
                type: object
                properties:
                  plans:
                    type: array
                    items: { $ref: '#/components/schemas/Plan' }
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCreatePlan
      summary: Create a plan for a product
      security: [{ bearerAuth: [] }]
      parameters: [{ $ref: '#/components/parameters/ProductIDPath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Plan' }
      responses:
        '200': { description: Created. }

  /admin/v1/plans/{id}:
    put:
      tags: [Admin]
      x-audience: admin
      operationId: adminUpdatePlan
      summary: Update a plan
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Plan' }
      responses:
        '200': { description: Updated. }

  /admin/v1/invoices:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminListInvoices
      summary: List invoices (filterable by fapiao status; CSV via Accept)
      description: >
        account_id / fapiao_status filter the page; Accept:text/csv streams
        the current filter as a CSV download for the accountant.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: account_id, in: query, schema: { type: integer, format: int64 } }
        - { name: fapiao_status, in: query, schema: { type: string, enum: [none, requested, issued] } }
        - $ref: '#/components/parameters/PageQuery'
        - $ref: '#/components/parameters/PageSizeQuery'
      responses:
        '200': { description: Invoices (JSON page or CSV attachment). }

  /admin/v1/invoices/{invoice_no}/fapiao:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminRegisterFapiao
      summary: Register the out-of-band issued 数电票号 (fapiao_status → issued)
      description: >
        Records that the operator issued the fapiao through the tax bureau's
        system. Idempotent per fapiao_no — replaying the same number is a
        200; a different number against an issued invoice is a 409 (void
        out-of-band first, then correct deliberately). A number already
        issued on a DIFFERENT invoice is also a 409 — a 数电票号 identifies
        exactly one transaction.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: invoice_no, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fapiao_no]
              properties:
                fapiao_no: { type: string, description: 数电票号 }
      responses:
        '200':
          description: Registered (or idempotent replay).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Invoice' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { description: A different fapiao number is already registered. }

  # ── 对公核销 (offline bank-transfer confirm console) ─────────────────────
  /admin/v1/payment-orders:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminListPaymentOrders
      summary: Browse payment orders by method/status
      description: >
        Cross-account order browse backing the 对公核销 tab (typical filter:
        method=bank_transfer&status=pending). Empty filters mean "any".
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: method, in: query, schema: { type: string } }
        - { name: status, in: query, schema: { type: string, enum: [pending, paid, failed, cancelled, expired, refunded] } }
        - $ref: '#/components/parameters/PageQuery'
        - $ref: '#/components/parameters/PageSizeQuery'
      responses:
        '200': { description: Orders page. }

  /admin/v1/payment-orders/{order_no}/confirm-received:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminConfirmBankTransferReceived
      summary: Confirm an offline bank-transfer order as received (对公核销)
      description: >
        After verifying the incoming wire on the corporate bank statement,
        marks the pending bank_transfer order paid and drives the SAME
        MarkOrderPaid→Activate sequence a provider webhook does (wallet
        credit / subscription activation / module hooks). Idempotent — a
        second confirm replays 200 with ONE activation and ONE credit total.
        received_amount_cny, when provided, must cover the order amount
        (underpaid wires are a 422 with zero state change; the received
        amount only lands in the audit trail).
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: order_no, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                received_amount_cny: { type: number, format: double, description: Actual amount on the bank statement }
                note: { type: string, description: Operator note (bank / receipt no.) — audit params only }
      responses:
        '200':
          description: Confirmed (activated) or idempotent replay.
          content:
            application/json:
              schema:
                type: object
                properties:
                  order_no: { type: string }
                  status: { type: string }
                  activated: { type: boolean }
                  replayed: { type: boolean }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { description: Wrong method (not bank_transfer) or non-pending status. }
        '422': { description: received_amount_cny is less than the order amount. }

  /admin/v1/refunds:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminListRefunds
      summary: List a customer's refunds (admin, account_id required)
      description: >
        Admin-scoped counterpart to GET /api/v1/refunds (which is IDOR-locked
        to the caller's own account_id). account_id is required — there is
        no cross-account "list all" query; RefundRepo only supports
        per-account listing today.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: account_id, in: query, required: true, schema: { type: integer, format: int64 } }
        - $ref: '#/components/parameters/PageQuery'
        - $ref: '#/components/parameters/PageSizeQuery'
      responses:
        '200': { description: Refunds page. }

  /admin/v1/refunds/{refund_no}/approve:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminApproveRefund
      summary: Approve a refund (platform-side fulfilment only)
      description: >-
        Approves a pending refund. There is no original-instrument
        (card/Alipay/bank) refund path — this response states what actually
        happened so support never promises one. Subscription/one_time orders:
        the amount is CREDITED to the account's platform wallet
        (refund_destination=wallet_balance). Topup orders: the payment-time
        wallet credit is REVERSED — a wallet debit
        (refund_destination=topup_reversal); returning the customer's cash is
        an out-of-band operator action. Amounts above the auto-approval
        threshold are rejected with 409 pointing at qr-approve.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: refund_no, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Approved — platform-side fulfilment applied.
          content:
            application/json:
              schema:
                type: object
                properties:
                  approved: { type: boolean }
                  refund_destination:
                    type: string
                    enum: [wallet_balance, topup_reversal]
                    description: >-
                      What approving did with the money: wallet_balance =
                      credited to the platform wallet; topup_reversal = the
                      topup's wallet credit was reversed (debit). Omitted when
                      the order could not be resolved.

  /admin/v1/refunds/{refund_no}/reject:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminRejectRefund
      summary: Reject a refund
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: refund_no, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Rejected. }

  /admin/v1/refunds/{refund_no}/qr-approve:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminQRApproveRefund
      summary: QR-delegate refund approval (boss biometric-confirms on APP)
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: refund_no, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: QR session minted. }

  /admin/v1/redemption-codes/batch:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminBatchRedemptionCodes
      summary: Batch generate redemption codes
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [count, product_id, plan_code, duration_days]
              properties:
                count: { type: integer, minimum: 1, maximum: 1000 }
                product_id: { type: string }
                plan_code: { type: string }
                duration_days: { type: integer }
                notes: { type: string }
                expires_at: { type: string, format: date-time }
      responses:
        '200':
          description: Codes generated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  codes:
                    type: array
                    items: { type: string }

  /admin/v1/reports/financial:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminFinancialReport
      summary: Financial reconciliation report (summary or 台账 ledger mode)
      description: >
        mode=summary (default) aggregates by date × product × plan × method;
        mode=ledger emits one row per PAID order plus MONTHLY SUMMARY rows —
        the revenue-ledger registration shape. Accept:text/csv downloads
        either mode as CSV.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: from, in: query, required: true, schema: { type: string, format: date } }
        - { name: to, in: query, required: true, schema: { type: string, format: date } }
        - { name: group_by, in: query, schema: { type: string, enum: [day, month], default: day } }
        - { name: mode, in: query, schema: { type: string, enum: [summary, ledger], default: summary } }
      responses:
        '200': { description: Report rows (JSON or CSV attachment). }

  /admin/v1/reconciliation/issues:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminListReconciliationIssues
      summary: List unresolved reconciliation issues
      security: [{ bearerAuth: [] }]
      responses:
        '200': { description: Issues list. }

  /admin/v1/reconciliation/issues/{id}/resolve:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminResolveReconciliationIssue
      summary: Resolve a reconciliation issue
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
      responses:
        '200': { description: Resolved. }

  # ── Flow control plane (Pillar A — Observe) ──────────────────────────────
  /admin/v1/flows/{entity_kind}/{entity_id}/timeline:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminGetFlowTimeline
      summary: Per-entity state-transition timeline
      description: >
        Non-invasive read of an in-flight flow's persisted state-transition
        history + live current state + the operator audit union. entity_kind is
        one of payment_order|subscription|preauth|credits_grant|account_purge;
        entity_id is the order_no or the stringified row id.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: entity_kind, in: path, required: true, schema: { type: string } }
        - { name: entity_id, in: path, required: true, schema: { type: string } }
        - { name: limit, in: query, required: false, schema: { type: integer } }
      responses:
        '200':
          description: Timeline (current + transitions + operator_actions).
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '400': { description: Invalid request parameters. }

  # ── Flow control plane (Pillar B — Intervene) ────────────────────────────
  /admin/v1/stuck-flows/payment-orders:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminListStuckPaymentOrders
      summary: Stuck payment-order inventory
      description: >
        Predictive "about to get stuck" cohorts — stale pending orders (likely
        missed webhook) + paid orders with no active subscription + expired
        对公转账 orders from the last 30 days (reopenable via recover's
        verify_and_complete). Complements the already-failed
        reconciliation/issues list.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: min_age_minutes, in: query, required: false, schema: { type: integer } }
      responses:
        '200':
          description: Stuck-order cohorts.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true

  /admin/v1/stuck-flows/payment-orders/{no}/recover:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminRecoverStuckPaymentOrder
      summary: Recover or force-expire a stuck pending order
      description: >
        action=verify_and_complete re-drives the missed-webhook recovery
        (provider query → mark paid → activate). For bank_transfer orders there
        is no provider to query: an expired order is only re-opened back to
        pending (outcome=reopened, never auto-paid) and an already-pending one
        returns outcome=await_bank_confirm — money lands solely via the
        bank-transfer confirm-received flow. action=force_expire abandons a
        stuck pending order. Idempotent; Idempotency-Key required.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: no, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action]
              properties:
                action: { type: string, enum: [verify_and_complete, force_expire] }
      responses:
        '200': { description: Recovery outcome. }
        '400': { description: Invalid request parameters. }

  /admin/v1/stuck-flows/payment-orders/{no}/retrigger-completion:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminRetriggerPaymentCompletion
      summary: Retrigger post-payment completion for a paid order
      description: >
        Re-drives activation for an order that is PAID but never activated.
        Idempotent on the order's activation key, so a replay no-ops on a cycle
        that already exists. Idempotency-Key required.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: no, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Retriggered. }
        '404': { $ref: '#/components/responses/NotFound' }

  /admin/v1/stuck-flows/preauths:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminListStuckPreAuths
      summary: Stuck pre-authorization inventory
      description: >
        Active pre-authorization holds past their expires_at — the "should have
        settled or released by now" cohort. Read-only.
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Stale active pre-auth holds.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true

  /admin/v1/stuck-flows/preauths/{id}/resolve:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminResolveStuckPreAuth
      summary: Force-settle or force-release a stuck pre-auth hold
      description: >
        action=force_settle charges actual_amount + releases the hold (reuses
        SettlePreAuth); action=force_release returns the frozen amount (reuses
        ReleasePreAuth). Idempotency-Key required.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action]
              properties:
                action: { type: string, enum: [force_settle, force_release] }
                actual_amount: { type: number, description: Required (>=0) for force_settle. }
      responses:
        '200': { description: Resolution outcome. }
        '400': { description: Invalid request parameters. }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /admin/v1/stuck-flows/subscriptions:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminListStuckSubscriptions
      summary: Stuck subscription inventory
      description: >
        The two lifecycle-sweep cohorts — active subscriptions past expires_at
        (should enter grace) and grace subscriptions past grace_until (should
        end). Read-only.
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Stuck-subscription cohorts.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true

  /admin/v1/stuck-flows/subscriptions/{id}/resolve:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminResolveStuckSubscription
      summary: Drive a stuck subscription forward
      description: >
        action=run_expiry advances one lifecycle step (active→grace via Expire or
        grace→expired via EndGrace, by live status); action=extend_grace pushes
        grace_until out (AdminOverride, requires grace_until); action=retrigger_renewal
        always returns 503 — a renewal re-drive charges real money and requires a
        durable compensating saga, which this deployment does not provide; use
        extend_grace and re-run checkout instead. Idempotency-Key required.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action]
              properties:
                action: { type: string, enum: [run_expiry, extend_grace, retrigger_renewal] }
                grace_until: { type: string, format: date-time, description: Required for extend_grace. }
      responses:
        '200': { description: Resolution outcome. }
        '400': { description: Invalid request parameters. }
        '404': { $ref: '#/components/responses/NotFound' }

  /admin/v1/stuck-flows/credits-grants/{id}/revoke:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminRevokeStuckCreditsGrant
      summary: Revoke a credits grant
      description: >
        Marks the grant revoked + claws back its remaining credits + appends a
        ledger row (reuses the append-only RevokeGrant). Idempotent re-revoke
        no-ops. Idempotency-Key required.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, description: 'entity.RevokeReason*; defaults to admin.' }
      responses:
        '200': { description: Revoked. }
        '404': { $ref: '#/components/responses/NotFound' }

  /admin/v1/stuck-flows/purges:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminListStuckPurges
      summary: Stuck account-purge inventory
      description: >
        Account purges stuck in 'purging' longer than min_age_minutes (default
        10) — a crashed or hung destructive cascade. Read-only.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: min_age_minutes, in: query, required: false, schema: { type: integer } }
      responses:
        '200':
          description: In-flight (stuck) purges.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true

  /admin/v1/stuck-flows/purges/{id}/resolve:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminResolveStuckPurge
      summary: Abort a stuck account purge
      description: >
        action=abort marks a stuck 'purging' row failed (freeing the in-flight
        lock), guarded by a precheck that the account is still Active — i.e. the
        cascade stalled before destroying anything. There is deliberately NO
        resume: the purge worker forbids cascade re-runs (double-debit risk); a
        half-completed purge needs per-step manual resolution. Idempotency-Key
        required.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action]
              properties:
                action: { type: string, enum: [abort] }
      responses:
        '200': { description: Abort outcome. }
        '400': { description: Invalid request parameters. }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  # ── Flow control plane (Pillar C — Reliability) ──────────────────────────
  /admin/v1/reliability/loops/{name}/run-now:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminRunReliabilityLoopNow
      summary: Run a supervised control loop now
      description: >
        Triggers one immediate out-of-schedule tick of the named supervised loop
        (reconciliation, credits_expiry, the sweepers, ...). 409 when the loop is
        already running; 404 for an unknown loop. Idempotency-Key required.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: name, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Loop ran; post-run health returned. }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { description: Loop is currently running. }

  /admin/v1/settings:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminListSettings
      summary: List platform settings
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Settings list.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
    put:
      tags: [Admin]
      x-audience: admin
      operationId: adminUpdateSettings
      summary: Batch update platform settings
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [settings]
              properties:
                settings:
                  type: array
                  items:
                    type: object
                    required: [key, value]
                    properties:
                      key: { type: string }
                      value: { type: string }
      responses:
        '200':
          description: >-
            Updated. `restart_required:true` (+ `restart_note`) is returned
            when the batch touched a boot-only key (the payment webhook
            secrets / epay URLs) — the value is persisted but the live
            provider keeps the boot value until a pod restart. All other
            keys take effect immediately.
          content:
            application/json:
              schema:
                type: object
                properties:
                  updated: { type: integer }
                  restart_required: { type: boolean }
                  restart_note: { type: string }

  /admin/v1/settings/email-test:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminTestEmail
      summary: Send a test email to the calling admin via the live SMTP config
      description: >-
        Sends through the SAME effective-config metered sender production
        emails use (admin.settings value wins, EMAIL_* env fallback), so a
        200 proves the real delivery path end-to-end. The recipient is
        locked to the caller's token email (not a request field) so the
        endpoint can't be used as a relay. 502 carries the SMTP error; 503
        when no SMTP is configured anywhere.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: Idempotency-Key, in: header, required: true, schema: { type: string }, description: "Required; a retry with the same key replays the cached verdict instead of sending again." }
      responses:
        '200':
          description: Test email accepted by the SMTP server.
          content:
            application/json:
              schema:
                type: object
                properties:
                  sent: { type: boolean }
                  to: { type: string }
        '502': { description: SMTP delivery failed (error in message). }
        '503': { description: No SMTP configured / sender not wired. }

  /admin/v1/settings/qrcode:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminUploadQRCode
      summary: Upload public QR code (for `/api/v1/public/qrcode/:type`)
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                type: { type: string }
                file: { type: string, format: binary }
      responses:
        '200': { description: Uploaded. }

  /admin/v1/organizations:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminListOrganizations
      summary: List organizations (operator)
      security: [{ bearerAuth: [] }]
      responses:
        '200': { description: Organizations. }
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCreateOrganization
      summary: Create a top-level ENTERPRISE organization (B2B2B tree root)
      description: >
        Mints the parent-less enterprise org (e.g. `lurus-hq`) that distributor
        children hang off. `org_type` must be `enterprise` — personal orgs
        self-serve via `POST /api/v1/organizations`, distributor/end_user are
        child orgs. Idempotency-Key required.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: Idempotency-Key, in: header, required: true, schema: { type: string }, description: "Stripe-style at-most-once key." }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, slug, org_type]
              properties:
                name: { type: string }
                slug: { type: string, pattern: '^[a-z0-9-]{3,32}$' }
                org_type: { type: string, enum: [enterprise] }
                owner_account_id:
                  type: integer
                  format: int64
                  description: Defaults to the calling admin when omitted.
      responses:
        '201': { description: Organization created. }
        '422': { description: org_type not enterprise, or slug/name validation failed. }

  /admin/v1/organizations/{id}:
    patch:
      tags: [Admin]
      x-audience: admin
      operationId: adminUpdateOrganizationStatus
      summary: Update organization status
      security: [{ bearerAuth: [] }]
      parameters: [{ $ref: '#/components/parameters/AccountIDPath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                status: { type: string }
      responses:
        '200': { description: Updated. }

  /admin/v1/apps:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminListApps
      summary: List apps from `apps.yaml` joined with Zitadel state
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: App registry view.
          content:
            application/json:
              schema:
                type: object
                properties:
                  org: { type: string }
                  project: { type: string }
                  live_sync: { type: boolean }
                  apps:
                    type: array
                    items: { $ref: '#/components/schemas/AppEntry' }

  /admin/v1/apps/reconcile-now:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminReconcileApps
      summary: Trigger an immediate app-registry reconciliation pass
      security: [{ bearerAuth: [] }]
      responses:
        '200': { description: Reconciliation triggered. }

  /admin/v1/apps/{name}/{env}/rotate-secret:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminRotateAppSecret
      summary: Manually rotate an OIDC client_secret
      description: Confidential clients only — 400 for PKCE apps.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: name, in: path, required: true, schema: { type: string } }
        - { name: env, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Rotated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  app: { type: string }
                  env: { type: string }
                  rotated_at: { type: string, format: date-time }
                  next_due_at: { type: string, format: date-time }
                  trigger: { type: string }

  /admin/v1/monitoring/netdata-ticket:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: mintNetdataProxyTicket
      summary: Mint a short-lived ticket cookie for the Netdata monitoring facade
      description: |
        Issues a 30-minute, path-scoped (`/admin/v1/proxy/netdata`), HttpOnly +
        Secure, HMAC-signed `netdata_proxy` cookie. The admin SPA calls this
        (admin JWT) on mount so the cookie-only `<iframe src=/admin/v1/proxy/netdata/>`
        and Netdata's subsequent asset/api requests — which cannot carry a
        Bearer header — authenticate to the reverse proxy. Possessing the
        ticket is proof of recent admin auth (only this endpoint, behind
        AdminAuth, can mint it). Stateless: re-minting just issues a fresh
        cookie, no server-side side effect. The proxy itself
        (`/admin/v1/proxy/netdata/*`) is an `Any` catch-all and not a separate
        API operation.
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Ticket cookie set (Set-Cookie netdata_proxy).
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  expires_in: { type: integer, description: Cookie TTL in seconds (1800). }
        '401': { description: Missing/invalid admin token. }
        '403': { description: Admin role required. }

  /admin/v1/apps/{name}/{env}/register-native:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: registerNativeApp
      summary: Mint-once registration of a native (desktop/mobile) OIDC app
      description: |
        Deliberate, idempotent operator action that mints the Zitadel OIDC
        app for a declared native product (the adopt-only reconcile loop
        never mints native apps). Re-running adopts the app the first call
        created and returns the same `client_id`. Refuses non-native apps
        (400), undeclared (app, env) (404), and an active delete tombstone
        (409 — clear-tombstone first). The operator records the returned
        `client_id` into `apps.yaml` + the product build; the reconciler then
        adopts + drift-checks it.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: name, in: path, required: true, schema: { type: string } }
        - { name: env, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Native app registered (or already registered — same client_id).
          content:
            application/json:
              schema:
                type: object
                properties:
                  app: { type: string }
                  env: { type: string }
                  zitadel_app_name: { type: string }
                  client_id: { type: string }
                  app_id: { type: string }
                  next_steps:
                    type: array
                    items: { type: string }
        '400': { description: App is not app_type=native. }
        '404': { description: (app, env) not declared in apps.yaml. }
        '409': { description: An active delete tombstone blocks registration — clear it first. }
        '503': { description: Reconciler not wired. }

  /admin/v1/apps/{name}/{env}/delete-request:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminRequestAppDeletion
      summary: Mint a QR-delegate session for OIDC app deletion
      description: Plants a 24h Redis tombstone on confirm.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: name, in: path, required: true, schema: { type: string } }
        - { name: env, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: QR session minted. }

  /admin/v1/apps/{name}/{env}/clear-tombstone:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminClearAppTombstone
      summary: Clear a 24h delete tombstone
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: name, in: path, required: true, schema: { type: string } }
        - { name: env, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Tombstone cleared.
          content:
            application/json:
              schema:
                type: object
                properties:
                  cleared: { type: boolean }
                  app: { type: string }
                  env: { type: string }

  /admin/v1/recovery/apps/{name}/{env}/resync:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminRecoveryAppsResync
      summary: Compound OIDC app recovery (clear-tombstone + reconcile + verify)
      description: |
        One-call replacement for the 3-step `Errors.App.NotFound` runbook
        (`clear-tombstone` → `reconcile-now` → verify `zitadel_app_id`).
        Returns per-step evidence. Idempotent under same `Idempotency-Key`.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: name, in: path, required: true, schema: { type: string } }
        - { name: env, in: path, required: true, schema: { type: string } }
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                dry_run:
                  type: boolean
                  description: When true, returns what would happen without mutating state.
      responses:
        '200':
          description: Recovery attempted (see outcome field for terminal state).
          content:
            application/json:
              schema:
                type: object
                properties:
                  outcome:
                    type: string
                    enum: [recovered, partial, failed, in_progress]
                  actions:
                    type: array
                    items:
                      type: object
                      properties:
                        step: { type: string }
                        ok: { type: boolean }
                        evidence: { type: string }

  /admin/v1/recovery/hook-dlq/drain:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminRecoveryHookDLQDrain
      summary: Drain pending hook DLQ failures in bulk
      description: |
        One-call replacement for the bash `for id in ...; do curl /replay; done`
        runbook. Absorbs 409 already_replayed, account-purged, and 501
        unsupported as skip-continue; only 502 hook_replay_failed counts as
        failed. Default `max_replays=20` keeps under the 30s router timeout;
        caller iterates while `more:true`.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                hook_name:
                  type: string
                  description: Optional filter; empty drains all hook names.
                max_replays:
                  type: integer
                  default: 20
                stop_on_error:
                  type: boolean
                  default: false
      responses:
        '200':
          description: Drain finished (or partial — check `more`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  replayed: { type: integer }
                  skipped_purged: { type: integer }
                  skipped_already_replayed: { type: integer }
                  skipped_unsupported: { type: integer }
                  failed: { type: integer }
                  more: { type: boolean }

  /admin/v1/api-keys:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCreateAPIKey
      summary: Create a Lurus API key (Zita service user + PAT under the hood)
      description: Idempotent on `name`; use `/rotate` to re-issue.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/APIKeyCreateRequest' }
      responses:
        '200':
          description: Created (token returned once).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/APIKeyCreateResponse' }
        '409': { $ref: '#/components/responses/Conflict' }
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminListAPIKeys
      summary: List API keys (token never returned)
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: API key list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys:
                    type: array
                    items: { $ref: '#/components/schemas/APIKey' }

  /admin/v1/api-keys/{name}/rotate:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminRotateAPIKey
      summary: Rotate an API key (re-issues token)
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: name, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: New token (returned once).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/APIKeyCreateResponse' }

  /admin/v1/api-keys/{name}:
    delete:
      tags: [Admin]
      x-audience: admin
      operationId: adminRevokeAPIKey
      summary: Revoke an API key
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: name, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Revoked. }

  /admin/v1/service-keys:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCreateServiceKey
      summary: Create a scoped internal service key
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [service_name, scopes]
              properties:
                service_name: { type: string }
                scopes:
                  type: array
                  items: { type: string }
                expires_at: { type: string, format: date-time }
      responses:
        '200': { description: Created (token returned once). }
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminListServiceKeys
      summary: List internal service keys
      security: [{ bearerAuth: [] }]
      responses:
        '200': { description: Service keys. }

  /admin/v1/service-keys/{id}:
    delete:
      tags: [Admin]
      x-audience: admin
      operationId: adminRevokeServiceKey
      summary: Revoke an internal service key
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Revoked. }

  /admin/v1/ops:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminListOps
      summary: Privileged-op catalogue
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Catalogue.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ops:
                    type: array
                    items: { $ref: '#/components/schemas/OpsCatalogEntry' }

  /admin/v1/audit-events:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminListAuditEvents
      summary: Persistent audit log query
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: op, in: query, schema: { type: string } }
        - { name: target_kind, in: query, schema: { type: string } }
        - { name: since, in: query, schema: { type: string, format: date-time } }
        - { name: until, in: query, schema: { type: string, format: date-time } }
        - $ref: '#/components/parameters/PageQuery'
        - $ref: '#/components/parameters/PageSizeQuery'
      responses:
        '200':
          description: Audit events page.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/AuditEvent' }
                  total: { type: integer, format: int64 }
        '400': { $ref: '#/components/responses/ValidationError' }

  /admin/v1/onboarding-failures:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminListHookFailures
      summary: List hook DLQ failures
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: pending, in: query, schema: { type: boolean, default: true } }
        - $ref: '#/components/parameters/PageQuery'
        - $ref: '#/components/parameters/PageSizeQuery'
      responses:
        '200':
          description: Failures page.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/HookFailure' }
                  total: { type: integer }
                  pending_depth: { type: integer }

  /admin/v1/onboarding-failures/{id}/replay:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminReplayHookFailure
      summary: Replay a single hook failure
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
      responses:
        '200':
          description: Replayed (or skipped — account purged since failure).
          content:
            application/json:
              schema:
                type: object
                properties:
                  replayed: { type: boolean }
                  skipped: { type: boolean }
                  reason: { type: string }
        '409':
          description: Already replayed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '501':
          description: Replay unsupported for this event type.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '502':
          description: Hook still fails.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /admin/v1/onboarding-failures/{id}/resolve:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminResolveHookFailure
      summary: Close a hook failure without replaying it
      description: >
        Dead-letter triage complement to replay: stamps the row out of the
        pending set WITHOUT re-running the hook — for rows whose side effect
        is unwanted (e.g. junk test accounts). The required reason lands in
        the hook.resolve audit event. Idempotent — resolving an
        already-closed row returns 200 with `already: true`.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason:
                  type: string
                  description: Why this row is being closed without replay (audited).
      responses:
        '200':
          description: Closed (or already closed — idempotent replay).
          content:
            application/json:
              schema:
                type: object
                properties:
                  resolved: { type: boolean }
                  already: { type: boolean }
        '400':
          description: Missing or blank reason.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: No such hook failure row.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /admin/v1/newapi/quota-sync-failures:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminListQuotaSyncFailures
      summary: List failed topup→NewAPI quota syncs
      description: >
        Durable backstop for topups whose mirror into NewAPI quota failed and
        could not be safely auto-retried (the increment is non-idempotent).
        Operators triage `failed` rows and recover each via reconcile (replay)
        or resolve (manual close).
      security: [{ bearerAuth: [] }]
      parameters:
        - name: status
          in: query
          schema: { type: string, enum: [failed, resolved, all], default: failed }
        - $ref: '#/components/parameters/PageQuery'
        - $ref: '#/components/parameters/PageSizeQuery'
      responses:
        '200':
          description: Quota-sync-failure page.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/NewAPIQuotaSyncFailure' }
                  total: { type: integer }
                  pending_depth:
                    type: integer
                    description: Live count of unresolved rows (-1 if the count lookup failed).

  /admin/v1/newapi/quota-sync-failures/{id}/reconcile:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminReconcileQuotaSyncFailure
      summary: Replay a failed quota sync
      description: >
        Re-applies the recorded quota delta to NewAPI and closes the row.
        Idempotent at the row level (a resolved row is a 200 no-op), so a retry
        cannot double-credit. NOT idempotent against NewAPI itself — confirm the
        NewAPI-side quota did NOT already move before replaying; if it did, use
        /resolve instead.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
      responses:
        '200':
          description: Quota re-applied; row resolved.
          content:
            application/json:
              schema:
                type: object
                properties:
                  reconciled: { type: boolean }
        '404':
          description: No row matches the id.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '502':
          description: Replay failed (NewAPI still unavailable); row stays queued.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          description: Backstop store not configured on this instance.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /admin/v1/newapi/quota-sync-failures/{id}/resolve:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminResolveQuotaSyncFailure
      summary: Manually close a failed quota sync without replay
      description: >
        Closes a row WITHOUT re-applying the quota — for when the operator has
        confirmed NewAPI already has the credit (so replaying would
        double-credit). Idempotent: an already-resolved row is a 200 no-op.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
      responses:
        '200':
          description: Row closed (resolution=manual).
          content:
            application/json:
              schema:
                type: object
                properties:
                  resolved: { type: boolean }
        '404':
          description: No row matches the id.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          description: Backstop store not configured on this instance.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ═══════════════════════════════════════════════════════════════════
  # Zita Tenants
  # ═══════════════════════════════════════════════════════════════════
  /admin/v1/zita/tenants:
    post:
      tags: [Zita Tenants]
      x-audience: admin
      operationId: createTenant
      summary: Create a new tenant (genesis flow)
      description: |
        Reserves the slug, provisions the bootstrap admin in Zita,
        seeds the wallet, attaches the starter plan, emits
        `zita.tenant.created`. Transactional.
      security: [{ bearerAuth: [] }, { internalKey: [] }]
      x-required-scope: account:write
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateTenantInput' }
      responses:
        '201':
          description: Tenant created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreateTenantResult' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409':
          description: Slug already taken.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                slugTaken:
                  value: { error: "tenant_already_exists", message: "Slug 'acme-corp' is already in use" }
    get:
      tags: [Zita Tenants]
      x-audience: admin
      operationId: listTenants
      summary: List tenants (paginated)
      security: [{ bearerAuth: [] }, { internalKey: [] }]
      x-required-scope: account:read
      parameters:
        - $ref: '#/components/parameters/PageQuery'
        - $ref: '#/components/parameters/PageSizeQuery'
        - name: status
          in: query
          schema:
            type: string
            enum: [active, suspended, archived, all]
            default: active
        - { name: q, in: query, schema: { type: string }, description: Substring match on slug or display_name. }
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenantList' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /admin/v1/zita/tenants/{slug}:
    get:
      tags: [Zita Tenants]
      x-audience: admin
      operationId: getTenant
      summary: Get tenant by slug
      security: [{ bearerAuth: [] }, { internalKey: [] }]
      x-required-scope: account:read
      parameters: [{ $ref: '#/components/parameters/SlugPath' }]
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Tenant' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /admin/v1/zita/tenants/{slug}/suspend:
    post:
      tags: [Zita Tenants]
      x-audience: admin
      operationId: suspendTenant
      summary: Suspend a tenant
      description: Idempotent on already-suspended. Emits `zita.tenant.suspended`.
      security: [{ bearerAuth: [] }, { internalKey: [] }]
      x-required-scope: account:write
      parameters: [{ $ref: '#/components/parameters/SlugPath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SuspendTenantInput' }
      responses:
        '200':
          description: Suspended.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Tenant' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /admin/v1/zita/tenants/{slug}/reactivate:
    post:
      tags: [Zita Tenants]
      x-audience: admin
      operationId: reactivateTenant
      summary: Reactivate a suspended tenant
      security: [{ bearerAuth: [] }, { internalKey: [] }]
      x-required-scope: account:write
      parameters: [{ $ref: '#/components/parameters/SlugPath' }]
      responses:
        '200':
          description: Reactivated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Tenant' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /admin/v1/zita/tenants/{slug}/data-export:
    post:
      tags: [Zita DSAR]
      x-audience: admin
      operationId: requestTenantDataExport
      summary: Request a tenant data export (DSAR)
      description: Stub today — auto-completes synchronously with a placeholder URL.
      security: [{ bearerAuth: [] }, { internalKey: [] }]
      x-required-scope: account:write
      parameters: [{ $ref: '#/components/parameters/SlugPath' }]
      responses:
        '202':
          description: Job queued (stub auto-completes synchronously).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DataExportJob' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /admin/v1/zita/tenants/{slug}/data-export/{job_id}:
    get:
      tags: [Zita DSAR]
      x-audience: admin
      operationId: getTenantDataExport
      summary: Get DSAR job status
      security: [{ bearerAuth: [] }, { internalKey: [] }]
      x-required-scope: account:read
      parameters:
        - $ref: '#/components/parameters/SlugPath'
        - $ref: '#/components/parameters/JobIDPath'
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DataExportJob' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ═══════════════════════════════════════════════════════════════════
  # Stubs added by the spec↔route drift gate (2026-05-25).
  # These routes are wired in router.go but were previously undocumented.
  # Minimal stubs (operationId + tag + 1 response) — schema fidelity
  # is a follow-up for each route's owner. Detailed shapes live in the
  # handler GoDoc and CLAUDE.md until then.
  # ═══════════════════════════════════════════════════════════════════

  # ── Public auth surface ──────────────────────────────────────────
  /api/v1/auth/login-or-register:
    post:
      tags: [Identity]
      x-audience: public
      operationId: loginOrRegister
      summary: Unified phone-based login-or-register (creates account on first call).
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Session issued (registration or login).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }

  /api/v1/auth/activate:
    get:
      tags: [Identity]
      x-audience: public
      operationId: activateEmail
      summary: Email-verify landing (user clicks the inbox link).
      parameters:
        - { name: token, in: query, required: true, schema: { type: string } }
      responses:
        '302':
          description: Redirect to the post-activation landing page.

  /api/v1/auth/check-password:
    post:
      tags: [Identity]
      x-audience: public
      operationId: checkPasswordHIBP
      summary: HIBP k-anonymity password proxy (browser sends SHA-1 prefix only).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [prefix]
              properties:
                prefix: { type: string, minLength: 5, maxLength: 5 }
      responses:
        '200':
          description: Count of breach occurrences for the matched suffix (0 = safe).
          content:
            application/json:
              schema:
                type: object
                properties:
                  count: { type: integer }

  /api/v1/auth/magic-link/request:
    post:
      tags: [Identity]
      x-audience: public
      operationId: requestMagicLink
      summary: Request a passwordless email login link (Wave 2A).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        '200':
          description: Always-200 — response shape does not reveal whether the email is registered.

  /api/v1/auth/magic-link/verify:
    get:
      tags: [Identity]
      x-audience: public
      operationId: verifyMagicLink
      summary: Verify a magic-link token and issue a session (302 to landing page).
      parameters:
        - { name: token, in: query, required: true, schema: { type: string } }
      responses:
        '302':
          description: Redirect to the post-login landing page (success or failure indistinguishable).

  /api/v1/auth/refresh:
    post:
      tags: [Identity]
      x-audience: public
      operationId: refreshSession
      summary: Rotate a refresh token and issue a new access JWT (ADR 0010).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refresh_token]
              properties:
                refresh_token: { type: string, description: Rotating refresh token from a prior auth/refresh or login. }
      responses:
        '200':
          description: New access + refresh tokens.
          content:
            application/json:
              schema:
                type: object
                required: [access_token, refresh_token, expires_in, token_type]
                properties:
                  access_token: { type: string }
                  refresh_token: { type: string, description: New rotated token; the prior token is invalidated. }
                  expires_in: { type: integer, description: Access token TTL in seconds. }
                  token_type: { type: string, enum: [Bearer] }
        '401':
          description: Refresh token invalid, expired, or already rotated.

  /api/v1/auth/policy:
    get:
      tags: [Identity]
      x-audience: public
      operationId: getAuthPolicy
      summary: Per-app effective auth policy (login tabs, turnstile site key).
      parameters:
        - { name: app, in: query, required: false, schema: { type: string } }
      responses:
        '200':
          description: Effective policy for the requested app (falls back to platform defaults).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }

  /api/v1/auth/policy/resolve:
    post:
      tags: [Identity]
      x-audience: public
      operationId: resolveAuthPolicy
      summary: Home-realm discovery — resolves whether a login identifier's org mandates enterprise SSO (T5a).
      description: >
        Deliberately does not distinguish "domain unclaimed" from "org exists
        but not SSO-only" — both return mode=native — to avoid an
        enumeration oracle.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [identifier]
              properties:
                identifier: { type: string, description: "Login identifier (email/username/phone)." }
      responses:
        '200':
          description: Resolved routing decision.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '400':
          description: Missing/empty identifier.

  /api/v1/public/ui-config:
    get:
      tags: [Identity]
      x-audience: public
      operationId: getPublicUIConfig
      summary: Public UI config — return_to domain allow-list for the login/register/DSAR pages.
      description: >
        No secrets. allowed_return_suffixes is derived from the deployment's
        PLATFORM_BASE_URL (see config.go) plus any operator-added
        ALLOWED_RETURN_DOMAINS aliases — replaces the hardcoded ".lurus.cn"
        check the front-end used before this endpoint existed, so a private
        (non-lurus.cn) install's own return_to round-trip works.
      responses:
        '200':
          description: Allowed return_to domain suffixes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  allowed_return_suffixes:
                    type: array
                    items: { type: string }

  # ── User account self-service ────────────────────────────────────
  /api/v1/payment/options:
    get:
      tags: [Wallet]
      x-audience: public
      operationId: getPaymentOptions
      summary: Public payment amount presets + tiered discount table (Wave 1E).
      responses:
        '200':
          description: Payment options.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }

  /api/v1/account/me/llm-key:
    get:
      tags: [Identity]
      x-audience: public
      operationId: getLLMKey
      summary: NewAPI bearer for LLM products via Bearer-JWT (sibling of getLLMToken for non-cookie clients).
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: LLM bearer key.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }

  /api/v1/account/me/sessions:
    get:
      tags: [Identity]
      x-audience: public
      operationId: listMySessions
      summary: List per-device sessions for the current user (Wave 2B).
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      responses:
        '200':
          description: Session list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  sessions:
                    type: array
                    items: { type: object, additionalProperties: true }

  /api/v1/account/me/sessions/revoke-others:
    post:
      tags: [Identity]
      x-audience: public
      operationId: revokeOtherSessions
      summary: Revoke all sessions except the current one (Wave 2B).
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      responses:
        '200':
          description: Revocation summary.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }

  /api/v1/account/me/sessions/{id}/revoke:
    post:
      tags: [Identity]
      x-audience: public
      operationId: revokeMySession
      summary: Revoke a single session by id (Wave 2B).
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Revoked.
        '404':
          description: Session not found or not owned by caller.

  /api/v1/account/me/api-keys:
    get:
      tags: [Identity]
      x-audience: public
      operationId: listMyApiKeys
      summary: List the current user's personal API keys (WS-B1).
      description: >
        Personal raw-bearer API keys for the 4th form-factor (TUI / CLI /
        headless / programmatic). Requires an interactive login (JWT or
        parent-domain session cookie): a personal API key itself is rejected
        (403) from the key-management surface so a leaked key cannot enumerate,
        mint, or revoke its siblings. Key hashes are never returned.
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      responses:
        '200':
          description: Personal API key list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { type: object, additionalProperties: true }
        '403':
          description: Caller authenticated with a personal API key (interactive session required).
    post:
      tags: [Identity]
      x-audience: public
      operationId: createMyApiKey
      summary: Mint a new personal API key (WS-B1).
      description: >
        The raw key is returned exactly once in `raw_key` and is unrecoverable
        afterwards. `expiration_days` defaults to 90 when omitted or non-positive.
        Send an Idempotency-Key header so a double-submit mints one key, not two.
        Requires an interactive session — a personal API key is rejected (403).
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 64 }
                expiration_days: { type: integer, default: 90 }
      responses:
        '201':
          description: API key minted (raw_key returned once).
          content:
            application/json:
              schema:
                type: object
                properties:
                  raw_key: { type: string }
                  key: { type: object, additionalProperties: true }
        '400':
          description: Invalid name or request body.
        '403':
          description: Caller authenticated with a personal API key (interactive session required).

  /api/v1/account/me/api-keys/{kid}:
    delete:
      tags: [Identity]
      x-audience: public
      operationId: revokeMyApiKey
      summary: Revoke one of the current user's personal API keys (WS-B1).
      description: Requires an interactive session — a personal API key is rejected (403).
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - { name: kid, in: path, required: true, schema: { type: string } }
      responses:
        '204':
          description: Revoked (idempotent).
        '403':
          description: Caller authenticated with a personal API key (interactive session required).

  /api/v1/account/me/tos/status:
    get:
      tags: [Identity]
      x-audience: public
      operationId: getTosStatus
      summary: Current Terms-of-Service acceptance status (Wave 1D).
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      responses:
        '200':
          description: ToS status.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }

  /api/v1/account/me/tos/accept:
    post:
      tags: [Identity]
      x-audience: public
      operationId: acceptTos
      summary: Accept the current ToS version (Wave 1D).
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      responses:
        '200':
          description: Acceptance recorded.

  # ── Self-service billing: overage / usage meter / model quotas ────
  /api/v1/account/me/billing/overage:
    get:
      tags: [Subscription]
      x-audience: public
      operationId: getMyOverage
      summary: Read my overage / usage-credits policy
      description: >
        Self-service read of the current user's per-product overage policy
        (`hard_stop` | `auto_payg`). All amounts are int64 minor units (分).
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - name: product_id
          in: query
          required: true
          schema: { type: string }
          description: Product whose subscription policy to read.
      responses:
        '200':
          description: Current overage policy.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OverageView' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    put:
      tags: [Subscription]
      x-audience: public
      operationId: setMyOverage
      summary: Set my overage / usage-credits policy
      description: >
        Self-service write of the current user's per-product overage policy.
        `hard_stop` normalises the rate / cap to zero. All amounts are int64
        minor units (分).
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SetOverageBody' }
      responses:
        '200':
          description: Updated overage policy.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OverageView' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/Conflict' }

  /api/v1/account/me/billing/meter:
    get:
      tags: [Subscription]
      x-audience: public
      operationId: getMyUsageMeter
      summary: My usage meter (% used + reset window)
      description: >
        Self-service read of the account-global credits allotment roll-up
        (granted / used / remaining, percent used, optional reset time).
        All amounts are int64 minor units (分).
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      responses:
        '200':
          description: Usage meter snapshot.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MeterView' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '503':
          description: Usage metering is not configured on this deployment.

  /api/v1/account/me/billing/model-quotas:
    get:
      tags: [Subscription]
      x-audience: public
      operationId: listMyModelQuotas
      summary: My current-week per-model quota buckets
      description: >
        Self-service read of this ISO-week's per-model weekly quota buckets.
        `remaining_cents` is -1 when the bucket is uncapped. All amounts are
        credits 分.
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      responses:
        '200':
          description: Current-week per-model quota buckets.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ModelQuotaList' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '503':
          description: Per-model quotas are not configured on this deployment.

  /api/v1/account/me/billing/auto-reload:
    get:
      tags: [Wallet]
      x-audience: public
      operationId: getMyAutoReload
      summary: Read my wallet auto-reload policy
      description: >
        Self-service read of the account's wallet auto-reload config — when the
        cash balance falls below `threshold_cents`, the default saved card is
        charged for `amount_cents` and the wallet is credited. Amounts are int64
        minor units (分). A missing config reads as disabled.
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      responses:
        '200':
          description: Current auto-reload config.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AutoReloadView' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '503':
          description: Wallet auto-reload is not configured on this deployment.
    put:
      tags: [Wallet]
      x-audience: public
      operationId: setMyAutoReload
      summary: Set my wallet auto-reload policy
      description: >
        Self-service write of the account's wallet auto-reload config. An enabled
        config must set a positive `amount_cents`. Amounts are int64 minor units
        (分). The charging engine itself is gated on an operator master switch.
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SetAutoReloadBody' }
      responses:
        '200':
          description: Updated auto-reload config.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AutoReloadView' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '503':
          description: Wallet auto-reload is not configured on this deployment.

  /api/v1/account/me/billing/plan-change/preview:
    get:
      tags: [Subscription]
      x-audience: public
      operationId: previewMyPlanChange
      summary: Preview a plan change (prorated diff)
      description: >
        Money-neutral projection of changing the live subscription for a product
        to another plan of the SAME billing cycle: the prorated price difference
        for the unused cycle portion, and whether it would charge (upgrade) or
        credit (downgrade) the cash wallet now. All amounts are int64 分.
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - { name: product_id, in: query, required: true, schema: { type: string } }
        - { name: new_plan_code, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: Plan-change projection (applied=false).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlanChangePreview' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: The target plan_code does not exist for this product.
        '409':
          description: No active subscription, or already on this plan.

  /api/v1/account/me/billing/plan-change:
    post:
      tags: [Subscription]
      x-audience: public
      operationId: changeMyPlan
      summary: Change plan (settle prorated diff)
      description: >
        Switch the live subscription to another plan of the same billing cycle,
        settling the prorated price difference immediately: an upgrade debits the
        cash wallet (402 if it can't cover it); a downgrade credits it. The cycle
        end is unchanged; feature entitlements update immediately and AI-credit
        grants realign at the next renewal. All amounts are int64 分.
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ChangePlanBody' }
      responses:
        '200':
          description: Plan changed and diff settled (applied=true).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlanChangePreview' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: Insufficient wallet balance for the prorated upgrade charge.
        '404':
          description: The target plan_code does not exist for this product.
        '409':
          description: No active subscription, or already on this plan.
        '503':
          description: Plan change is not available on this deployment.

  # ── Self-service saved payment methods (auto-reload, Feature G2) ───
  /api/v1/account/me/payment-methods:
    get:
      tags: [Wallet]
      x-audience: public
      operationId: listMyPaymentMethods
      summary: List my saved payment methods
      description: >
        Display fields only — vault handles (provider customer / payment-method
        refs) never leave the server.
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      responses:
        '200':
          description: Saved payment methods.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaymentMethodList' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/v1/account/me/payment-methods/setup:
    post:
      tags: [Wallet]
      x-audience: public
      operationId: startSavePaymentMethod
      summary: Start a hosted save-card flow
      description: >
        Returns a provider-hosted redirect URL for saving a card. `return_url`
        is optional — when omitted the deployment's configured default is used.
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/StartSetupBody' }
      responses:
        '200':
          description: Hosted save-card redirect URL.
          content:
            application/json:
              schema:
                type: object
                properties:
                  redirect_url: { type: string, format: uri }
                required: [redirect_url]
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '503':
          description: Saving a card is not available on this deployment.

  /api/v1/account/me/payment-methods/{id}/default:
    put:
      tags: [Wallet]
      x-audience: public
      operationId: setDefaultPaymentMethod
      summary: Make a saved card the auto-reload default
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          description: Saved payment-method ID.
          schema: { type: integer, format: int64 }
      responses:
        '200':
          description: Default updated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OKResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/v1/account/me/payment-methods/{id}:
    delete:
      tags: [Wallet]
      x-audience: public
      operationId: removePaymentMethod
      summary: Remove a saved card (soft delete)
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          description: Saved payment-method ID.
          schema: { type: integer, format: int64 }
      responses:
        '200':
          description: Removed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OKResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ── QR primitive (v1 legacy + v2 multi-action) ───────────────────
  /api/v1/public/qr-login/session:
    post:
      tags: [Identity]
      x-audience: public
      operationId: qrLoginCreateSession
      summary: v1 QR login — create a session (legacy, login-only).
      responses:
        '200':
          description: Session created.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }

  /api/v1/public/qr-login/{id}/status:
    get:
      tags: [Identity]
      x-audience: public
      operationId: qrLoginPollStatus
      summary: v1 QR login — poll session status (legacy).
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Session status.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }

  /api/v1/qr-login/{id}/confirm:
    post:
      tags: [Identity]
      x-audience: public
      operationId: qrLoginConfirm
      summary: v1 QR login — confirm a session from the authenticated APP (legacy).
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Confirmed.

  /api/v2/qr/session:
    post:
      tags: [Identity]
      x-audience: public
      operationId: qrV2CreateSession
      summary: v2 QR primitive — create a login-only session (public, no auth).
      responses:
        '200':
          description: Session created.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }

  /api/v2/qr/{id}/status:
    get:
      tags: [Identity]
      x-audience: public
      operationId: qrV2PollStatus
      summary: v2 QR primitive — poll session status.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Session status.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }

  /api/v2/qr/{id}:
    get:
      tags: [Identity]
      x-audience: public
      operationId: qrV2Preview
      summary: v2 QR primitive — preview session metadata (sig-gated, not JWT).
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Op-specific preview (target, initiator, risk).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }

  /api/v2/qr/{id}/confirm:
    post:
      tags: [Identity]
      x-audience: public
      operationId: qrV2Confirm
      summary: v2 QR primitive — confirm a multi-action session (login / join_org / delegate).
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Confirmed; action-specific outcome.

  /api/v2/qr/session/authed:
    post:
      tags: [Identity]
      x-audience: public
      operationId: qrV2CreateSessionAuthed
      summary: v2 QR primitive — create a non-login session (join_org / delegate) with caller identity.
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      responses:
        '200':
          description: Session created.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }

  # ── Admin: Distributor (RES-1) ────────────────────────────────────
  /admin/v1/distributors:
    post:
      tags: [Distributor]
      x-audience: admin
      operationId: adminCreateDistributor
      summary: Admin direct-create a distributor (skips KYC pending state).
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [owner_account_id, parent_org_id, name, slug, business_license_no, legal_representative, contact_email]
              properties:
                owner_account_id: { type: integer, format: int64 }
                parent_org_id: { type: integer, format: int64, description: Enterprise parent org (mig047 trigger enforces the pairing). }
                name: { type: string }
                slug: { type: string, pattern: '^[a-z0-9-]{3,32}$' }
                business_license_no: { type: string }
                legal_representative: { type: string }
                contact_email: { type: string, format: email }
                contact_phone: { type: string }
      responses:
        '201':
          description: Distributor created (KYC row starts active).
        '409':
          description: business_license_no already registered (`license_taken`).

  /admin/v1/distributors/{id}/kyc/reject:
    post:
      tags: [Distributor]
      x-audience: admin
      operationId: adminRejectDistributorKYC
      summary: Reject a distributor KYC submission (RES-1).
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: KYC rejected.

  # ── Admin: Identity Consistency Mesh — L1 / L2 ───────────────────
  /admin/v1/identity/drift:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminListIdentityDrift
      summary: Identity Mesh L1 — list current platform↔Zita drift rows.
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Drift rows.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }

  /admin/v1/identity/drift/{account_id}/repair:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminRepairIdentityDrift
      summary: Identity Mesh L1 — flag a drift row as manually repaired.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: account_id, in: path, required: true, schema: { type: integer, format: int64 } }
      responses:
        '200':
          description: Repaired.

  /admin/v1/identity/users/{account_id}/suspend:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminSuspendUser
      summary: Identity Mesh L2 — unified suspend (Zita + platform DB). Idempotency-Key required.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: account_id, in: path, required: true, schema: { type: integer, format: int64 } }
      responses:
        '200':
          description: Suspended.

  /admin/v1/identity/users/{account_id}/reactivate:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminReactivateUser
      summary: Identity Mesh L2 — unified reactivate (Zita + platform DB). Idempotency-Key required.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: account_id, in: path, required: true, schema: { type: integer, format: int64 } }
      responses:
        '200':
          description: Reactivated.

  /admin/v1/identity/users/{account_id}/reset-password:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminResetUserPassword
      summary: Identity Mesh L2 Wave A — admin-initiated password reset. Idempotency-Key required.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: account_id, in: path, required: true, schema: { type: integer, format: int64 } }
      responses:
        '200':
          description: Reset link issued.

  /admin/v1/identity/users/{account_id}/change-email:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminChangeUserEmail
      summary: Identity Mesh L2 Wave A — admin-initiated email change. Idempotency-Key required.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: account_id, in: path, required: true, schema: { type: integer, format: int64 } }
      responses:
        '200':
          description: Email changed.

  /admin/v1/identity/users/{account_id}/zita-link:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminForceLinkZitaSub
      summary: Identity Mesh L2 — operator-forced Zita-sub relink resolving an SSO identity conflict. Idempotency-Key required.
      description: >
        Resolves the case where UpsertByZitadelSub refuses an implicit relink
        (email already bound to a different Zita sub). Overwrites the
        account's Zitadel subject after verifying the new sub is not already
        bound to a DIFFERENT account.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: account_id, in: path, required: true, schema: { type: integer, format: int64 } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sub, reason]
              properties:
                sub: { type: string, description: "New Zitadel subject to bind." }
                reason: { type: string, description: "Free-text justification, required for the audit trail." }
      responses:
        '200':
          description: Linked (or already-linked, idempotent no-op).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }

  /admin/v1/identity/orgs/{org_id}/suspend:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminSuspendOrg
      summary: Identity Mesh L2 Wave B — org-scope suspend. Idempotency-Key required.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: org_id, in: path, required: true, schema: { type: integer, format: int64 } }
      responses:
        '200':
          description: Suspended.

  /admin/v1/identity/orgs/{org_id}/reactivate:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminReactivateOrg
      summary: Identity Mesh L2 Wave B — org-scope reactivate. Idempotency-Key required.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: org_id, in: path, required: true, schema: { type: integer, format: int64 } }
      responses:
        '200':
          description: Reactivated.

  /admin/v1/identity/orgs/{org_id}/delete:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminDeleteOrg
      summary: Identity Mesh L2 Wave B — org-scope delete. Idempotency-Key required.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: org_id, in: path, required: true, schema: { type: integer, format: int64 } }
      responses:
        '200':
          description: Deleted.

  /admin/v1/identity/bulk-create:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminBulkCreateUsers
      summary: Identity Mesh L2 Wave C — synchronous bulk human-user create (per-row idem internal).
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Bulk result summary.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }

  # ── Admin: Operations ────────────────────────────────────────────

  /admin/v1/org-api-keys:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminListOrgApiKeysCross
      summary: Cross-org operator view of org_api_keys (ADR 0008 — read-only).
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Org API keys across all orgs.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }

  /admin/v1/payment-settings:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminGetPaymentSettings
      summary: Wave 1E payment knobs — singleton billing.payment_settings row.
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Payment settings.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
    put:
      tags: [Admin]
      x-audience: admin
      operationId: adminUpdatePaymentSettings
      summary: Wave 1E payment knobs — overwrite the singleton row.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Updated.

  # ── Admin: per-org (white-label) config — Model B Slice 1 (ADR 0021) ──
  # Platform-operator surface for per-tenant overrides over global settings.
  # The admin-JWT middleware is the authorization gate; the handler elevates
  # the DB session to the service role so the operator can manage ANY org's
  # config (the migration-083 is_org_admin RLS policy would otherwise block a
  # non-member operator). Secret values are masked to bullets in all reads.
  /admin/v1/orgs/{id}/settings:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminListOrgSettings
      summary: List per-org config overrides (secret values masked).
      description: Returns only the keys explicitly overridden for this org. Secret values are returned as a fixed bullet mask, never in cleartext.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 }, description: Organization ID. }
      responses:
        '200':
          description: Org config overrides.
          content:
            application/json:
              schema:
                type: object
                required: [settings]
                properties:
                  settings:
                    type: array
                    items: { $ref: '#/components/schemas/OrgSettingOverride' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/orgs/{id}/settings/effective:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminGetOrgSettingsEffective
      summary: Resolved effective config for an org across the full key catalog.
      description: For every recognized config key, returns the effective value and its source (`override` | `global` | `default`). Secret values are masked.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 }, description: Organization ID. }
      responses:
        '200':
          description: Effective config across the catalog.
          content:
            application/json:
              schema:
                type: object
                required: [settings]
                properties:
                  settings:
                    type: array
                    items: { $ref: '#/components/schemas/OrgSettingEffective' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500':
          description: Repository error resolving effective config.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /admin/v1/orgs/{id}/settings/{key}:
    put:
      tags: [Admin]
      x-audience: admin
      operationId: adminPutOrgSetting
      summary: Set (override) a single per-org config value.
      description: |
        Validates the key against the recognized catalog and the value against
        the key's type rule. An empty value clears/inherits (still requires a
        recognized key). is_secret is derived from the catalog, not the caller.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 }, description: Organization ID. }
        - { name: key, in: path, required: true, schema: { type: string }, description: Config key (must be in the recognized catalog). }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OrgSettingPutBody' }
      responses:
        '200':
          description: Override stored.
          content:
            application/json:
              schema:
                type: object
                required: [ok, key]
                properties:
                  ok: { type: boolean }
                  key: { type: string }
                  category: { type: string }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500':
          description: Persistence error.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      tags: [Admin]
      x-audience: admin
      operationId: adminDeleteOrgSetting
      summary: Remove a per-org config override (revert to global/default).
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 }, description: Organization ID. }
        - { name: key, in: path, required: true, schema: { type: string }, description: Config key to clear. }
      responses:
        '204': { description: Override removed (idempotent). }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500':
          description: Persistence error.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ── Admin: unified-currency convergence dashboard (read-only) ──
  /admin/v1/identity/idps:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminListExternalIdPs
      summary: List enterprise-SSO external IdPs (live IAM state)
      description: >-
        No shadow table — reads reflect the IAM's live IdP records. Client
        secrets are write-only and never serialized.
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: External IdPs.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      additionalProperties: true
    put:
      tags: [Admin]
      x-audience: admin
      operationId: adminEnsureExternalIdP
      summary: Create-or-update an external IdP by name (converge; idempotent)
      description: >-
        The admin save button calls this unconditionally — the record is
        converged by `name`. `client_secret` is write-only: empty on update
        keeps the stored secret. `kind=saml` returns 501 (the seam exists;
        implementation is gated on the first SAML-only customer).
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: Idempotency-Key, in: header, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, issuer, client_id]
              properties:
                name: { type: string }
                kind: { type: string, enum: [oidc, saml], default: oidc }
                issuer: { type: string }
                client_id: { type: string }
                client_secret: { type: string, description: "Write-only; empty keeps stored." }
                scopes: { type: array, items: { type: string } }
                auto_create_user: { type: boolean }
      responses:
        '200':
          description: Converged IdP id.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
        '400': { description: Validation failure (name/issuer/client_id). }
        '501': { description: SAML not implemented yet. }
        '502': { description: IAM rejected the config. }

  /admin/v1/identity/idps/{idp_id}:
    delete:
      tags: [Admin]
      x-audience: admin
      operationId: adminDeleteExternalIdP
      summary: Delete an external IdP (detaches from login first)
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: idp_id, in: path, required: true, schema: { type: string } }
        - { name: Idempotency-Key, in: header, required: true, schema: { type: string } }
      responses:
        '200': { description: Deleted (idempotent). }

  /admin/v1/identity/idps/{idp_id}/login-link:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminSetIdPLoginLink
      summary: Show/hide the IdP on the login surface
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: idp_id, in: path, required: true, schema: { type: string } }
        - { name: Idempotency-Key, in: header, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [enabled]
              properties:
                enabled: { type: boolean }
      responses:
        '200': { description: Link state applied (idempotent both directions). }

  /api/v1/auth/sso/start:
    get:
      tags: [Identity]
      x-audience: public
      operationId: ssoStart
      summary: Begin enterprise-SSO federated login (302 to the corporate IdP)
      description: >-
        Browser-redirect endpoint. `redirect` (optional, same-origin absolute
        path only) is carried across the IdP round-trip in a short-lived
        cookie. Failures 302 to /login?sso_error=upstream.
      parameters:
        - { name: idp_id, in: query, required: true, schema: { type: string } }
        - { name: redirect, in: query, required: false, schema: { type: string } }
      responses:
        '302': { description: Redirect to the IdP authorization URL. }
        '400': { description: idp_id missing. }

  /api/v1/auth/sso/callback:
    get:
      tags: [Identity]
      x-audience: public
      operationId: ssoCallback
      summary: Finish enterprise-SSO login (sets the session cookie, 302 back)
      description: >-
        Landed on by the IAM's success redirect with `id` + `token`. Retrieves
        the intent, JIT-provisions (or links) the account, applies the
        suspended/deleted status gate, sets the `lurus_session` cookie and
        302s to the sanitized redirect path. Failure classes redirect to
        /login?sso_error={invalid_intent|upstream|identity_conflict|account_disabled}
        — an invalid intent is never conflated with upstream failure.
      parameters:
        - { name: id, in: query, required: true, schema: { type: string } }
        - { name: token, in: query, required: true, schema: { type: string } }
      responses:
        '302': { description: "Redirect — to the app on success, /login?sso_error=* on failure." }

  /admin/v1/ops/expire-stale-preauths:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminExpireStalePreAuths
      summary: Release all wallet pre-authorizations past their deadline
      description: >-
        One-sweep console action for what previously required raw SQL or
        waiting for the cron. Idempotent: only rows still active AND past
        deadline are touched, so a re-run releases 0. Audited as
        wallet.expire_stale_preauths.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: Idempotency-Key, in: header, required: true, schema: { type: string }, description: "Required; a retry with the same key replays the cached result." }
      responses:
        '200':
          description: Sweep result.
          content:
            application/json:
              schema:
                type: object
                properties:
                  released_count: { type: integer, format: int64 }

  /admin/v1/subscriptions/{id}/override:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminOverrideSubscription
      summary: Operator override on a specific subscription row (cancel / set_expiry)
      description: >-
        Targets an explicit subscription ID (unlike the user-facing cancel,
        which resolves account+product → active row), so duplicate/stale-row
        support cases are directly addressable. `cancel` mirrors the
        user-facing semantics (status→cancelled, auto-renew off; idempotent
        on an already-cancelled row); `set_expiry` moves expires_at. Audited
        as subscription.admin_override.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
        - { name: Idempotency-Key, in: header, required: true, schema: { type: string }, description: "Required; a retry with the same key replays the cached result." }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action]
              properties:
                action: { type: string, enum: [cancel, set_expiry] }
                expires_at: { type: string, format: date-time, description: "Required when action=set_expiry." }
      responses:
        '200':
          description: The updated subscription.
          content:
            application/json:
              schema:
                type: object
                properties:
                  subscription: { type: object, additionalProperties: true }
        '404': { description: Subscription not found. }

  /admin/v1/deploy/checklist:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminDeployChecklist
      summary: First-run deploy checklist (derived state)
      description: >-
        Read-only aggregation of what the running deployment knows about
        itself — migrations / internal_auth / zitadel / billing / payment /
        mail / sms / pii_crypto / apps / hook_dlq — each `ok` (green), `warn`
        (amber, operator attention) or `off` (grey, optional feature not
        configured — NOT an error). `internal_auth` warns when a downstream
        service presents a stale INTERNAL_API_KEY (the silent cross-service
        drift signal). Every probe degrades gracefully; the endpoint never
        500s because a dependency is down (that is exactly when it's needed).
        This is the keystone deliverability surface: a new enterprise operator
        opens it post-install to confirm the platform is correctly wired.
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Checklist items.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      required: [key, status, detail]
                      properties:
                        key: { type: string }
                        status: { type: string, enum: [ok, warn, off] }
                        detail: { type: string }

  /admin/v1/overview:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminOverview
      summary: Operator overview — four domains, one verdict each
      description: >-
        The landing answer to "can we do business right now". Computes
        nothing new: every number already existed on some other surface,
        and this endpoint republishes them in ONE shape so the console does
        not have to know eight response formats. Signals are grouped into
        four domains in a fixed order — money → people → machine → duty —
        and each domain ships a `coverage` census with the SAME shape as
        the top-level `overall`, so a verdict can never be read without the
        size of the blind spot behind it. Read-only, and never 500s: a
        broken source degrades to one `unknown` signal, because an operator
        opens this page during an incident.
      security: [{ bearerAuth: [] }, { cookieAuth: [] }]
      responses:
        '200':
          description: Overview snapshot.
          content:
            application/json:
              schema:
                type: object
                required: [generated_at, version, display_timezone, overall, domains]
                properties:
                  generated_at: { type: string, format: date-time }
                  version: { type: string, description: Build SHA that produced these numbers. }
                  display_timezone:
                    type: string
                    examples: ["Asia/Shanghai"]
                    description: >-
                      The wall clock every day/month boundary on the operator
                      surfaces is cut on. Travels with the payload so the SPA
                      does not guess and a reader in another zone can tell.
                  overall: { $ref: '#/components/schemas/OpsCoverage' }
                  domains:
                    type: array
                    items:
                      type: object
                      required: [domain, label, coverage, signals]
                      properties:
                        domain: { type: string, enum: [money, people, machine, duty] }
                        label: { type: string }
                        coverage: { $ref: '#/components/schemas/OpsCoverage' }
                        signals:
                          type: array
                          items: { $ref: '#/components/schemas/OpsSignal' }
        '401': { description: Not authenticated. }
        '403': { description: Not a platform admin. }

  /admin/v1/system-health:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminSystemHealth
      summary: Emergent system health (control loops + dependencies + backlogs)
      description: >-
        Single read-only view of the platform's background fabric, rolled up
        to one emergent `status` (healthy | degraded | down) via the same
        worst-of reduction the public /sli `system_health` field uses.
        Composes three signals — every readiness dependency (critical + soft),
        the reliability Supervisor's per-loop health snapshot, and the queue
        backlog depths (hook DLQ / hook outbox / …) each deep-linked to its
        admin drill-down page. Soft dependencies and dormant loops never drive
        the rollup to down. Never 500s — a probe/lookup failure degrades to a
        `_lookup_error` field rather than erasing the signal.
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Emergent health snapshot.
          content:
            application/json:
              schema:
                type: object
                required: [status, generated_at, summary, dependencies, loops, backlogs]
                properties:
                  status: { type: string, enum: [healthy, degraded, down] }
                  generated_at: { type: string, format: date-time }
                  version: { type: string }
                  summary:
                    type: object
                    properties:
                      healthy: { type: integer }
                      degraded: { type: integer }
                      down: { type: integer }
                      dormant: { type: integer }
                  dependencies:
                    type: array
                    items:
                      type: object
                      required: [name, class, status]
                      properties:
                        name: { type: string }
                        class: { type: string, enum: [critical, soft] }
                        status: { type: string, enum: [healthy, degraded, down] }
                        last_error: { type: string }
                        runbook: { type: string }
                  loops:
                    type: array
                    items:
                      type: object
                      required: [name, kind, status]
                      properties:
                        name: { type: string }
                        kind: { type: string }
                        status: { type: string, enum: [healthy, degraded, down] }
                        last_run: { type: string, format: date-time }
                        last_success: { type: string, format: date-time }
                        consecutive_fails: { type: integer }
                        pending: { type: integer }
                        last_error: { type: string }
                        p99_millis: { type: integer }
                        runbook: { type: string }
                        dormant: { type: boolean }
                  backlogs:
                    type: array
                    items:
                      type: object
                      required: [name, current, threshold, status]
                      properties:
                        name: { type: string }
                        current: { type: integer }
                        threshold: { type: integer }
                        status: { type: string, enum: [healthy, degraded, down] }
                        admin_view: { type: string }
                        runbook: { type: string }

  /admin/v1/stats/products:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminGetProductStats
      summary: Per-product revenue/usage aggregates for the console dashboard.
      description: |
        Aggregates the wallet ledger (spend_by_type, ledger sign convention —
        debits negative) and generic usage events (events / quantity /
        credits_cents, the shadow-billing side) per product over a trailing
        window. Products with no activity still appear; ledger rows without a
        product_id appear under product_id "" so unattributed money stays
        visible.
      security: [{ bearerAuth: [] }]
      parameters:
        - name: days
          in: query
          required: false
          description: Trailing window in days (1–365, default 30).
          schema: { type: integer, minimum: 1, maximum: 365, default: 30 }
      responses:
        '200':
          description: Per-product aggregates.
          content:
            application/json:
              schema:
                type: object
                required: [days, since, products]
                properties:
                  days: { type: integer }
                  since: { type: string, format: date-time }
                  products:
                    type: array
                    items:
                      type: object
                      properties:
                        product_id: { type: string }
                        name: { type: string }
                        billing_mode: { type: string }
                        spend_by_type:
                          type: object
                          additionalProperties:
                            type: object
                            properties:
                              count: { type: integer, format: int64 }
                              total: { type: number, format: double }
                        usage:
                          type: object
                          properties:
                            events: { type: integer, format: int64 }
                            quantity: { type: integer, format: int64 }
                            credits_cents: { type: integer, format: int64 }
        '400': { description: days out of range. }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/stats/convergence:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminGetConvergenceStats
      summary: Unified-currency readiness counts (platform DB + best-effort newhub).
      description: |
        Platform counts (accounts / wallets / newapi-linked) are authoritative,
        read from the local DB. The newhub block is best-effort: when
        NEWHUB_INTERNAL_URL is unset or unreachable it degrades to
        available:false with zero counts; the endpoint never fails on newhub
        unavailability.
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Convergence statistics.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConvergenceStats' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500':
          description: Platform stats query failed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ── Admin: wallet ↔ TigerBeetle ledger drift (read-only, ADR 0022) ──
  /admin/v1/ledger/drift/{account_id}:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminGetLedgerDrift
      summary: Wallet ↔ credits-ledger dual-write shadow drift for one account.
      description: |
        Compares the authoritative Postgres spendable balance
        (wallets.balance − wallets.frozen, in fen) against the TigerBeetle
        credits-ledger account's available balance and reports the per-fen delta.
        Registered only when the mirror is live (BILLING_LEDGER=dual|tigerbeetle);
        in the default postgres mode the route is not registered (404).
      security: [{ bearerAuth: [] }]
      parameters:
        - name: account_id
          in: path
          required: true
          description: Platform account id.
          schema: { type: integer, format: int64 }
      responses:
        '200':
          description: Drift snapshot for the account.
          content:
            application/json:
              schema:
                type: object
                required: [account_id, pg_balance_fen, tb_available_fen, drift_fen, in_sync]
                properties:
                  account_id: { type: integer, format: int64 }
                  pg_balance_fen: { type: integer, format: int64 }
                  tb_available_fen: { type: integer, format: int64 }
                  drift_fen: { type: integer, format: int64 }
                  in_sync: { type: boolean }
        '400':
          description: account_id is not a positive integer.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500':
          description: Balance lookup failed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ── Admin: Casdoor-compat console shim (ADR 0024 — UI-only rebrand) ──
  # Zitadel stays the identity engine; these read-only endpoints RESHAPE
  # data the platform already owns into the JSON shapes the Casdoor-derived
  # admin console (apps/admin-casdoor) fetches. Endpoint names mirror the
  # console's fetch paths (get-*). List endpoints return the Casdoor
  # envelope {status, data, data2:<total count>}.
  /admin/v1/casdoor/get-dashboard:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorGetDashboard
      summary: Casdoor-console dashboard — cards, 30-day growth, provider donut, MFA gauge, login heatmap.
      description: |
        DB-backed cards are authoritative; the applications / providers /
        login-heatmap blocks come from optional seams that degrade honestly
        (`sources.*_available=false`) when unwired or failing.
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Dashboard aggregate.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/get-records:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorGetRecords
      summary: Audit events reshaped into the Casdoor record-list shape.
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Record list envelope.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/get-providers:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorGetProviders
      summary: Combined identity (IdP) + payment provider list, Casdoor-shaped.
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Provider list envelope.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/get-users:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorGetUsers
      summary: Platform accounts reshaped into the Casdoor user-list shape.
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: User list envelope.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/get-organizations:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorGetOrganizations
      summary: Organizations reshaped into the Casdoor organization-list shape.
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Organization list envelope.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/get-organization-names:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorGetOrganizationNames
      summary: Flat organization name list for the console header's org selector.
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Organization-name list envelope.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/get-global-users:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorGetGlobalUsers
      summary: All-organizations user list variant (same global account pool as get-users).
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: User list envelope.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/get-global-providers:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorGetGlobalProviders
      summary: All-organizations provider list variant (same provider pool as get-providers).
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Provider list envelope.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/get-tokens:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorGetTokens
      summary: API keys reshaped into the Casdoor token-list shape (no secrets).
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Token list envelope.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/get-applications:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorGetApplications
      summary: Declared OIDC apps (apps.yaml registry) in the Casdoor application-list shape.
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Application list envelope (empty when no registry is mounted).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/get-products:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorGetProducts
      summary: Product catalogue reshaped into the Casdoor product-list shape.
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Product list envelope.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/get-subscriptions:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorGetSubscriptions
      summary: Global subscription ledger in the Casdoor subscription-list shape.
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Subscription list envelope.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/get-payments:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorGetPayments
      summary: Global payment-order ledger in the Casdoor payment-list shape.
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Payment list envelope.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/get-sessions:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorGetSessions
      summary: Every account's live sessions in the Casdoor session-list shape (JTIs excluded).
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Session list envelope.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  # ── Admin: Casdoor-compat console WRITE + get-one shims ──
  # Same UI-only rebrand posture (ADR 0024). The console's BaseListPage
  # edit/add/delete buttons POST to these; get-<singular> backs the edit
  # page load. The identity engine still owns credentials/apps — engine
  # writes here return the Casdoor error envelope (HTTP 200, status:"error")
  # pointing at the real flow rather than fabricating a local record.
  /admin/v1/casdoor/get-user:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorGetUser
      summary: One account reshaped into the Casdoor user object (edit-page load).
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: User object envelope.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/get-account:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorGetAccount
      summary: The signed-in admin in the Casdoor account shape (console session bootstrap; id from the JWT, never a param).
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Account object envelope.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/add-user:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorAddUser
      summary: Guarded — user creation flows through the identity engine (returns error envelope).
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Casdoor write envelope (status ok/error).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/update-user:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorUpdateUser
      summary: Apply platform-owned profile fields + forbidden toggle to an account.
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Casdoor write envelope (status ok/error).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/delete-user:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorDeleteUser
      summary: Reversible account suspend (Casdoor delete → status flip, no hard delete).
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Casdoor write envelope (status ok/error).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/set-password:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorSetPassword
      summary: Admin reset a user's password via the identity engine (no local credential store).
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Casdoor write envelope (status ok/error).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/get-organization:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorGetOrganization
      summary: One organization reshaped into the Casdoor organization object.
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Organization object envelope.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/add-organization:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorAddOrganization
      summary: Create an organization (Casdoor add-organization shape).
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Casdoor write envelope (status ok/error).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/update-organization:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorUpdateOrganization
      summary: Update an organization's display fields.
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Casdoor write envelope (status ok/error).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/delete-organization:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorDeleteOrganization
      summary: Reversible organization suspend (Casdoor delete → status flip).
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Casdoor write envelope (status ok/error).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/get-roles:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorGetRoles
      summary: Org membership reshaped into the Casdoor role-list shape (read-only, no Casbin).
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Role list envelope.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/get-role:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorGetRole
      summary: One role (org membership view) in the Casdoor role object shape.
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Role object envelope.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/get-product:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorGetProduct
      summary: One product reshaped into the Casdoor product object (edit-page load).
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Product object envelope.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/add-product:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorAddProduct
      summary: Create a product (spend gate defaults OFF, status active).
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Casdoor write envelope (status ok/error).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/update-product:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorUpdateProduct
      summary: Update a product's display fields + forbidden toggle (billing mode preserved).
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Casdoor write envelope (status ok/error).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/delete-product:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorDeleteProduct
      summary: Reversible product delete (status flip; anchors plans/subscriptions).
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Casdoor write envelope (status ok/error).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/get-plans:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorGetPlans
      summary: Product plans (optionally ?product/&owner filtered) in the Casdoor plan-list shape.
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Plan list envelope.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/get-plan:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorGetPlan
      summary: One plan reshaped into the Casdoor plan object (edit-page load).
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Plan object envelope.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/add-plan:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorAddPlan
      summary: Create a plan for a product (status active).
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Casdoor write envelope (status ok/error).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/update-plan:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorUpdatePlan
      summary: Update a plan's pricing/cycle/default fields + forbidden toggle.
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Casdoor write envelope (status ok/error).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/delete-plan:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorDeletePlan
      summary: Reversible plan delete (status flip; anchors live subscriptions).
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Casdoor write envelope (status ok/error).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/get-subscription:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorGetSubscription
      summary: One subscription in the Casdoor subscription object shape (edit-page load).
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Subscription object envelope.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/add-subscription:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorAddSubscription
      summary: Guarded — subscriptions are born from checkout/provisioning flows (returns error envelope).
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Casdoor write envelope (status ok/error).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/update-subscription:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorUpdateSubscription
      summary: Map a console edit onto the operator override (cancel / set_expiry / extend_grace).
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Casdoor write envelope (status ok/error).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/delete-subscription:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorDeleteSubscription
      summary: Cancel a subscription via the operator override (row survives for billing history).
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Casdoor write envelope (status ok/error).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/get-payment:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorGetPayment
      summary: One payment order (by order number handle) in the Casdoor payment object shape.
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Payment object envelope.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/get-token:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorGetToken
      summary: One API key (by name handle) in the Casdoor token object shape — metadata only, never the secret.
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Token object envelope.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/add-token:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorAddToken
      summary: Mint an API key via the identity engine — plaintext returned once in accessToken.
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Casdoor write envelope; data carries the one-time accessToken.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/update-token:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorUpdateToken
      summary: Guarded — tokens are immutable; rotate or revoke instead (returns error envelope).
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Casdoor write envelope (status ok/error).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/rotate-token:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorRotateToken
      summary: Rotate an API key (revoke + remint, same name) — plaintext returned once.
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Casdoor write envelope; data carries the one-time accessToken.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/delete-token:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorDeleteToken
      summary: Revoke an API key (engine PAT invalidated; row survives as audit pointer).
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Casdoor write envelope (status ok/error).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/delete-session:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorDeleteSession
      summary: Revoke one account session (kick a device); idempotent on already-revoked.
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Casdoor write envelope (status ok/error).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/get-provider:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorGetProvider
      summary: One provider (identity IdP or payment gateway) in the Casdoor provider object shape (secrets never echoed).
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Provider object envelope.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/add-provider:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorAddProvider
      summary: Create/converge an identity provider in the engine (clientSecret write-only); payment category is guarded.
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Casdoor write envelope (status ok/error).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/update-provider:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorUpdateProvider
      summary: Re-ensure an identity provider (converged by name; empty clientSecret keeps the stored one).
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Casdoor write envelope (status ok/error).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/delete-provider:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorDeleteProvider
      summary: Delete an identity provider from the engine (name resolved to IdP id); idempotent on absent.
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Casdoor write envelope (status ok/error).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/get-application:
    get:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorGetApplication
      summary: One declared OIDC app (apps.yaml registry) in the Casdoor application object shape.
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Application object envelope.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/add-application:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorAddApplication
      summary: Guarded — applications are declarative (apps.yaml → reconciler); returns error envelope.
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Casdoor write envelope (status ok/error).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/update-application:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorUpdateApplication
      summary: Guarded — applications are declarative (apps.yaml → reconciler); returns error envelope.
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Casdoor write envelope (status ok/error).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /admin/v1/casdoor/delete-application:
    post:
      tags: [Admin]
      x-audience: admin
      operationId: adminCasdoorDeleteApplication
      summary: Guarded — applications are declarative (apps.yaml → reconciler); returns error envelope.
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Casdoor write envelope (status ok/error).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '401': { $ref: '#/components/responses/Unauthorized' }

  # ── Internal: runtime billing-config poll (service-to-service) ──
  /internal/v1/billing-config:
    get:
      tags: [Internal]
      x-audience: internal
      operationId: internalGetBillingConfig
      summary: Poll the runtime unified-billing feature flags.
      description: |
        Returns the live admin.settings `billing.unified_enabled` flag plus
        the per-product billing-mode map (migration 094) so a downstream
        service (e.g. newhub) picks up an operator console toggle without a
        pod restart. `product_modes` values: `off` | `shadow` | `enforce`.
        Polling is advisory — the platform enforces the same gate on its own
        spend endpoints, so a stale poller cannot charge a product the
        operator dialled down. Requires the `wallet:read` scope.

        `local_ledger_advisory` (admin.settings `billing.local_ledger_advisory`,
        default false) demotes downstream LOCAL quota ledgers to shadow
        bookkeeping for platform-governed requests (Track A single-ledger
        convergence): newhub skips its local user-balance 402 and treats local
        write failures as meter loss instead of blocking settle.
      security: [{ internalKey: [] }]
      responses:
        '200':
          description: Billing feature flags.
          content:
            application/json:
              schema:
                type: object
                required: [unified_billing_enabled]
                properties:
                  unified_billing_enabled: { type: boolean }
                  local_ledger_advisory: { type: boolean }
                  require_product_attribution: { type: boolean }
                  product_modes:
                    type: object
                    additionalProperties:
                      type: string
                      enum: [off, shadow, enforce]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  # ── Internal: usage + kova lifecycle ─────────────────────────────
  /internal/v1/usage/events:
    post:
      tags: [Internal]
      x-audience: internal
      operationId: reportUsageEvents
      summary: Wave 1F G4 — universal per-account per-product usage ingest (idempotency_key in body).
      security: [{ internalKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Ingested (deduped on idempotency_key).

  /internal/v1/orgs/{id}/services/kova-tester/revoke:
    post:
      tags: [Kova Provisioning]
      x-audience: internal
      operationId: revokeKovaTester
      summary: Revoke a previously-provisioned kova tester (sibling of provisionKovaTester).
      security: [{ internalKey: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
      responses:
        '200':
          description: Revoked (idempotent — second call on absent tester is a no-op).

  # ═══════════════════════════════════════════════════════════════════
  # Webhooks
  # ═══════════════════════════════════════════════════════════════════
  /webhook/epay:
    get:
      tags: [Webhooks]
      x-audience: internal
      operationId: epayWebhook
      summary: Epay (易支付) async notification
      parameters:
        - { name: out_trade_no, in: query, schema: { type: string } }
        - { name: trade_status, in: query, schema: { type: string } }
        - { name: sign, in: query, schema: { type: string } }
      responses:
        '200':
          description: Processed.
          content:
            text/plain:
              schema: { type: string, examples: ["success"] }

  /webhook/stripe:
    post:
      tags: [Webhooks]
      x-audience: internal
      operationId: stripeWebhook
      summary: Stripe webhook (signature-verified)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
      responses:
        '200':
          description: Received.
          content:
            application/json:
              schema:
                type: object
                properties:
                  received: { type: boolean }

  /webhook/creem:
    post:
      tags: [Webhooks]
      x-audience: internal
      operationId: creemWebhook
      summary: Creem webhook (signature-verified)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
      responses:
        '200':
          description: Received.
          content:
            application/json:
              schema:
                type: object
                properties:
                  received: { type: boolean }

  /webhook/alipay:
    post:
      tags: [Webhooks]
      x-audience: internal
      operationId: alipayWebhook
      summary: Alipay async notification
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              additionalProperties: true
      responses:
        '200': { description: Processed. }

  /webhook/wechat:
    post:
      tags: [Webhooks]
      x-audience: internal
      operationId: wechatPayWebhook
      summary: WeChat Pay v3 notification
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
      responses:
        '200': { description: Processed. }

  /webhook/worldfirst:
    post:
      tags: [Webhooks]
      x-audience: internal
      operationId: worldFirstWebhook
      summary: WorldFirst (万里汇) notification
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
      responses:
        '200': { description: Processed. }
