# RoamGo Partner API — OpenAPI 3.1 spec.
#
# Hand-maintained alongside the Zod validators in src/app/api/partner/v1/.
# When you add or change an endpoint there, edit the matching path here
# in the same commit. Partners import this file into Postman / Insomnia /
# their typed-client generator of choice.
#
# Authentication is HMAC-signed; OpenAPI 3.x doesn't have a first-class
# scheme for that, so we declare it as `apiKey` with extension fields
# describing the canonical-string format. See docs/roamgo-partner-quickstart.md
# §1 for full signing instructions.
openapi: 3.1.0
info:
  title: RoamGo Partner API
  version: "1.0.0"
  description: |
    Server-to-server HTTP API for resellers placing eSIM orders on
    behalf of their end customers. See `docs/roamgo-partner-api.md`
    for the design doc and `docs/roamgo-partner-quickstart.md` for a
    walkthrough.
  contact:
    name: RoamGo Partner Support
    email: partners@roamgo.net
servers:
  - url: https://api.roamgo.net
    description: Production
  - url: http://localhost:3001
    description: Local dev

# ----------------------------------------------------------------------
# Auth
# ----------------------------------------------------------------------
components:
  securitySchemes:
    RoamgoHMAC:
      type: apiKey
      in: header
      name: Authorization
      description: |
        `Roamgo-HMAC-SHA256 keyId=<keyId>, ts=<unix>, sig=<hex>`

        `sig` is HMAC-SHA256 of `{ts}\n{METHOD}\n{path-with-query}\n{sha256(body)}`,
        lowercase hex. See quickstart §1 for code samples in Node /
        Python / curl.

  parameters:
    Page:
      name: page
      in: query
      schema: { type: integer, minimum: 1, default: 1 }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      description: |
        Strongly recommended on POST /orders and POST /esims/{id}/topups.
        Same `(reseller, key)` pair within 24h returns the original
        response; FAILED prior order attempts return 409
        idempotency_conflict instead.
      schema: { type: string, maxLength: 255 }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum:
                - unauthorized
                - signature_invalid
                - signature_expired
                - key_revoked
                - forbidden
                - not_found
                - validation_failed
                - plan_not_in_catalog
                - plan_inactive
                - insufficient_credit
                - rate_limited
                - idempotency_conflict
                - internal
            message: { type: string }
            details:
              description: Optional structured payload, validator-specific.

    Plan:
      type: object
      properties:
        id: { type: string, example: "pln_jp_1gb_7d" }
        name: { type: string, example: "Japan — 1 GB, 7 days" }
        region: { type: [string, "null"] }
        countries:
          type: array
          items: { type: string, minLength: 2, maxLength: 2 }
        dataAmountMB:
          type: integer
          description: Fixed-data allowance in MB. Unlimited plans return 0; use unlimited=true and tier for semantics.
        unlimited: { type: boolean }
        validityDays: { type: integer }
        tier: { type: [string, "null"], enum: [Lite, Essential, Plus, null] }
        wholesaleCents:
          type: integer
          description: RoamGo's cost. What you pay.
        yourPriceCents:
          type: integer
          description: wholesaleCents × (1 + your margin %).
        currency: { type: string, example: "USD" }

    Esim:
      type: object
      properties:
        id: { type: string }
        iccid: { type: [string, "null"] }
        smdpAddress: { type: [string, "null"] }
        matchingId: { type: [string, "null"] }
        activationCode: { type: [string, "null"] }
        lpaString:
          type: [string, "null"]
          description: 'Composed `LPA:1$smdp$matchingId` for QR rendering.'
        qrUrl:
          type: string
          description: Absolute URL to GET the QR PNG.
        status:
          type: string
          enum: [PENDING, ISSUED, INSTALLED, ACTIVE, DEPLETED, EXPIRED, CANCELLED]
        activatedAt: { type: [string, "null"], format: date-time }
        expiresAt: { type: [string, "null"], format: date-time }
        dataUsedMB: { type: integer }
        reinstallCount: { type: integer }

    TopUpOption:
      type: object
      properties:
        providerTopUpPlanId: { type: string, example: "topup_1gb_7d" }
        slug: { type: [string, "null"] }
        name: { type: string, example: "Global 1 GB, 7 days" }
        description: { type: [string, "null"] }
        dataAmountMB:
          type: integer
          description: Fixed-data allowance in MB. Unlimited top-ups return 0; use unlimited=true for semantics.
        unlimited: { type: boolean }
        validityDays: { type: integer }
        costCents:
          type: integer
          description: Supplier cost.
        priceCents:
          type: integer
          description: Cost plus the reseller margin.
        currency: { type: string, example: "USD" }

    EsimTopUp:
      type: object
      properties:
        id: { type: string }
        esimId: { type: string }
        iccid: { type: string, description: "Non-null on the wire — top-ups are only accepted for eSIMs whose ICCID is known." }
        provider: { type: string, enum: [ESIM_ACCESS, AIRALO] }
        providerTopUpPlanId: { type: string }
        providerOrderNo: { type: [string, "null"] }
        name: { type: string }
        dataAmountMB: { type: [integer, "null"], description: "Null for unlimited top-ups; the `unlimited` flag is the source of truth for semantics." }
        unlimited: { type: boolean }
        validityDays: { type: integer }
        errorMessage: { type: [string, "null"], description: "Populated when status is FAILED; null otherwise." }
        status:
          type: string
          enum: [PENDING_PAYMENT, PAID, PROCESSING, COMPLETED, FAILED, CANCELLED]
        priceCents: { type: integer }
        currency: { type: string }
        externalRef: { type: [string, "null"] }
        createdAt: { type: string, format: date-time }
        paidAt: { type: [string, "null"], format: date-time }
        completedAt: { type: [string, "null"], format: date-time }

    Order:
      type: object
      properties:
        id: { type: string }
        status:
          type: string
          enum: [PENDING, PAID, PROVISIONING, FULFILLED, FAILED, REFUNDED]
        customerEmail: { type: string, format: email }
        externalRef: { type: [string, "null"] }
        metadata:
          type: [object, "null"]
          additionalProperties: true
        totalCents: { type: integer }
        currency: { type: string }
        items:
          type: array
          items:
            type: object
            properties:
              planId: { type: string }
              planName: { type: string }
              providerPlanId: { type: string }
              quantity: { type: integer }
              unitPriceCents: { type: integer }
              totalCents: { type: integer }
              esims:
                type: array
                items: { $ref: "#/components/schemas/Esim" }
        invoice:
          type: [object, "null"]
          properties:
            number: { type: string, example: "RG-2026-000841" }
            url: { type: string, format: uri }
            status: { type: string, enum: [ISSUED, PAID, VOID] }
            totalCents: { type: integer }
            currency: { type: string }
        createdAt: { type: string, format: date-time }
        paidAt: { type: [string, "null"], format: date-time }
        fulfilledAt: { type: [string, "null"], format: date-time }

    WebhookEndpoint:
      type: object
      properties:
        id: { type: string }
        url: { type: string, format: uri }
        events:
          type: array
          items: { type: string }
        label: { type: [string, "null"] }
        enabled: { type: boolean }
        createdAt: { type: string, format: date-time }

  # Error response template — every error 4xx/5xx looks like this.
  responses:
    BadRequest:
      description: Validation failed.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Missing/invalid signature, expired timestamp, or revoked key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: Reseller account inactive, or key scope insufficient.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Resource not found, or not owned by the calling reseller.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: Token bucket exhausted. Inspect `Retry-After`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
      headers:
        Retry-After: { schema: { type: integer } }
        X-RateLimit-Limit: { schema: { type: integer } }
        X-RateLimit-Remaining: { schema: { type: integer } }
        X-RateLimit-Reset: { schema: { type: integer } }

