openapi: 3.1.0
info:
  title: Boursa Embedded Investing API
  version: "2.0.0"
  description: |
    Embedded-investing infrastructure for EGX. The platform operates as an
    FRA-registered Digital Platform Manager: it captures onboarding for the
    partner broker's decision and transmits client orders, end-to-end
    encrypted, to the licensed broker — which opens accounts, executes, and
    holds client cash and securities. The platform provides no investment
    advice, recommendations, or rankings; instruments and funds are served in
    neutral alphabetical order.

    Conventions: all monetary amounts, prices, NAVs, and fund units are integer
    micros (1e-6 EGP / units); share quantities are whole integers. Errors use
    stable machine-readable codes: {"error":{"code","message"},"request_id"}.

    Authentication: every request carries `Authorization: Bearer <api_key>`.
    State-changing requests also require an `Idempotency-Key` header.
    Money-moving requests (orders, fund orders, transfers) additionally require
    HMAC signing: `X-Boursa-Timestamp` (unix seconds, ±300s) and
    `X-Boursa-Signature` = hex(HMAC-SHA256(signing_secret,
    timestamp + "\n" + METHOD + "\n" + PATH + "\n" + idempotency_key + "\n" + body)).
    Binding the idempotency key into the signature blocks replay-with-a-fresh-key.

    Webhooks are signed: `X-Boursa-Signature` =
    hex(HMAC-SHA256(webhook_secret, event_id + "." + timestamp + "." + body));
    deliveries are at-least-once — deduplicate by `X-Boursa-Event-Id`. Call
    `POST /v1/webhooks/test` to send yourself a signed `webhook.test` event.

    Rate limits: responses carry `X-RateLimit-Limit` and `X-RateLimit-Remaining`;
    a `429 RATE_LIMITED` means slow down. List endpoints paginate by `?limit`
    (1–200, default 100) today; cursor pagination is on the roadmap.

    Errors: the envelope is `{"error":{"code","message","errors"?},"request_id"}`
    where `errors[]` carries per-field validation detail when present. Error
    titles are bilingual — send `Accept-Language: ar` for Arabic (ar-EG).
servers:
  - url: https://sandbox.boursa.sh
    description: Sandbox (simulated broker, exchange, depository, rail; controllable clock)
security:
  - bearerAuth: []