# Default security: every operation needs the HMAC header. Override
# below if you ever expose a public endpoint.
security:
  - RoamgoHMAC: []

# ----------------------------------------------------------------------
# Paths
# ----------------------------------------------------------------------
paths:
  /api/partner/v1/me:
    get:
      summary: Account snapshot
      tags: [Account]
      responses:
        "200":
          description: Reseller, balance, scope, rate limits.
          content:
            application/json:
              schema:
                type: object
                properties:
                  reseller:
                    type: object
                    properties:
                      id: { type: string }
                      companyName: { type: [string, "null"] }
                      contactName: { type: [string, "null"] }
                      marginPercent: { type: string }
                      active: { type: boolean }
                  credit:
                    type: object
                    properties:
                      balanceCents: { type: integer }
                      currency: { type: string }
                      lowThresholdCents: { type: [integer, "null"] }
                  testMode: { type: boolean }
                  scope: { type: string, enum: [FULL, ORDERS, READ] }
                  rateLimits:
                    type: object
                    properties:
                      requestsPerMinute: { type: integer }
                      ordersPerMinute: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/partner/v1/plans:
    get:
      summary: Browse the catalog
      tags: [Plans]
      parameters:
        - name: country
          in: query
          schema: { type: string, minLength: 2, maxLength: 2 }
        - name: region
          in: query
          schema: { type: string, maxLength: 64 }
        - name: tier
          in: query
          schema: { type: string, enum: [Lite, Essential, Plus, FIXED] }
        - name: minDays
          in: query
          schema: { type: integer, minimum: 1, maximum: 365 }
        - name: days
          in: query
          schema: { type: integer, minimum: 1, maximum: 365 }
        - name: dataMB
          in: query
          schema: { type: integer, minimum: 1, maximum: 1000000 }
        - name: unlimited
          in: query
          schema: { type: string, enum: ["true", "false"] }
        - { $ref: "#/components/parameters/Page" }
      responses:
        "200":
          description: Paginated catalog filtered by your reseller whitelist.
          content:
            application/json:
              schema:
                type: object
                properties:
                  plans:
                    type: array
                    items: { $ref: "#/components/schemas/Plan" }
                  page: { type: integer }
                  pageSize: { type: integer }
                  total: { type: integer }
                  totalPages: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/partner/v1/plans/{id}:
    get:
      summary: Plan detail + per-country network coverage
      tags: [Plans]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Plan + grouped coverage.
        "404": { $ref: "#/components/responses/NotFound" }

  /api/partner/v1/orders:
    get:
      summary: List your orders, newest first
      tags: [Orders]
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [PENDING, PAID, PROVISIONING, FULFILLED, FAILED, REFUNDED]
        - name: customerEmail
          in: query
          schema: { type: string, format: email }
        - name: externalRef
          in: query
          schema: { type: string }
        - name: createdAfter
          in: query
          schema: { type: string, format: date-time }
        - { $ref: "#/components/parameters/Page" }
      responses:
        "200":
          description: Paginated slim list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  orders:
                    type: array
                    items: { $ref: "#/components/schemas/Order" }
                  page: { type: integer }
                  pageSize: { type: integer }
                  total: { type: integer }
                  totalPages: { type: integer }
    post:
      summary: Place an order on behalf of an end customer
      tags: [Orders]
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [planId, customerEmail]
              properties:
                planId: { type: string }
                quantity: { type: integer, minimum: 1, maximum: 50, default: 1 }
                customerEmail: { type: string, format: email }
                customerName: { type: string, maxLength: 120 }
                externalRef: { type: string, maxLength: 120 }
                metadata:
                  type: object
                  description: Free-form ≤ 4KB.
      responses:
        "202":
          description: |
            Order accepted, credit debited, fulfillment enqueued. Listen
            for `order.fulfilled` webhook for the eSIM artifacts.
          headers:
            Idempotent-Replayed:
              schema: { type: string }
              description: Present as "true" when this response is an idempotent replay of a prior request with the same Idempotency-Key.
          content:
            application/json:
              schema:
                type: object
                properties:
                  order: { $ref: "#/components/schemas/Order" }
        "402":
          description: Insufficient credit.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: Idempotency-Key collision (prior attempt was FAILED).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/partner/v1/orders/{id}:
    get:
      summary: Order detail with eSIM artifacts (when FULFILLED)
      tags: [Orders]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Full order shape, including items + esims + invoice link.
          content:
            application/json:
              schema:
                type: object
                properties:
                  order: { $ref: "#/components/schemas/Order" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/partner/v1/orders/{id}/refund:
    post:
      summary: Refund a partner order (idempotent)
      description: |
        Flips Order.status → REFUNDED, credits the reseller's balance
        back the totalCents, and fires an `order.refunded` webhook.
        Requires ORDERS scope. Test-mode orders are refundable for
        integration-testing symmetry but skip the credit adjustment.

        Idempotent: a second call on an already-refunded order
        returns 200 with `refunded: false, alreadyRefunded: true`
        and does NOT re-credit.
      tags: [Orders]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: |
                    Optional operator note. Trimmed and capped at 200
                    chars. Persisted on the credit ledger entry and
                    echoed in the outbound webhook envelope.
      responses:
        "200":
          description: |
            Refund applied, or a no-op if the order was already refunded.
          content:
            application/json:
              schema:
                type: object
                required: [order, refunded]
                properties:
                  order: { $ref: "#/components/schemas/Order" }
                  refunded:
                    type: boolean
                    description: true on the first successful refund; false if already refunded.
                  alreadyRefunded:
                    type: boolean
                    description: Present and true only when refunded=false because status was already REFUNDED.
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: |
            Invalid `reason` type in body (must be string if provided).
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/partner/v1/orders/{id}/invoice.pdf:
    get:
      summary: Stream the invoice PDF
      tags: [Orders]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: PDF bytes. 1-day private cache.
          content:
            application/pdf: {}
        "404": { $ref: "#/components/responses/NotFound" }

  /api/partner/v1/esims/{id}/qr:
    get:
      summary: Activation QR PNG
      tags: [eSIMs]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: PNG bytes, 1-hour private cache.
          content:
            image/png: {}
        "404": { $ref: "#/components/responses/NotFound" }

  /api/partner/v1/esims/{id}/reinstall:
    post:
      summary: Re-arm activation profile so QR can be installed on a new device
      tags: [eSIMs]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: New (or refreshed) activation credentials.
          content:
            application/json:
              schema:
                type: object
                properties:
                  esim: { $ref: "#/components/schemas/Esim" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429":
          description: |
            Reinstall rate-limit hit (3/24h per eSIM). Inspect Retry-After.

  /api/partner/v1/esims/{id}/topups:
    get:
      summary: List data top-ups available for an existing eSIM
      tags: [eSIMs]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Supplier-backed data add-ons priced with your reseller margin.
          content:
            application/json:
              schema:
                type: object
                properties:
                  topUps:
                    type: array
                    items: { $ref: "#/components/schemas/TopUpOption" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      summary: Buy a data top-up for an existing eSIM
      tags: [eSIMs]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [topUpProviderPlanId]
              properties:
                topUpProviderPlanId: { type: string, maxLength: 180 }
                externalRef: { type: string, maxLength: 120 }
                metadata:
                  type: object
                  additionalProperties: true
                  description: Optional partner metadata, max 4KB serialized.
      responses:
        "201":
          description: Top-up charged to reseller credit and sent to the supplier.
          headers:
            Idempotent-Replayed:
              schema: { type: string }
              description: Present as "true" when the response is an idempotent replay.
          content:
            application/json:
              schema:
                type: object
                properties:
                  topUp: { $ref: "#/components/schemas/EsimTopUp" }
        "200":
          description: Idempotent replay of a prior successful request.
          headers:
            Idempotent-Replayed:
              schema: { type: string }
        "404": { $ref: "#/components/responses/NotFound" }
        "402":
          description: Reseller credit wallet does not have enough balance.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: eSIM cannot be topped up, selected top-up is unavailable, or request body is invalid.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: |
            Idempotency-Key conflict. Two shapes:
              - Prior attempt with this key ended in FAILED — the auto
                REFUND_TOP_UP has already restored the debit. Mint a
                fresh key and retry.
              - Same key was used for a different eSIM.
            `error.details.priorTopUpId` points at the prior attempt.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/partner/v1/credit/balance:
    get:
      summary: Current balance + low-balance threshold
      tags: [Credit]
      responses:
        "200":
          description: Cents-precise balance + threshold metadata.
          content:
            application/json:
              schema:
                type: object
                properties:
                  balanceCents: { type: integer }
                  currency: { type: string }
                  lowThresholdCents: { type: [integer, "null"] }

  /api/partner/v1/credit/entries:
    get:
      summary: Cursor-paginated ledger
      tags: [Credit]
      parameters:
        - name: cursor
          in: query
          schema: { type: string, format: date-time }
        - name: type
          in: query
          schema:
            type: string
            enum: [TOP_UP_STRIPE, TOP_UP_BANK, TOP_UP_CRYPTO, TOP_UP_ADJUSTMENT, DEBIT_ORDER, DEBIT_TOP_UP, REFUND_ORDER]
      responses:
        "200":
          description: 50 entries + nextCursor (null when exhausted).
          content:
            application/json:
              schema:
                type: object
                properties:
                  entries:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        type: { type: string }
                        amountCents:
                          type: integer
                          description: Signed (positive = credit, negative = debit).
                        note: { type: [string, "null"] }
                        orderId: { type: [string, "null"] }
                        createdAt: { type: string, format: date-time }
                  nextCursor: { type: [string, "null"], format: date-time }

  /api/partner/v1/webhooks:
    get:
      summary: List your registered endpoints
      tags: [Webhooks]
      responses:
        "200":
          description: Endpoint metadata (never includes the signing secret).
    post:
      summary: Register a new webhook endpoint
      tags: [Webhooks]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri }
                events:
                  type: array
                  items: { type: string }
                  description: Empty = subscribe to everything.
                label: { type: string, maxLength: 120 }
      responses:
        "201":
          description: Endpoint + freshly-minted signing secret (one-shot reveal).
          content:
            application/json:
              schema:
                type: object
                properties:
                  endpoint: { $ref: "#/components/schemas/WebhookEndpoint" }
                  secret:
                    type: string
                    description: Save now. RoamGo cannot retrieve this again.
        "403":
          description: Insufficient scope (FULL required).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/partner/v1/webhooks/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      summary: Endpoint detail
      tags: [Webhooks]
      responses:
        "200":
          description: Endpoint metadata.
    post:
      summary: Update endpoint (toggle enabled, change events, rotate secret)
      tags: [Webhooks]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                events: { type: array, items: { type: string } }
                label: { type: [string, "null"] }
                enabled: { type: boolean }
                rotateSecret:
                  type: boolean
                  description: |
                    True mints a fresh secret. Old secret stops working
                    immediately; plan for brief mismatch on your end.
      responses:
        "200":
          description: Updated endpoint. Includes `secret` field iff rotated.
    delete:
      summary: Delete endpoint (cascades delivery history)
      tags: [Webhooks]
      responses:
        "200":
          description: '`{ "deleted": true }`'

  /api/partner/v1/webhooks/{id}/deliveries:
    get:
      summary: Delivery history for an endpoint
      tags: [Webhooks]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - name: status
          in: query
          schema:
            type: string
            enum: [PENDING, RETRYING, SUCCEEDED, FAILED, DISABLED]
        - name: cursor
          in: query
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: 50 deliveries + nextCursor.

  /api/partner/v1/webhooks/{id}/deliveries/{deliveryId}/retry:
    post:
      summary: Re-queue a delivery for the next cron tick
      tags: [Webhooks]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: deliveryId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: '`{ delivery: { id, status }, requeued: boolean }`'
        "422":
          description: Delivery is DISABLED — re-enable the endpoint first.