paths:
  /v1/kyc/applications:
    post:
      summary: Create a KYC application (onboarding capture)
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [full_name, national_id, date_of_birth, email, phone, address]
              properties:
                full_name: { type: string }
                national_id: { type: string, description: "14-digit Egyptian national ID" }
                date_of_birth: { type: string, format: date }
                email: { type: string }
                phone: { type: string }
                address: { type: string }
      responses:
        "201":
          description: Application captured (state `screened` or `screening_referred`)

  /v1/kyc/applications/{id}/documents/{kind}:
    put:
      summary: Upload an identity document for the application (required before submit)
      description: |
        Raw image bytes (JPEG/PNG/WebP, sniffed, ≤ 512 KiB) sealed under the
        tenant key before storage. With both ID sides on file, `submit` routes the
        application to the broker's document review on the dealer console.
      parameters:
        - { $ref: "#/components/parameters/PathID" }
        - { name: kind, in: path, required: true, schema: { type: string, enum: [national_id_front, national_id_back] } }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      requestBody:
        required: true
        content:
          image/jpeg: { schema: { type: string, format: binary } }
          image/png: { schema: { type: string, format: binary } }
          image/webp: { schema: { type: string, format: binary } }
      responses:
        "200": { description: Stored }
        "422": { description: "IMAGE_TOO_LARGE / IMAGE_FORMAT_UNSUPPORTED / APPLICATION_NOT_EDITABLE" }

  /v1/kyc/applications/{id}/submit:
    post:
      summary: Submit a screened application to the broker for the account-opening decision
      parameters:
        - { $ref: "#/components/parameters/PathID" }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      responses:
        "200":
          description: Broker decision — `approved` (returns investor_id) or `rejected`

  /v1/kyc/applications/{id}:
    get:
      summary: Application status
      parameters: [{ $ref: "#/components/parameters/PathID" }]
      responses: { "200": { description: OK } }

  /v1/accounts:
    get:
      summary: List the tenant's accounts (onboarded investors), newest first
      description: |
        The correspondent-level account directory. An account is the same record
        as an investor, in account shape: id, account_number (the reference at the
        broker of record), status, currency, suitability standing, and the broker
        block. Scoped to the authenticated tenant.
      parameters:
        - { name: status, in: query, description: "Filter by standing", schema: { type: string, enum: [active, restricted, closed] } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 100 } }
      responses:
        "200": { description: "{ accounts: [{ id, account_number, status, currency, name, shariah_only, suitability: { tier, recorded, risk_acknowledged }, broker, created_at }] }" }
  /v1/accounts/{id}:
    get:
      summary: One account — the account-shaped twin of GET /v1/investors/{id}
      parameters: [{ $ref: "#/components/parameters/PathID" }]
      responses:
        "200": { description: "{ id, account_number, status, currency, name, shariah_only, suitability, broker, created_at }" }
        "404": { description: NOT_FOUND (unknown id or another tenant's account) }

  /v1/investors/{id}:
    get:
      summary: Investor mirror account (includes the broker's direct contact channel)
      parameters: [{ $ref: "#/components/parameters/PathID" }]
      responses: { "200": { description: OK } }

  /v1/investors/{id}/suitability:
    put:
      summary: Record the appropriateness questionnaire and risk acknowledgement
      description: Required before the first order. A gate, never advice.
      parameters:
        - { $ref: "#/components/parameters/PathID" }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [questionnaire, risk_acknowledged]
              properties:
                questionnaire: { type: object }
                risk_acknowledged: { type: boolean }
                shariah_only:
                  type: boolean
                  description: Investor-elected screen — blocks NEW purchases of non-compliant instruments/funds (SHARIAH_RESTRICTED); divestment always allowed.
      responses: { "200": { description: Recorded } }

  /v1/investors/{id}/balances:
    get:
      summary: Cash balances (settled / reserved / unsettled, integer micros)
      parameters: [{ $ref: "#/components/parameters/PathID" }]
      responses: { "200": { description: OK } }

  /v1/investors/{id}/positions:
    get:
      summary: Securities and fund-unit positions
      parameters: [{ $ref: "#/components/parameters/PathID" }]
      responses: { "200": { description: OK } }

  /v1/investors/{id}/activities:
    get:
      summary: Immutable ledger activity feed
      parameters: [{ $ref: "#/components/parameters/PathID" }]
      responses: { "200": { description: OK } }

  /v1/transfers:
    post:
      summary: Deposit or withdraw over the payment rail
      description: Signed + idempotent. Rail limits apply (per transaction and per day).
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
        - { $ref: "#/components/parameters/SigTimestamp" }
        - { $ref: "#/components/parameters/Signature" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [investor_id, direction, amount_micros]
              properties:
                investor_id: { type: string }
                direction: { type: string, enum: [deposit, withdrawal] }
                amount_micros: { type: integer, format: int64 }
      responses:
        "201": { description: Transfer pending on the rail }
        "422": { description: "Rejected (e.g. RAIL_LIMIT_EXCEEDED, INSUFFICIENT_SETTLED_CASH)" }
    get:
      summary: List transfers (newest first; durable history)
      parameters:
        - { name: investor_id, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 100, maximum: 200 } }
      responses: { "200": { description: "{ transfers: [...] }" } }

  /v1/transfers/{id}:
    get:
      summary: Transfer status
      parameters: [{ $ref: "#/components/parameters/PathID" }]
      responses: { "200": { description: OK } }

  /v1/orders:
    post:
      summary: Place an equity order (transmitted encrypted to the broker, which executes)
      description: |
        Signed + idempotent. Pre-transmission technical validation: session,
        instrument status, tick, ±20% price band, settled-cash/securities
        reservation. Market buys reserve at the band ceiling plus maximum fees.
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
        - { $ref: "#/components/parameters/SigTimestamp" }
        - { $ref: "#/components/parameters/Signature" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [investor_id, symbol, side, type, qty]
              properties:
                investor_id: { type: string }
                symbol: { type: string }
                side: { type: string, enum: [buy, sell] }
                type: { type: string, enum: [market, limit] }
                qty: { type: integer, format: int64 }
                limit_price_micros: { type: integer, format: int64 }
                time_in_force:
                  type: string
                  enum: [day, gtc, ioc, fok]
                  default: day
                  description: "day expires at session close; gtc never self-expires (rests across sessions); ioc/fok never rest."
                client_order_id:
                  type: string
                  description: "Tenant-unique dedup key; reuse is rejected with CLIENT_ORDER_ID_EXISTS."
      responses:
        "200": { description: Order accepted (acked) — track via webhooks }
        "422": { description: "Rejected (TICK_VIOLATION, PRICE_BAND_EXCEEDED, MARKET_CLOSED, SUITABILITY_REQUIRED, INVALID_TIME_IN_FORCE, CLIENT_ORDER_ID_EXISTS, ...)" }

  /v1/orders/{id}:
    get:
      summary: Order status
      parameters: [{ $ref: "#/components/parameters/PathID" }]
      responses: { "200": { description: OK } }
    delete:
      summary: Cancel an open order (releases the unspent reservation)
      parameters:
        - { $ref: "#/components/parameters/PathID" }
        - { $ref: "#/components/parameters/IdempotencyKey" }
        - { $ref: "#/components/parameters/SigTimestamp" }
        - { $ref: "#/components/parameters/Signature" }
      responses: { "200": { description: Cancelled (or already terminal) } }
  /v1/orders/{id}/contract-note:
    get:
      summary: Contract note (trade confirmation) — derived from the order's executions
      parameters: [{ $ref: "#/components/parameters/PathID" }]
      responses:
        "200": { description: "{ symbol, side, trade_date, settlement_date, filled_qty, avg_price_micros, gross_micros, commission_micros, statutory_fees_micros, withholding_tax_micros, net_consideration_micros, fills }" }
        "422": { description: NO_EXECUTIONS — order has not (partially) filled }

  /v1/fund-orders:
    post:
      summary: Subscribe to or redeem a fund (the savings rail; broker as receiving entity)
      description: Signed + idempotent. Processes at the fund's next NAV cutoff; settles on its cycle.
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
        - { $ref: "#/components/parameters/SigTimestamp" }
        - { $ref: "#/components/parameters/Signature" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [investor_id, fund_code, side]
              properties:
                investor_id: { type: string }
                fund_code: { type: string }
                side: { type: string, enum: [subscribe, redeem] }
                amount_micros: { type: integer, format: int64, description: "subscribe" }
                units_micros: { type: integer, format: int64, description: "redeem" }
      responses:
        "200": { description: Accepted — processes at the next NAV cutoff }
        "422": { description: Rejected }
    get:
      summary: List fund orders (newest first; durable history)
      parameters:
        - { name: investor_id, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 100, maximum: 200 } }
      responses: { "200": { description: "{ fund_orders: [...] }" } }

  /v1/fund-orders/{id}:
    get:
      summary: Fund order status
      parameters: [{ $ref: "#/components/parameters/PathID" }]
      responses: { "200": { description: OK } }

  /v1/instruments:
    get:
      summary: Tradable instruments (neutral alphabetical order)
      parameters:
        - { name: shariah, in: query, schema: { type: boolean }, description: "true → only screened-compliant instruments (provider tag, not advice)" }
      responses: { "200": { description: OK } }

  /v1/instruments/{symbol}/quote:
    get:
      summary: Reference price, band, tick, and session state
      parameters:
        - name: symbol
          in: path
          required: true
          schema: { type: string }
      responses: { "200": { description: OK } }

  /v1/funds:
    get:
      summary: Available funds with current NAV (neutral alphabetical order)
      parameters:
        - { name: shariah, in: query, schema: { type: boolean }, description: "true → only screened-compliant funds (provider tag, not advice)" }
      responses: { "200": { description: OK } }

  /v1/markets:
    get:
      summary: Markets and their session state on the platform clock
      responses: { "200": { description: "{ markets: [{ market_id, currency, session_open, trading_day }], now }" } }
  /v1/clock:
    get:
      summary: Market clock — whether the EGX session is open now
      responses: { "200": { description: "{ timestamp, is_open, next_open, next_close }" } }
  /v1/calendar:
    get:
      summary: Trading days with session times and settlement date (EGX Sun–Thu 10:00–14:30, T+2)
      parameters:
        - { name: start, in: query, schema: { type: string, format: date } }
        - { name: end, in: query, schema: { type: string, format: date } }
      responses: { "200": { description: "{ calendar: [{ date, open, close, session_open, session_close, settlement_date }] }" } }
  /v1/markets/{market_id}/instruments/{symbol}/ohlcv:
    get:
      summary: Daily OHLCV candles (chronological)
      parameters:
        - { name: market_id, in: path, required: true, schema: { type: string } }
        - { name: symbol, in: path, required: true, schema: { type: string } }
        - { name: days, in: query, schema: { type: integer, default: 90, maximum: 365 } }
      responses: { "200": { description: "{ market_id, symbol, status, days, ordering, candles: [{ date, open_micros, high_micros, low_micros, close_micros, volume }] }" } }
  /v1/markets/{market_id}/instruments/{symbol}/quote:
    get:
      summary: Live quote (intraday tape + previous close)
      parameters:
        - { name: market_id, in: path, required: true, schema: { type: string } }
        - { name: symbol, in: path, required: true, schema: { type: string } }
      responses: { "200": { description: "{ price_micros, prev_close_micros, change_pct, as_of, session_open }" } }

  /v1/investors/{id}/portfolio:
    get:
      summary: Portfolio valuation, cash buckets, positions, allocation (descriptive — R1)
      parameters: [{ $ref: "#/components/parameters/PathID" }]
      responses: { "200": { description: "{ as_of, total_micros, cash, positions, allocation_pct, unrealized_micros }" } }
  /v1/investors/{id}/portfolio/exposures:
    get:
      summary: Sector / asset-class exposures and concentration (HHI)
      parameters: [{ $ref: "#/components/parameters/PathID" }]
      responses: { "200": { description: OK } }
  /v1/investors/{id}/portfolio/performance:
    get:
      summary: Time-weighted return from daily snapshots
      parameters:
        - { $ref: "#/components/parameters/PathID" }
        - { name: days, in: query, schema: { type: integer, default: 90 } }
      responses: { "200": { description: "{ series: [{ date, total_micros, net_flow_micros }], twr_pct, mwr_annualized_pct }. twr_pct is the true (geometric) time-weighted return; mwr_annualized_pct is the money-weighted (annualized XIRR) return and is omitted when it cannot be solved. Both are account-describing (FRA-332 safe)." } }
  /v1/investors/{id}/portfolio/risk:
    get:
      summary: Descriptive risk statistics (volatility, max drawdown, VaR95, beta) — never advice
      parameters:
        - { $ref: "#/components/parameters/PathID" }
        - { name: days, in: query, schema: { type: integer, default: 60 } }
      responses: { "200": { description: OK } }
  /v1/investors/{id}/statement:
    get:
      summary: Periodic account statement (cash flows, trade + fund activity, fees, closing cash)
      parameters:
        - { $ref: "#/components/parameters/PathID" }
        - { name: days, in: query, schema: { type: integer, default: 90, maximum: 3650 } }
      responses: { "200": { description: "{ period_days, cash, trading, funds, closing_cash }" } }

  /v1/fees:
    get:
      summary: The executing broker's published commission and statutory fees
      responses: { "200": { description: OK } }

  /v1/disclosures:
    get:
      summary: Role and risk disclosures (English and Arabic)
      responses: { "200": { description: OK } }

  /v1/reports:
    post:
      summary: Request a generated PDF report (async; e.g. an investor statement)
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kind, investor_id]
              properties:
                kind: { type: string, description: "e.g. investor_statement" }
                investor_id: { type: string }
                period_days: { type: integer }
      responses: { "202": { description: "Accepted; poll the report id for status" } }
    get:
      summary: List generated reports
      responses: { "200": { description: OK } }
  /v1/reports/batch:
    post:
      summary: Batch-enqueue statements for many investors (the period-end run)
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type, period_start, period_end]
              properties:
                investor_ids: { type: array, items: { type: string }, description: "explicit list (or set all_active)" }
                all_active: { type: boolean, description: "generate for every active investor of the tenant" }
                type: { type: string, enum: [client_statement] }
                period_start: { type: string, description: "YYYY-MM-DD inclusive" }
                period_end: { type: string, description: "YYYY-MM-DD inclusive" }
                sections: { type: array, items: { type: string } }
                locale: { type: string, enum: [en, ar] }
      responses: { "202": { description: "{ queued_report_ids: [...], skipped: [{ investor_id, reason }] } — a per-investor rejection is a skip, not a batch failure" } }
  /v1/reports/{id}:
    get:
      summary: Report status (pending → ready → failed)
      parameters: [{ $ref: "#/components/parameters/PathID" }]
      responses: { "200": { description: OK } }
  /v1/reports/{id}/download:
    get:
      summary: Download the rendered PDF (decrypted on the fly; the download is audited)
      parameters: [{ $ref: "#/components/parameters/PathID" }]
      responses:
        "200": { description: "application/pdf" }
        "409": { description: "Report not ready" }

  /v1/branding:
    get:
      summary: The tenant's document theme (applied to generated statements/reports)
      responses: { "200": { description: OK } }
    put:
      summary: Set the tenant's document theme
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, description: "theme tokens (colors, display name, etc.)" }
      responses: { "200": { description: Saved } }
  /v1/branding/logo:
    get:
      summary: The tenant's branding logo bytes
      responses: { "200": { description: "image/*" } }
    put:
      summary: Upload the tenant's branding logo (PNG/JPEG/WebP/SVG, sealed at rest)
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          image/png: { schema: { type: string, format: binary } }
          image/jpeg: { schema: { type: string, format: binary } }
          image/svg+xml: { schema: { type: string, format: binary } }
      responses: { "200": { description: Stored } }

  /v1/webhooks:
    post:
      summary: Set the tenant webhook endpoint (returns the signing secret once)
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri }
      responses: { "200": { description: OK } }
  /v1/webhooks/test:
    post:
      summary: Send a signed webhook.test event to the configured endpoint (verify your receiver)
      responses:
        "202": { description: "{ delivery_id, event_type: webhook.test, status: queued } — delivered like a real event, X-Boursa-Signature included" }
        "422": { description: "WEBHOOK_NOT_CONFIGURED — set a webhook URL first" }

  /v1/complaints:
    post:
      summary: File a complaint into the electronic complaints log
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subject, body]
              properties:
                investor_id: { type: string }
                subject: { type: string }
                body: { type: string }
      responses: { "201": { description: Logged } }

  /v1/webhook-deliveries:
    get:
      summary: Recent webhook delivery attempts and their status
      responses: { "200": { description: OK } }
  /v1/webhook-deliveries/{id}/replay:
    post:
      summary: Re-queue a failed delivery (after your endpoint recovers)
      parameters: [{ $ref: "#/components/parameters/PathID" }]
      responses:
        "202": { description: "{ delivery_id, status: pending }" }
        "404": { description: "no failed delivery with that id" }

  /v1/dashboard/summary:
    get:
      summary: Tenant-level KPIs (investors, AUM, orders, open orders, fund orders, pending webhooks)
      responses: { "200": { description: OK } }
  /v1/dashboard/metering:
    get:
      summary: Billable metering events for the tenant
      responses: { "200": { description: OK } }
  /v1/dashboard/investors:
    get:
      summary: Tenant's investors with status and headline figures
      responses: { "200": { description: OK } }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: Tenant API key
  parameters:
    PathID:
      name: id
      in: path
      required: true
      schema: { type: string }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema: { type: string, maxLength: 128 }
      description: Required on all state-changing requests; replays return the original response.
    SigTimestamp:
      name: X-Boursa-Timestamp
      in: header
      required: true
      schema: { type: string }
      description: Unix seconds; must be within ±300s.
    Signature:
      name: X-Boursa-Signature
      in: header
      required: true
      schema: { type: string }
      description: hex(HMAC-SHA256(signing_secret, ts + "\n" + METHOD + "\n" + PATH + "\n" + idempotency_key + "\n" + body)). The idempotency_key is the exact value of the Idempotency-Key header; omitting it from the message yields SIGNATURE_INVALID.
