openapi: 3.1.0
info:
  title: Datastream API
  version: 1.0.0
  description: |
    The single versioned path into the Datastream data platform.

    Datastream still flows one direction (MBO → mirror → enriched → consumers):
    reads come from the mirror, and the write endpoints are proxied to Mindbody
    rather than written to Datastream. Every request is scoped to the sites
    bound to its API key; a request can never widen its own scope.

    Covers the schedule and operational endpoints, client reads (profile,
    memberships, passes/services, credits, transactions, enrollments),
    enriched sales, and the sellable catalog (packages, contracts, promo codes).

    **Profile composition.** `GET /v1/clients/{client_id}/profile` returns
    identity plus contracts, services, a bounded enrollment window, and
    recent sale headers in one request, plus unbounded `visits_total`,
    `last_visit`, and `spend_total`. The per-resource reads remain for
    callers that need a different window, paging, credits, transactions,
    or sale items.

    ## Data freshness

    Freshness is the mirror's, not MBO's, and it is **per-site, not uniform** —
    every site is tagged `realtime` or `daily`/`paused` in
    `ds_config.site_configuration`, and the tag decides which of the two
    mechanisms below actually updates it. As of 2026-08-05:

    - **`realtime` sites (Flow, Flow Yoga Georgetown today)** — a webhook
      consumer (`itflow_datastream`) drains Mindbody's webhook stream and
      re-fetches only the entity a webhook actually touched, after a short hold
      window per family (roster bookings 60s, client/membership changes 300s,
      sales/contracts 900s). Typical event-to-mirror lag is **1–3 minutes**.
      This is **event-driven, not polled** — a class or client with no new
      webhook event since the last scheduled pass still shows that pass's
      data, however old, until something actually changes on it. There is no
      way to tell from a response alone whether a given row is
      webhook-fresh or pass-stale; assume the latter unless you know the site
      is `realtime` and the entity was recently touched.
    - **`daily`/`paused` sites (every other site today)** — the scheduled pass
      is the only mechanism: three fetches a day (08:00 / 18:00 / 23:00 UTC),
      so a change is invisible to read endpoints for **up to 10 hours** — the
      widest gap is 08:00→18:00.

    Visits also have a 7-day forward horizon regardless of tier. The sync
    pulls a future class's roster only when it starts within 7 days and
    already has bookings (`itflow_datastream` ClassFamily
    `VISIT_FUTURE_WINDOW_DAYS`). So a roster two weeks out is empty because it
    was never pulled, not because nobody booked — while `total_booked` on the
    class itself stays correct at any horizon, since it comes from the class
    record rather than the visit pull.

    **Writes made through this API are a third path, separate from both.** A
    `POST`/`PATCH`/`DELETE` write endpoint (bookings, purchases, contract
    purchases, teacher assignment) is proxied straight to Mindbody and its own
    response is live immediately — Mindbody, not the mirror, answered it. For
    up to 24 hours afterward, `GET /v1/schedule/{class_id}/roster` and the
    sales endpoints layer a **read-after-write overlay**
    (`ds_api.booking_write` / `ds_api.purchase_write`) on top of the mirror
    read, so the write shows up in those reads immediately too, without
    waiting on either the scheduled pass or a webhook. **The one write not yet
    covered by an overlay is check-in** (`POST
    .../roster/{visit_id}/check-in`): its own response is live, but a roster
    read right after still shows the mirror's stale `signed_in` for anyone but
    the caller who just made the change — see that endpoint's own note.

    **`GET /v1/reports/*`** (except `mbo-usage`) read from `ds_enriched`, a
    separate incremental transform run outside this repo
    (`CALL ds_enriched.run_all_sites(0)`) on top of the mirror above — so a
    report inherits the mirror's own freshness plus however far behind that
    transform is, which this repo does not track. If a report looks stale
    while the raw mirror does not, the enriched transform is where to look,
    not here.

    Fields marked *not yet populated* resolve to `null` because the underlying
    value is absent from the MBO mirror — the legacy API sourced them from the
    warehouse. See `docs/VERIFY.md` in the repo.
servers:
  - url: https://datastream.fvmgt.com/
    description: |
      Test deployment. The product domain is still an open item (PLAN.md §9) —
      this host is temporary and not meant for external consumers.

security:
  - bearerAuth: []

tags:
  - name: schedule
    description: Classes, rosters, and the rooms they run in.
  - name: staff
    description: Teachers and other staff.
  - name: locations
    description: Studios and their bookable rooms.
  - name: events
    description: Enrollments — events, trainings and retreats.
  - name: packages
    description: What is for sale — packages, contracts, promo codes.
  - name: clients
    description: One client's profile, bookings, passes, contracts, transactions.
  - name: sales
    description: Completed sales from the enriched layer, with items and payments.
  - name: config
    description: The tenant registry.
  - name: reports
    description: >
      Dashboard-shaped aggregate rollups replacing the legacy dw_flow
      ai_*_snapshot_v2 tables. Hand-written GROUP BY queries, not schema-map
      resources — see src/routes/v1/reports.ts.
  - name: identity
    description: >
      Which Mindbody staff user a key's writes are performed as — the "Sold By"
      on every sale it makes.
  - name: auth
    description: >
      Browser-facing Auth0 login — the legacy PHP site's Auth0Flow ported to
      this API. Auth0 (custom domain per AUTH0_CUSTOM_DOMAIN) verifies
      identity; the business guards and the first-party session
      (`flow_ds_session`, 400-day rolling expiry) are this service's. No
      bearer key on any of these; CORS with credentials for the console
      origin. Endpoints that end a browser flow 302 to the flow's `return_to`
      with `?outcome=<code>`. **The complete outcome vocabulary** — each
      endpoint's description says which subset it emits:


      `logged_in` (session cookie set on the redirect) ·
      `bad_connection` (connection outside the allowlist) ·
      `no_email` (profile carries no email, e.g. Facebook without the email
      permission) · `email_mismatch` (authenticated email differs from the
      flow's checkout_email) · `signup_required` (verified identity, no Flow
      client; carries `signup_token=` when the flow declared a site) ·
      `passwordless_blocked` (email connection, no Flow client, no site
      context) · `amr_blocked` (passkey/MFA DB login with no Flow client —
      dead end) · `pending_verification` (DB user, email not yet verified) ·
      `auth0_error` (Auth0 reported `?error=`; `detail=` carries its
      description) · `exchange_failed` (code exchange or token verification
      failed) · `magic_link_invalid` (magic-verify: missing parameters or a
      wrong/expired/used code) · `linked` (link round succeeded;
      `provider=`, `email=`) · `link_email_mismatch` (link round: the
      provider identity's email differs from the session's; nothing linked) ·
      `link_failed` (link round: the Management link call failed;
      `provider=`). `email=` rides along whenever one is known.
  - name: ops
    description: Health and the spec itself.

paths:
  /status:
    get:
      tags: [ops]
      summary: Health check
      description: |
        Unauthenticated. Runs a single trivial query against the mirror to
        confirm the pool can reach the database — not a check of sync
        freshness or any individual site. A 200 means "the API can talk to
        Postgres," nothing more.
      security: []
      responses:
        '200':
          description: Service healthy.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusResponse' }
        '503':
          description: Database unreachable.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusResponse' }

  /status/schema-assumptions:
    get:
      tags: [ops]
      summary: Fields resting on unverified JSON paths
      description: |
        The reconciliation checklist. Each entry is a mapped field whose
        `_document` path was not observed in the schema dump.
      security: []
      responses:
        '200':
          description: The current assumption list.

  /v1/docs:
    get:
      tags: [ops]
      summary: This document
      description: |
        The live spec, generated from this same file — served as YAML so it
        stays diffable and matches what's committed to the repo. api-fvmgt's
        combined API docs page (/) fetches this alongside api-funnel's /v1/docs
        and renders both as one operation list.
      security: []
      responses:
        '200':
          description: The OpenAPI spec as YAML.
          content:
            text/yaml:
              schema: { type: string }

  /v1/stream:
    get:
      tags: [ops]
      summary: Live change stream (SSE)
      description: |
        Server-Sent Events, not a normal request/response call — the
        connection stays open and pushes an event each time something this
        key can read changes (bookings, check-ins, roster updates). Read
        scope only: subscribing tells a caller what changed, it never lets
        them change anything, so a `raw:read`-only check-in screen and a
        `booking:write` widget can both listen on the same key.

        Long-lived by design, so none of the usual response machinery
        applies here — no envelope, no ETag, no pagination, no Cache-Control.
        The rate limiter in `authenticate` still runs once at connect time,
        which is what stops one caller from opening hundreds of these.
      parameters:
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: |
            `text/event-stream`. Never closes on its own; the client (or a
            proxy timeout) ends the connection.

  /v1/schedule:
    get:
      tags: [schedule]
      summary: Class schedule
      description: |
        Backed by `ds_mbo.class`. Defaults to a single day (today) — an unbounded
        default over 196k rows is not useful. Use either `date`+`days` (the legacy
        pair) or `start_date`+`end_date`, not both.
      parameters:
        - $ref: '#/components/parameters/site_id'
        - name: date
          in: query
          description: Window start. Defaults to today (UTC).
          schema: { type: string, format: date }
        - name: days
          in: query
          description: Window length in days from `date`.
          schema: { type: integer, minimum: 1, maximum: 90, default: 1 }
        - name: start_date
          in: query
          schema: { type: string, format: date }
        - name: end_date
          in: query
          description: Inclusive.
          schema: { type: string, format: date }
        - name: location_id
          in: query
          schema: { type: string }
        - name: location
          in: query
          description: |
            Name-based sibling of `location_id`, for a caller that only has
            the studio's display name on hand. Exact match against
            `location_name`.
          schema: { type: string, example: West Gate }
        - name: staff_id
          in: query
          schema: { type: string }
        - name: teacher_id
          in: query
          description: Deprecated alias for `staff_id`, retained from the legacy API.
          deprecated: true
          schema: { type: string }
        - name: teacher
          in: query
          description: |
            Name-based sibling of `staff_id`/`teacher_id`, for a caller that
            only has the teacher's display name on hand. Exact match against
            `staff_name`.
          schema: { type: string, example: Ronli Sokol }
        - name: category
          in: query
          schema: { type: string }
        - name: class_type
          in: query
          description: |
            Matches the class description's `sessionType`. Common values
            include `Class`, `Workshop`, `Event`, `Community`, `Retreat`,
            `Teacher Trainings` — see `session_type` on `/v1/events` for the
            full set actually in use at a site.
          schema: { type: string, example: Workshop }
        - name: class_schedule_id
          in: query
          description: |
            The recurring schedule an instance belongs to — **and the join back
            to `/v1/events`.** An event's `event_id` and a class instance's
            `class_schedule_id` are the same id, so this answers "which bookable
            class is this event", which is the only route from an event to a
            `class_id`.


            **The date window does not apply when this is set.** A caller
            holding an `event_id` does not know when its occurrences fall, which
            is the reason it is asking — so requiring dates alongside it would
            defeat the lookup. Explicit `start_date`/`end_date` (or
            `date`/`days`) still apply if you pass them, and are worth passing
            for a long-running weekly schedule, which can have years of
            instances and would otherwise return its oldest page first.
          schema: { type: string, example: '11762' }
        - name: class_schedule_ids
          in: query
          description: |
            Comma-separated batch form of `class_schedule_id`, so an events
            listing resolves every bookable class id in one request instead of
            one per event. Maximum 100. Mutually exclusive with
            `class_schedule_id` — passing both is a 400 rather than a silent
            preference.
          schema: { type: string, example: '11762,10489,9517' }
        - name: include_cancelled
          in: query
          description: |
            Cancelled classes are excluded by default — MBO records a
            discontinued recurring schedule the same way as a one-off
            cancellation, so unfiltered results include classes the studio no
            longer offers. Pass `true` to include them.
          schema: { type: boolean, default: false }
        - $ref: '#/components/parameters/modified_since'
        - $ref: '#/components/parameters/limit_batch'
        - $ref: '#/components/parameters/offset'
        - $ref: '#/components/parameters/include_inactive'
      responses:
        '200':
          description: A page of classes.
          headers:
            ETag: { $ref: '#/components/headers/ETag' }
            Last-Modified: { $ref: '#/components/headers/LastModified' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/ClassSummary' }
        '304': { $ref: '#/components/responses/NotModified' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/schedule/{class_id}:
    get:
      tags: [schedule]
      summary: One class
      description: |
        A single scheduled class instance by its MBO `class_id`, joined the
        same way `/v1/schedule` is (room, staff, description). `class_id`s
        are per-site sequential and collide across tenants — this key's
        `SiteScope` is what keeps a lookup from crossing into another studio.
      parameters:
        - name: class_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: The class.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/ClassSummary' }
        '304': { $ref: '#/components/responses/NotModified' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/class-descriptions/{class_schedule_id}:
    get:
      tags: [schedule]
      summary: One class type's description
      description: |
        The class name, category, and full HTML description for a class
        *type* (e.g. "Flow"), keyed by `class_schedule_id` — the same field
        every `/v1/schedule` row carries. Split out of `/v1/schedule` because
        the description text repeats verbatim across every instance of a
        class type and was ~52% of that endpoint's payload by weight; this
        route is meant to be fetched on demand (e.g. when a user expands a
        class for details) and cached long by the client, not called once per
        schedule row.
      parameters:
        - name: class_schedule_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: The class type's description.
          headers:
            ETag: { $ref: '#/components/headers/ETag' }
            Cache-Control:
              schema: { type: string, example: 'private, max-age=3600' }
              description: >
                Static reference content — cached far longer than
                `/v1/schedule`'s 60s so repeat lookups of the same
                `class_schedule_id` are served from the browser's HTTP cache.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/ClassDescription' }
        '304': { $ref: '#/components/responses/NotModified' }
        '404': { $ref: '#/components/responses/NotFound' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/schedule/class:
    get:
      tags: [schedule]
      summary: One class (legacy query-param form)
      deprecated: true
      description: |
        Identical response to `/v1/schedule/{class_id}` — same lookup, same
        join, same site scoping — just `class_id` as a query param instead of
        a path segment. Retained so legacy consumers repoint without a path
        change; new integrations should use the path form.
      parameters:
        - name: class_id
          in: query
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: The class.
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/schedule/{class_id}/roster:
    get:
      tags: [schedule]
      summary: Class roster
      description: |
        Backed by `ds_mbo.class_visit`, joined to `ds_mbo.client` on
        `(client_id, site_id)` — never on id alone, since MBO ids collide across
        sites.

        Freshness caveat: as of 2026-08-05, `realtime`-tier sites (currently Flow and
        Flow Yoga Georgetown) get roster changes pushed via webhook — a booking,
        cancellation, or membership change typically lands in the mirror within
        1–3 minutes. That is **event-driven, not polled**: a class with no new
        activity since the switch still reflects whatever the last scheduled pass
        (08:00/18:00/23:00 UTC) wrote, which can be hours old even for a class
        starting soon. `daily`/`paused` sites still rely solely on the scheduled
        passes. Confirm a class has a recent `sync_dirty_entity` fetch before
        trusting it as live.
      parameters:
        - name: class_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
        - $ref: '#/components/parameters/modified_since'
        - $ref: '#/components/parameters/limit_batch'
        - $ref: '#/components/parameters/offset'
      responses:
        '200':
          description: A page of roster entries.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/RosterEntry' }
        '304': { $ref: '#/components/responses/NotModified' }

  /v1/schedule/{class_id}/services:
    get:
      tags: [schedule]
      summary: Pricing options that can pay for one class
      description: |
        What a client could buy in order to be booked into this specific class.
        Requires `raw:read`.

        **This is a live Mindbody read, not a mirror read** — the only GET in
        this API that is. `ds_mbo.service` holds the catalog, but *which*
        services a particular class accepts is a relationship Mindbody keeps
        and the mirror does not carry, on either layer.

        Its reason for existing is the `payment_required` refusal from
        `POST /v1/schedule/{class_id}/bookings`. Without this, a consumer
        facing that refusal can only offer the studio's whole price list — and
        some classes (free community classes, intro offers, comps) have an
        option that costs nothing, which Mindbody still requires be claimed
        before it will book. `is_free` is that answer, per option.

        The class is resolved inside the key's site scope first, so a class the
        key cannot read is a `404` and never reaches Mindbody.

        By default only options the studio sells online are returned — an
        option a studio will not sell online is not one a kiosk may sell.
        `sell_online=false` opts a staff-side caller out of that filter.

        Not cached server-side, and Mindbody meters calls: fetch this when a
        booking has actually been refused, not on every schedule render. The
        response carries `Cache-Control: private, max-age=300`.
      parameters:
        - name: class_id
          in: path
          required: true
          schema: { type: string }
        - name: free_only
          in: query
          description: Return only options priced at zero.
          schema: { type: string, enum: ['true', 'false'] }
        - name: sell_online
          in: query
          description: >-
            Defaults to `true` — only options flagged sellable online. Pass
            `false` to include the rest.
          schema: { type: string, enum: ['true', 'false'] }
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: The options that can pay for this class.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/ClassService' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/schedule/{class_id}/bookings:
    post:
      tags: [schedule]
      summary: Book a client into a class
      description: |
        Adds a client to a class. Requires the `booking:write` scope, and is
        served only where the deployment has writes enabled.

        **This does not write to Datastream.** The booking is proxied to
        Mindbody, which is the system of record; `ds_mbo.class_visit` picks it
        up on the next sync, so a roster read straight after a booking will not
        show it yet. Datastream still flows one direction.

        The class is resolved inside the key's site scope first — a class the
        key cannot read is a `404`, not a `403`, so the endpoint never confirms
        a class it will not serve. The Mindbody site and staff login used are
        the ones registered for that class's site.

        Business rules are Mindbody's: capacity, eligibility, late-booking
        windows and payment are its call, and its own message comes back
        verbatim on a `422`. The single precondition checked here is a
        cancelled class, which is a `409`.

        **One thing is checked after the fact: that the visit was paid for.**
        `RequirePayment: true` is not a guarantee — Mindbody will sometimes
        accept the booking and return a visit with no `service_id`, which puts
        someone on the roster for free. When that happens on a class that sells
        only paid options, the visit is removed again (scoped to that visit, so
        an existing booking survives) and the call returns `422
        payment_required`. A class offering a $0 option, or nothing at all, is
        treated as free and the visit stands. `require_payment: false` opts out
        of the whole check. If the rollback itself fails, the response is a
        `503` naming the visit that needs removing by hand.

        Send `test: true` to have Mindbody validate the booking without
        creating one. The response echoes `test` — a consumer that ignores it
        will read a dry run as a real booking. A dry run returns `visit_id: 0`,
        since Mindbody answers with a placeholder visit rather than none.

        **Mindbody does not dedupe.** The same client booked into the same
        class twice gets two distinct visits — verified in production, and the
        mirror cannot be used to pre-check because it syncs three times a day.
        Send an `Idempotency-Key` and a repeat replays the first response
        instead of booking again. Without one, a retry or a double-click
        creates a duplicate.
      parameters:
        - name: class_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/idempotency_key'
        - $ref: '#/components/parameters/site_id'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BookingRequest' }
      responses:
        '201':
          description: The booking, as Mindbody created it.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Booking' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

    delete:
      tags: [schedule]
      summary: Remove a client from a class
      description: |
        Cancels a client's place. Requires `booking:write`.

        Scoped to the class, not a visit id, because Mindbody's
        `removeclientfromclass` takes `(client_id, class_id)` and has no
        per-visit form. One call clears **every** visit that client holds in the
        class, so it is the cleanup for the duplicates `POST` can create.

        Not a no-op when there is nothing to remove: a second call returns
        `422` with "No class visit found…". Useful as a way to confirm a client
        is clear.

        `late_cancel` defaults off, so the forgiving outcome — credit returned,
        no penalty — is the one you get by accident.
      parameters:
        - name: class_id
          in: path
          required: true
          schema: { type: string }
        - name: client_id
          in: query
          required: true
          schema: { type: string }
        - name: late_cancel
          in: query
          description: Apply the studio's late-cancel penalty instead of returning the credit.
          schema: { type: boolean, default: false }
        - name: send_email
          in: query
          schema: { type: boolean, default: false }
        - name: test
          in: query
          description: Validate against Mindbody without removing anything.
          schema: { type: boolean, default: false }
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: The client was removed.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          class_id: { type: integer }
                          client_id: { type: string }
                          class_name: { type: [string, 'null'] }
                          start_time: { type: [string, 'null'] }
                          site_id: { type: string }
                          removed: { type: boolean }
                          test: { type: boolean }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/schedule/{class_id}/roster/{visit_id}/check-in:
    post:
      tags: [schedule]
      summary: Mark a booked client signed in (or undo it)
      description: |
        Flips `signed_in` on a visit the client already holds. Requires the
        `checkin:write` scope — deliberately separate from `booking:write`,
        since this cannot add, remove, or move anyone's booking, only mark
        arrival on a booking that already exists.

        `visit_id` is the roster row's `booking_id`, from a prior `GET
        /v1/schedule/{class_id}/roster`. It is not independently re-verified
        against the class — the same trust model `DELETE .../bookings` already
        uses for a caller-supplied `client_id` — but the Mindbody session used
        is scoped to the class's site, so a visit id from another site fails
        there, not here.

        **Known gap:** unlike book/cancel, this write is not yet reflected by
        the read-after-write overlay. A roster read immediately after still
        shows the mirror's stale `signed_in` for anyone but the caller, who
        should trust this response instead.
      parameters:
        - name: class_id
          in: path
          required: true
          schema: { type: string }
        - name: visit_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/idempotency_key'
        - $ref: '#/components/parameters/site_id'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CheckInRequest' }
      responses:
        '200':
          description: Mindbody's updated visit.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/CheckIn' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/schedule/{class_id}/teacher:
    patch:
      tags: [schedule]
      summary: Assign a teacher or substitute to one class instance
      description: |
        Sets the `staff_id` on a single scheduled class instance via
        Mindbody's `updateclass`. Requires the `schedule:write` scope, and is
        served only where the deployment has writes enabled.

        This is the "sub" verb: Mindbody itself decides whether the result
        reads as a substitute by comparing the instance's staff to its
        recurring schedule's own default — this endpoint does not set that
        flag directly. To change the schedule's *permanent* teacher instead
        (the default every future-generated instance inherits), use `PATCH
        /v1/schedule-definitions/{class_schedule_id}/teacher`.

        The class is resolved inside the key's site scope first, same as
        `POST .../bookings` — a class the key cannot read is a `404`. A
        cancelled class is a `409`.

        **Does not write to Datastream and is not yet reflected by the
        read-after-write overlay** — a schedule read immediately after still
        shows the mirror's stale teacher until the next sync.
      parameters:
        - name: class_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/idempotency_key'
        - $ref: '#/components/parameters/site_id'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AssignTeacherRequest' }
      responses:
        '200':
          description: Mindbody's updated class.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/ClassInstance' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/schedule/{class_id}/substitute:
    post:
      tags: [schedule]
      summary: Assign a substitute through Mindbody's substitution verb
      description: |
        Assigns a substitute teacher to one class instance via Mindbody's
        `substituteclassteacher`. Requires the `schedule:write` scope, and is
        served only where the deployment has writes enabled.

        **Not the same call as `PATCH /v1/schedule/{class_id}/teacher`**, and the
        difference matters. That route uses `updateclass` and simply sets
        `staff_id`. This one uses Mindbody's substitution verb, which also takes
        `override_conflicts` — and with the override off (the default here)
        Mindbody **refuses a substitute who already has something booked in that
        slot**. `updateclass` does not ask that question at all. For an automated
        substitute flow that check is the point: it is the only thing between
        "the first willing teacher said yes" and a double-booked teacher, and a
        double-booking surfaces as two rooms expecting the same person.

        Use the `teacher` route for a studio admin setting who is teaching. Use
        this one when a substitute is being placed and the conflict check should
        stand.

        All three e-mail flags default to `false`. A substitution that e-mails a
        studio's whole booked roster is not something to inherit by forgetting a
        field.

        The class is resolved inside the key's site scope first — a class the key
        cannot read is a `404`, and a cancelled class is a `409`.

        **Does not write to Datastream.** The mirror picks the change up on its
        next sync, so a schedule read straight afterwards still shows the old
        teacher.
      parameters:
        - name: class_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/idempotency_key'
        - $ref: '#/components/parameters/site_id'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SubstituteTeacherRequest' }
      responses:
        '200':
          description: The substitution as carried out.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Substitution' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422':
          description: >-
            Mindbody rejected the substitution. With `override_conflicts` false
            this is what a booking conflict looks like — the substitute is not
            free at that hour.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/schedule/{class_id}/cancel:
    post:
      tags: [schedule]
      summary: Cancel one class occurrence
      description: |
        Cancels a single scheduled class via Mindbody's `cancelsingleclass`.
        Requires the `schedule:write` scope, and is served only where the
        deployment has writes enabled.

        **`send_client_email` has no default and must be stated.** `true` makes
        Mindbody e-mail every student booked into the class. For the flow this
        was built for, that e-mail *is* the student notification — the caller
        holds no student phone numbers and no SMS consent, so nothing else tells
        them the class is off. Both values are consequential in opposite
        directions (a silent cancellation, or an unexpected blast to a roster),
        which is exactly when a default is the wrong shape.

        Mindbody's verb is a cancellation, not a delete: the class stays on the
        schedule flagged cancelled, which is what a student looking for it needs
        to see. `hide_cancel` hides it anyway and defaults to `false`, because a
        hidden cancellation reads to a student like a class that was never there.

        An **already-cancelled** class is a `409` rather than a no-op. Refusing a
        redundant cancel costs one confusing message; allowing it risks a second
        e-mail to a roster that was already told.

        Send an `Idempotency-Key`. A retried cancel is a second e-mail to
        everyone who was booked.

        **Does not write to Datastream** — the mirror reflects the cancellation
        on its next sync.
      parameters:
        - name: class_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/idempotency_key'
        - $ref: '#/components/parameters/site_id'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CancelClassRequest' }
      responses:
        '200':
          description: The cancelled class.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/CancelledClass' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The class is already cancelled.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/schedule/{class_id}/restore:
    post:
      tags: [schedule]
      summary: Restore one cancelled class occurrence
      description: |
        Reopens a single cancelled class via Mindbody's `updateclass`
        (`IsCanceled: false`). Requires `schedule:write`.

        Mindbody's `cancelsingleclass` has no inverse verb. Whether this
        actually restores a class cancelled through that verb — and whether
        booked students come back — is unverified (docs/VERIFY.md §42).

        A class that is not cancelled is a `409`.
      parameters:
        - name: class_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/idempotency_key'
        - $ref: '#/components/parameters/site_id'
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ScheduleActorRequest' }
      responses:
        '200':
          description: The restored class.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/ClassInstance' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The class is not cancelled.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/schedule/{class_id}/history:
    get:
      tags: [schedule]
      summary: Change history for one class
      description: |
        The writes **this API** made against one class instance and its
        recurring definition. Requires `raw:read`.

        Mindbody has no class audit trail. These rows are `ds_api.schedule_write`
        (sql/038): `created`, `updated`, `substituted`, `cancelled`, `restored`.
        A change typed in the Mindbody back office will not appear.

        Empty when the table has not been applied, or when nothing has been
        written through this API. Fail-open — never a 503 for a missing table.

        Series-level edits are matched by `class_schedule_id` so a definition
        change shows on every occurrence.
      parameters:
        - name: class_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/offset'
      responses:
        '200':
          description: Newest event first.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/ScheduleHistoryEvent' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/schedule/{class_id}/live:
    get:
      tags: [schedule]
      summary: One class as Mindbody has it right now
      description: |
        The current teacher and cancellation state of one class, read **live from
        Mindbody**. Requires `raw:read`.

        Use `GET /v1/schedule/{class_id}` for anything that renders a schedule —
        it reads the mirror and costs nothing. Use this one when the question is
        specifically *"has this changed in Mindbody since we last looked?"*. Two
        reasons the mirror cannot answer that:

        - **Freshness.** The mirror is refreshed by sync windows and webhooks.
          A substitute assigned by hand in Mindbody minutes ago needs to be
          visible within one cron tick, not one sync window.
        - **`is_cancelled` is not on the mirror read at all.** `/v1/schedule`
          strips it, because that route excludes cancelled classes by default and
          the flag would always read `false` there. A consumer asking "was this
          cancelled?" cannot get an answer from it.

        **Tenancy is `site_id`, not a mirror lookup.** Every other per-class route
        proves the class is in scope by reading the mirror first. That would
        defeat this endpoint — the class asked about may be one the mirror has not
        absorbed yet, which is a case it must answer rather than `404`. So the
        caller names `site_id`, it is checked against the key's own scope, and
        Mindbody scopes the class id to that site through its `SiteId` header.
        Mindbody class ids are per-site sequential and collide across sites, so
        that header is what makes the lookup unambiguous.

        A class Mindbody does not know is a `404`, never an empty success — so a
        consumer cannot mistake "Mindbody has no such class" for "Mindbody says
        nobody is teaching it".

        Costs one metered Mindbody call per request and is `no-store`. A stale
        "not cancelled" is the answer that sends someone to a class that is off.
      parameters:
        - name: class_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: The class as Mindbody currently has it.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/LiveClass' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/schedule-definitions:
    get:
      tags: [schedule]
      summary: List recurring class schedule definitions
      description: |
        The recurring **definition** a class is generated from — as opposed to
        `/v1/schedule`, which lists the generated instances. Reads
        `ds_mbo.class_schedule`. Requires `raw:read`.

        Its purpose is prefilling an edit form: before this table began syncing
        (2026-08-18) nothing exposed what a schedule was actually set to, so
        editing a class could only have been a blind overwrite.

        **Four fields Mindbody does not return on this resource**, verified as
        0 of 612 rows at both the flat and nested paths: `staff_pay_rate`,
        `booking_status`, the room (`resource_id`) and any capacity. Pay rate
        and booking status are exactly the two fields Mindbody *demands* when
        publishing, so an editor can show every other current value and must
        prompt for those two with no default — defaulting `booking_status`
        silently changes whether students must pay to book. Room and capacity
        are recoverable per-instance from `/v1/schedule` (`room_id`,
        `max_capacity`); the other two are recoverable nowhere.

        Coverage is partial and still filling — 612 rows across 4 sites, up
        from 362 across 1 site six hours earlier. A site whose schedules have
        not synced returns an empty list, which is indistinguishable here from
        a site that has none.
      parameters:
        - name: class_description_id
          in: query
          description: Schedules for one class type. The id `POST /v1/schedule-definitions` takes.
          schema: { type: string }
        - name: staff_id
          in: query
          description: Schedules whose **permanent** teacher is this staff member.
          schema: { type: string }
        - name: location_id
          in: query
          schema: { type: string }
        - name: schedule_active
          in: query
          description: |
            `true` or `false`. Matches the mirror's `_scheduleActive`, which
            stores the literal text rather than a numeric flag.
          schema: { type: string, enum: ['true', 'false'] }
        - name: modified_since
          in: query
          schema: { type: string, format: date-time }
        - { $ref: '#/components/parameters/limit' }
        - { $ref: '#/components/parameters/offset' }
        - { $ref: '#/components/parameters/site_id' }
      responses:
        '200': {description: List envelope of class schedule definitions}
    post:
      tags: [schedule]
      summary: Publish a new recurring class
      description: |
        Creates a recurring class schedule via Mindbody's
        `addclassschedule` — the class type, location, days/times, and its
        **permanent teacher** in one call. Mindbody generates the individual
        instances; the mirror picks them up on its next sync. Requires
        `schedule:write`, and is served only where the deployment has writes
        enabled.

        `site_id` is required here (not just a narrowing filter): there is no
        existing mirror row to read tenancy off before the class exists, so
        the caller's own site scope is checked directly against it. Mindbody's
        `LocationId` is a separate required body field — one site can hold
        several Mindbody locations, so it cannot be derived from `site_id`.

        Returns the new schedule's id and the class instances Mindbody generated
        from it. Those instances are not readable from `GET /v1/schedule`
        immediately — they appear once the mirror picks them up.
      parameters:
        - $ref: '#/components/parameters/idempotency_key'
        - name: site_id
          in: query
          required: true
          description: |
            The 32-character Datastream site id to publish into. Must be one
            of the key's own sites — anything else is a 403.
          schema: { type: string }
          example: 194b29112cf18b58d8a387198cfdc0db
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/NewClassScheduleRequest' }
      responses:
        '201':
          description: The new schedule's id and the instances Mindbody generated.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/CreatedClassSchedule' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/schedule-definitions/{class_schedule_id}:
    get:
      tags: [schedule]
      summary: One recurring class schedule definition
      description: |
        The definition behind a series, by its `class_schedule_id` — the id
        `/v1/schedule` returns on every instance it generated, and the one
        Mindbody's own back office puts in its edit-class URL. Requires
        `raw:read`.

        Dates come back as `YYYY-MM-DD` and times as `HH:MM:SS`. Mindbody
        stores each the other way round from how it reads: a date carries a
        meaningless midnight, and a **time carries a meaningless date** —
        `1899-12-30`, the OLE Automation epoch. Both are normalised here, so
        no consumer has to know that.

        See the list operation for the four fields this resource cannot tell
        you, which is the constraint that shapes any edit UI built on it.
      parameters:
        - name: class_schedule_id
          in: path
          required: true
          schema: { type: string }
        - { $ref: '#/components/parameters/site_id' }
      responses:
        '200': {description: Item envelope of one class schedule definition}
        '404': {$ref: '#/components/responses/NotFound'}
    patch:
      tags: [schedule]
      summary: Edit a recurring class schedule
      description: |
        Edits the recurring definition through Mindbody's `updateclassschedule`
        — the class type, location, permanent teacher, days, dates, times, room
        and capacity. Requires `schedule:write` and a deployment with writes
        enabled. `site_id` is required for the same reason it is on the publish
        route: no mirror row proves tenancy before the call.

        **PATCH semantics, and they are load-bearing.** Send only the fields you
        are changing; an omitted field is left alone. A full-replacement PUT is
        not offered because it cannot be done safely — Mindbody does not return
        `staff_pay_rate` or `booking_status` on a schedule, so no caller can
        construct a complete representation, and a PUT would blank whatever it
        omitted.

        **Unverified and potentially destructive:** whether *Mindbody* treats an
        omitted field as "leave alone" or as "clear". If it clears, an edit
        wipes the pay rate and booking status of a live class — and those are
        precisely the two fields this API cannot read back to detect it. Until
        one live call settles this, treat any edit as potentially destructive to
        them.

        **Changing time, date or days may require clearing the room first.**
        Mindbody's back office states the room must be removed before those
        change, making a reschedule a clear → change → reassign sequence with a
        partial-failure window. Send `room_id: 0` to clear. Whether the
        constraint applies to a class with no bookings is unverified.
      parameters:
        - name: class_schedule_id
          in: path
          required: true
          schema: { type: string }
        - name: site_id
          in: query
          required: true
          schema: { type: string }
        - { $ref: '#/components/parameters/idempotency_key' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              description: At least one field. An empty body is a 400, not a no-op.
              properties:
                class_description_id: { type: string }
                location_id: { type: string, description: Mindbody's numeric location id, not the Datastream site id. }
                staff_id: { type: string, description: The schedule's permanent teacher, inherited by every future instance. }
                staff_pay_rate: { type: integer, minimum: 1, maximum: 21, description: A pay rate SLOT, not an amount. }
                booking_status:
                  type: string
                  enum: [PaymentRequired, BookAndPayLater, Free]
                  description: Decides whether a student must pay to book. Never defaulted.
                days_of_week:
                  type: array
                  items: { type: string, enum: [Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday] }
                start_date: { type: string, format: date }
                end_date: { type: string, format: date }
                start_time: { type: string, example: '17:00' }
                end_time: { type: string, example: '18:00' }
                room_id: { type: string, description: 'Mindbody ResourceId. 0 clears the room.' }
                max_capacity: { type: integer }
                retain_schedule_changes:
                  type: boolean
                  description: Whether per-instance overrides (a substitute, a one-off room) survive the edit. Mindbody's default is unverified.
                actor_staff_id: { type: string }
                actor_name: { type: string }
      responses:
        '200': {description: Item envelope of the updated schedule}
        '400': {$ref: '#/components/responses/BadRequest'}
        '403': {$ref: '#/components/responses/Forbidden'}
  /v1/schedule-definitions/{class_schedule_id}/teacher:
    patch:
      tags: [schedule]
      summary: Change a recurring schedule's permanent teacher
      description: |
        Sets `staff_id` on the recurring class schedule itself via
        Mindbody's `updateclassschedule` — the default every
        future-generated instance inherits, not just one occurrence (see
        `PATCH /v1/schedule/{class_id}/teacher` for that). Requires
        `schedule:write`, and is served only where the deployment has writes
        enabled.
      parameters:
        - name: class_schedule_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/idempotency_key'
        - name: site_id
          in: query
          required: true
          description: |
            The 32-character Datastream site id the schedule belongs to.
            Must be one of the key's own sites — anything else is a 403.
          schema: { type: string }
          example: 194b29112cf18b58d8a387198cfdc0db
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AssignTeacherRequest' }
      responses:
        '200':
          description: Mindbody's updated recurring schedule.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/ClassSchedule' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/enrollment-definitions:
    post:
      tags: [events]
      summary: Publish a new enrollment (workshop, series, teacher training)
      description: |
        Creates an enrollment via Mindbody's `addenrollmentschedule` — the
        class type, location, days/times, its teacher, and the seats and
        pricing options a place is sold with. Mindbody generates the individual
        occurrences; the mirror picks them up on its next sync. Requires
        `schedule:write`, and is served only where the deployment has writes
        enabled.

        **An enrollment is not a class schedule, and this is not how you enrol
        someone.** Three neighbouring endpoints are easy to confuse:

        | Endpoint | Creates |
        |---|---|
        | `POST /v1/schedule-definitions` | a recurring **drop-in class** |
        | `POST /v1/enrollment-definitions` (this one) | a bounded **enrollment** |
        | `POST /v1/events/{event_id}/enrollments` | nothing — it fills a seat in one |

        An enrollment runs for a fixed span and is bought as a whole rather
        than dropped into, which is why it carries `end_date`, a waitlist and
        an explicit pricing-option list where a class does not.

        `site_id` is required here (not just a narrowing filter): the
        enrollment does not exist yet, so there is no mirror row to read
        tenancy off, and the caller's own site scope is checked against it
        directly. Mindbody's `location_id` is a separate required body field —
        one site can hold several Mindbody locations, so it cannot be derived
        from `site_id`.

        Returns the new schedule's id and the occurrences Mindbody generated.
        Those are not readable from `GET /v1/events` immediately — they appear
        once the mirror picks them up.
      parameters:
        - $ref: '#/components/parameters/idempotency_key'
        - name: site_id
          in: query
          required: true
          description: |
            The 32-character Datastream site id to publish into. Must be one
            of the key's own sites — anything else is a 403.
          schema: { type: string }
          example: 194b29112cf18b58d8a387198cfdc0db
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/NewEnrollmentScheduleRequest' }
      responses:
        '201':
          description: The new enrollment's id and the occurrences Mindbody generated.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/CreatedEnrollmentSchedule' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/enrollment-definitions/{class_schedule_id}:
    patch:
      tags: [events]
      summary: Edit an existing enrollment
      description: |
        Updates an enrollment via Mindbody's `updateenrollmentschedule`.
        Requires `schedule:write`, and is served only where the deployment has
        writes enabled.

        **Only the fields you send are written.** That is forced rather than
        chosen: four fields on an enrollment cannot be read back anywhere —
        `staff_pay_rate`, `booking_status`, the room and any capacity (see §30
        in `docs/VERIFY.md`, which measured 0 of 612 class-schedule rows
        carrying them; `/v1/events` has the same gap). A caller therefore
        cannot reconstruct the current record, so a full-replace contract would
        force everyone to invent values for the fields they cannot read — and a
        guessed `booking_status` silently changes whether students must pay to
        book. The response echoes `updated_fields` so you can see what actually
        went.

        **Two fields are deliberately not accepted here**, both settled against
        Mindbody's published request bodies:

        - `pricing_option_ids` — **pricing cannot be changed on an update at
          all.** `PricingOptionsProductIds` is in `addenrollmentschedule`'s body
          and simply not in `updateenrollmentschedule`'s. Set it at creation, or
          change it in the Mindbody back office under "Assign pricing option".
        - `class_description_id` — Mindbody documents `ClassDescriptionId` on
          this endpoint as *"Used only internally, overridden if sent"*, so an
          enrollment's class type cannot be changed here. Rejecting it beats
          returning a 200 for a change that never happened.

        Both are a 400 naming the field, not a silent drop.

        **Mindbody leaves an omitted field alone** — confirmed 2026-09-03 by a
        single-field patch that left the room, capacity, pay rate, booking
        status, times, dates, teacher and location all untouched
        (`docs/VERIFY.md` §40). Sending only what you mean to change is the
        intended way to use this endpoint.

        Mindbody regenerates occurrences when the dates or days change, so the
        response can carry a fresh `class_instance_ids` list. Changes are not
        visible on `GET /v1/events` until the mirror syncs.
      parameters:
        - name: class_schedule_id
          in: path
          required: true
          description: The enrollment's schedule id, as returned by `POST /v1/enrollment-definitions`.
          schema: { type: string }
          example: '11666'
        - $ref: '#/components/parameters/idempotency_key'
        - name: site_id
          in: query
          required: true
          description: |
            The 32-character Datastream site id the enrollment belongs to. Must
            be one of the key's own sites — anything else is a 403. Required
            because a Mindbody `class_schedule_id` is not a query-builder
            resource, so there is no mirror row to prove tenancy against, and
            Mindbody ids are per-site sequential and collide across sites.
          schema: { type: string }
          example: 194b29112cf18b58d8a387198cfdc0db
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EditEnrollmentScheduleRequest' }
      responses:
        '200':
          description: The enrollment's id, any regenerated occurrences, and the fields that were sent.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/UpdatedEnrollmentSchedule' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/sync/trigger:
    post:
      tags: [sync]
      summary: Trigger a scoped MBO sync job (one site + one data family)
      description: |
        Dispatches a `sync-ManualTrigger` job on the itflow_datastream sync
        droplet — one site, one data family, an optional date range — via a
        direct HTTP call (over the private VPC) to that droplet's internal
        sync-trigger listener. Requires the `sync:write` scope.

        **Fire-and-forget: this does not wait for the sync to finish.** A real
        sync can run for minutes; the response reports only that the job was
        *dispatched*, via `202 Accepted`. Completion (success or failure) is
        logged to this service's own stdout, not returned in the response.

        `site_id` is this service's own 32-character hex Datastream site id
        (`ds_config.sites._id` — the same value `GET /v1/sites` returns as
        `site_id`), **not** an MBO numeric site id. itflow_datastream's own
        sync-control lookup keys off that same hex id. This route checks only
        the `sync:write` scope, not per-site tenancy — grant the scope
        accordingly.
      parameters:
        - $ref: '#/components/parameters/idempotency_key'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SyncTriggerRequest' }
      responses:
        '202':
          description: The job was dispatched (not necessarily finished).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/SyncTriggerResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/sync/services:
    post:
      tags: [sync]
      summary: Re-pull one site's pricing options from Mindbody into the mirror, now
      description: |
        Asks the itflow_datastream sync droplet to re-read Mindbody's
        `/sale/services` for one site and write it to `ds_mbo.service`, then
        answers once the rows are written. Requires the `sync:write` scope.

        What it is for: `ds_mbo.service` (and so `/v1/packages`) is otherwise
        only as fresh as the last ingest run (08:00, 18:00, 23:00 UTC), so a
        pricing option added in Mindbody cannot be picked until the next run.
        Call this, then re-read `/v1/packages`. Rally's "Sync pricing list"
        does exactly that.

        **Waits, unlike `/v1/sync/trigger`.** One Mindbody call per 1,000
        options, so the answer comes back in seconds with the count written.

        **Not a sync run.** No execution is created and no watermark moves, so
        it cannot stand in for a scheduled SalesFamily run the way a
        `/v1/sync/trigger` services pull would. The sync droplet writes the
        rows; this service only asks.

        `site_id` is required and must be inside the key's scope (unlike
        `/v1/sync/trigger`), because it picks the studio whose Mindbody is
        called. Presses for a site that is already refreshing share one
        refresh. `Cache-Control: no-store`.
      parameters:
        - $ref: '#/components/parameters/site_id_required'
      responses:
        '200':
          description: The site's services were re-read from Mindbody and written.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          ok: { type: boolean }
                          site_id: { type: string }
                          services: { type: integer, description: Services written to ds_mbo.service. }
                          mbo_requests: { type: integer, description: Billable Mindbody calls made, retries included. }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/sync/enrollment:
    post:
      tags: [sync]
      summary: Re-pull one enrollment and all its dates from Mindbody into the mirror, now
      description: |
        Asks the itflow_datastream sync droplet to re-read ONE enrollment and
        every one of its dates from Mindbody, write them to `ds_mbo`, project
        them into `ds_enriched`, and mark the dates Mindbody no longer has for
        it. It answers once that is done. Requires the `sync:write` scope.

        What it is for: `POST /v1/enrollment-definitions` writes no mirror
        row, so a freshly published event reaches `/v1/events` only at the
        next ingest run, and its dates reach `/v1/schedule` only once they are
        inside the 90-day class window. Call this after creating or editing an
        enrollment. Funnel does, after it publishes or edits an event.

        **Dates are reconciled.** A date stored for the schedule that Mindbody
        no longer returns is marked removed, which `/v1/schedule` filters out
        (unless `include_stale=true`), and one it returns again is restored.
        An edited event therefore does not keep its old date next to the new
        one.

        **Not a sync run.** No execution is created and no watermark moves, so
        it cannot stand in for a scheduled ClassFamily run the way a
        `/v1/sync/trigger` would. The sync droplet writes the rows; this service
        only asks.

        `found: false` means Mindbody has no enrollment with that id, and
        nothing was written. `enriched: false` means the sync droplet has no
        `ds_enriched` connection: the mirror was written, but nothing was
        projected or marked. `site_id` is required and must be inside the key's
        scope. `Cache-Control: no-store`.
      parameters:
        - $ref: '#/components/parameters/site_id_required'
        - name: class_schedule_id
          in: query
          required: true
          description: The enrollment's Mindbody class schedule id (`event_id` on `/v1/events`).
          schema:
            type: integer
            minimum: 1
      responses:
        '200':
          description: The enrollment was re-read from Mindbody, written, and projected.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          ok:
                            type: boolean
                          site_id:
                            type: string
                          class_schedule_id:
                            type: integer
                          found:
                            type: boolean
                            description: False when Mindbody has no enrollment with that id; nothing was written.
                          enrollments:
                            type: integer
                            description: Enrollment rows written to ds_mbo.enrollment.
                          classes:
                            type: integer
                            description: Class rows written to ds_mbo.class - every date Mindbody returned.
                          removed:
                            type: integer
                            description: Stored dates Mindbody no longer returns, now marked removed.
                          restored:
                            type: integer
                            description: Dates that had been marked removed and are back.
                          enriched:
                            type: boolean
                            description: False when the sync droplet has no ds_enriched connection.
                          mbo_requests:
                            type: integer
                            description: Billable Mindbody calls made, retries included.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/sync/history:
    get:
      tags: [sync]
      summary: Recent sync execution history for one site + data family
      description: |
        Reads recent execution history for one site + data family from the
        itflow_datastream sync droplet's internal listener
        (`/internal/sync-history`, itflow_datastream PR #41), via the same
        private-VPC HTTP call `POST /v1/sync/trigger` uses. Requires the
        `sync:write` scope — this is read access to an internal admin/ops
        feature (recent sync executions), not a general-purpose Datastream
        read, so it is gated by the same scope as triggering a sync rather
        than a `*:read` scope.

        `site_id` is this service's own 32-character hex Datastream site id
        (`ds_config.sites._id` — the same value `GET /v1/sites` returns as
        `site_id`), **not** an MBO numeric site id, and this route checks only
        the `sync:write` scope, not per-site tenancy — same caveat as
        `POST /v1/sync/trigger`.
      parameters:
        - name: site_id
          in: query
          required: true
          schema:
            type: string
            pattern: '^[0-9a-f]{32}$'
          example: 194b29112cf18b58d8a387198cfdc0db
        - name: family
          in: query
          required: true
          schema:
            type: string
            enum: [ClientFamily, ClassFamily, TransactionFamily, SalesFamily]
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 10
          description: Most-recent-first. Defaults to 10, capped at 50.
      responses:
        '200':
          description: Recent executions, most-recent-first.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/SyncHistoryResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/sync/next-range:
    get:
      tags: [sync]
      summary: The date range the next scoped MBO sync would use, for one site + one data family
      description: |
        Read-only companion to `POST /v1/sync/trigger`: computes what
        `date_start` / `date_end` a sync launched right now, with no explicit
        dates, would resolve to — without launching anything. Requires the
        `sync:write` scope (same reasoning as the trigger route: read-only,
        but only useful to a caller that can already trigger a sync).

        Calls the sync droplet's internal `GET /internal/sync-status` listener
        for the SyncControl's raw stored `between.start` / `between.end`, then
        applies the same date arithmetic as itflow_datastream's
        `SyncControlService.launchExecution`: `date_end` is yesterday in
        US/Central, and `date_start` is `between.end + 1 day` (or
        `between.start` on a SyncControl that has never run).

        Backs the FETCH admin page's date-input prefill in api-fvmgt.
      parameters:
        - name: site_id
          in: query
          required: true
          schema:
            type: string
            pattern: '^[0-9a-f]{32}$'
          description: |
            This service's own 32-character hex Datastream site id
            (`ds_config.sites._id`), the same value `GET /v1/sites` returns as
            `site_id` and the same id space `POST /v1/sync/trigger` uses.
        - name: family
          in: query
          required: true
          schema:
            type: string
            enum: [ClientFamily, ClassFamily, TransactionFamily, SalesFamily]
      responses:
        '200':
          description: The computed next-sync date range.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/SyncNextRangeResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/purchases:
    post:
      tags: [sales]
      summary: Check out a catalog item for a client
      description: |
        Sells one service (package/drop-in) or retail product to a client via
        Mindbody's `checkoutshoppingcart`. A service Mindbody has locked to
        contract sales (`sale_in_contract_only`) is refused with 409 naming the
        contract to sell instead. Requires the `purchase:write` scope,
        and is served only where the deployment has writes enabled.

        **This does not write to Datastream.** The cart is proxied to Mindbody,
        the system of record; the sale reaches `ds_enriched.sales` on the next
        sync. Datastream still flows one direction.

        The item is resolved inside the key's site scope first — an item the
        key cannot read is a `404`, never a `403`.

        **`test` defaults TRUE**, the opposite of a booking: this endpoint
        moves money on a client's account, so a real sale must say
        `test: false` out loud. A dry run has Mindbody validate and price the
        whole cart (tax included) and returns the totals with `sale_id: null`.

        Mindbody owns the total, tax included, and refuses a payment off by a
        cent. Omit `payment_amount` and the endpoint resolves the authoritative
        total itself (one retry with Mindbody's own calculated figure). An
        explicit `payment_amount` is never corrected — a mismatch comes back as
        a `422` carrying Mindbody's message with the real total in it.

        **No card is charged.** The payment is recorded against a Mindbody
        custom payment method (e.g. 19 = "Square" on the Flow site), which is a
        label, not a processor. Whatever collects the actual money does so
        before calling this.

        Mindbody does not dedupe sales. Send an `Idempotency-Key` and a repeat
        replays the first response instead of charging the account again.
      parameters:
        - $ref: '#/components/parameters/idempotency_key'
        - $ref: '#/components/parameters/site_id_required'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PurchaseRequest' }
      responses:
        '200':
          description: "Dry run (`test: true`) — Mindbody's priced cart, no sale created."
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Purchase' }
        '201':
          description: The sale, as Mindbody created it.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Purchase' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/free-packages:
    get:
      tags: [sales]
      summary: The $0 pricing options a studio may hand out
      description: |
        The read side of `POST /v1/get-free-package`: where that answers "may I
        comp this one", this answers "which ones are on offer at all". Requires
        `raw:read`.

        **Not a filter on `/v1/packages`.** `$0` is a fact about the catalog;
        "this studio hands this one out at the desk" is a policy decision about
        it, and so is "ClassPass posts its own visits, so never comp it by
        hand". Folding that into the general catalog read would make every other
        consumer opt out of one consumer's rules.

        **The policy lives server-side on purpose.** The console it serves sits
        behind a proxy with no authentication in front of it, so a list held in
        a page's JavaScript decides what that page draws and nothing more.

        Five exclusions, applied in the order they are cheapest: discontinued,
        priced above zero, a program outside the allowlist, a denied name
        (ClassPass, Dynamic Pricing, Late Cancelled, No Show, Consultation), and
        anything a membership carries. That last one takes two signals — MBO's
        own `sale_in_contract_only` lock catches three options on the Flow site,
        while membership of a contract's `contract_items` catches six, and the
        extra three are $0 and unlocked, so the cart would happily sell one on
        its own.

        The program allowlist is by **id**, not name, so a studio renaming a
        program cannot silently change what the API offers. Mindbody program ids
        are per-site, so a second studio may need its own ids added before its
        options appear — the symptom is options quietly absent, not an error.

        Unpaged: this is a policy list a consumer renders whole, not a catalog
        page. `total_count` equals the number of rows returned.
      parameters:
        - name: q
          in: query
          description: Free-text match on the option name, two characters minimum.
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: The offerable $0 options, same row shape as `/v1/packages`.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Package' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/get-free-package:
    post:
      tags: [sales]
      summary: Comp a $0 pricing option to a client
      description: |
        Adds a pricing option priced at **$0** to a client's Mindbody account as
        a comp. Requires the `purchase:write` scope, and is served only where the
        deployment has writes enabled.

        **No money moves and Square is never involved.** The cart is tendered
        against Mindbody's own built-in `Comp` type — the one the front desk uses
        to hand someone a free pass. There is no `payment_method_id`, no stored
        card, no token and no Square payment record. That is why this is its own
        endpoint rather than a mode inside `POST /v1/purchases`, which requires a
        payment source.

        **The price is verified server-side.** The caller names a pricing option;
        it does not get to assert that the option is free. This endpoint re-reads
        the price from the mirror inside the key's own site scope and refuses
        anything that is not exactly zero with
        `400 Selected package is not free`. An option whose price the mirror does
        not know is refused the same way — unknown is not free.

        **This does not write to Datastream.** The cart is proxied to Mindbody,
        the system of record; the sale reaches `ds_enriched.sales` on the next
        sync. The grant is separately recorded in `ds_api.free_package_grant` as
        an audit ledger — who comped what, to whom, on which key.

        The option is resolved inside the key's site scope first, so one the key
        cannot read is a `404`, never a `403`. An option Mindbody has locked to
        contract sales (`sale_in_contract_only`) is refused with a `409` naming
        the contract to sell instead.

        **Once every 6 months, per client, per pricing option.** A client who
        already received this option inside that window is refused with a `409`
        and the member-facing message *"It looks like you've already claimed this
        offer. Please contact support for more details."* The specifics — when it
        was granted, which sale, which package — are deliberately kept out of the
        message and written to the server log against the request id instead, so
        support can answer a follow-up without the refusal itself starting an
        argument at the front desk.

        The limit is per `(site, client, package)` — a *different* free option is
        unaffected, and it is not a blanket cooldown on free passes. Checked on a
        dry run too, so `test: true` answers what the real call would do.

        **Event tickets are exempt.** A pricing option on the `Event Single-Day`
        or `Event Multi-Day` program skips the check entirely: events recur, a
        member may legitimately attend several in six months, and one `$0 Ticket`
        option is what admits them to every one. The response reports `program`
        and `repeat_limit_applied` so a consumer can see which rule was applied
        without knowing this list. An exempt option never returns the `503`
        below either — it does not read the ledger at all.

        The exemption matches the program **name**, which is studio-configured
        rather than a Mindbody constant, and the match is exact rather than a
        prefix on "Event". A program outside that pair keeps the limit, which is
        the safe direction: an event ticket wrongly limited is a visible `409`
        someone reports, while a standing offer wrongly exempted is an unlimited
        giveaway nobody notices.

        The check reads `ds_api.free_package_grant`, so it only knows about
        grants made through **this endpoint**. A comp handed out directly in the
        Mindbody UI, or before this endpoint existed, is invisible to it and does
        not start the clock.

        **`test` defaults FALSE here**, unlike `POST /v1/purchases`. That
        endpoint moves money, so a real sale has to be asked for out loud; this
        one moves none, and defaulting to a dry run would mean every caller has
        to opt in to the endpoint doing its job. Send `test: true` to have
        Mindbody validate the whole cart without creating a sale — the response
        is a `200` with `sale_id: null`, and nothing is recorded.

        Mindbody does not dedupe sales. Send an `Idempotency-Key` and a repeat
        replays the first response instead of handing out a second free pass.
      parameters:
        - $ref: '#/components/parameters/idempotency_key'
        - $ref: '#/components/parameters/site_id_required'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FreePackageRequest' }
      responses:
        '200':
          description: "Dry run (`test: true`) — Mindbody validated the cart, no sale created."
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/FreePackageGrant' }
        '201':
          description: The comp, as Mindbody created it.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/FreePackageGrant' }
        '400':
          description: |
            Bad request. `Selected package is not free` when the option's price
            is not zero, or is unknown. The price that was read is recorded in
            the server log against this request id, not returned — the error
            body carries only `code` and `message`, like every other error here.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: |
            Either the client already received this pricing option inside the
            6-month window — `It looks like you've already claimed this offer.
            Please contact support for more details.` — or Mindbody has locked the
            option to contract sales, in which case the message names the
            contract to sell instead.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503':
          description: |
            Mindbody is unreachable, **or** the repeat-limit ledger
            (`ds_api.free_package_grant`, sql/044) cannot be read. The limit
            fails closed: eligibility that cannot be established is not
            eligibility, so no comp is made.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }

  /v1/gift-cards:
    get:
      tags: [sales]
      summary: The gift card denominations a site sells
      description: |
        A **live Mindbody read**, like `/v1/payments/methods`. `ds_enriched`
        records gift cards that were *sold*, but the denominations a studio
        currently offers are Mindbody configuration and nothing mirrors them.
        `source` is `mbo` on every row.

        Gift cards are configured per site, so a key covering several sites
        must name one with `?site_id=` — the endpoint will not guess, because
        guessing sells the wrong studio's card.

        Two prices, and they answer different questions. `card_value` is what
        the recipient can spend; `sale_price` is what the purchaser is charged.
        They are equal on the Flow site today, but a "pay $80, get $100"
        promotion separates them, and showing the wrong one either undercharges
        or misprices the offer.

        Zero-value cards are omitted — Mindbody keeps them as templates and a
        storefront offering "$0 Gift" looks broken.
      parameters:
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: The site's sellable gift cards.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/GiftCard' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/gift-cards/purchases:
    post:
      tags: [sales]
      summary: Buy one gift card
      description: |
        Buys a gift card via Mindbody's `purchasegiftcard` and emails the
        recipient. Requires `purchase:write` and a deployment with writes
        enabled. Replaces the legacy `/v2/products/store/giftcard/purchase`.

        **The price comes from the catalog, not the request.** The legacy
        endpoint charged whatever `amount` the caller sent — a POST naming the
        $100 card with `amount: 1` charged a dollar and issued a hundred-dollar
        barcode. Here the server looks the card up and charges its `sale_price`.
        `amount` may be sent as an assertion; if it disagrees the request is a
        `400`, not a discount.

        **This does not write to Datastream.** The purchase is proxied to
        Mindbody, the system of record; the sale reaches `ds_enriched.sales` on
        the next sync.

        **`test` defaults TRUE.** A dry run has Mindbody validate the whole
        purchase, creates nothing, issues no barcode and sends no email. A real
        purchase must say `test: false` out loud.

        **No card number is accepted.** `stored_card` charges the card Mindbody
        already holds for the purchaser, identified by its last four.
        `custom` records the sale against a Mindbody custom payment method
        without charging anything — for when Square (or anything else) took the
        money first. Sending a PAN is a `400`.

        Mindbody's own gift card receipt is suppressed; Flow's branded email
        carries the chosen design, the message and the barcode. That send is
        best-effort: by the time it runs the money is taken and the barcode
        exists, so a failed email is reported as `email_sent: false` rather than
        failing a completed purchase.

        Mindbody does not dedupe. Send an `Idempotency-Key` and a repeat replays
        the first response instead of issuing a second card.
      parameters:
        - $ref: '#/components/parameters/idempotency_key'
        - $ref: '#/components/parameters/site_id'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/GiftCardPurchaseRequest' }
      responses:
        '200':
          description: "Dry run (`test: true`) — nothing created, no barcode, no email."
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/GiftCardPurchase' }
        '201':
          description: The gift card, as Mindbody issued it.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/GiftCardPurchase' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/locations:
    get:
      tags: [sales]
      summary: Square merchant Locations on this account
      description: |
        Square's own Locations (`GET /v2/locations`), not Mindbody studios
        and not the mapping table. Use the ids here to rempoint
        `ds_api.square_location_map` once real studio Locations exist.
        Requires only a valid key — same as `/v1/payments/square/config`.
      parameters:
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: Square Locations on this merchant account.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ListEnvelope'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/config:
    get:
      tags: [sales]
      summary: Square Web Payments SDK bootstrap values for one Mindbody location
      description: |
        Application id, environment, the SDK script URL, and the Square
        location that `location_id` (Mindbody's) maps to — a console page
        needs all of it to render a card form and call
        `POST /v1/payments/square/charge`. Requires only a valid key — none
        of this is secret; the Web Payments SDK ships the application id to
        the browser by design.

        **`location_id` is required.** One Square merchant account (one
        application id, one access token) serves every Flow studio, but
        Mindbody's `location_id` and Square's own location id are different
        id spaces with no natural correspondence, so the mapping lives in
        `ds_api.square_location_map` (`src/db/square-locations.ts`), one row
        per (site, Mindbody location). Tenancy is proven the same way
        `/v1/purchases` proves it for an item: the location is read inside
        the key's scope first, a `404` for one it cannot see, never a `403`
        that confirms it exists.

        Returns `503` when the account itself isn't configured
        (`SQUARE_APPLICATION_ID` / `SQUARE_ACCESS_TOKEN`), or when this
        specific location has no Square location mapped yet.
      parameters:
        - name: location_id
          in: query
          required: true
          schema: { type: string }
          description: Mindbody's location id (from `GET /v1/locations`).
        - $ref: '#/components/parameters/site_id_required'
      responses:
        '200':
          description: Bootstrap values for the Web Payments SDK, for this location.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/SquareConfig' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/charge:
    post:
      tags: [sales]
      summary: Charge a card via Square and check out the cart in Mindbody
      description: |
        The endpoint `/v1/purchases` deliberately does not have: this one
        actually moves money. Requires the `payment:write` scope, kept
        separate from `purchase:write` because it can debit a card, and is
        served only where the deployment has writes enabled and Square is
        configured.

        Runs three steps, in order, per the square-mbo-payments design
        (verified against production Mindbody and the Square sandbox,
        2026-07-30):

          1. A `test: true` Mindbody checkout to learn the authoritative total
             — tax included — **before Square is touched**.
          2. `POST /v2/payments` at Square for exactly that figure, using
             exactly one of `source_id` (a Web Payments SDK token from the
             browser) or `card_id` (a card already on file for this client;
             Square customers are keyed `<site_id>:<client_id>`). A card
             number is never seen server-side.
          3. A `test: false` Mindbody checkout at that same figure, recorded
             against the deployment's Square custom payment method
             (`SQUARE_MBO_PAYMENT_METHOD_ID`, 19 on the Flow site).

        **If step 3 fails after step 2 succeeded**, the card has already been
        charged. This endpoint refunds it automatically, alerts through
        flow-notify, and returns `422` naming what happened either way — it
        never leaves a charged-but-unfulfilled payment silent.

        Mindbody discards any payment reference it is given (`Payments[].
        Metadata.Notes` verified stored `NULL`), so the Square payment id and
        the Mindbody sale id are linked only in this service's own ledger
        (`ds_api.square_payment`) — not automation for refunds issued in the
        Mindbody admin panel; that is a separate, unbuilt design.

        **`Idempotency-Key` is required, not optional** — unlike `/v1/purchases`,
        where the worst case of a dropped retry is a duplicate sale. Here it
        is a duplicate real charge.
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string, minLength: 8, maxLength: 255 }
        - $ref: '#/components/parameters/site_id_required'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SquareChargeRequest' }
      responses:
        '201':
          description: The sale, as Mindbody created it, plus the Square payment.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/SquarePurchase' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/failed-attempt:
    post:
      tags: [sales]
      summary: Report a card-entry failure that never reached /charge
      description: |
        The Web Payments SDK refuses a bad card number, an incomplete form, or
        a cancelled Apple Pay sheet in the browser — none of those call
        `/v1/payments/square/charge`, so the purchase-failure alert hook never
        sees them. The card form POSTs here instead. Does not move money.
        Requires `payment:write`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason: { type: string }
                stage:
                  type: string
                  description: tokenize, wallet, or ach — defaults to tokenize.
                client_id: { type: string }
                location_id: { type: string }
                item: { type: string }
      responses:
        '204': { description: Alert accepted (or logged if notify is unset). }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/payments/square/plans:
    get:
      tags: [sales]
      summary: Square subscription plan variations on this merchant account
      description: |
        Every `SUBSCRIPTION_PLAN_VARIATION` in the Square Catalog, flattened
        with its parent plan's name. `amount` is **dollars**, not Square's
        cents, matching every other money field in this API.

        Not site-filtered, and it cannot be: a Square Catalog belongs to the
        merchant account, not to a Datastream site. Behind `payment:write`
        rather than a read scope because it calls Square with the merchant
        credential — this is not a Datastream read, and `raw:read` keys are
        minted freely for developers.
      responses:
        '200':
          description: Plan variations, most recently returned by Square first.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/SquarePlanVariation' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '503': { $ref: '#/components/responses/Unavailable' }
    post:
      tags: [sales]
      summary: Create a Square subscription plan with one variation
      description: |
        Creates a `SUBSCRIPTION_PLAN` and its first variation in one call.

        **A Square Catalog object cannot be deleted through this API**, only
        ignored, which is why `Idempotency-Key` is required and why this sits
        behind `ENABLE_WRITES` like every other upstream state change.

        Omit `periods` for a plan that renews indefinitely — that is what 18 of
        Flow's 25 Mindbody contracts actually are
        (`ContractAutomaticallyRenews`). The 6 that stop after a year are
        `periods: 12`. Prices are tax inclusive, matching Mindbody's own
        contract totals.
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string, minLength: 8, maxLength: 255 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SquarePlanRequest' }
      responses:
        '201':
          description: The plan variation Square created.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/SquarePlanVariation' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/plans/from-contract:
    post:
      tags: [sales]
      summary: Copy a Mindbody contract onto a Square subscription plan
      description: |
        Reads the Mindbody contract's name, recurring total, and autopay
        cadence, then creates a Square `SUBSCRIPTION_PLAN` with one variation
        at that price — or returns the existing variation when one already
        matches name and amount. Catalog objects cannot be deleted through
        this API, so a reuse is preferred to a second copy.

        This is the Plan half of Subscribe. The contract id is still stored
        on the subscription so each paid invoice can sell the standing pack;
        Square never enrolls a Mindbody membership.
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string, minLength: 8, maxLength: 255 }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mbo_contract_id]
              properties:
                mbo_contract_id:
                  type: string
                  description: Mindbody contract id (`GET /v1/contracts`).
      responses:
        '200':
          description: An existing Square plan already matched this contract.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/SquarePlanVariation' }
        '201':
          description: The plan variation Square created from the contract.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/SquarePlanVariation' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/cards:
    get:
      tags: [sales]
      summary: Square cards on file for a Mindbody client
      description: |
        Enabled cards stored on the Square Customer keyed
        `<site_id>:<client_id>`. A client who has never been billed at Square
        has no customer — this returns an empty list, not a 404.

        Disabled cards are omitted. Scope is `payment:write`: the read hits
        Square with the merchant credential.
      parameters:
        - name: client_id
          in: query
          required: true
          schema: { type: string }
          description: Mindbody client id.
        - $ref: '#/components/parameters/site_id_required'
      responses:
        '200':
          description: Cards on file for this client.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/SquareCardOnFile' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/subscriptions:
    get:
      tags: [sales]
      summary: A client's Square subscriptions, live status joined to the Mindbody link
      description: |
        Square is authoritative for `square_status`; this service's own
        `ds_api.square_subscription` is authoritative for which Mindbody
        contract a subscription stands for, because Square's Subscription
        object has no `reference_id` and no metadata to hold one.

        A row recorded here but absent at Square comes back with
        `square_status: null`, and a subscription at Square with no local row
        comes back with `recorded_status: "not_recorded"`. Neither is dropped —
        that divergence is exactly what a reconciliation job looks for.

        `is_winding_down` is the field to read, not `status`: Square keeps a
        cancelled subscription `ACTIVE` until the period already paid for ends.
      parameters:
        - name: client_id
          in: query
          required: true
          schema: { type: string }
          description: Mindbody client id.
        - $ref: '#/components/parameters/site_id_required'
      responses:
        '200':
          description: Subscriptions for this client.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/SquareSubscription' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/Unavailable' }
    post:
      tags: [sales]
      summary: Enrol a client on a Square subscription (starts real recurring billing)
      description: |
        Find-or-create the Square Customer, store the card against it, create
        the subscription — in that order. Square charges the first period
        immediately unless `start_date` is in the future, so **a 201 means real
        money has already moved**.

        **When `mbo_contract_id` is set, this request sells the standing
        ServicePricingOption (method 19) after Square charges** —
        `mbo_entitlement` is `sold` or `failed`. Waiting on
        `invoice.payment_made` for the first period left paid members with
        no pack: that webhook often arrives before the local subscription
        row exists. Later cycles still sell on the webhook. That is a pack,
        not a membership. Without a contract id, `mbo_entitlement` is `none`.

        Send **exactly one** of `source_id` (a fresh Web Payments SDK token to
        save) or `card_id` (a card already on file for this client; it is
        checked against the customer's own cards before use). Only credit and
        debit cards can be stored — Apple Pay, Google Pay, Cash App Pay and ACH
        cannot, so a token from those flows will be rejected by Square.

        A client with neither a name nor an email address in Mindbody is
        refused with `409`: Square silently deactivates such a subscription at
        the next billing cycle.
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string, minLength: 8, maxLength: 255 }
        - $ref: '#/components/parameters/site_id_required'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SquareSubscribeRequest' }
      responses:
        '201':
          description: The subscription Square created. Billing has started.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/SquareSubscription' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/subscriptions-with-credit:
    post:
      tags: [sales]
      summary: Square subscription → Mindbody account credit → Mindbody contract bought with that credit
      description: |
        The same Square flow as `POST /v1/payments/square/subscriptions` —
        find-or-create the Customer, store the card, charge the first period,
        create the subscription on the next cadence, refund if that fails — and
        the same request fields, plus `test`.

        **The Mindbody half differs.** Instead of selling a pack, this calls
        `/sale/purchaseaccountcredit` (Custom method 19, Account Payments item
        `13380`) for **exactly the amount Square charged**, so the client's
        Account Balance rises by the money that moved. It never calls
        `checkoutshoppingcart`.

        `promo_code` is priced from `ds_mbo.promo_code` (Percent / FlatRate),
        not a cart dry run: it must exist and be active, but its dates and
        applicable items are not checked. The discounted first period is what
        Square charges and what is credited; renewals stay on the plan amount.

        `test` defaults **true**: no Square write (the plan list is read for
        the price) and Mindbody `purchaseaccountcredit` with `Test: true`. It
        returns `200` with what would be charged and credited. A non-null
        `mbo_sale_id` there means Mindbody did not honour `Test`.

        `test: false` needs `Idempotency-Key` and exactly one of `source_id` /
        `card_id`, and returns `201`. `mbo_credit` is `added`, `failed` or
        `none` (nothing charged today because `start_date` is in the future).
        On `failed` the subscription is kept and ops is alerted to add the
        credit by hand — there is no automatic refund, and retrying through
        `mbo-credit` would charge Square again. `account_balance_after` is read
        live from Mindbody, falling back to `account_balance_before +
        credit_amount`.

        **Step 3** (after the credit lands): `/sale/purchasecontract` for the
        required `mbo_contract_id` with `UseAccountCredit: true` and the same
        `promo_code` as `PromotionCode`. Requires `contract:write` as well as
        `payment:write`. `mbo_contract` is `purchased`, `failed` (subscription
        and credit kept; retry `POST /v1/contracts/{id}/purchases` with
        `use_account_credit` — nothing charges twice), `skipped` (the credit
        failed) or `none` (nothing charged today).

        **Square's charge, the credit and the contract's first payment must
        match to the cent.** The contract side is its `first_charge_total`
        minus the same catalog promo. A mismatch, or a contract not sold at the
        location, is `409` before any Square write — also in test mode. The
        contract sale is read back afterwards; if Mindbody debited a different
        amount, `contract_charge_mismatch` is true and ops is alerted.

        `test: true` never calls `purchasecontract` — Mindbody has no dry run
        for it — and reports `mbo_contract: would_purchase`.

        `mbo_contract_id` is not stored on the subscription row, so renewals
        add no credit and sell no pack yet, while Mindbody's own autopay on the
        contract debits the account each cycle.
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          description: Required when `test` is false.
          schema: { type: string, minLength: 8, maxLength: 255 }
        - $ref: '#/components/parameters/site_id_required'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/SquareSubscribeRequest'
                - type: object
                  required: [mbo_contract_id]
                  properties:
                    mbo_contract_id:
                      type: string
                      description: The Mindbody contract bought with the credit in step 3.
                    test:
                      type: boolean
                      default: true
                      description: Dry run — no Square write, Mindbody credit with Test true, no purchasecontract.
      responses:
        '200':
          description: Dry run (`test` true). Nothing was charged or credited.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          test: { type: boolean, enum: [true] }
                          client_id: { type: string }
                          location_id: { type: integer }
                          site_id: { type: string }
                          plan_variation_id: { type: string }
                          plan_name: { type: [string, 'null'] }
                          cadence: { type: [string, 'null'] }
                          plan_amount: { type: number }
                          promo_code: { type: [string, 'null'] }
                          promo_discount: { type: number }
                          charge_amount: { type: number }
                          credit_amount: { type: number }
                          mbo_credit: { type: string, enum: [would_add, none] }
                          mbo_contract_id: { type: string }
                          contract_name: { type: [string, 'null'] }
                          contract_first_charge: { type: [number, 'null'] }
                          contract_recurring_payment_total: { type: [number, 'null'] }
                          mbo_contract: { type: string, enum: [would_purchase, none] }
                          mbo_contract_note: { type: string }
                          account_payment_id: { type: string }
                          payment_method_id: { type: integer }
                          account_balance_before: { type: [number, 'null'] }
                          account_balance_after: { type: [number, 'null'] }
                          mbo_sale_id: { type: [integer, 'null'] }
                          purchase: { type: [object, 'null'] }
        '201':
          description: Square is billing. `mbo_credit` says whether the account was credited.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data:
                        allOf:
                          - $ref: '#/components/schemas/SquareSubscription'
                          - type: object
                            properties:
                              test: { type: boolean, enum: [false] }
                              square_payment_id: { type: [string, 'null'] }
                              promo_code: { type: [string, 'null'] }
                              promo_discount: { type: number }
                              charge_amount: { type: number }
                              credit_amount: { type: number }
                              mbo_credit: { type: string, enum: [added, failed, none] }
                              mbo_credit_note: { type: string }
                              mbo_sale_id: { type: [integer, 'null'] }
                              account_payment_id: { type: string }
                              payment_method_id: { type: integer }
                              contract_name: { type: [string, 'null'] }
                              contract_first_charge: { type: [number, 'null'] }
                              mbo_contract: { type: string, enum: [purchased, failed, skipped, none] }
                              mbo_contract_note: { type: string }
                              contract_sale_id: { type: [integer, 'null'] }
                              client_contract_id: { type: [integer, string, 'null'] }
                              contract_charged_total: { type: [number, 'null'] }
                              contract_charge_verified: { type: boolean }
                              contract_charge_mismatch: { type: boolean }
                              account_balance_before: { type: [number, 'null'] }
                              account_balance_after:
                                type: [number, 'null']
                                description: After the credit and the contract debit, read live from Mindbody.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/subscriptions/{subscription_id}/test-charge:
    post:
      tags: [sales]
      summary: Removed — use /accelerate
      description: |
        Removed. This charged the card and sold the pack without a Square
        subscription invoice. Use `POST …/accelerate` instead.
      parameters:
        - name: subscription_id
          in: path
          required: true
          schema: { type: string }
      responses:
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/payments/square/subscriptions/{subscription_id}/accelerate:
    post:
      tags: [sales]
      summary: Test accelerator — real Square invoice via daily swap or recreate
      description: |
        Square will not generate a subscription invoice before
        `charged_through_date`. That field is read-only. SwapPlan takes
        effect at period end. There is no production "bill now".

        `mode=swap_daily` parks this subscription on a same-price DAILY
        variation. Same Square id / member URL. The next invoice is still
        `charged_through_date`; after that, invoices are daily.

        `mode=recreate_daily` cancels this subscription at the end of the
        paid period and CreateSubscriptions a DAILY replacement with
        `start_date` today so Square invoices immediately. New Square id,
        new member URL.

        Neither mode charges a card or sells a pack. The existing
        `invoice.payment_made` webhook is the entitlement path.

        Fail-closed: `payment:write` **and** `ENABLE_SUBSCRIPTION_TEST_ACCELERATE`
        (default off). 403 when the switch is off. Not a public member-page
        control — do not proxy this from `fvmgt.com/{id}`.

        Requires `Idempotency-Key`.
      parameters:
        - name: subscription_id
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string, minLength: 8, maxLength: 255 }
        - $ref: '#/components/parameters/site_id'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mode]
              properties:
                mode:
                  type: string
                  enum: [swap_daily, recreate_daily]
      responses:
        '200':
          description: Swap scheduled. Next invoice is still charged_through_date.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
        '201':
          description: New daily subscription created. Square invoices today.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/subscriptions/{subscription_id}/cancel:
    post:
      tags: [sales]
      summary: Cancel a Square subscription at the end of the paid period
      description: |
        Square's only cancel semantics, and the right ones: the member keeps
        what they have already paid for. The subscription stays `ACTIVE` with a
        `canceled_date` until that date passes, so read `is_winding_down`, not
        `status`.

        Tenancy comes from this service's own record of the subscription, so a
        subscription created outside this service — straight in the Square
        dashboard, say — cannot be cancelled here. That is deliberate: the row
        is the only thing tying a Square subscription to a Flow site, and
        cancelling on a caller-supplied id alone would let any `payment:write`
        key cancel anything on the merchant account.

        Changes nothing in Mindbody, because nothing was created there.
      parameters:
        - name: subscription_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: The subscription, now winding down.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/SquareSubscription' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/subscriptions/{subscription_id}:
    get:
      tags: [sales]
      summary: One subscription, as the member sees it
      description: |
        The read behind the member landing page: what they pay, when it renews,
        which card, which studio, and everything already scheduled — in one
        call, so the page is not stitching four responses together in a browser.

        **`status` alone is not the state.** Square keeps a cancelled
        subscription `ACTIVE` until the period already paid for ends, and it
        omits pending changes unless the read asks for them. Read
        `is_winding_down`, `is_paused`, `ends_on`, `resumes_on` and `actions`;
        this endpoint always asks Square for actions so they are never silently
        empty.

        `next_billing_date` is Square's `charged_through_date`, not a date
        derived from the cadence — Square invoices on the date the member is
        paid through, and a locally computed date drifts the moment a pause or
        an anchor change lands. It is `null` when nothing further will be
        billed.

        `plan_name` is the **Mindbody contract's** name, not the Square plan's.
        The plan was created by copying the contract, so they usually match, but
        the contract is what the member bought and what studio staff will say
        back to them.

        `today` is the studio's date in the studio's zone, so a UI can bound a
        date picker without trusting the visitor's clock.

        Tenancy is this service's own record of the subscription, exactly as on
        `/cancel`: a subscription created straight in the Square dashboard is a
        404 here, whatever the key's scope.
      parameters:
        - name: subscription_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: The member view of one subscription.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/MemberSubscription' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/subscriptions/{subscription_id}/events:
    get:
      tags: [sales]
      summary: What has already happened to a subscription, from Square
      description: |
        Square's own event log for this subscription, oldest first. Distinct
        from `actions` on the read above, which is what is *scheduled* and has
        not happened.

        This is the only place a deactivation reason is visible — a card that
        stopped working shows up here as a `DEACTIVATE_SUBSCRIPTION` with
        Square's detail, and nowhere else in this API.
      parameters:
        - name: subscription_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: Subscription events, oldest first.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/SquareSubscriptionEvent' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/subscriptions/{subscription_id}/payments:
    get:
      tags: [sales]
      summary: Paid invoices on a subscription, oldest first
      description: |
        Recurring charges Square has already collected for this subscription.
        Built from `invoice_ids` on the live Square subscription, not from
        `/events` — Square's lifecycle log never includes a weekly renewal.
      parameters:
        - name: subscription_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: Paid invoices, oldest first.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          type: object
                          properties:
                            invoice_id: { type: string }
                            amount: { type: number, nullable: true }
                            paid_at: { type: string, nullable: true }
                            status: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/subscriptions/{subscription_id}/pause:
    post:
      tags: [sales]
      summary: Skip payments, move a renewal date, or stop billing from a date
      description: |
        One endpoint behind three member requests, because Square's pause is the
        only mechanism that serves any of them:

        - **"Skip my next payment"** — `cycles: 1`.
        - **"Bill me again on the 18th"** — `resume_date`. This is also how a
          **non-monthly** plan moves its renewal date; `/billing-anchor` only
          works for monthly cadences.
        - **"Stop taking payments after the 1st"** — `pause_date` with no
          resume. Square has **no scheduled cancel** (`UpdateSubscription`
          refuses to set a future `canceled_date`), so an open-ended pause from
          a date is the only way to express this. **It is a pause, not a
          cancellation** — the subscription survives and can be resumed. The
          read reports it as `payments_stop_after`, never as `ends_on`.

        `cycles` and `resume_date` are mutually exclusive; Square rejects the
        pair. `pause_date` combines with either.

        Without `pause_date` the pause takes effect at the end of the period
        already paid for, never mid-period. Square has no mid-period pause, and
        prorating one here would be this service inventing a refund policy.

        Undo by deleting the returned `PAUSE` action; that also removes the
        paired `RESUME`.
      parameters:
        - name: subscription_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                cycles:
                  type: integer
                  minimum: 1
                  maximum: 24
                  description: Billing cycles to skip. Mutually exclusive with `resume_date`.
                resume_date:
                  type: string
                  format: date
                  description: Bill again on this date. Mutually exclusive with `cycles`.
                pause_date:
                  type: string
                  format: date
                  description: |
                    When the pause starts. Omitted means the end of the period
                    already paid for. With no `cycles` and no `resume_date`, the
                    pause is open-ended — the closest Square gets to "stop
                    billing me after this date".

                    **Square snaps this up to a billing-period boundary.** A
                    weekly plan asked to pause from 2026-11-01 came back paused
                    from 2026-11-06. Read `pause_starts` on the response, not
                    the date you sent.
                reason:
                  type: string
                  maxLength: 255
      responses:
        '200':
          description: The subscription, with the scheduled pause in `actions`.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/SquareSubscription' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/subscriptions/{subscription_id}/resume:
    post:
      tags: [sales]
      summary: Start billing again after a pause
      description: |
        With no `resume_date` this resumes **immediately**: the request sends
        Square's `IMMEDIATE` change timing, because Square's default
        (`END_OF_BILLING_CYCLE`) reads to a member as the button having done
        nothing.
      parameters:
        - name: subscription_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                resume_date:
                  type: string
                  format: date
                  description: Resume on this date instead of now.
      responses:
        '200':
          description: The subscription, resuming.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/SquareSubscription' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/subscriptions/{subscription_id}/billing-anchor:
    post:
      tags: [sales]
      summary: Move the renewal date of a monthly membership
      description: |
        **Monthly cadences only — Square's limit, not a choice made here.**
        Sending this for a weekly plan is refused by Square with a message
        naming `monthly_billing_anchor_date`, a field the request never
        contained, which reads like a caller mistake and is not one: the
        endpoint exists only for cadences that have a day-of-month. This service
        therefore refuses a non-monthly cadence with `400` **before** calling
        Square, and names the remedy — `POST …/pause` with `resume_date` moves
        the renewal date for every other cadence.

        `effective_date` is the date the member wants to renew on from now on.
        This also derives `monthly_billing_anchor_date` from that date's day of
        the month, so "renew on the 3rd" keeps meaning the 3rd next month —
        which sending `effective_date` alone would not do.

        An anchor of 29, 30 or 31 is refused with `400` rather than silently
        becoming "the last day of a short month": the member's intent is
        unrecoverable there, and Square would pick a substitute day rather than
        ask.

        Square prorates the cycle the change lands in. It does not skip one and
        it does not double-bill.
      parameters:
        - name: subscription_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [effective_date]
              additionalProperties: false
              properties:
                effective_date:
                  type: string
                  format: date
                  description: The new renewal date. `YYYY-MM-DD`, studio wall clock.
      responses:
        '200':
          description: The subscription, with the anchor change in `actions`.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/SquareSubscription' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/subscriptions/{subscription_id}/actions/{action_id}:
    delete:
      tags: [sales]
      summary: Undo a scheduled change that has not taken effect yet
      description: |
        The only way back from a scheduled cancel, a pause or a queued anchor
        move. Square has no "uncancel", and `UpdateSubscription` cannot null a
        `canceled_date`.

        An `action_id` Square has already applied, or never had, is Square's own
        404 passed through. Nothing is inferred from a missing action.
      parameters:
        - name: subscription_id
          in: path
          required: true
          schema: { type: string }
        - name: action_id
          in: path
          required: true
          schema: { type: string }
          description: From `actions[].action_id` on the read or any write above.
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: The subscription, with that action gone from `actions`.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/SquareSubscription' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/subscriptions/mbo-probe:
    post:
      tags: [sales]
      summary: Dry-run a Mindbody cart type for the Square-subscription entitlement spike
      description: |
        Sends `checkoutshoppingcart` with `Test: true` and custom payment
        method 19 (`SQUARE_MBO_PAYMENT_METHOD_ID`) so we can see whether
        Mindbody accepts `Item.Type` of `Contract`, `GiftCard`, or `Product`
        — the unresolved half of a Square subscription (VERIFY.md §15).

        Always a dry run. There is no `test` field and no commit path.
        Never calls `purchasecontract` (that endpoint silently ignores
        `Test` and would enrol a real Autopay). Never writes a sale.

        The catalog id is loaded through the query builder (`contracts` or
        `products`); an id outside the key's site scope is a 404.
      parameters:
        - $ref: '#/components/parameters/site_id_required'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [client_id, location_id, probe, item_id]
              additionalProperties: false
              properties:
                client_id: { type: string }
                location_id: { type: string }
                probe:
                  type: string
                  enum: [contract, giftcard, product]
                  description: Which `Item.Type` to send (`Contract` / `GiftCard` / `Product`).
                item_id:
                  type: string
                  description: >
                    A contract_id (probe=contract), a product_id (probe=product),
                    or a gift_card_id from GET /v1/gift-cards (probe=giftcard).
                    Gift cards are not in the products mirror; an unknown
                    giftcard id is still sent to Mindbody.
      responses:
        '200':
          description: Mindbody accepted the Test cart. `sale_id` is always null.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          probe: { type: string, enum: [contract, giftcard, product] }
                          item_id: { type: string }
                          item_name: { type: [string, 'null'] }
                          mbo_item_type: { type: string, enum: [Contract, GiftCard, Product] }
                          client_id: { type: string }
                          location_id: { type: integer }
                          site_id: { type: string }
                          payment_method_id: { type: integer }
                          test: { type: boolean, enum: [true] }
                          sale_id: { type: 'null' }
                          membership_id: { type: [integer, 'null'] }
                          membership_name: { type: [string, 'null'] }
                          contract_items: {}
                          cart: { type: [object, 'null'] }
                          sub_total: { type: [number, 'null'] }
                          discount_total: { type: [number, 'null'] }
                          tax_total: { type: [number, 'null'] }
                          grand_total: { type: [number, 'null'] }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/subscriptions/mbo-entitle:
    post:
      tags: [sales]
      summary: Option 1 — re-sell a contract's standing ServicePricingOption
      description: |
        Loads the Mindbody contract, skips every `oneTimeItem` intro line,
        and sends the standing `ServicePricingOption` through
        `checkoutshoppingcart` as `Item.Type: Service` against custom
        payment method 19 (`SQUARE_MBO_PAYMENT_METHOD_ID`).

        `test` defaults true (dry run, `sale_id` null, no card). `test: false`
        charges Square first (requires `source_id` and `Idempotency-Key`),
        then commits the Mindbody sale labeled Square and records
        `ds_api.square_payment`. If Mindbody fails after the charge, Square
        is refunded. Never calls `purchasecontract`. Does **not** assign
        the contract's Membership. The `invoice.payment_made` webhook uses
        the same cart write without a second charge — Square already billed.

        A standing option flagged `saleInContractOnly` (Unlimited 11145)
        cannot go through the cart. A `test` dry-run with `promo_code`
        then prices from `ds_mbo.promo_code` (`promo_priced_from_catalog`)
        so Subscribe can still discount the first Square charge.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [client_id, location_id, mbo_contract_id]
              additionalProperties: false
              properties:
                client_id: { type: string }
                location_id: { type: string }
                mbo_contract_id: { type: string }
                item_id:
                  type: string
                  description: >
                    Optional standing ServicePricingOption id. Must be on
                    this contract and must not be a oneTimeItem. Defaults
                    to the first standing option.
                test:
                  type: boolean
                  default: true
                  description: Dry run unless explicitly false.
                source_id:
                  type: string
                  description: >
                    Web Payments SDK nonce. Required when `test` is false
                    unless `already_paid` is true.
                already_paid:
                  type: boolean
                  default: false
                  description: >
                    Skip the Square charge. For retrying the pack after a
                    paid subscription invoice. Do not send with source_id.
                notes:
                  type: string
                  maxLength: 255
                  description: >
                    Mindbody item SalesNotes. Payment Metadata.Notes is
                    discarded by MBO. Defaults to "Paid via Square" (or
                    that plus the Square payment / invoice id).
                promo_code:
                  type: string
                  description: >
                    Mindbody promo code on this pack sale. Subscribe uses
                    the same field for the first period only.
      responses:
        '200':
          description: Dry run accepted. `sale_id` is null.
        '201':
          description: Square charged and the Mindbody pack sale committed.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          mbo_contract_id: { type: string }
                          contract_name: { type: [string, 'null'] }
                          option_id: { type: string }
                          option_name: { type: [string, 'null'] }
                          option_price: { type: [number, 'null'] }
                          skipped_one_time_items:
                            type: array
                            items:
                              type: object
                              properties:
                                id: { type: string }
                                name: { type: [string, 'null'] }
                                price: { type: [number, 'null'] }
                          mbo_item_type: { type: string, enum: [Service] }
                          client_id: { type: string }
                          location_id: { type: integer }
                          site_id: { type: string }
                          payment_method_id: { type: integer }
                          test: { type: boolean }
                          sale_id: { type: [integer, 'null'] }
                          square_payment_id: { type: [string, 'null'] }
                          square_receipt_url: { type: [string, 'null'] }
                          notes: { type: string }
                          membership_id: { type: [integer, 'null'] }
                          membership_name: { type: [string, 'null'] }
                          membership_assigned: { type: boolean, enum: [false] }
                          cart: { type: [object, 'null'] }
                          sub_total: { type: [number, 'null'] }
                          discount_total: { type: [number, 'null'] }
                          tax_total: { type: [number, 'null'] }
                          grand_total: { type: [number, 'null'] }
                          promo_priced_from_catalog:
                            type: boolean
                            description: >
                              True when the cart refused a saleInContractOnly
                              pack and the promo was priced from ds_mbo.promo_code.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/subscriptions/mbo-credit:
    post:
      tags: [sales]
      summary: Option 2 — fund house credit from Account Payments item 13380
      description: |
        Funds a client's Mindbody house account so a later
        `purchasecontract` can use `UseAccountCredit` without a card.

        `via=cart` sends Account Payments item `13380` ("Square Account")
        through `checkoutshoppingcart` as `Item.Type: Product` with
        `Metadata.Price` / `Amount` set to the requested dollars. Live
        2026-08-21: MBO accepted the id but priced the catalog line at $0
        and 422'd a $1.01 payment.

        `via=purchaseaccountcredit` (default) calls
        `/sale/purchaseaccountcredit` with Custom method 19 and
        `AccountPaymentId: 13380`. Amount is first-class on this write.

        `test` defaults true (MBO only, no card charge). `test: false` is a
        real transaction: charge Square first (`source_id` +
        `Idempotency-Key` required), then fund the credit, and write
        `ds_api.square_payment` so `mbo-credit-refund` can send the money
        back. If Mindbody fails after the charge, Square is refunded
        automatically. `via=cart` is dry-run only.

        Never calls `purchasecontract`. Does **not** assign a Membership.
      parameters:
        - $ref: '#/components/parameters/site_id_required'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [client_id, location_id, amount]
              additionalProperties: false
              properties:
                client_id: { type: string }
                location_id: { type: string }
                amount:
                  type: number
                  exclusiveMinimum: 0
                  description: Dollars of house credit to fund.
                account_payment_id:
                  type: string
                  default: '13380'
                  description: Flow site Account Payments item "Square Account".
                via:
                  type: string
                  enum: [cart, purchaseaccountcredit]
                  default: purchaseaccountcredit
                test:
                  type: boolean
                  default: true
                source_id:
                  type: string
                  description: Web Payments SDK nonce. Required when test is false.
      responses:
        '200':
          description: Mindbody accepted the credit write (or the dry run).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          via: { type: string, enum: [cart, purchaseaccountcredit] }
                          account_payment_id: { type: string }
                          account_payment_name: { type: string }
                          client_id: { type: string }
                          location_id: { type: integer }
                          site_id: { type: string }
                          payment_method_id: { type: integer }
                          amount: { type: number }
                          account_balance_before: { type: [number, 'null'] }
                          test: { type: boolean }
                          sale_id: { type: [integer, 'null'] }
                          membership_assigned: { type: boolean, enum: [false] }
                          cart: { type: [object, 'null'] }
                          purchase: { type: [object, 'null'] }
                          amount_paid: { type: [number, 'null'] }
                          square_payment_id: { type: [string, 'null'] }
        '201':
          description: Square charged and Mindbody credited.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/subscriptions/mbo-contract:
    post:
      tags: [sales]
      summary: Charge Square and enrol a Mindbody contract
      description: |
        One write: charge Square for any house-credit shortfall, fund that
        credit, then `purchasecontract` with `UseAccountCredit`. The
        membership still shows Payment Method Account — Mindbody cannot
        take method 19 on that endpoint. The Square charge is the money;
        the credit+debit pair nets GIFT/Debit to $0.

        `test: true` (default) is local only. `purchasecontract` has no
        dry run and ignores `Test`. `test: false` requires
        `payment:write` and `contract:write`, `confirm_amount`, an
        `Idempotency-Key`, and `source_id` when a charge is needed.

        If Mindbody credit fails after the card charge, Square is
        refunded. If enrol fails after credit, Square stays charged —
        they have credit; retry enrol, do not charge again.
      parameters:
        - $ref: '#/components/parameters/site_id_required'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [client_id, location_id, contract_id]
              additionalProperties: false
              properties:
                client_id: { type: string }
                location_id: { type: string }
                contract_id: { type: string }
                confirm_amount:
                  type: number
                  description: |
                    Required when test is false. Must match `first_charge_total` (or
                    `first_charge_amount` before tax) within a few dollars — NOT
                    `first_payment_total`, which is Mindbody's pre-discount figure
                    on contracts with a built-in first-autopay discount.
                source_id:
                  type: string
                  description: Web Payments SDK nonce. Required when house credit is short.
                send_email: { type: boolean, default: false }
                test: { type: boolean, default: true }
      responses:
        '200':
          description: Local preflight. Nothing charged, no membership.
        '201':
          description: Square charged (if needed) and the membership was created.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/subscriptions/mbo-credit-refund:
    post:
      tags: [sales]
      summary: Option 2 — refund the Square charge for a cycle
      description: |
        Sends money back through Square for a charge this service recorded
        in `ds_api.square_payment`. Looks up by `mbo_sale_id` or
        `square_payment_id`. A Mindbody sale that was only labeled method 19
        (no ledger row) 404s — Square never took that money.

        Does **not** reverse Mindbody house credit or terminate a contract.
        `mbo_reversed` is always false. `Idempotency-Key` is required.
      parameters:
        - $ref: '#/components/parameters/site_id_required'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [client_id]
              additionalProperties: false
              properties:
                client_id: { type: string }
                mbo_sale_id: { type: string }
                square_payment_id: { type: string }
                amount:
                  type: number
                  exclusiveMinimum: 0
                  description: Dollars to refund. Defaults to whatever remains on the payment.
                reason: { type: string }
      responses:
        '200':
          description: Square accepted the refund. Mindbody is unchanged.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          client_id: { type: string }
                          mbo_sale_id: { type: [string, 'null'] }
                          square_payment_id: { type: string }
                          square_refund_id: { type: string }
                          square_refund_status: { type: string }
                          amount: { type: number }
                          mbo_reversed: { type: boolean, enum: [false] }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/reconcile-refunds:
    post:
      tags: [sales]
      summary: Refund Square when a staff member refunded the sale in Mindbody
      description: |
        The design doc's §4. **Reads the mirror, calls Mindbody not at all.**

        There is no refund *event*, but `clientSale.created` fires for the
        return sale itself — a POS return is a new sale with negative lines —
        and it reaches the webhook receiver within a second, so
        `ds_enriched.sale_payments` already holds the negative payment row. That
        row is the refund, and it carries the tender.

        This replaced a live `/sale/sales` poll that could not work: it pulled a
        three-day window with `limit 200, offset 0` and no pagination, on a site
        that books more than 200 sales in a day, and it separately inspected the
        *original* sale — which never carries the refund tender, only
        `Returned: true`.

        A refund labeled **Account** (method 16, or a tender named exactly
        "Account") is house credit and Square is left alone
        (`skipped_account`). Any other tender refunds the linked Square payment.

        The link back to Square is client + amount **within two cents** + the
        sale's item ids as a tiebreak. The tolerance is not fuzziness: Square is
        charged the contract's tax-inclusive total and Mindbody's own return line
        can total a cent less, so exact-cent matching never fired for a
        subscription charge.

        **One return settles one charge, once.** Each refund stamps the return
        sale's id onto the ledger row it settled (`mbo_return_sale_id`,
        unique-keyed), consumed returns are skipped, and the Square idempotency
        key derives from the return id so a re-pairing is rejected at Square
        itself. Requires sql/031; without it this endpoint refunds **nothing**
        (fail-closed) rather than matching blind — matching without the binding
        is what over-refunded $3.05 on 2026-08-21 (VERIFY.md §35). At most 5
        refunds per pass; the rest wait for the next pass behind an alert.

        Does **not** reverse Mindbody house credit or terminate a contract.
        There is no Public API for that. `payment:write` required. An in-process
        sweep runs the same reads every 60s as a backstop against a missed
        webhook — but only when flow-notify is configured, so unattended money
        movement always has a reachable human.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                client_id: { type: string }
                mbo_sale_id: { type: string }
      responses:
        '200':
          description: One row per sale inspected or refunded.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          type: object
                          properties:
                            action:
                              type: string
                              enum: [refunded, already_refunded, not_returned, no_match, skipped, skipped_account, error]
                            site_id: { type: string }
                            mbo_sale_id: { type: [string, 'null'] }
                            mbo_client_id: { type: [string, 'null'] }
                            square_payment_id: { type: [string, 'null'] }
                            square_refund_id: { type: [string, 'null'] }
                            amount: { type: [number, 'null'] }
                            detail: { type: [string, 'null'] }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/webhook-events:
    get:
      tags: [sales]
      summary: Square webhook deliveries this service has received
      description: |
        The audit trail behind `POST /webhooks/square`, most recent first —
        including deliveries whose signature failed to verify, flagged by
        `signature_verified`, because a forged or misconfigured delivery is
        exactly what you want a record of. `invoice.scheduled_charge_failed`
        also opens the +1h / +3d / +7d / +10 dunning ladder in `ds_api.square_dunning`.

        Not site-scoped: a Square webhook envelope carries a merchant id and a
        Square location id, neither of which maps to a Datastream site without
        first reading the payload.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 25 }
      responses:
        '200':
          description: Recent webhook deliveries.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/SquareWebhookEvent' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/contracts/{contract_id}/purchases:
    post:
      tags: [packages]
      summary: Sell a membership to a client
      description: |
        Buys a contract (membership/autopay) for a client via Mindbody's
        `purchasecontract`. Requires the `contract:write` scope — deliberately
        separate from `purchase:write`, because a membership creates a recurring
        billing obligation rather than a one-off charge.

        **Mindbody has no dry run for this endpoint**, and it silently ignores
        unknown fields — verified against production on 2026-08-02: `Test: true`
        and a deliberately bogus field produced byte-identical responses. So
        `test` is never forwarded. `test: true` (**the default**) runs a local
        preflight instead: it reads the contract, checks it is purchasable at the
        location Mindbody would check against, and returns the terms and the
        amounts that would be charged, with `validated_locally_only: true`. It
        cannot tell you whether the card will authorise or whether the studio's
        own rules allow the sale — only a real purchase does that.

        A real purchase (`test: false`) additionally requires **`confirm_amount`**,
        the first payment the caller expects to charge. It must match the catalog
        or the request is a `409`. There is no dry run and the amount is otherwise
        absent from the request, so this is what stops a consumer whose catalog has
        drifted from silently charging the wrong figure.

        **`first_month_discount`** mints a one-use Mindbody promo (`Amount`,
        `NumberOfAutopays: 1`, scoped to the contract's pricing options), sells
        with it, then deactivates. `confirm_amount` must be the catalog first
        payment minus that discount (a few dollars of tax slack). A promo that
        would discount every autopay is refused. Do not send `promo_code` at the
        same time.

        **Payment sources:** `use_account_credit` (draws on the client's
        Mindbody house account — Payment Method Account) or `stored_card`
        (charges the card on file). `UseAccountCredit` will open a tab if
        the balance is short; a real purchase therefore `409`s unless
        `account_balance` covers the first payment. Fund credit first with
        `POST /v1/payments/square/subscriptions/mbo-credit`. Raw card
        details are refused with a `400` — accepting a PAN would place this
        service in PCI DSS scope. `use_direct_debit` is refused too: Mindbody
        answers `Direct debit not enabled` for this site.

        Only a stale staff token is retried. The purchase itself never is — a
        retry Mindbody accepted the first time would enrol the client twice.
        Send an `Idempotency-Key`.
      parameters:
        - name: contract_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/idempotency_key'
        - $ref: '#/components/parameters/site_id_required'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ContractPurchaseRequest' }
      responses:
        '200':
          description: "Local preflight (`test: true`) — nothing was sent to Mindbody."
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/ContractPreflight' }
        '201':
          description: The membership, as Mindbody created it.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/ContractPurchase' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/schedule/roster:
    get:
      tags: [schedule]
      summary: Class roster (legacy query-param form)
      deprecated: true
      description: |
        Everyone booked into one class — name, booking status, whether they
        checked in. `class_id` as a query param rather than a path segment;
        kept for callers that haven't repointed to the equivalent path-form
        endpoint.
      parameters:
        - name: class_id
          in: query
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: A page of roster entries.

  /v1/staff:
    get:
      tags: [staff]
      summary: Staff and instructors
      description: |
        Backed by `ds_mbo.staff`.

        **Not yet populated:** `slug`. The mirror's staff entity does not carry
        it — the legacy API read it from `mb_flow_staff` on the warehouse.

        **`bio` is not returned here.** It is a long free-text biography that
        was ~62% of this endpoint's payload by weight at `limit=1000`; fetch it
        on demand from `GET /v1/staff/{staff_id}/bio` instead.
      parameters:
        - $ref: '#/components/parameters/site_id'
        - name: location_id
          in: query
          schema: { type: string }
        - name: active
          in: query
          schema: { type: boolean }
        - name: class_teacher
          in: query
          description: Restrict to staff who teach classes.
          schema: { type: boolean }
        - $ref: '#/components/parameters/modified_since'
        - $ref: '#/components/parameters/limit_batch'
        - $ref: '#/components/parameters/offset'
      responses:
        '200':
          description: A page of staff.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/StaffMember' }
        '304': { $ref: '#/components/responses/NotModified' }

    post:
      tags: [staff]
      summary: Create a staff member
      description: |
        Creates a Mindbody staff record via `staff/addstaff`. Requires
        `staff:write`, and is served only where the deployment has writes
        enabled.

        **The record has no login, and no API can give it one.** Mindbody's
        `addstaff` has no username or password field; a login can only be set
        in Manager Tools → Staff. `POST /v1/staff/{staff_id}/permissions`
        refuses a staff member without one, so the sequence is create here →
        set the login by hand → assign the permission group. The response says
        so in `next_step`.

        `class_teacher` and `appointment_instructor` default to **false**
        rather than to Mindbody's own default: the first consumer of this
        endpoint creates an identity for an app, and a staff member who can
        teach appears in every teacher picker in the business.

        `site_id` is required (not just a narrowing filter): there is no
        existing mirror row to read tenancy off before the staff member
        exists, so the caller's own site scope is checked directly against it.
      parameters:
        - $ref: '#/components/parameters/idempotency_key'
        - name: site_id
          in: query
          required: true
          description: |
            The 32-character Datastream site id to create the staff member in.
            Must be one of the key's own sites — anything else is a 403.
          schema: { type: string }
          example: 194b29112cf18b58d8a387198cfdc0db
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/NewStaffRequest' }
      responses:
        '201':
          description: The staff member, as Mindbody created it.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/CreatedStaffMember' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/staff/permission-groups:
    get:
      tags: [staff]
      summary: The site's Mindbody Role names
      description: |
        The permission-group names as they read in Manager Tools → Staff →
        **Role**. Requires `staff:read` or `staff:write`, not `raw:read`.

        Mindbody has no endpoint that lists a site's groups —
        `GET /staff/staffpermissions` reads one staff member. This catalog is
        the Flow business's Role dropdown, captured 2026-09-03 from Manager
        Tools, so a console can render a picker without hardcoding the names.
        It is **not** a live Mindbody read and will drift if a group is
        renamed upstream.

        `site_id` is still required (the key must name a site it holds) even
        though every Flow site shares this list today.
      parameters:
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: The known Role names, alphabetically.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/StaffPermissionGroupName' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/staff/active:
    get:
      tags: [staff]
      summary: The site's staff roster as Mindbody has it right now
      description: |
        The **live** active-staff roster, straight from Mindbody. Requires
        `staff:read` or `staff:write`.

        Use `GET /v1/staff` for a directory — it reads the mirror and costs
        nothing. Use this one when the question is *"who still works here?"*,
        because the mirror genuinely cannot answer it:

        Mindbody returns **only active staff** and carries no "was deactivated"
        flag, so absence from the response is the only signal that someone left.
        `ds_mbo.staff` is upserted by the sync, which means a departed staff
        member keeps `active = true` there indefinitely. Measured 2026-08-19 for
        the Flow site: **205 mirror rows marked active against 198 live**, and 4
        of the 7 extras were class teachers whose rows had not been touched for
        three weeks. A consumer reconciling a contact list or a substitute pool
        against the mirror keeps asking people who are gone.

        Two further reasons this exists rather than a mirror filter: `/v1/staff`
        exposes no phone number at all, and this response carries one per staff
        member.

        **Behind `staff:read` or `staff:write`, not `raw:read`.** `raw:read` is
        held by every read consumer including the public website, and this
        response carries staff mobile numbers and e-mail addresses. `staff:write`
        still opens it.

        `class_teacher=true` filters the result **after** the Mindbody call;
        Mindbody has no filter for it. One page at `limit=500`, un-paginated: the
        largest roster in the business is 201.

        `no-store`. A cached roster is a message to someone who left.
      parameters:
        - $ref: '#/components/parameters/site_id'
        - name: class_teacher
          in: query
          description: >-
            Restrict to staff who teach classes. Applied after the Mindbody call,
            not by Mindbody.
          schema: { type: string, enum: ['true', 'false'] }
      responses:
        '200':
          description: Every staff member Mindbody currently reports as active.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/LiveStaffMember' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/staff/session:
    post:
      tags: [staff]
      summary: Prove a Mindbody staff login
      description: |
        Verifies a Mindbody staff email + password via `/usertoken/issue` and
        returns who that login is. Used by Rally (and anything else that is a
        staff console) so people sign in with the same credentials as Portal.

        The token is discarded — this is identity, not a Mindbody session.
        Teachers are refused. A login gets a 200 when Mindbody reports
        `User.Type` Admin or Owner (Portal's `staff_type` gate), or when
        the permission group is one of Super Admin, Support Staff,
        Webmaster, Network Owner/Manager, Flow Manager, Assistant Manager.

        Admin and Owner tokens always have `User.Id` 0. That is not a
        usable `StaffId` — permissions are skipped unless the mirror can
        resolve a real staff id from the email.

        Tries each site in the key's scope that has an `mbo.siteId` until
        one accepts, unless `site_id` is named. Does **not** require
        `ENABLE_WRITES` or `staff:read`. The password is the credential.
        The stored password is never returned.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/StaffSessionRequest' }
      responses:
        '200':
          description: The staff member Mindbody authenticated.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/StaffSession' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/staff/{staff_id}:
    patch:
      tags: [staff]
      summary: Edit an existing staff record
      description: |
        Updates contact details on a Mindbody staff record via `updatestaff`.
        Requires `staff:write`, and is served only where the deployment has
        writes enabled.

        In practice this is the **phone fix**: a teacher says the number the
        studio holds for them is wrong, and this writes the correction back to
        Mindbody, which is the system of record every other copy converges from.
        Correcting it anywhere else produces a value the next sync overwrites —
        `ds_mbo` in particular has exactly one writer, the sync, so a patch
        written there is erased by the next pull.

        **Genuinely partial.** Only the fields named in the body reach Mindbody,
        which treats an absent key as "leave it alone". That matters most on the
        phone fields: sending an empty value for a number the caller never
        mentioned would wipe it.

        At least one field is required. An empty body would otherwise be a
        metered Mindbody call that changes nothing and answers `200`.

        **Does not write to Datastream** — the mirror reflects the new number on
        its next sync. A consumer that needs it immediately should use the value
        it just sent.
      parameters:
        - name: staff_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/idempotency_key'
        - $ref: '#/components/parameters/site_id'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateStaffRequest' }
      responses:
        '200':
          description: The staff record as Mindbody echoes it back.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/CreatedStaffMember' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/staff/{staff_id}/permissions:
    get:
      tags: [staff]
      summary: Read a staff member's permission group
      description: |
        What this staff member is allowed to do, read **live from Mindbody**
        (`/staff/staffpermissions`) rather than from the mirror. `ds_mbo.staff`
        carries no permissions and nothing syncs them, so there is no mirrored
        answer to give — the same reason
        `GET /v1/schedule/{class_id}/services` is a live read.

        Returns **one staff member's** group. Mindbody has no endpoint that lists
        the permission groups a business has defined. `GET
        /v1/staff/permission-groups` is our catalog of the Flow Role dropdown
        for a picker; the matching `POST` still takes a group *name*.

        Requires **`staff:read` or `staff:write`**, not `raw:read`. `raw:read` is
        held by every read consumer including the public website; enumerating who
        can do what in a studio's Mindbody does not belong on that key.
        `staff:read` lets a console render this without also creating staff.
        `staff:write` still opens it.

        Served `no-store`: this is the answer to "what can they do right now",
        usually read immediately before changing it.

        Needs Mindbody staff credentials for the site, so a site without them
        returns 503. Not gated on `ENABLE_WRITES` — it changes nothing.
      parameters:
        - name: staff_id
          in: path
          required: true
          schema: { type: string }
          example: '100000015'
        - name: site_id
          in: query
          required: true
          description: The 32-character Datastream site id. Must be one of the key's own.
          schema: { type: string }
          example: 194b29112cf18b58d8a387198cfdc0db
      responses:
        '200':
          description: The staff member's permission group, as Mindbody reports it.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/StaffPermissionGroup' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }
    post:
      tags: [staff]
      summary: Assign a staff member's permission group
      description: |
        Puts a staff member in a named permission group via
        `staff/updatestaffpermissions`. Requires `staff:write`, and is served
        only where the deployment has writes enabled.

        **The staff member must already have a login.** Mindbody rejects the
        call outright otherwise, and that rejection passes through as a 422
        carrying Mindbody's own wording.

        The group is named. Mindbody has no list API —
        `GET /v1/staff/permission-groups` is our catalog of the Flow Role
        dropdown, not an upstream read. A name that does not exist in Manager
        Tools is a 422.

        POST rather than PUT: Mindbody's verb takes a group name, not a
        representation of the permissions, so there is no document for a PUT
        to replace.
      parameters:
        - name: staff_id
          in: path
          required: true
          schema: { type: string }
        - name: site_id
          in: query
          required: true
          description: |
            The 32-character Datastream site id the staff member belongs to.
            Must be one of the key's own sites — anything else is a 403.
            Required because Mindbody staff ids are per-site sequential and
            collide across sites, so an id alone does not identify a tenant.
          schema: { type: string }
          example: 194b29112cf18b58d8a387198cfdc0db
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/StaffPermissionsRequest' }
      responses:
        '200':
          description: The permission group, as Mindbody echoed it back.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/StaffPermissionGroup' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/keys/self/mbo-identity:
    get:
      tags: [identity]
      summary: Which Mindbody staff user this key sells as
      description: |
        The acting Mindbody login on the **calling key** — the staff user
        Mindbody stamps as "Sold By" on every sale that key makes. Requires
        `identity:write`.

        `configured: false` means the key falls back to its site's shared
        login, which is the default and why most keys' sales are
        indistinguishable in a studio's reports.

        **The stored password is never returned**, here or anywhere.

        There is no key id in this path, and no parameter that takes one: the
        only key any verb here can reach is the one that authenticated the
        request.
      responses:
        '200':
          description: The key's acting identity.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/KeyMboIdentity' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

    put:
      tags: [identity]
      summary: Set the Mindbody login this key sells as
      description: |
        Stores a Mindbody staff login on the **calling key**, so its sales are
        stamped "Sold By" that staff member rather than the site's shared API
        user. Requires `identity:write`, and is served only where the
        deployment has writes enabled.

        `identity:write` is deliberately separate from `purchase:write`: a key
        that may sell should not thereby be able to change who it sells as.

        **The login is verified before it is stored.** It must mint a real
        Mindbody token against `site_id` first; a rejection is a 422 carrying
        Mindbody's own wording and **nothing is written**. This exists because
        a password wrong by one character otherwise stores cleanly and fails
        hours later, mid-sale, at a kiosk.

        **Replacing an existing identity requires `replace: true`** — a 409
        otherwise. Overwriting re-attributes every future sale, and finding
        that out from a payroll report is the failure this prevents.

        **The key must be bound to exactly one site** — a 409 otherwise. The
        stored login is looked up by key id with no site in the query, so one
        login would be used for every site the key holds, and Mindbody logins
        are per site.

        Setting an identity drops the key's cached Mindbody tokens, so the
        change takes effect on the next sale rather than after Mindbody's
        24-hour token expiry.
      parameters:
        - name: site_id
          in: query
          description: >
            Accepted for consistency with the rest of `/v1`, but the site the
            login is verified against comes from the request body.
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SetKeyMboIdentityRequest' }
      responses:
        '200':
          description: The stored identity, with the Mindbody user it resolved to.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/KeyMboIdentitySet' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

    delete:
      tags: [identity]
      summary: Revert this key to its site's shared login
      description: |
        Removes the acting login from the **calling key**, so its sales go back
        to being stamped with the site's shared API user. Requires
        `identity:write`, and is served only where the deployment has writes
        enabled.

        This is the way back when a Mindbody staff login is disabled or its
        password changes: every sale that key makes fails until it is either
        fixed or cleared, and clearing it should not require a database
        session.

        Returns 200 with `cleared: false` when nothing was set, rather than a
        404 — the caller asked for a state and that is the state it gets.
      responses:
        '200':
          description: The key, now on its site's shared login.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/KeyMboIdentityCleared' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/staff/{staff_id}/photo:
    get:
      tags: [staff]
      summary: Authored staff profile photo
      description: |
        Bytes Rally stored for this staff member in `ds_api.staff_photo`.
        Mindbody Public API v6 can read `imageUrl` and cannot write one, so
        a photo change cannot live on the mirror. `site_id` is required —
        staff ids collide across sites. `raw:read` or `staff:read`.
        Without sql/043 this 404s rather than 500ing.
      parameters:
        - name: staff_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
        - name: t
          in: query
          required: false
          schema: { type: string }
          description: Cache-buster. Ignored by the handler.
      responses:
        '200':
          description: The stored image.
          content:
            image/jpeg: { schema: { type: string, format: binary } }
            image/png: { schema: { type: string, format: binary } }
            image/webp: { schema: { type: string, format: binary } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    put:
      tags: [staff]
      summary: Set a staff profile photo
      description: |
        Stores a JPEG, PNG, or WebP on `ds_api.staff_photo` and overlays
        `image_url` on `GET /v1/staff`. Does not call Mindbody — UpdateStaff
        has no image field. Requires `staff:write` and ENABLE_WRITES.
        Body is base64, max 600KB decoded. Rally resizes first.
      parameters:
        - name: staff_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [image_base64]
              properties:
                image_base64: { type: string }
      responses:
        '200':
          description: The overlay image_url Rally should paint.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }
    delete:
      tags: [staff]
      summary: Clear an authored staff photo
      description: |
        Drops the `ds_api.staff_photo` row so `GET /v1/staff` falls back
        to Mindbody's mirrored `imageUrl`. Requires `staff:write`.
      parameters:
        - name: staff_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: Overlay cleared.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/staff/{staff_id}/bio:
    get:
      tags: [staff]
      summary: One staff member's biography
      description: |
        A teacher's full-text biography, keyed by `staff_id` — the same field
        every `/v1/staff` row carries. Split out of `/v1/staff` because `bio`
        was ~62% of that endpoint's payload by weight at `limit=1000`; this
        route is meant to be fetched on demand (e.g. when a user expands a
        teacher profile) and cached long by the client, not called once per
        staff row.
      parameters:
        - name: staff_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: The staff member's bio.
          headers:
            ETag: { $ref: '#/components/headers/ETag' }
            Cache-Control:
              schema: { type: string, example: 'private, max-age=3600' }
              description: >
                Static reference content — cached far longer than a single
                `/v1/staff` page refresh so repeat lookups of the same
                `staff_id` are served from the browser's HTTP cache.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/StaffBio' }
        '304': { $ref: '#/components/responses/NotModified' }
        '404': { $ref: '#/components/responses/NotFound' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/locations:
    get:
      tags: [locations]
      summary: Studio locations
      description: |
        Backed by `ds_mbo.location`. PLAN.md §6 listed `ds_config` as the source,
        but `ds_config.sites` is the tenant registry and has no addresses; the
        mirror holds the data matching this shape. Requires `raw:read`.

        **Not yet populated:** `slug`, `image_url`. `amenities` passes through MBO's
        array of objects rather than the array of strings the legacy schema
        advertised.

        Each row also carries `content` — hand-authored fields Mindbody has no
        equivalent for (`ds_api.location_content`, sql/035), including the
        populated `slug` and a Google Maps URL. It is `null` where nobody has
        authored any, and `null` throughout on a deployment without sql/035.
      parameters:
        - $ref: '#/components/parameters/site_id'
        - name: has_classes
          in: query
          schema: { type: boolean }
        - $ref: '#/components/parameters/modified_since'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/offset'
      responses:
        '200':
          description: A page of locations.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Location' }
        '304': { $ref: '#/components/responses/NotModified' }

  /v1/events:
    get:
      tags: [events]
      summary: Enrollments, workshops, and events
      description: |
        Backed by `ds_mbo.enrollment`.

        **Not yet populated:** `max_capacity`, `total_booked` — the enrollment
        entity has no counts, and the legacy API aggregated them warehouse-side.
        `pricing` is not served at all: it came from `cms_pricing` on the Portal,
        which is another product's schema (CLAUDE.md rule 5).

        `image_url` is served, but sourced differently from every other field
        here: `flow_cms_posts` / `flow_cms_post_attachments` are also the
        Portal's schema, so this API reads them over the Portal's
        events-to-thirdparty HTTP API (keyed by `class_description_id`, not
        `event_id`) rather than joining them in SQL. It is `null` whenever the
        Portal has no matching post/attachment, and whenever
        `PORTAL_EVENTS_API_URL` / `PORTAL_EVENTS_API_KEY` are unset — a Portal
        outage costs a missing image, never a failed request.
      parameters:
        - $ref: '#/components/parameters/site_id'
        - name: start_date
          in: query
          description: >
            Events that start on or after this date. Does not keep a long-running
            enrollment whose first day is already in the past (use the event
            itself if you need the full span).
          schema: { type: string, format: date }
        - name: end_date
          in: query
          description: Events that start on or before this date.
          schema: { type: string, format: date }
        - name: location_id
          in: query
          schema: { type: string }
        - name: category
          in: query
          description: Style of the description ("Yoga", "Meditation"), not the kind of event.
          schema: { type: string }
        - name: class_description_id
          in: query
          description: >
            All scheduled dates for one class description — i.e. every occurrence
            of a single Publisher post. Note `flow_cms_posts.mbo_enrollment_id`
            stores this id, not `event_id`.
          schema: { type: string }
        - name: session_type
          in: query
          description: MBO's own label — Event, Community, Workshop, Retreat, Teacher Trainings, ...
          schema: { type: string }
        - name: program
          in: query
          description: MBO program the description is filed under — Event Single-Day, Workshops, Retreats, Teacher Trainings, ...
          schema: { type: string }
        - name: event_type
          in: query
          description: >
            Retreats and trainings folded out of `session_type` OR `program`;
            everything else is `event`. Either signal is enough — a Continuing
            Education session under the Teacher Trainings program is a training.
            Program IDs are not consulted: MBO reuses id 23 for both
            "Event Single-Day" and "Workshops".
          schema: { type: string, enum: [event, training, retreat] }
        - $ref: '#/components/parameters/modified_since'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/offset'
      responses:
        '200':
          description: A page of events.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/EventSummary' }
        '304': { $ref: '#/components/responses/NotModified' }

  /v1/events/{event_id}:
    get:
      tags: [events]
      summary: One event
      description: |
        A single enrollment — a training, retreat, or workshop by MBO
        `event_id`. An event is a date RANGE, not one class instance: a
        mentorship can run May → January as one row, so this shows the span
        rather than pretending it's a single session.
      parameters:
        - name: event_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: The event.
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/events/{event_id}/services:
    get:
      tags: [events]
      summary: Ticket tiers for an event
      description: |
        What a place at this event costs. Requires `raw:read`.

        **Live Mindbody, not the mirror** — same reason as
        `/v1/schedule/{class_id}/services`: the catalog is mirrored but which
        options a given schedule accepts is not.

        In Mindbody an enrolment IS a class schedule, so this asks
        `/sale/services` by `classScheduleId` (the event id), not by `classId`
        (one occurrence). The distinction matters: an occurrence key returns a
        plausible list for the wrong thing.

        Only options the studio sells online are returned by default; a staff
        comp tier flagged `SellOnline: false` is not something a kiosk may sell.
        `Cache-Control: private, max-age=300`.
      parameters:
        - name: event_id
          in: path
          required: true
          schema: { type: string }
        - name: free_only
          in: query
          schema: { type: string, enum: ['true', 'false'] }
        - name: sell_online
          in: query
          description: Defaults to `true`. Pass `false` to include options not sold online.
          schema: { type: string, enum: ['true', 'false'] }
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: The tiers on sale for this event.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/EventTicketTier' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/clients/{client_id}/has-credits:
    get:
      tags: [clients]
      summary: What a client can currently pay with
      description: |
        Everything this client holds that is valid on a date, sorted by what
        kind of booking it pays for. Requires `raw:read`; writes nothing.

        The generic credit question. **No event, class or booking is named** —
        it answers "what do they hold", not "is that enough for X". Deciding a
        specific booking belongs to that booking's endpoint:
        `POST /v1/events/{event_id}/enrollments` calls this same logic and then
        intersects `client_event_services` with that event's own pricing
        options.

        **Three client-pack endpoints exist and they are easy to confuse:**

        | | `/services` | `/credits` | this |
        |---|---|---|---|
        | source | mirror | mirror | **live Mindbody** |
        | freshness | lags a sync window | lags a sync window | now |
        | shape | raw rows | usable rows | three buckets by booking type |
        | cached | yes | 300s | never |

        Use `/services` to list a client's packs and `/credits` for a "My
        Passes" view. Use this one to decide a booking — a pack sold two
        minutes ago has to count, and the mirror can be a sync window behind.

        **Which id each bucket carries differs, and it matters.**
        `client_event_services` holds catalog **product** ids, because that is
        what an event's pricing-option list names. `client_services` holds the
        client's own **service** ids, which is what `addclienttoclass` takes.

        **`NO_CREDITS` is a 200**, not an error: "holds nothing" is a
        successful answer to the question asked.

        A pack's validity depends on a date — an unused Day Pass skips its
        active/expiry window because its clock has not started, while every pack
        must still not have expired. `check_date` defaults to today in the
        studio's timezone; set it to ask whether a pack will still be good on a
        future date.
      parameters:
        - name: client_id
          in: path
          required: true
          schema: { type: string }
        - name: check_date
          in: query
          description: 'YYYY-MM-DD. Defaults to today in the studio timezone.'
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: The client's credits. `NO_CREDITS` is a normal answer here.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/ClientHasCredits' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/events/{event_id}/enrollments:
    post:
      tags: [events]
      summary: Enrol a client in an event
      description: |
        Puts a client in an event. Requires `booking:write`, and writes only
        where the deployment has writes enabled.

        **This does not write to Datastream** — the enrolment is proxied to
        Mindbody and the mirror picks it up on the next sync.

        **One enrolment buys the series.** `enroll_date_forward` defaults to
        today in the studio's own timezone, which takes every remaining date of
        a run already under way and every date of one that has not started.
        Pass it explicitly to start somewhere else. Past dates are never
        enrolled into.

        **This endpoint does not take payment, but it does now check for it.**
        Mindbody's `addclienttoenrollment` has no `RequirePayment` (unlike
        `addclienttoclass`) and will enrol whoever it is given, so the check is
        made here: a client who holds no pricing option this event accepts is
        refused with `payment_required` (422) and nothing is sent to Mindbody.
        Sell the ticket first — `GET /v1/events/{event_id}/services` lists what
        this event takes, and `GET /v1/clients/{client_id}/has-credits`
        shows what the client already holds without attempting the write.

        The response reports `final_service_id` — the option that authorised the
        enrolment. It is **not** sent to Mindbody and is not a receipt:
        `addclienttoenrollment` has no `ClientServiceId` field, so Mindbody
        chooses which pack to draw down itself.

        Mindbody does not dedupe: the same client enrolled twice takes two
        spots. Send an `Idempotency-Key` and a repeat replays the first
        response.

        The response carries `dates` — every occurrence the enrolment landed on,
        which is how a caller finds the one happening today to check someone in
        against (`POST /v1/schedule/{class_id}/roster/{visit_id}/check-in`).
      parameters:
        - name: event_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/idempotency_key'
        - $ref: '#/components/parameters/site_id'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [client_id]
              properties:
                client_id: { type: string }
                enroll_date_forward:
                  type: string
                  description: 'YYYY-MM-DD. Defaults to today in the studio timezone.'
                send_email: { type: boolean, default: false }
                test: { type: boolean, default: false }
      responses:
        '201':
          description: The enrolment, as Mindbody created it.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/EventEnrollment' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/events/detail:
    get:
      tags: [events]
      summary: One event (legacy query-param form)
      deprecated: true
      description: |
        Same event lookup as `/v1/events/{event_id}`, with `event_id` as a
        query param instead of a path segment. Kept for callers that haven't
        repointed yet.
      parameters:
        - name: event_id
          in: query
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: The event.

  /v1/packages:
    get:
      tags: [packages]
      summary: Purchasable services and packages
      description: |
        Backed by `ds_mbo.service`. Hides discontinued packages unless
        `discontinued=true`.

        The legacy `location_id` filter is deliberately not implemented — it would
        require matching inside the `sellAtLocationIds` JSON array, whose element
        type is unverified, and a filter that quietly matches nothing is worse than
        an absent one. `location_ids` is returned on every row for client-side
        filtering.

        **Not yet populated:** `description`, `is_auto_renewing`.
      parameters:
        - $ref: '#/components/parameters/site_id'
        - name: q
          in: query
          description: >
            Free-text search over `package_name` (case-insensitive substring).
            Minimum 2 characters; shorter is a 400. Composes with the other
            filters by AND, so `?q=intro&sell_online=true` narrows both ways.
            `description` is not searched — it is empty on every service row.
          schema: { type: string, minLength: 2 }
        - name: sell_online
          in: query
          schema: { type: boolean }
        - name: is_intro_offer
          in: query
          schema: { type: boolean }
        - name: discontinued
          in: query
          schema: { type: boolean, default: false }
        - name: type
          in: query
          schema: { type: string }
        - $ref: '#/components/parameters/modified_since'
        - $ref: '#/components/parameters/limit_batch'
        - $ref: '#/components/parameters/offset'
      responses:
        '200':
          description: A page of packages.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Package' }
        '304': { $ref: '#/components/responses/NotModified' }

  /v1/contracts:
    get:
      tags: [packages]
      summary: The contract catalog — memberships and autopays a client can buy
      description: >
        ds_mbo.sale_contract: what is FOR SALE, as opposed to
        /v1/clients/{client_id}/contracts, which is what a client HOLDS.


        MBO returns contracts per location, so a contract sold at three studios
        appears three times, each row carrying its own `location_id`. Pricing is
        quoted as `first_payment_total`, `recurring_payment_total` and
        `total_contract_amount`; `contract_items` carries the line items the
        contract grants.


        `first_payment_total` is Mindbody's own figure and is PRE-discount on
        contracts with a built-in first-autopay discount ("30 Days for $30" reads
        90.19 there and 30.00 on Mindbody's contract screen). Every row therefore
        also carries `first_charge_amount` / `first_charge_tax` /
        `first_charge_total` (day-one charge from the contract terms net of
        `built_in_discount`) — quote and confirm from those.


        `description` is not included in this list response — it was 69.5KB of
        a 214KB payload (31%) across 174 rows. Fetch it on demand from
        `GET /v1/contracts/{contract_id}/description` rather than expecting it
        inline.

        Carries `source` as upstream provenance, `"mbo"` today (see
        docs/DS-ENRICHED-V2.md).
      parameters:
        - {name: sold_online, in: query, schema: {type: boolean}}
        - {name: is_intro_offer, in: query, schema: {type: boolean}}
        - {name: autopay_enabled, in: query, schema: {type: boolean}}
        - {name: location_id, in: query, schema: {type: string}}
        - {name: modified_since, in: query, schema: {type: string, format: date-time}}
      responses:
        '200': {description: List envelope of contracts}
  /v1/contracts/{contract_id}:
    get:
      tags: [packages]
      summary: One contract from the catalog
      description: >
        The sellable membership definition itself (terms, pricing, autopay
        schedule) — not a client's holding of it. For what a specific client
        actually has, see `/v1/clients/{client_id}/contracts`. Includes
        `description`, unlike the list response.
      parameters: [{name: contract_id, in: path, required: true, schema: {type: string}}]
      responses:
        '200': {description: Contract}
        '404': {description: Not found within the key's site scope}
  /v1/contracts/{contract_id}/description:
    get:
      tags: [packages]
      summary: One contract's description
      description: |
        The contract name and full HTML description, keyed by `contract_id` —
        the same field every `/v1/contracts` row carries. Split out of
        `/v1/contracts` because the description text was ~31% of that
        endpoint's payload by weight; this route is meant to be fetched on
        demand (e.g. when a user expands a contract for details) and cached
        long by the client, not called once per list row.
      parameters:
        - name: contract_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: The contract's description.
          headers:
            ETag: { $ref: '#/components/headers/ETag' }
            Cache-Control:
              schema: { type: string, example: 'private, max-age=3600' }
              description: >
                Static reference content — cached far longer than
                `/v1/contracts`'s 900s so repeat lookups of the same
                `contract_id` are served from the browser's HTTP cache.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/ContractDescription' }
        '304': { $ref: '#/components/responses/NotModified' }
        '404': { $ref: '#/components/responses/NotFound' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /v1/rooms:
    get:
      tags: [locations]
      summary: Bookable rooms at the site
      description: |
        `ds_mbo.resource` — `{room_id, name, location_id}` — plus `capacity`.

        **`capacity`** is how many people fit in the room. MBO has no such
        field: `/site/resources` returns `{Id, Name}` and nothing else, so this
        is merged in code from `ds_api.room_capacity` rather than mapped off the
        mirror. It fails soft, so `capacity` is `null` on every room until
        `sql/040` is applied. Afterwards `null` still means **nobody has
        authored one** — distinct from `0`, which means the room cannot be
        booked.

        **Read only for now.** There is no write endpoint yet, so
        `/publisher/room-capacities` on the Portal is still where staff change a
        capacity, and a change there does **not** reach this table. Expect the
        two to disagree until the write path lands.

        `location_id` exists because the sync now pulls rooms one location at a
        time (itflow_datastream#50). MBO's `/site/resources` returns `{Id, Name}`
        and no location, so the pairing comes from which location was asked for
        rather than from the response.

        **A NULL `location_id` means unknown, not "no location".** Rows written
        before that change, and rooms at a site whose location list could not be
        read, carry none — and are therefore absent from a `location_id`-filtered
        result. An unfiltered call is still the way to see every room.

        Carries `source` as upstream provenance, `"mbo"` today (see
        docs/DS-ENRICHED-V2.md).
      parameters:
        - name: location_id
          in: query
          description: >
            Mindbody's numeric location id, as returned by `GET /v1/locations`.
            Rooms with no recorded location are excluded.
          schema: {type: string}
          example: '3'
        - {name: modified_since, in: query, schema: {type: string, format: date-time}}
      responses:
        '200': {description: List envelope of rooms}
  /v1/products:
    get:
      tags: [packages]
      summary: The retail catalog — merchandise, not class packs
      description: >
        `ds_mbo.product`: water bottles, apparel, cacao, gift cards. Distinct from
        `/v1/packages`, which is `ds_mbo.service` (class packs and drop-ins).


        MBO's `/sale/products` was not pulled until 2026-08-01, so this is empty
        until the sync's next SalesFamily run — an empty list here means "not
        synced yet", not "no retail". Every field except `product_id` and
        `site_id` is currently unverified against real rows; see
        `/status/schema-assumptions`.

        Carries `source` as upstream provenance, `"mbo"` today (see
        docs/DS-ENRICHED-V2.md).
      parameters:
        - name: q
          in: query
          description: >
            Free-text search by name or product code — matches `name`,
            `product_id` and `barcode_id` (case-insensitive substring across all
            three). Minimum 2 characters; shorter is a 400. Composes with the
            other filters by AND, so `?q=mat&category_id=36` narrows both ways.
            `description` is not searched — it is empty on every product row.
          schema: { type: string, minLength: 2 }
        - {name: category_id, in: query, schema: {type: string}}
        - {name: sell_online, in: query, schema: {type: boolean}}
        - {name: discontinued, in: query, schema: {type: boolean}}
        - {name: modified_since, in: query, schema: {type: string, format: date-time}}
      responses:
        '200': {description: List envelope of retail products}
  /v1/products/{product_id}:
    get:
      tags: [packages]
      summary: One retail product
      description: A single physical/retail item from the catalog (mats, apparel, gift cards) — not a class package or membership contract.
      parameters: [{name: product_id, in: path, required: true, schema: {type: string}}]
      responses:
        '200': {description: Product}
        '404': {description: Not found within the key's site scope}
  /v1/appointments:
    get:
      tags: [schedule]
      summary: 1:1 appointments — the private-session counterpart to /v1/schedule
      description: >
        ds_enriched.appointments — massage, private yoga, intake assessments.
        A studio that sells these has staff time and revenue here that appears
        nowhere in the class endpoints.


        `status` is Mindbody verbatim and an OPEN set (Completed, Booked,
        Cancelled, Arrived, …), passed through rather than mapped to an enum.
        Cancelled rows are INCLUDED by default, because a report on lost revenue
        needs them; pass `exclude_cancelled=true` for the day view a front desk
        wants.


        `client_id` is a string, not an integer. Mindbody lets a site choose its
        own client id format and at least one live site uses the phone number
        ("(512) 689-5226"), so do not parse it as numeric. `client_name` and
        `client_email` are denormalized onto the row and are **`null` when the
        client is not mirrored yet** — fall back to `client_id` rather than
        rendering an empty row.


        **Show `program_name`, not `session_type_id`.** Mindbody puts no service
        NAME on an appointment, only a numeric session type nothing can resolve.
        `program_name` is the studio's own category — "Massage", "Skincare",
        "Acupuncture", "Yoga Privates" — resolved through the service catalog,
        and is the label a front desk reads.


        Two source fields are deliberately absent: `notes` (free-text staff
        scratch carrying client phone numbers and medical detail) and
        `onlineDescription` (the service's marketing HTML, identical across every
        appointment of a session type). Both remain in ds_mbo.appointment.


        Carries `source` as upstream provenance, `"mbo"` today (see
        docs/DS-ENRICHED-V2.md).
      parameters:
        - {name: client_id, in: query, schema: {type: string}, description: Mindbody client id — a string, not always numeric}
        - {name: client, in: query, schema: {type: string}, description: Client display name, for a caller without the id}
        - {name: program, in: query, schema: {type: string}, description: Studio category, e.g. Massage or Skincare}
        - {name: staff_id, in: query, schema: {type: string}, description: Provider}
        - {name: provider, in: query, schema: {type: string}, description: Provider display name, for a caller without the id}
        - {name: location_id, in: query, schema: {type: string}}
        - {name: location, in: query, schema: {type: string}, description: Location display name}
        - {name: status, in: query, schema: {type: string}, description: Exact match on the Mindbody status, an open set}
        - {name: exclude_cancelled, in: query, schema: {type: boolean}, description: Drop Cancelled rows without enumerating every other status}
        - {name: session_type_id, in: query, schema: {type: string}}
        - {name: staff_requested, in: query, schema: {type: boolean}, description: Client asked for this provider by name}
        - {name: first_appointment, in: query, schema: {type: boolean}}
        - {name: start_after, in: query, schema: {type: string, format: date-time}}
        - {name: start_before, in: query, schema: {type: string, format: date-time}}
        - {name: modified_since, in: query, schema: {type: string, format: date-time}}
      responses:
        '200': {description: List envelope of appointments}
  /v1/appointments/{appointment_id}:
    get:
      tags: [schedule]
      summary: One appointment
      parameters:
        - {name: appointment_id, in: path, required: true, schema: {type: string}}
      responses:
        '200': {description: Item envelope of the appointment}
        '404': {description: No appointment with that id in this key's site scope}
  /v1/rooms/{room_id}/capacity:
    put:
      tags: [locations]
      summary: Set or clear how many people fit in a room
      description: >
        The only place a room's capacity can be written. Mindbody has no
        capacity on a resource — `/site/resources` returns `{Id, Name}` — so
        there is no upstream to proxy to, and this writes `ds_api`, the schema
        this service owns.


        `site_id` is required in the body rather than inferred from the key.
        Room ids are per-site sequential, a key may hold several sites, and a
        request about the wrong studio should fail before it changes anything.
        The value is checked against the key's site scope, never trusted.


        `capacity: null` is a real value — "someone looked at this room and
        left it blank" — and it **overwrites**. The field must be present:
        omitting it is a 400, so clearing a capacity is always deliberate.


        Requires `config:write`, deliberately not implied by `config:read`,
        plus the `ENABLE_WRITES` kill switch. No `Idempotency-Key` — setting
        the same number twice is the same result.


        The Portal's /publisher/room-capacities writes its own separate table
        and the two do **not** sync. Pick one and stay there.
      parameters:
        - {name: room_id, in: path, required: true, schema: {type: string}, description: Mindbody resource id}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [site_id, capacity]
              properties:
                site_id:     {type: string, description: "32-char site id, must be in the key's scope"}
                capacity:    {type: integer, nullable: true, minimum: 0, description: "Null clears it"}
                location_id: {type: string, nullable: true, description: "Stored for reference; does not move the room"}
                room_name:   {type: string, nullable: true, description: "Stored so a row is recognisable by eye"}
      responses:
        '200': {description: Item envelope of the stored value}
        '400': {description: "capacity absent, negative, fractional, or an unknown body field"}
        '403': {description: "Missing config:write, writes disabled, or a site outside the key scope"}
  /v1/courses:
    get:
      tags: [classes]
      summary: Courses — the enrollment catalog a publisher picks from
      description: >
        ds_mbo.class_description — what Mindbody's back office calls a COURSE
        under Services & Pricing → Enrollments, where each program ("Event
        Single-Day", "Retreats", "Teacher Trainings") holds a list of courses
        and a course is what a studio schedules an enrollment from. MBO's API
        noun for the same record is a class description.


        Do not confuse it with `/v1/class-descriptions/{class_schedule_id}`,
        which is a projection of ds_enriched.classes for CLASS copy. Different
        table, different question.


        PREFER `program_ids`, WITH A `site_id`. Measured on live data: within
        one site, id 23 carries both "Event Single-Day" (319 courses) and
        "Workshops" (243), and id 41 carries both "Event Multi-Day" and
        "Workshop Series". A course stores the program name as it was when the
        row synced, so a renamed program leaves older rows under the old label —
        the id survives a rename and the name does not. `program_names`
        therefore returns fewer rows than it should whenever a program has been
        renamed. It stays available for a caller holding only a label (the names
        match `program` on `/v1/events`), but reach for the id first. Ids are
        per-site, so pair `program_ids` with `site_id` unless the key is already
        scoped to one site.


        Both accept a CSV, because the question a publisher asks spans programs.


        `session_type_id`, `category_id`, `subcategory`, `subcategory_id`,
        `image_url` and `is_active` are mapped from MBO's ClassDescription
        contract but **not yet observed populated** — the enriched transform
        reads only the name paths. See docs/VERIFY.md §45. Prefer the mirror's
        own soft-delete flag over `is_active`; the query builder already applies
        it. `source` is the literal `"mbo"` until sql/039 adds the column.


        Carries `source` as upstream provenance, `"mbo"` today (see
        docs/DS-ENRICHED-V2.md).
      parameters:
        - {name: program_ids, in: query, schema: {type: string}, description: 'CSV of program ids, max 25. Preferred — survives a program rename. Pair with site_id'}
        - {name: program_names, in: query, schema: {type: string}, description: 'CSV of program names, max 25. Misses rows when a program was renamed'}
        - {name: session_type_name, in: query, schema: {type: string}}
        - {name: category, in: query, schema: {type: string}}
        - {name: modified_since, in: query, schema: {type: string, format: date-time}}
      responses:
        '200': {description: List envelope of courses}
        '400': {description: More than 25 programs named}
  /v1/courses/{course_id}:
    get:
      tags: [classes]
      summary: One course
      description: >
        ds_mbo.class_description by id. Carries `source` as upstream
        provenance. See /v1/courses for the unverified fields.
      parameters:
        - {name: course_id, in: path, required: true, schema: {type: string}}
      responses:
        '200': {description: Item envelope}
        '404': {description: No course with that id in the key's site scope}
  /v1/promo-codes:
    get:
      tags: [packages]
      summary: Promo code definitions and their terms
      description: >
        ds_mbo.promo_code — the codes that exist and what they do:
        `discount_type` ("Percent" or "FlatRate") with `discount_amount`, the
        `activation_date`/`expiration_date` window, `days_valid`, `max_uses`,
        and `applicable_items`.


        Definitions only. Datastream records the discount a sale received
        (`sale_items.discount_amount`) but not which code produced it, and MBO's
        sale payload has no promo field — so redemption counts are not
        available from this API at any endpoint.


        `current=true` is the practical filter: active AND inside its date
        window. Most expired codes are still flagged active in MBO, so
        `active=true` alone over-reports what a student could actually redeem.
        Open-ended codes carry an expiration of 2099-12-31.

        Carries `source` as upstream provenance, `"mbo"` today (see
        docs/DS-ENRICHED-V2.md).
      parameters:
        - {name: code, in: query, schema: {type: string}, description: Exact match}
        - {name: current, in: query, schema: {type: boolean}, description: Active and within its date window}
        - {name: active, in: query, schema: {type: boolean}}
        - {name: allow_online, in: query, schema: {type: boolean}}
        - {name: discount_type, in: query, schema: {type: string, enum: [Percent, FlatRate]}}
        - {name: modified_since, in: query, schema: {type: string, format: date-time}}
      responses:
        '200': {description: List envelope of promo codes}
  /v1/promo-codes/validate:
    get:
      tags: [packages]
      summary: Validate a typed promo code and return what it is worth
      description: >
        Case-insensitive lookup of one currently-redeemable code (active and
        inside its date window). Splits MBO's Percent/FlatRate pair into
        `percent_off` / `amount_off` so a register can reprice without knowing
        that spelling. 404 if the code is missing, inactive, or expired.
      parameters:
        - {name: code, in: query, required: true, schema: {type: string}}
      responses:
        '200': {description: Item envelope of the quoted code}
        '400': {description: code is missing}
        '404': {description: Not a current code in this key's site scope}
  /v1/promo-codes/{promo_code_id}:
    get:
      tags: [packages]
      summary: One promo code
      description: Discount code definition — percent/amount off, validity window, applicable services. Redemption is tracked on the sale, not here.
      parameters: [{name: promo_code_id, in: path, required: true, schema: {type: string}}]
      responses:
        '200': {description: Promo code}
        '404': {description: Not found within the key's site scope}
  /v1/reports/sales-daily:
    get:
      tags: [reports]
      summary: Daily revenue by location and category (cash-accounting adjusted)
      description: >
        Each row also carries `paidQuantity`, units on line items that charged
        something, counted per line item (the daily rollup cannot tell free
        passes from a paid one on the same day).
        Replaces ai_sales_snapshot_v2 — specifically dw_flow's
        vw__dm_sales_adjusted shape, which is what the old Redash dashboards
        actually read. Sums ds_enriched.sale_items (excluding line items
        literally named "Tip") and unions a negated slice of
        ds_enriched.sale_payments for non-cash payment types (Groupon,
        ClassPass, Debt Write Off, LivingSocial, Comp/Guest, Flow Yoga, Gift
        Card, Account) — those sales show full retail price on the purchased
        item but no real cash came in, so the payment leg offsets it back out.
        Excludes ds_enriched.config_excluded_clients (test/demo accounts).

        Requires `date_from`/`date_to` in practice — an unbounded call can
        time out over the full sales history.
      parameters:
        - {name: date_from, in: query, schema: {type: string, format: date}}
        - {name: date_to, in: query, schema: {type: string, format: date}}
        - {name: location_id, in: query, schema: {type: string}}
      responses:
        '200': {description: List envelope of daily revenue rows}
  /v1/reports/membership-changes:
    get:
      tags: [reports]
      summary: Point-in-time active membership counts by type and location
      description: >
        Replaces ai_membership_snapshot_v2's membership-by-type chart
        (dw_flow.pc_memberByType). For each client, ranks their
        ds_enriched.client_contracts rows by start_date/agreement_date and
        keeps only the most recent contract that is active/unexpired/
        non-terminated as of `as_of` (default today) — a true point-in-time
        snapshot, not a raw count of every contract row started in a date
        range. Excludes config_excluded_clients.

        `changeInMembership` (new/renewed/cancelled/expired, diffed
        day-over-day by the old cron against yesterday's snapshot) has no
        equivalent — there is no daily snapshot history table here to diff
        against — and is intentionally omitted.
      parameters:
        - {name: as_of, in: query, schema: {type: string, format: date}, description: Snapshot date, default today}
        - {name: location_id, in: query, schema: {type: string}}
      responses:
        '200': {description: List envelope of membership-type/status counts}
  /v1/reports/membership-activity:
    get:
      tags: [reports]
      summary: Membership events — new, renewed and cancelled contracts by day
      description: >
        The event log that /v1/reports/membership-changes is not.
        membership-changes is a point-in-time snapshot (who holds an active
        membership as of one date); this endpoint reads the dates already on
        every ds_enriched.client_contracts row as events, for a "New" /
        "Cancellations" view over a date window.

        `changeType` is one of:

        - `new` — a contract whose start_date falls in the window and which
        is the client's first contract at that site (no row for the same
        site_id + client_id with an earlier start_date). eventDate =
        start_date.

        - `renewed` — a contract starting in the window where such an earlier
        contract does exist (autopay roll-overs materialised as a fresh row,
        and lapsed members returning — the data does not distinguish them).
        eventDate = start_date.

        - `cancelled` — a contract whose termination_date falls in the
        window. eventDate = termination_date.

        NOT reported: `expired`. An end_date passing without a
        termination_date is ambiguous here — autopay contracts renew past
        their end_date — so it would count live members as expirations.

        One row per (siteId, locationId, locationName, eventDate, changeType,
        membershipType); `clients` is COUNT(DISTINCT client_id). Excludes
        config_excluded_clients in every branch. `location_id` filters on the
        contract's own location.

        `date_from`/`date_to` are REQUIRED, date_from <= date_to, and the
        window may span at most 366 days — 400 otherwise.
      parameters:
        - {name: date_from, in: query, required: true, schema: {type: string, format: date}}
        - {name: date_to, in: query, required: true, schema: {type: string, format: date}}
        - {name: location_id, in: query, schema: {type: string}}
      responses:
        '200':
          description: >
            List envelope of rows {siteId, locationId, locationName, eventDate,
            changeType, membershipType, clients} ordered by eventDate
            descending, at most 5000 rows.
        '400': {description: date_from/date_to missing, malformed, inverted, or spanning more than 366 days}
  /v1/reports/class-performance:
    get:
      tags: [reports]
      summary: Class attendance and capacity rolled up by class/teacher/day-time
      description: >
        Replaces ai_class_performance_snapshot_v2 + ai_class_detail_snapshot_v2
        in one shape, from ds_enriched.classes. "Best"/"worst" rankings (best
        class times, top candidates for replacement) are this same rollup
        sorted ascending instead of descending on avgClassAttendance — there
        is nothing further to compute.

        Excluded: cancelled classes, and dates marked removed (is_removed) -
        deleted or moved in Mindbody, as /v1/schedule already leaves them out.
        Counting those would report classes that never ran, and a moved event
        on both its old and its new date.

        Not excluded: config_excluded_clients. classes.total_signed_in is a
        count at the class-occurrence grain (no client_id column on this
        table), so a test account's visit can't be excluded here without a
        per-class fan-out join down to class_visit.
      parameters:
        - {name: date_from, in: query, schema: {type: string, format: date}}
        - {name: date_to, in: query, schema: {type: string, format: date}}
        - {name: location_id, in: query, schema: {type: string}}
      responses:
        '200': {description: List envelope of class rollup rows}
  /v1/reports/student-visits:
    get:
      tags: [reports]
      summary: Students ranked by signed-in visit count in a window
      description: >
        One row per client with a signed-in visit count inside a required
        date window, optionally scoped to location / teacher / class name,
        with an exclusive "more than N" floor (`min_visits`). Computed live
        from ds_mbo.class_visit so the window range-scans
        idx_siteId_startDateTime (same index as /reports/new-students and
        /reports/recent-visitors).         `staff_name` matches the staff display name or the usual
        Mindbody abbreviation (Adam Horowitz = Adam H.) by resolving
        ds_mbo.staff and filtering visits on entity.staffId. Class name
        still INNER JOINs ds_enriched.classes on (site, class_id).

        `date_from`/`date_to` are REQUIRED, date_from <= date_to, and the
        window may span at most 90 days — 400 otherwise. Name/email/phone
        join ds_enriched.clients after LIMIT. Excludes
        config_excluded_clients. Ordered by visitCount descending, at most
        5000 rows.
      parameters:
        - {name: date_from, in: query, required: true, schema: {type: string, format: date}}
        - {name: date_to, in: query, required: true, schema: {type: string, format: date}}
        - {name: location_id, in: query, schema: {type: string}, description: Comma-separated Mindbody location ids}
        - {name: staff_name, in: query, schema: {type: string}, description: Display name or abbreviation; resolved to staff id}
        - {name: class_name, in: query, schema: {type: string}}
        - {name: min_visits, in: query, schema: {type: integer, minimum: 0}, description: Exclusive floor — HAVING COUNT(*) > min_visits}
      responses:
        '200':
          description: >
            List envelope of rows {clientId, siteId, visitCount, lastVisit,
            locationId, firstName, lastName, fullName, email, phone}
            ordered by visitCount descending, at most 5000 rows.
        '400': {description: date_from/date_to missing, malformed, inverted, or spanning more than 90 days}
  /v1/reports/new-students:
    get:
      tags: [reports]
      summary: New students by first-ever signed-in visit, per day and location
      description: >
        Replaces the dw_flow first-visit marts (vw_first_visits / the v1
        client_first_visits table), which were removed in the v2 enriched
        rebuild (docs/DS-ENRICHED-V2.md, "Gone in v2"). Computed live from the
        raw mirror ds_mbo.class_visit.

        "New student" means a client's FIRST-EVER signed-in visit — lifetime,
        not first-within-the-window. A returning student who visits inside
        the window is not counted, however long the gap since their last
        visit. (An earlier version ranked visits only inside the window,
        which counted every returning student as "new" once per window.)

        Two stages: (1) one row per client with MIN(start) over signed-in
        visits inside [date_from, date_to] — a range scan of
        idx_siteId_startDateTime bounded by the window; (2) a NOT EXISTS
        anti-join back into class_visit for any signed-in visit before
        `date_from`, a point seek per in-window client on
        (_siteId, _clientId). Grouped by the date and location of that first
        visit. Excludes config_excluded_clients.

        `date_from`/`date_to` are REQUIRED — 400 without both. `location_id`,
        if given, scopes both stages to that location: a client's first-ever
        signed-in visit *at that location*.
      parameters:
        - {name: date_from, in: query, required: true, schema: {type: string, format: date}}
        - {name: date_to, in: query, required: true, schema: {type: string, format: date}}
        - {name: location_id, in: query, schema: {type: string}}
      responses:
        '200':
          description: >
            List envelope of rows {siteId, locationId, visitDate, newStudents}
            ordered by visitDate descending, at most 5000 rows.
        '400': {description: date_from/date_to missing, malformed, or inverted}
  /v1/reports/recent-visitors:
    get:
      tags: [reports]
      summary: Clients ranked by most recent signed-in class visit
      description: >
        One row per client, ordered by last signed-in visit descending.
        Replaces the retired v1 client_last_visits mart (docs/DS-ENRICHED-V2.md,
        "Gone in v2") for an All Contacts list sorted by last visit. Computed
        live from ds_mbo.class_visit: GROUP BY client_id, MAX(_startDateTime),
        range-scanning idx_siteId_startDateTime. Signed-in only, future
        bookings excluded, test/demo accounts dropped via config_excluded_clients.

        Default window is the last 30 days so the aggregate can range-scan
        idx_siteId_startDateTime on ds_mbo.class_visit (same index as
        /reports/new-students). date_from/date_to override. Name/email/phone
        come from a LEFT JOIN to ds_enriched.clients after LIMIT. Paginated
        with limit/offset; total_count is distinct clients in the window.
      parameters:
        - {name: date_from, in: query, schema: {type: string, format: date}}
        - {name: date_to, in: query, schema: {type: string, format: date}}
        - {name: limit, in: query, schema: {type: integer}}
        - {name: offset, in: query, schema: {type: integer}}
      responses:
        '200': {description: List envelope of clients ranked by last visit}
  /v1/reports/promo-detail:
    get:
      tags: [reports]
      summary: Promo code catalog and discounted sale line items (unjoined)
      description: >
        Replaces ai_promo_detail_snapshot_v2. Datastream does not tie a sale
        to the promo code that discounted it — sale_items only carries
        discount_amount, no code reference, and MBO's sale payload itself has
        no promo field to mirror. So this returns two independent lists: the
        promo code catalog (ds_mbo.promo_code) and sale_items rows with a
        discount in the date window (config_excluded_clients-filtered) — not
        a fabricated join. A caller who needs "how much did FREEWEEK cost us"
        cannot get that from Datastream today.
      parameters:
        - {name: date_from, in: query, schema: {type: string, format: date}}
        - {name: date_to, in: query, schema: {type: string, format: date}}
      responses:
        '200':
          description: Two-list envelope — promo_codes and discounted_sale_items
  /v1/reports/mbo-usage:
    get:
      tags: [reports]
      summary: Mindbody API call volume — sync side and write side
      description: >
        Requires `config:read` (ops data, not client data). Two sources,
        tagged by `source`, one row per (date, site, family|endpoint):


        - `sync` — itflow_datastream's scheduled and webhook-driven pulls,
        read from `ds_config.sync_control_execution` (that service's own
        schema, read-only here). `family` is a sync family (`ClassFamily`,
        `TransactionFamily`, …); `endpoint` is null.

        - `write_api` — this service's own writes (bookings, cancels,
        check-ins, schedule/teacher edits), counted at the single choke
        point in `mboFetch()` and stored in `ds_api.mbo_call_log`. `endpoint`
        is the MBO path (e.g. `/class/addclienttoclass`); `family` is null.


        Defaults to the last 7 days if neither `date_from` nor `date_to` is
        given — this is a spend dashboard, not a full-history report.


        **Not covered:** the legacy Java sync service on AWS, which also
        calls Mindbody and is not instrumented anywhere this API can read.
        That spend has to be read from Mindbody's own account dashboard, not
        this endpoint — the response `note` field repeats this so a chart
        never implies otherwise.


        **Degrades gracefully** if `ds_api.mbo_call_log` (sql/019) hasn't
        been applied yet: the response falls back to sync-only rows rather
        than erroring.
      parameters:
        - {name: date_from, in: query, schema: {type: string, format: date}}
        - {name: date_to, in: query, schema: {type: string, format: date}}
      responses:
        '200':
          description: >
            List envelope. Each row: source (sync|write_api), date, siteId,
            family (nullable), endpoint (nullable), requests, retries
            (sync only), failed (write_api only).
        '403': { $ref: '#/components/responses/Forbidden' }
  /v1/sites:
    get:
      tags: [config]
      summary: Tenant registry for this key
      description: |
        Backed by `ds_config.sites`, scoped to the key's own sites. Requires
        `config:read`.

        An empty response does not mean "no such site": two data-bearing site ids
        are absent from the registry entirely, so a key can be bound to a site that
        has data but no registry row.
      parameters:
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/offset'
      responses:
        '200':
          description: A page of registry rows.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Site' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/clients:
    get:
      tags: [clients]
      summary: Look up clients by id, unique id, email, status, or modified_since
      description: >
        Client profile rows: `client_id`, name, `email`, `mobile_phone`,
        `status` / `active`, `account_balance`, `creation_date`,
        `home_location_id`, `booking_suspended`, `suspension_start`,
        `suspension_end`, `address`, `address2`, `city`, `state`,
        `postal_code`, `country`, etc.

        `booking_suspended` / `suspension_start` / `suspension_end` lift
        `$.entity.suspensionInfo` (MBO `ClientSuspensionInfo`) — a client
        booking freeze, not contract `AutopayStatus`. Nested paths are
        unverified (docs/VERIFY.md §43); they are null when Scheduling
        Suspensions is off or the object is empty.

        Requires at least one narrowing filter — an unfiltered call is a full
        per-site dump and returns 400. `email` resolves through the enriched
        layer's indexed email column, then serves the fresh mirror row; an email
        registered since the last enriched refresh will not resolve.

        Carries `source` as upstream provenance, `"mbo"` today (see
        docs/DS-ENRICHED-V2.md).
      parameters:
        - {name: client_id, in: query, schema: {type: string}}
        - {name: client_unique_id, in: query, schema: {type: string}}
        - {name: email, in: query, schema: {type: string}}
        - {name: status, in: query, schema: {type: string}}
        - {name: modified_since, in: query, schema: {type: string, format: date-time}}
        - {name: site_id, in: query, schema: {type: string}, description: Narrow within the key's site scope}
        - {name: limit, in: query, schema: {type: integer, default: 500}}
        - {name: offset, in: query, schema: {type: integer, default: 0}}
      responses:
        '200': {description: List envelope of client profiles}
        '400': {description: No narrowing filter supplied}
    post:
      tags: [clients]
      summary: Create a Mindbody client (kiosk walk-in)
      description: >
        Adds a client at the key's site via Mindbody AddClient. Used by the
        lobby iPad form — the device token is the authority, not an Auth0
        identity. Duplicate email is treated as success (the existing client
        is returned). Requires `checkin:write` and ENABLE_WRITES.
      parameters:
        - {name: site_id, in: query, required: true, schema: {type: string}}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [first_name, last_name, email, phone]
              properties:
                first_name: {type: string}
                last_name: {type: string}
                email: {type: string, format: email}
                phone: {type: string}
      responses:
        '201': {description: 'Item envelope: {client_id, first_name, last_name, name, email, phone, site_id}'}
        '400': {description: Missing or implausible fields}
        '403': {description: ENABLE_WRITES is off, or the key lacks checkin:write}
        '422': {description: Mindbody refused the create}
        '503': {description: Mindbody unavailable}
  /v1/clients/{client_id}:
    patch:
      tags: [clients]
      summary: Edit a Mindbody client's name, email, or phone
      description: >
        Live UpdateClient. Staff correction of the studio record — the
        mirror follows on the next sync. Genuinely partial: only named
        fields are sent. Requires `checkin:write` and ENABLE_WRITES.
      parameters:
        - {name: client_id, in: path, required: true, schema: {type: string}}
        - {name: site_id, in: query, required: true, schema: {type: string}}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                first_name: {type: string}
                last_name: {type: string}
                email: {type: string, format: email}
                phone: {type: string}
      responses:
        '200': {description: 'Item envelope: {client_id, first_name, last_name, name, email, phone, site_id}'}
        '400': {description: Empty body, unknown field, or implausible email/phone}
        '403': {description: ENABLE_WRITES is off, or the key lacks checkin:write}
        '422': {description: Mindbody refused the update}
        '503': {description: Mindbody unavailable}
  /v1/clients/search:
    get:
      tags: [clients]
      summary: Typeahead search — name, email, phone, or client_id in one `q` param
      description: >
        For a search-as-you-type box, not a list page: capped small (default
        and effective limit 8) and always ordered by name. `q` matches against
        name, email, mobile_phone, client_unique_id, and client_id together, so
        the caller doesn't need to know which field the user typed into.

        Reads the enriched mirror (real columns, not the raw MBO JSON), so it
        carries the same freshness caveat as `/v1/clients?email=`: refreshes
        on the enriched transform's schedule, not live.
      parameters:
        - {name: q, in: query, required: true, schema: {type: string, minLength: 2}}
        - {name: site_id, in: query, schema: {type: string}, description: Narrow within the key's site scope}
        - {name: limit, in: query, schema: {type: integer, default: 8}}
      responses:
        '200': {description: List envelope of matching client profiles}
        '400': {description: q missing or under 2 characters}
  /v1/clients/{client_id}/profile:
    get:
      tags: [clients]
      summary: One-request identity + holdings for a profile first paint
      description: >
        Composes the existing client, contracts, services, enrollments, and
        sale-header reads so a profile does not need 4–5 parallel `/v1` calls.
        No new schema — each list is the same ResourceSpec as its dedicated
        route. Sale items are omitted (headers only).

        Requires `raw:read` and `enriched:read`. Enrollments and sales are
        all-time (paged at 1000). Contracts 50 and services 100 stay
        first-paint capped. `date_from` / `date_to` optionally bound
        enrollments and sales. `?limit=` does not change those.

        Item envelope data: `{ client, contracts, services, enrollments,
        sales, enrollments_window: { date_from, date_to }, visits_total,
        last_visit, spend_total }`. `enrollments_window` is null/null when
        unbounded. `visits_total` is an unbounded COUNT of signed-in
        `class_visit` rows. `last_visit` is an unbounded MAX of signed-in
        `start_time` (null when they have never signed in). `spend_total`
        is an unbounded SUM of `ds_enriched.sales.sale_total` (do not sum
        sale_items and sale_payments together). Each nested row carries
        `source`. Cache-Control is 60s unless a read-after-write overlay
        applied, then `no-store`.
      parameters:
        - {name: client_id, in: path, required: true, schema: {type: string}}
        - {name: date_from, in: query, schema: {type: string, format: date}, description: Optional enrollment and sales window start (YYYY-MM-DD). Omit for all-time.}
        - {name: date_to, in: query, schema: {type: string, format: date}, description: Optional enrollment and sales window end (YYYY-MM-DD). Omit for all-time.}
        - {name: exclude_membership_cycles, in: query, schema: {type: boolean}, description: "Drop autopay-cycle rows from the services block. See the /services endpoint."}
        - {name: site_id, in: query, schema: {type: string}, description: Narrow within the key's site scope}
      responses:
        '200':
          description: >
            Item envelope of `{ client, contracts, services, enrollments,
            sales, enrollments_window, visits_total, last_visit,
            spend_total }`. `client` matches `/v1/clients`; the arrays
            match their dedicated list endpoints.
        '403': {description: Missing raw:read or enriched:read}
        '404': {description: No client with that client_id in this key's site scope}
  /v1/clients/{client_id}/enrollments:
    get:
      tags: [clients]
      summary: Bookings — upcoming, or a window of visit history
      description: >
        Defaults to start_time >= now. `include_past=true` lifts the floor;
        `date_from`/`date_to` set an explicit window instead and suppress the
        default. Joined to class/description/staff/location for display names.


        For visit history, prefer a window: results are ordered by start time
        ASC and capped at limit=1000, so `include_past=true` on a long-standing
        member returns their OLDEST page of visits, not their most recent.

        Carries `source` as upstream provenance, `"mbo"` today (see
        docs/DS-ENRICHED-V2.md).
      parameters:
        - {name: client_id, in: path, required: true, schema: {type: string}}
        - {name: include_past, in: query, schema: {type: boolean, default: false}}
        - {name: date_from, in: query, schema: {type: string, format: date}}
        - {name: date_to, in: query, schema: {type: string, format: date}}
      responses:
        '200': {description: List envelope of bookings with booking_id for cancels}
  /v1/clients/{client_id}/credits:
    get:
      tags: [clients]
      summary: Active passes with remaining balance ("My Passes & Credits")
      description: >
        Current services with an unexpired window; `remaining_count` /
        `total_count` answer "7 of 10 classes left, expires June 15". Subset of
        `/services` useful for a profile "active credits" strip. Each row
        carries `entitlement_kind` and `contract_id` — see `/services`.
      parameters:
        - {name: client_id, in: path, required: true, schema: {type: string}}
        - {name: expires_after, in: query, schema: {type: string, format: date}}
        - {name: exclude_membership_cycles, in: query, schema: {type: boolean}}
      responses:
        '200': {description: List envelope of active services}
  /v1/clients/{client_id}/services:
    get:
      tags: [clients]
      summary: All service/pass records for a client (raw, incl. expired)
      description: >
        Every client_service row: packs, unlimited periods, intro offers.
        `is_current` splits active vs past; includes `service_name`,
        `remaining_count`, `total_count`, `active_date`, `expiration_date`,
        `program_name`. Reads from `ds_enriched.client_services` (cut over
        2026-08-04); carries `source` as upstream provenance, `"mbo"` today
        (see docs/DS-ENRICHED-V2.md).


        **Membership cycles.** MBO writes a new row every autopay billing cycle
        of a recurring membership, so a client on a monthly Unlimited has dozens
        of rows named "Unlimited" next to their genuine one-off passes.
        `entitlement_kind` tells them apart — `membership_cycle` (granted by a
        contract, with that contract in `contract_id`), `standalone_pass`
        (bought on its own), or `unknown` (no matching sale row to classify
        against; 17% of rows, concentrated in pre-2025 history). Pass
        `exclude_membership_cycles=true` to drop the cycles and keep both other
        kinds. It is opt-in: the raw cycle history is what a diagnostic holdings
        view wants, and an active membership's *current* cycle is a genuinely
        usable entitlement.
      parameters:
        - {name: client_id, in: path, required: true, schema: {type: string}}
        - {name: is_current, in: query, schema: {type: boolean}}
        - {name: active_from, in: query, schema: {type: string, format: date}}
        - {name: exclude_membership_cycles, in: query, schema: {type: boolean}}
      responses:
        '200': {description: List envelope of client services}
  /v1/clients/{client_id}/services/{client_service_id}:
    post:
      tags: [clients]
      summary: Edit one pass (start, expiration, remaining credits)
      description: |
        Proxies Mindbody `UpdateClientServices`. The mirror is not written;
        the next sync picks the row up. `test` defaults true: the handler
        lists live client services and does not post. Send `test: false` to
        commit.

        Editable fields: `start_date` (ActiveDate), `expiration_date`
        (ExpirationDate), `remaining_count` (Remaining visits left).
        `duration_days` is a convenience that sets expiration from
        `start_date` (`start + duration_days`); do not send it with
        `expiration_date`.

        Remaining is MBO `Remaining`, not the original purchased `Count`
        (docs/VERIFY.md §44). Requires `purchase:write` and `ENABLE_WRITES`.
        Dates are studio-local `YYYY-MM-DD`.
      parameters:
        - {name: client_id, in: path, required: true, schema: {type: string}}
        - {name: client_service_id, in: path, required: true, schema: {type: string}}
        - $ref: '#/components/parameters/site_id'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                start_date: {type: string, format: date}
                expiration_date: {type: string, format: date}
                duration_days: {type: integer, minimum: 1}
                remaining_count: {type: integer, minimum: 0}
                test: {type: boolean, default: true}
      responses:
        '200': {description: Item envelope of the patched pass}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '422': {$ref: '#/components/responses/UpstreamRejected'}
        '503': {$ref: '#/components/responses/Unavailable'}
  /v1/clients/{client_id}/contracts:
    get:
      tags: [clients]
      summary: Contracts / autopay memberships for a client
      description: >
        What the client **holds** (not the sellable catalog at `/v1/contracts`).
        Fields include `contract_name`, `autopay_status`, `agreement_date`,
        `start_date`, `end_date`. Use autopay_status / end_date to separate
        active memberships from past ones. Reads from
        `ds_enriched.client_contracts` (cut over 2026-08-04; memberships are
        merged into this table upstream); carries `source` as upstream
        provenance, `"mbo"` today (see docs/DS-ENRICHED-V2.md).
      parameters:
        - {name: client_id, in: path, required: true, schema: {type: string}}
        - {name: autopay_status, in: query, schema: {type: string}}
        - {name: start_from, in: query, schema: {type: string, format: date}}
      responses:
        '200': {description: List envelope of client contracts}
  /v1/clients/{client_id}/contracts/hold:
    post:
      tags: [clients]
      summary: Pause (suspend) a client's membership
      description: |
        Proxies Mindbody `SuspendContract`. Same match + preview/commit shape
        as cancel. `test` defaults true.

        `end_date` is preferred over `duration_days` / match-text parsing.
        When a Square subscription is linked to this client (and the key
        holds `payment:write`), a commit also pauses Square billing so the
        member is not charged through the hold. A Square failure after the
        Mindbody hold landed is returned as `billing_note`, not a 500.

        Requires `contract:write` and `ENABLE_WRITES`.
      parameters:
        - {name: client_id, in: path, required: true, schema: {type: string}}
        - $ref: '#/components/parameters/site_id'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                client_contract_id: {type: string}
                contract_name: {type: string}
                match_text: {type: string}
                start_date: {type: string, format: date}
                end_date: {type: string, format: date}
                duration_days: {type: number}
                open_ended: {type: boolean, default: false}
                suspension_type: {type: string}
                test: {type: boolean, default: true}
      responses:
        '200': {description: Item envelope of the paused contract}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '409': {$ref: '#/components/responses/Conflict'}
        '422': {$ref: '#/components/responses/UpstreamRejected'}
        '503': {$ref: '#/components/responses/Unavailable'}
  /v1/clients/{client_id}/contracts/billing-date:
    post:
      tags: [clients]
      summary: Move the next membership billing date
      description: |
        Square-billed memberships move the Square renewal (billing-anchor
        for monthly; pause-until-resume otherwise). Mindbody-billed
        memberships PATCH the next `UpcomingAutopayEvent` via
        `UpdateClientContractAutopays`. `ScheduleDate` is an extra
        (VERIFY §46) and may no-op — a commit re-lists and says so.

        Empty upcoming + no Square subscription is a 409, not a toast.
        `test` defaults true. Requires `contract:write` and `ENABLE_WRITES`.
      parameters:
        - {name: client_id, in: path, required: true, schema: {type: string}}
        - $ref: '#/components/parameters/site_id'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [billing_date]
              properties:
                client_contract_id: {type: string}
                contract_name: {type: string}
                match_text: {type: string}
                billing_date: {type: string, format: date}
                test: {type: boolean, default: true}
      responses:
        '200': {description: Item envelope of the moved billing date}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '409': {$ref: '#/components/responses/Conflict'}
        '422': {$ref: '#/components/responses/UpstreamRejected'}
        '503': {$ref: '#/components/responses/Unavailable'}
  /v1/clients/{client_id}/contracts/cancel:
    post:
      tags: [clients]
      summary: Terminate a client's membership
      description: |
        Proxies Mindbody `TerminateContract`. Lists live contracts first
        (`GetClientContracts`) so the instance id is real. `test` defaults
        true and stops after the list. Requires `contract:write` and
        `ENABLE_WRITES`.
      parameters:
        - {name: client_id, in: path, required: true, schema: {type: string}}
        - $ref: '#/components/parameters/site_id'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                client_contract_id: {type: string}
                contract_name: {type: string}
                match_text: {type: string}
                termination_date: {type: string, format: date}
                termination_comments: {type: string}
                termination_code: {type: string}
                test: {type: boolean, default: true}
      responses:
        '200': {description: Item envelope of the terminated contract}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '409': {$ref: '#/components/responses/Conflict'}
        '422': {$ref: '#/components/responses/UpstreamRejected'}
        '503': {$ref: '#/components/responses/Unavailable'}
  /v1/clients/{client_id}/contracts/{client_contract_id}/autopays:
    get:
      tags: [clients]
      summary: Past and upcoming autopays for one membership
      description: |
        Upcoming rows are live Mindbody `GetClientContracts.UpcomingAutopayEvents`.
        Past rows are `ds_enriched.sale_items` for that client_contract instance
        (VERIFY §37). Also returns the site's locations and payment methods so
        a consumer can edit without a second hop.

        Scope `raw:read`. `Cache-Control: no-store`.
      parameters:
        - {name: client_id, in: path, required: true, schema: {type: string}}
        - {name: client_contract_id, in: path, required: true, schema: {type: string}}
        - $ref: '#/components/parameters/site_id'
      responses:
        '200': {description: Item envelope of upcoming, past, locations, payment_methods}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '503': {$ref: '#/components/responses/Unavailable'}
    post:
      tags: [clients]
      summary: Edit one upcoming membership autopay
      description: |
        Proxies Mindbody `UpdateClientContractAutopays`. Official fields are
        amount and the AutopayStartDate/AutopayEndDate window (the payment's
        current `original_schedule_date`). `schedule_date`, `location_id`,
        and `payment_method` / `payment_method_id` are forwarded as extras
        (VERIFY §45); Mindbody may ignore them. The handler re-lists live
        events after a commit.

        `test` defaults true. Requires `contract:write` and `ENABLE_WRITES`.
        Dates are studio-local `YYYY-MM-DD`.
      parameters:
        - {name: client_id, in: path, required: true, schema: {type: string}}
        - {name: client_contract_id, in: path, required: true, schema: {type: string}}
        - $ref: '#/components/parameters/site_id'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                schedule_date: {type: string, format: date}
                original_schedule_date: {type: string, format: date}
                amount: {type: number, minimum: 0}
                location_id: {type: string}
                payment_method: {type: string}
                payment_method_id: {type: string}
                product_id: {type: string}
                test: {type: boolean, default: true}
      responses:
        '200': {description: Item envelope of the live upcoming list after the write}
        '400': {$ref: '#/components/responses/BadRequest'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '403': {$ref: '#/components/responses/Forbidden'}
        '422': {$ref: '#/components/responses/UpstreamRejected'}
        '503': {$ref: '#/components/responses/Unavailable'}
  /v1/clients/{client_id}/stored-card:
    get:
      tags: [clients]
      summary: Live Mindbody card on file for a client
      description: |
        The card Mindbody holds on the account (`ClientCreditCard`): `last4`
        and `card_type` only. Card expiration is not returned (VERIFY §5).

        A live Mindbody read — `ds_mbo.client_transaction` is cron-only and
        has been measured days behind a first-time Credit Card sale, so Rally
        cannot infer "card on file" from that table for a new client.

        Empty `last4` / `card_type` means none on file, not a 404. A client
        outside the key's scope is a 404 and never reaches Mindbody. Scope is
        `raw:read`. `Cache-Control: no-store`.
      parameters:
        - {name: client_id, in: path, required: true, schema: {type: string}}
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: Item envelope of the stored card (fields empty when none).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/MboStoredCard' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/Unavailable' }
  /v1/clients/{client_id}/transactions:
    get:
      tags: [clients]
      summary: Payment transaction history for a client
      description: >
        Card charges for the client: `amount`, `status`, `transaction_time`,
        `card_type`, `cc_last_four`, `is_settled`. Card expiration month/year
        are deliberately NOT mapped anywhere in this service and can never
        appear. Reads from `ds_mbo.client_transaction`; carries `source` as
        upstream provenance, `"mbo"` today (see docs/DS-ENRICHED-V2.md).
      parameters:
        - {name: client_id, in: path, required: true, schema: {type: string}}
        - {name: auth_from, in: query, schema: {type: string, format: date-time}}
        - {name: auth_to, in: query, schema: {type: string, format: date-time}}
      responses:
        '200': {description: List envelope}
  /v1/sales:
    get:
      tags: [sales]
      summary: Sale headers from the enriched layer (scope enriched:read)
      description: >
        One row per sale with item_count / payment_count / sale_total /
        payment_total rollups — most reads never need the line tables.
        `sale_date` is date-only; `transacted_at` is the datetime when known
        (prefer it for clocks/sorting). Requires at least one narrowing filter.
        `adjusted=true` returns 400 until sale_adjusted exists (PLAN.md §9):
        refusing loudly beats silently serving unadjusted numbers.


        A sale made through `POST /v1/purchases` appears here before the mirror
        has it, carrying `source: "pending_write"` and counted in `total_count`.
        It retires the moment the sync carries the same sale id. Such a response
        is `Cache-Control: no-store`. The same applies to `/v1/sales/{id}`,
        `/{id}/items` and `/{id}/payments`.

        Every row also carries `source` as upstream provenance — `"mbo"` today
        for every synced row (ds_enriched v2's provenance column, see
        docs/DS-ENRICHED-V2.md). It exists so a future second raw source (e.g.
        a Momence/Arketa mirror) can land in the same tables without growing a
        parallel set of prefixed columns; `"pending_write"` is this API's own
        overlay value layered on top of that column, not a competing source.
      parameters:
        - {name: client_id, in: query, schema: {type: string}}
        - {name: date_from, in: query, schema: {type: string, format: date}}
        - {name: date_to, in: query, schema: {type: string, format: date}}
        - {name: location_id, in: query, schema: {type: string}}
        - {name: modified_since, in: query, schema: {type: string, format: date-time}}
        - {name: adjusted, in: query, schema: {type: boolean}, description: NOT IMPLEMENTED — 400s}
      responses:
        '200': {description: List envelope of sale headers}
        '400': {description: No narrowing filter, or adjusted=true}
  /v1/sales/items:
    get:
      tags: [sales]
      summary: Line items across many sales, or across a product (batch)
      description: >
        Two reads in one endpoint. **At least one of `sale_ids` or
        `product_id` is required** — without a narrowing filter this would be
        a dump of every line item in the key's scope.


        `sale_ids` is the batched form of `/v1/sales/{sale_id}/items`: pass a
        comma-separated list (same convention as every other multi-value
        param) and get every matching line back in one flat list, one
        `WHERE sale_id IN (...)` query instead of one request per sale. Built
        for purchase-history pages that otherwise fire two requests per sale
        after listing them. Behaviour is unchanged from before `product_id`
        existed. Registered ahead of `/v1/sales/{sale_id}` so the literal path
        `items` is not swallowed by that route's `sale_id` parameter.


        `product_id` answers the other direction — "which clients bought MBO
        product X in this window?" — for a caller that has no sale ids up
        front. **`date_from` and `date_to` are both required alongside
        `product_id`** (400 otherwise): a sale-id list is self-bounding, a
        product id is not, and a popular pass spans years of rows. The date
        range is *not* required when `sale_ids` is the narrowing filter.


        Every row carries `transacted_at` (the datetime off the sale header,
        joined on site + sale id), so a roster read does not need a follow-up
        `/v1/sales` call for the clock time. `sale_date` remains the date-only
        column on the line itself.


        Returns carry a negative `unit_price` AND a negative `item_quantity`:
        MBO puts the sign in both columns, so the naive `unit_price *
        item_quantity` flips a refund back to positive. Extended list price is
        `unit_price * ABS(item_quantity)`. `total_amount` is already signed and
        net of discount, so a purchase total is simply `SUM(total_amount)`.
        Pass `exclude_refunds=true` to drop returned lines entirely; the
        default is `false`, so refunds are included and existing callers see no
        change.


        The read-after-write overlay (pending `POST /v1/purchases` rows) applies
        to the `sale_ids` form only — it is keyed by sale id and has no way to
        find a pending line by product.
      parameters:
        - {name: sale_ids, in: query, schema: {type: string}, description: Comma-separated sale ids, max 200 per call. Required unless product_id is given.}
        - {name: product_id, in: query, schema: {type: string}, description: Comma-separated MBO product ids, max 100 per call. Requires date_from and date_to.}
        - {name: date_from, in: query, schema: {type: string, format: date}, description: Inclusive lower bound on sale_date. Required with product_id.}
        - {name: date_to, in: query, schema: {type: string, format: date}, description: Inclusive upper bound on sale_date. Required with product_id.}
        - {name: client_id, in: query, schema: {type: string}}
        - {name: exclude_refunds, in: query, schema: {type: boolean, default: false}, description: 'true drops lines with is_returned = true.'}
      responses:
        '200': {description: List envelope of line items across all requested sales or products}
        '400': {description: Neither sale_ids nor product_id, over an id ceiling, or product_id without date_from/date_to}
  /v1/sales/items/summary:
    get:
      tags: [sales]
      summary: Per-product rollup of line items in a date window
      description: >
        The same filters as `/v1/sales/items`, grouped per product, so a list
        screen showing twenty posts is one call rather than twenty roster
        reads. Rows are
        `{site_id, product_id, purchases, units, distinct_clients,
        total_amount, first_sale, last_sale}`.


        `date_from` and `date_to` are **both required** — the rollup is always
        windowed; a grouped scan of every line item ever sold is not a
        summary. `product_id` is optional: omit it to rank every product sold
        in the window, pass it (comma-separated, max 100) to restrict the
        rollup to the ones you care about.


        `site_id` is part of the grouping, not decoration: MBO product ids are
        per-site sequential and collide across studios, so a multi-site key
        gets one row per (site, product) rather than two studios silently
        merged under one id.


        Sign convention: `units` is `SUM(ABS(item_quantity))` because a
        returned line carries a negative quantity and would otherwise cancel a
        real purchase out of the count. `total_amount` is `SUM(total_amount)`
        raw — that column is already signed and net of discount, so a refund
        correctly pulls revenue down. `purchases` counts line rows;
        `distinct_clients` is the roster size.


        Internal QA/demo accounts (`ds_enriched.config_excluded_clients`) are
        excluded, matching every other aggregate in this service. Responses are
        `Cache-Control: no-store` and capped at 5000 rows.
      parameters:
        - {name: date_from, in: query, required: true, schema: {type: string, format: date}}
        - {name: date_to, in: query, required: true, schema: {type: string, format: date}}
        - {name: product_id, in: query, schema: {type: string}, description: Comma-separated MBO product ids, max 100 per call.}
        - {name: client_id, in: query, schema: {type: string}}
        - {name: exclude_refunds, in: query, schema: {type: boolean, default: false}}
      responses:
        '200': {description: List envelope of per-product rollups}
        '400': {description: Missing date_from/date_to, an inverted range, or more than 100 product ids}
  /v1/sales/payments:
    get:
      tags: [sales]
      summary: Payments for multiple sales in one call (batch)
      description: >
        Batched form of `/v1/sales/{sale_id}/payments` — same `sale_ids`
        convention and same IN (...) shape as `/v1/sales/items`.
      parameters:
        - {name: sale_ids, in: query, required: true, schema: {type: string}, description: Comma-separated sale ids, max 200 per call.}
      responses:
        '200': {description: List envelope of payments across all requested sales}
        '400': {description: Missing/empty sale_ids, or more than 200 ids}
  /v1/sales/{sale_id}/refund:
    post:
      tags: [sales]
      summary: Return a sale in Mindbody, and refund Square when that sale was charged
      description: >
        Staff refund from Rally's Profile timeline. Mindbody
        `POST /sale/returnsale` is the system of record for the items.
        If `ds_api.square_payment` has a completed charge for this sale,
        Square is refunded after the return lands — never before, and never
        when the ledger has no row (method 19 is a label, not a processor).


        Square is inspected before ReturnSale when a ledger row exists, so a
        down card processor does not take the pass off the client and leave
        the charge. A Square failure after Mindbody returned is a 503 that
        names both outcomes — never a bare success.


        `test` defaults true. Send `test: false` to commit. Scope is
        `purchase:write` (the same grant that already sells). `site_id` is
        required. See docs/VERIFY.md §45.
      parameters:
        - {name: sale_id, in: path, required: true, schema: {type: string}}
        - {name: site_id, in: query, required: true, schema: {type: string}}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [client_id]
              properties:
                client_id: {type: string}
                reason: {type: string}
                test: {type: boolean, default: true}
      responses:
        '200':
          description: >
            Preview (test true) or committed return. square_refunded is true
            only when Square accepted a refund.
        '409':
          description: Sale already returned, or Square already fully refunded and Mindbody already returned.
        '422':
          description: Mindbody refused the return (used items, unsupported tender). Message is passed through.
        '503':
          description: Mindbody returned the sale but Square refund failed — the card is still charged.
  /v1/sales/{sale_id}:
    get:
      tags: [sales]
      summary: One sale header
      description: >
        The transaction record itself (date, client, location, totals) from
        the enriched layer — line items and payments are separate reads
        (`/v1/sales/{sale_id}/items`, `/payments`), joined by this same
        `sale_id`.
      parameters: [{name: sale_id, in: path, required: true, schema: {type: string}}]
      responses:
        '200': {description: Sale header}
        '404': {description: Not found within the key's site scope}
  /v1/sales/{sale_id}/items:
    get:
      tags: [sales]
      summary: Line items of a sale (revenue earned)
      description: >
        Never sum together with /payments — the same money appears in both.


        Returns carry a negative `unit_price` AND a negative `item_quantity`:
        MBO puts the sign in both columns, so the naive `unit_price *
        item_quantity` flips a refund back to positive. Extended list price is
        `unit_price * ABS(item_quantity)`. `total_amount` is already signed and
        net of discount, so a purchase-history total is simply
        `SUM(total_amount)`; the discount is the gap between that and the
        extended list price. Verified against MBO's ALL Purchases PDF.


        Each row carries `source` (`ds_enriched.sale_items.source`, default
        `"mbo"`) — provenance, same convention as `/v1/sales` — and
        `transacted_at`, the header's datetime joined on site + sale id
        (`sale_date` on the line itself is date-only).
      parameters: [{name: sale_id, in: path, required: true, schema: {type: string}}]
      responses:
        '200': {description: List envelope}
  /v1/sales/{sale_id}/payments:
    get:
      tags: [sales]
      summary: Payments of a sale (money received)
      description: >
        Never sum together with /items — the same money appears in both.


        The two method fields read backwards from what the names suggest, and
        this is MBO's naming carried through verbatim: `payment_type` is the
        human label MBO prints on its reports ("Square", "Account", "Credit
        Card", "Comp/Guest"); `payment_method` is the numeric MBO code behind
        it ("19", "16", "4", "7"). Display `payment_type`; match on
        `payment_method`.


        Each row carries `source` (`ds_enriched.sale_payments.source`, default
        `"mbo"`) — provenance, same convention as `/v1/sales`.
      parameters: [{name: sale_id, in: path, required: true, schema: {type: string}}]
      responses:
        '200': {description: List envelope}
  /v1/auth/login:
    get:
      tags: [auth]
      summary: Start a browser login through Auth0
      security: []
      description: >
        Browser-facing (no bearer key — the whole /v1/auth surface is mounted
        outside the API-key middleware). Signs a short-lived state cookie
        (`flow_ds_auth_txn`) and 302s to the custom domain's Universal Login
        /authorize with `response_type=code`, `scope "openid profile email
        offline_access"` (the refresh token backs later passkey enrollment)
        and `prompt=login`. Auth0 is only the identity verifier here, exactly
        as on the legacy PHP site: the callback applies the business guards
        and issues this service's OWN session, discarding the Auth0
        artifacts.


        `return_to` is allowlisted to https://api.fvmgt.com and
        https://staging.flowyogatx.com (plus localhost in development) — the
        open-redirect guard. Answers 503 when the AUTH0_* variables are
        unset.
      parameters:
        - {name: connection, in: query, schema: {type: string, enum: [password, google, facebook, email]}, description: 'Maps to the Auth0 connection: password → Username-Password-Authentication, google → google-oauth2, facebook → facebook, email → email (passwordless). Omitted = the Universal Login''s own picker.'}
        - {name: login_hint, in: query, schema: {type: string, format: email}, description: Prefills the login form.}
        - {name: checkout_email, in: query, schema: {type: string, format: email}, description: Pins the flow to a checkout's email; a different authenticated email ends in outcome=email_mismatch.}
        - {name: return_to, in: query, schema: {type: string, format: uri}, description: Where the callback 302s back to. Allowlisted; defaults to AUTH0_DEFAULT_RETURN_TO.}
        - {name: site_id, in: query, schema: {type: string}, description: 'Optional studio context — the 32-hex Datastream site id or the MBO numeric id (e.g. 30313), validated against the active registry (unknown → 400). When set and the identity has no Flow client, the callback offers signup instead of a dead end.'}
      responses:
        '302': {description: Redirect to the Auth0 Universal Login, with the signed transaction cookie on the response.}
        '400': {description: Unknown connection, or return_to outside the allowlist.}
        '503': {description: Auth0 is not configured on this deployment.}
  /v1/auth/pending-kiosk-return:
    get:
      tags: [auth]
      summary: Latest lobby-kiosk join URL, if one is live
      security: []
      description: >
        The API console calls this after a magic-verify dump. When a lobby
        QR is waiting, the response carries that unguessable short-lived
        `/j/` URL so the phone can be sent back. Empty `{url:null}` when
        nothing is live.
      responses:
        '200': {description: 'Item envelope: {url, site_id} — both null when no kiosk join is pending.'}
        '503': {description: Auth0 is not configured on this deployment.}
  /v1/auth/callback:
    get:
      tags: [auth]
      summary: Auth0 redirect target — verify, guard, and issue the first-party session
      security: []
      description: >
        Not called by consumers directly: Auth0 sends the browser here after
        the Universal Login. Validates the signed state, exchanges the code at
        the custom domain's /oauth/token, verifies the RS256 ID token against
        the tenant JWKS (iss/aud/exp/nonce), then applies the guards ported
        from the legacy Auth0Flow::handleCallback and 302s to the flow's
        `return_to` with `?outcome=<code>` (and `email=` when the profile
        carried one).


        A LOGIN round emits (see the `auth` tag description for what each
        code means): `logged_in`, `bad_connection`, `no_email`,
        `email_mismatch`, `signup_required`, `passwordless_blocked`,
        `amr_blocked`, `pending_verification`, `auth0_error`,
        `exchange_failed`. Only `logged_in` sets the `flow_ds_session` cookie
        (400-day rolling expiry, HttpOnly, SameSite=Lax; Domain=.fvmgt.com +
        Secure in production). A LINK round (started by `GET /v1/auth/link`,
        told apart by the signed transaction cookie) skips the login guards
        entirely and emits `linked`, `link_email_mismatch`, or `link_failed`
        — the first-party session is untouched in every link branch.


        When the login flow declared a `site_id` and the verified identity
        has no Flow client, the two dead ends (`signup_required`,
        `passwordless_blocked`) instead redirect as
        `outcome=signup_required&signup_token=<signed blob>&email=` — a
        15-minute HMAC-signed token carrying the verified email, connection,
        Auth0 sub, site, and any profile names, which `POST /v1/auth/signup`
        redeems. Without a site the legacy outcomes stand unchanged, and
        `amr_blocked`/`bad_connection` never convert.


        The account lookup resolves email → client through the same enriched
        index `/v1/clients?email=` uses, over ALL active sites — a login email
        is global, not per-tenant (the documented system-scope exception in
        src/auth/login-scope.ts). On `logged_in` the session records the first
        matched client's `client_id` and `site_id`.
      parameters:
        - {name: code, in: query, schema: {type: string}}
        - {name: state, in: query, schema: {type: string}}
        - {name: error, in: query, schema: {type: string}}
        - {name: error_description, in: query, schema: {type: string}}
      responses:
        '302': {description: Redirect to the flow's return_to with ?outcome=.}
        '400': {description: State missing, expired, tampered, or mismatched — the flow did not start at /v1/auth/login.}
        '503': {description: Auth0 is not configured on this deployment.}
  /v1/auth/magic-link:
    post:
      tags: [auth]
      summary: Email a first-party magic link
      security: []
      description: >
        Step 1 of the first-party magic-link flow, replacing Auth0's
        Classic-UL-only {{ link }} links. Asks the tenant to send a
        passwordless OTP (`POST /passwordless/start` with `send: "code"` on
        the email connection); the tenant's email template — which we control
        — wraps that code in a link to `/v1/auth/magic-verify`. Answers 202
        `{sent: true}` whether or not the address has an Auth0 user (existence
        is deliberately not leaked; Auth0's unknown-user 400 is masked and
        logged server-side). Rate-limited to 3 sends per address per 5
        minutes.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: {type: string, format: email}
                site_id: {type: string, description: 'Optional studio context (32-hex Datastream id or MBO numeric id; unknown → 400). Stashed server-side for 15 minutes keyed by the address — the emailed link cannot carry it — and read back by magic-verify to offer signup when no Flow client exists. In-memory, single-instance (see docs/VERIFY.md §25).'}
      responses:
        '202': {description: 'Item envelope: {sent: true}. The send is Auth0''s to finish.'}
        '400': {description: Missing or malformed email.}
        '429': {description: 'rate_limited: more than 3 sends for this address inside 5 minutes. Retry-After is set.'}
        '502': {description: 'upstream_error: Auth0 failed to accept the send (bad credentials, tenant outage).'}
        '503': {description: Auth0 is not configured on this deployment.}
  /v1/auth/magic-verify:
    get:
      tags: [auth]
      summary: Redeem a magic-link code for a first-party session
      security: []
      description: >
        Step 2 — the URL the email template links to. Arrives from any device
        and mail client, so every defect ends in a friendly redirect rather
        than an error page. Redeems the OTP at the custom domain's
        /oauth/token (passwordless OTP grant), verifies the returned RS256 ID
        token against the tenant JWKS exactly like the /authorize callback
        (no nonce — the single-use OTP is the replay protection), then runs
        the same ported guards with the connection pinned to "email".


        302 to AUTH0_DEFAULT_RETURN_TO with `?outcome=`: `logged_in` (Flow
        account found; the `flow_ds_session` cookie is set on this response,
        identical to the callback's), `passwordless_blocked` (no Flow
        account and no studio context), `signup_required` (no Flow account
        but a studio IS known — from `?site_id=` or the send's stash — with
        `signup_token=` for `POST /v1/auth/signup`), or `magic_link_invalid`
        (missing parameters, or Auth0 refused the code: wrong, expired, or
        already used). `email=` rides along whenever one is known.
      parameters:
        - name: email
          in: query
          required: true
          schema: {type: string, format: email}
        - name: code
          in: query
          required: true
          schema: {type: string}
          description: "The OTP from the email (the template's {{ code }})."
        - name: site_id
          in: query
          required: false
          schema: {type: string}
          description: Optional studio context (hex or MBO numeric). Unresolvable values are ignored with a log line — this URL arrives from an email client, so the graceful floor is the pre-signup outcome, not an error page.
      responses:
        '302': {description: Redirect to AUTH0_DEFAULT_RETURN_TO with ?outcome= (and the session cookie when logged_in).}
        '503': {description: Auth0 is not configured on this deployment.}
  /v1/auth/passkey-token:
    post:
      tags: [auth]
      summary: Prepare a logged-in browser to enroll a passkey
      security: []
      description: >
        Requires the `flow_ds_session` cookie (401 otherwise) — works after
        any login, password or magic link. Auth0's own enrollment screens are
        disabled on this tenant; the browser runs the WebAuthn ceremony itself
        against Auth0's My Account API, and this endpoint supplies the two
        things only the server can: a database-connection identity to hang
        the passkey on, and a My Account access token.


        (a) Via the Management API (client credentials, tenant domain,
        token cached until expiry): if the session's user has no
        `auth0`-provider identity, a shadow Username-Password-Authentication
        user is created (same email, verified, random 32-char password) and
        linked under the primary — idempotent on repeat calls. (b) The
        session's stored refresh token (captured at login via
        offline_access, AES-256-GCM-encrypted in the cookie) is exchanged at
        the custom domain for a My Account API token scoped to
        create/read/delete:me:authentication_methods. If the tenant rotates
        refresh tokens, the successor is re-sealed into the session cookie on
        this response.


        No credential material ever passes through this service — the
        response only points the browser at Auth0.
      responses:
        '200':
          description: >
            Item envelope: `{access_token, expires_in, me_base:
            "https://login.fvmgt.com/me/v1", connection:
            "Username-Password-Authentication", identity_user_id}` —
            `identity_user_id` is the database identity's bare user_id (no
            provider prefix), which enrollment calls reference.
        '401': {description: No valid session cookie.}
        '409': {description: 'passkey_reauth_required: the session carries no refresh token (predates offline_access, or rotation consumed it). A fresh login fixes it.'}
        '502': {description: 'upstream_error: an Auth0 Management or token call failed.'}
        '503': {description: Auth0 is not configured on this deployment.}
  /v1/auth/signup:
    post:
      tags: [auth]
      summary: Create the Mindbody client a signup_required outcome asked for
      security: []
      description: >
        Redeems a `signup_token` (minted by the callback or magic-verify when
        a VERIFIED identity had no Flow client and the flow declared a site).
        The browser contributes only names and an optional phone — the email,
        studio, connection and Auth0 sub stay server-signed inside the token.
        Creates the client via Mindbody's `POST /client/addclient` at the
        token's site through the same per-site staff-credential session layer
        bookings use, behind the same `ENABLE_WRITES` kill switch (403 when
        off). Mindbody's "email already in use" refusal is treated as
        success-equivalent: the existing client is looked up LIVE at that
        site and logged in; if it cannot be retrieved, 409.


        On success: sets the same `flow_ds_session` cookie a normal login
        issues (no refresh token — passkey enrollment answers 409
        passkey_reauth_required until the next real login) and returns 201
        with the /me-shaped body. The mirror will not carry the new client
        until its next sync; the session works regardless, but an immediate
        re-LOGIN before that sync still lands on signup_required (docs/VERIFY
        §25).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [signup_token, first_name, last_name]
              properties:
                signup_token: {type: string, description: The signed blob from the signup_required redirect. 15-minute expiry.}
                first_name: {type: string}
                last_name: {type: string}
                phone: {type: string, description: Optional. Lenient E.164-ish; Mindbody canonicalizes.}
      responses:
        '201': {description: 'Item envelope: {authenticated: true, user: {email, name, connection, email_verified, auth0_sub, client_id, site_id, logged_in_at}} — the /me shape. Session cookie set on this response.'}
        '400': {description: Missing/invalid/expired signup_token, empty names, or implausible phone.}
        '403': {description: ENABLE_WRITES is off on this deployment.}
        '409': {description: 'conflict: the email already has a client at this studio that could not be retrieved — log in instead.'}
        '422': {description: 'upstream_rejected: Mindbody refused the create (its message passes through).'}
        '503': {description: Auth0 unconfigured, site not configured for Mindbody writes, or Mindbody unavailable.}
  /v1/auth/profile:
    post:
      tags: [auth]
      summary: Fill missing name/phone on the session's Mindbody client
      security: []
      description: >
        After a login that already created the account (Google, Facebook,
        password, or magic link), the redirect may carry `missing=phone`
        (or first_name,last_name). This endpoint writes those fields through
        Mindbody `POST /client/updateclient`. Cookie session is required;
        401 without. Behind ENABLE_WRITES.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                first_name: {type: string}
                last_name: {type: string}
                phone: {type: string}
      responses:
        '200': {description: Item envelope with the updated user. Session cookie refreshed.}
        '400': {description: Nothing to update, or implausible phone.}
        '401': {description: No session cookie.}
        '403': {description: ENABLE_WRITES is off.}
        '422': {description: Mindbody refused the update.}
        '503': {description: Auth0 unconfigured or Mindbody unavailable.}
  /v1/auth/methods:
    get:
      tags: [auth]
      summary: What login methods this account has
      security: []
      description: >
        Session-cookie-authenticated (401 without). Reads the user via the
        Management API. `password` is deliberately not "a DB identity
        exists": shadow identities created for passkey enrollment carry a
        random password nobody knows (marked `user_metadata.shadow_identity`
        at creation), so a DB identity counts only when unmarked or when
        `user_metadata.password_set` is "true". Identities predating the
        marker can misreport password:true — docs/VERIFY §26. `email_otp` is
        constant true (passwordless is a tenant capability, not a per-user
        enrollment). `passkeys` counts type=passkey entries in the
        Management authentication-methods list.
      responses:
        '200': {description: 'Item envelope: {password: bool, google: bool, facebook: bool, email_otp: true, passkeys: int}.'}
        '401': {description: No valid session cookie.}
        '502': {description: 'upstream_error: a Management API call failed.'}
        '503': {description: Auth0 is not configured on this deployment.}
  /v1/auth/link:
    get:
      tags: [auth]
      summary: Start linking a social identity to the logged-in account
      security: []
      description: >
        Session-cookie-authenticated (401 without). 302s to the Universal
        Login in LINK MODE: the user re-authenticates at the provider to
        prove they control that identity, and the callback — instead of
        running the login guards — links it into the session's user via the
        Management API. The first-party session is untouched throughout; the
        Auth0-side session is discarded as in every other round (no
        offline_access, tokens discarded).


        Callback outcomes for a link round, 302 to return_to:
        `outcome=linked&provider=<google-oauth2|facebook>&email=` on
        success; `outcome=link_email_mismatch&email=<the social email>` when
        the provider identity's email differs (case-insensitive) from the
        session's — no link is made; `outcome=link_failed&provider=` when
        the Management link call fails.
      parameters:
        - {name: connection, in: query, required: true, schema: {type: string, enum: [google, facebook]}}
        - {name: return_to, in: query, schema: {type: string, format: uri}, description: Allowlisted; defaults to AUTH0_DEFAULT_RETURN_TO.}
      responses:
        '302': {description: Redirect to the Auth0 Universal Login (link round).}
        '400': {description: Unknown connection or disallowed return_to.}
        '401': {description: No valid session cookie.}
        '503': {description: Auth0 is not configured on this deployment.}
  /v1/auth/unlink:
    post:
      tags: [auth]
      summary: Remove a linked social identity
      security: []
      description: >
        Session-cookie-authenticated (401 without). Unlinks the given
        provider's identity from the account via the Management API. The
        email/passwordless method always remains usable, so the only
        refusals are: the provider is not linked (400), or it IS the
        account's primary identity — the one it was created with — which
        cannot be unlinked from itself (409).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [provider]
              properties:
                provider: {type: string, enum: [google-oauth2, facebook]}
      responses:
        '200': {description: 'Item envelope: {unlinked: true, provider}.'}
        '400': {description: Bad provider, or that provider is not linked.}
        '401': {description: No valid session cookie.}
        '409': {description: 'conflict: the provider is the account''s primary identity.'}
        '502': {description: 'upstream_error: a Management API call failed.'}
        '503': {description: Auth0 is not configured on this deployment.}
  /v1/auth/password-setup:
    post:
      tags: [auth]
      summary: Send the set-a-password email for this account
      security: []
      description: >
        Session-cookie-authenticated (401 without). Ensures a
        database-connection identity exists (created shadow-marked if
        missing, same flow passkey enrollment uses), records
        `user_metadata.password_requested`, then has Auth0 send its branded
        change-password email via /dbconnections/change_password. Completion
        is invisible to this service (no Auth0 Action is installed), so
        GET /v1/auth/methods keeps reporting password:false for a
        shadow-marked identity even after the user sets a real password —
        the approximation is documented in docs/VERIFY §26.
      responses:
        '202': {description: 'Item envelope: {sent: true}.'}
        '401': {description: No valid session cookie.}
        '502': {description: 'upstream_error: a Management call or the email send failed.'}
        '503': {description: Auth0 is not configured on this deployment.}
  /v1/auth/me:
    get:
      tags: [auth]
      summary: Who the session cookie says the browser is
      security: []
      description: >
        Reads only the `flow_ds_session` cookie — no Auth0 call, no database.
        Item envelope: `{authenticated, user}` where `user` is null when there
        is no valid session, else `{email, name, connection, email_verified,
        auth0_sub, client_id, site_id, logged_in_at}`. `logged_in_at` is UTC
        ISO-8601 — session metadata, not studio wall-clock data. CORS allows
        https://api.fvmgt.com (and localhost) with credentials, so the test
        console can call this cross-origin.


        Every authenticated call also RE-ISSUES the session cookie with a
        fresh 400-day expiry (rolling renewal — 400 days being the browser
        cap on cookie lifetime, so an active session never lapses).
      responses:
        '200': {description: 'Item envelope: {authenticated: boolean, user: object | null}.'}
        '503': {description: Auth0 is not configured on this deployment.}
  /v1/auth/logout:
    post:
      tags: [auth]
      summary: End the first-party session
      security: []
      description: >
        Clears the `flow_ds_session` cookie and returns `{logged_out: true,
        auth0_logout_url}` — the tenant's /v2/logout URL the page can send the
        browser to for a full Auth0-side sign-out (the API never ends the
        Auth0 session itself; it only ever held its own).
      responses:
        '200': {description: 'Item envelope: {logged_out: true, auth0_logout_url: string}.'}
        '503': {description: Auth0 is not configured on this deployment.}

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer <key>`. Keys are stored hashed in
        `ds_config.api_key` and carry their own site binding, scopes
        (`raw:read`, `enriched:read`, `config:read`), and rate limit.

  headers:
    ETag:
      description: Weak validator over the response body. Send back as `If-None-Match`.
      schema: { type: string }
    LastModified:
      description: Newest `_updated_at` across the matched rows.
      schema: { type: string }

  parameters:
    site_id:
      name: site_id
      in: query
      description: |
        Comma-separated 32-character Datastream site ids. Narrows *within* the
        key's own sites; anything outside them is a 403. This is not the MBO
        numeric site id.
      schema: { type: string }
      example: 194b29112cf18b58d8a387198cfdc0db
    site_id_required:
      name: site_id
      in: query
      required: true
      description: |
        The one 32-character Datastream site id this sale is made against, from
        `GET /v1/sites`. Unlike the optional `site_id` elsewhere, **exactly one
        id** is accepted here — a list is a 400.

        Required because the site used to be inferred from whichever catalog row
        matched the item id first. Mindbody ids are per-site sequential and
        collide across sites, so for a key scoped to more than one site that
        could be another studio's row — and the sale landed on that studio's
        books with a response indistinguishable from a correct one. A sale names
        its site.

        Still checked against the key's own scope, so it can only ever narrow:
        an id outside the key's sites is a 403, not a 400.
      schema: { type: string }
      example: 194b29112cf18b58d8a387198cfdc0db
    modified_since:
      name: modified_since
      in: query
      description: Return only rows the mirror updated at or after this instant.
      schema: { type: string, format: date-time }
    limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 1000, default: 50 }
    limit_batch:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 1000, default: 500 }
    offset:
      name: offset
      in: query
      schema: { type: integer, minimum: 0, default: 0 }
    include_inactive:
      name: include_inactive
      in: query
      description: Include rows the mirror has flagged inactive or removed.
      schema: { type: boolean, default: false }
    idempotency_key:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Opaque, caller-chosen, unique per intended booking. A repeat within 24
        hours replays the first response and sets `Idempotency-Replayed: true`
        rather than booking again. Failures are not recorded, so a key frees up
        for a genuine retry. Scoped to the API key.

        Reusing a key for a *different* request body returns `409` rather than
        replaying the first response. A request still in flight also returns
        `409` — retry once it completes. If the store backing this is
        unavailable, the request returns `503` rather than running unguarded;
        retry with the same key.
      schema: { type: string, pattern: '^[\w.:-]{8,255}$' }

  responses:
    NotModified:
      description: The client's cached copy is current.
    BadRequest:
      description: Malformed or unrecognised parameters.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Forbidden:
      description: Missing scope, or a site outside the key's scope.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    NotFound:
      description: No such record within the key's site scope.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    RateLimited:
      description: Per-key rate limit exceeded. See `Retry-After`.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Conflict:
      description: A precondition this API checks itself — e.g. a cancelled class.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    UpstreamRejected:
      description: |
        Mindbody understood the request and declined it — class full, no
        eligible service, already booked. `error.message` is Mindbody's own
        wording, suitable for showing to a client.

        `POST /v1/schedule/{class_id}/bookings` narrows `error.code` to say
        *which* refusal it was, because only one of them is solved by buying
        something:

        | code | meaning | what a consumer should do |
        |---|---|---|
        | `payment_required` | nothing on the account pays for this class | offer passes for sale |
        | `class_full` | no room left, under any of Mindbody's four full codes | show the message; do not offer a pass |
        | `client_suspended` | the account is on hold | send them to a staff member |

        All three are 422 and carry the same envelope, so a consumer that does
        not branch on them still reads "Mindbody declined this" and shows the
        message. Every other endpoint returns `upstream_rejected`.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Unavailable:
      description: The database or Mindbody is unreachable or busy.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }

  schemas:
    ListEnvelope:
      type: object
      required: [status, data, total_count, limit, offset]
      properties:
        status: { type: string, enum: [success] }
        data: { type: array, items: {} }
        total_count: { type: integer }
        limit: { type: integer }
        offset: { type: integer }

    BookingRequest:
      type: object
      required: [client_id]
      additionalProperties: false
      properties:
        client_id:
          type: string
          description: The Mindbody client id to book.
        client_service_id:
          type: string
          description: |
            Pay with this specific package. Omit to let Mindbody pick an
            eligible one, which is what a normal online booking does.
        require_payment:
          type: boolean
          default: true
          description: |
            Defaults on. An unpaid visit is a real hole in a studio's books, so
            booking without payment has to be asked for.
        send_email:
          type: boolean
          default: false
          description: |
            Defaults off, unlike Mindbody's own default — a platform API should
            not email a studio's client as a side effect the caller did not ask
            for.
        waitlist:
          type: boolean
          default: false
        cross_regional_booking:
          type: boolean
          default: false
        cross_regional_booking_client_service_site_id:
          type: string
        test:
          type: boolean
          default: false
          description: Validate against Mindbody without creating the booking.

    CheckInRequest:
      type: object
      required: [client_id]
      additionalProperties: false
      properties:
        client_id:
          type: string
          description: |
            The Mindbody client this visit belongs to. Not sent to Mindbody
            (which needs only the visit id) — carried for the audit log.
        signed_in:
          type: boolean
          default: true
          description: |
            Defaults to true: this endpoint's whole reason to exist is
            checking someone IN. Send false to undo a mistaken check-in.

    AssignTeacherRequest:
      type: object
      required: [staff_id]
      additionalProperties: false
      properties:
        staff_id:
          type: string
          description: The Mindbody staff id to assign.
        substitute:
          type: boolean
          default: true
          description: |
            Documentation of intent only, on the per-instance route — Mindbody
            itself decides sub-vs-not by comparing to the schedule's own
            default teacher. Required so a caller cannot reach for the wrong
            endpoint by accident when they meant a permanent change. Not
            accepted on the schedule-definitions route, where every change is
            permanent by definition.
        actor_staff_id:
          type: string
          description: Mindbody staff id of the person who clicked. Optional.
        actor_name:
          type: string
          description: Display name of the person who clicked. Optional.

    ScheduleActorRequest:
      type: object
      additionalProperties: false
      properties:
        actor_staff_id:
          type: string
          description: Mindbody staff id of the person who clicked. Optional.
        actor_name:
          type: string
          description: Display name of the person who clicked. Optional.

    ScheduleHistoryEvent:
      type: object
      required: [id, action, scope, created_at, source]
      properties:
        id: { type: integer }
        site_id: { type: string }
        class_id: { type: string, nullable: true }
        class_schedule_id: { type: string, nullable: true }
        action:
          type: string
          enum: [created, updated, substituted, cancelled, restored]
        scope:
          type: string
          enum: [one, series]
        actor_staff_id: { type: string, nullable: true }
        actor_name: { type: string, nullable: true }
        before:
          type: object
          nullable: true
          additionalProperties: true
        after:
          type: object
          nullable: true
          additionalProperties: true
        created_at:
          type: string
          format: date-time
          description: UTC ISO-8601. Audit time, not studio wall clock.
        source:
          type: string
          enum: [datastream]
          description: These rows are authored here, not mirrored from Mindbody.

    SubstituteTeacherRequest:
      type: object
      required: [staff_id]
      additionalProperties: false
      properties:
        staff_id:
          type: string
          description: The Mindbody staff id to put on the class.
        override_conflicts:
          type: boolean
          default: false
          description: |
            Left `false`, Mindbody refuses a substitute who already has something
            booked in that slot, and the call comes back `422` carrying its own
            wording. That refusal is the reason this endpoint exists rather than
            `PATCH .../teacher`, which does not check. Set `true` only when a
            human has decided the conflict is acceptable.
        send_client_email:
          type: boolean
          default: false
          description: >-
            Mindbody e-mails everyone booked into the class. Defaults off: a
            substitution should not blast a roster by accident.
        send_original_teacher_email: { type: boolean, default: false }
        send_substitute_teacher_email: { type: boolean, default: false }
        test:
          type: boolean
          default: false
          description: |
            **Not a dry run, and `true` is rejected with a 400.** Measured
            2026-08-20: Mindbody accepts `Test: true` on this endpoint, answers
            200, and **applies the substitution anyway** — a "safe probe" moved a
            real class off its teacher and had to be reverted by hand.

            `Test` is honoured per endpoint, never globally: `checkoutshoppingcart`
            dry-runs, this verb and `purchasecontract` do not. There is no dry run
            for a substitution. Omit the field, or send `false`.
        actor_staff_id: { type: string }
        actor_name: { type: string }

    CancelClassRequest:
      type: object
      required: [send_client_email]
      additionalProperties: false
      properties:
        send_client_email:
          type: boolean
          description: |
            **Required — there is no default.** `true` makes Mindbody e-mail
            every student booked into the class, and for the flow this exists for
            that e-mail is the only student notification there is. `false`
            cancels silently. Both are consequential, in opposite directions.
        hide_cancel:
          type: boolean
          default: false
          description: >-
            Hide the cancelled class from the public schedule. Defaults off,
            because a hidden cancellation reads to a student like a class that
            was never scheduled.
        actor_staff_id: { type: string }
        actor_name: { type: string }

    UpdateStaffRequest:
      type: object
      additionalProperties: false
      description: >-
        At least one field is required. Only the fields present are sent to
        Mindbody, which leaves anything absent untouched — so omitting a phone
        number does not clear it.
      minProperties: 1
      properties:
        mobile_phone:
          type: string
          description: The field this endpoint exists for — the teacher phone fix.
        home_phone: { type: string }
        work_phone: { type: string }
        email: { type: string }
        first_name: { type: string }
        last_name: { type: string }
        bio:
          type: string
          description: >
            Public staff biography. Mindbody stores HTML; only sent when named.

    SyncTriggerRequest:
      type: object
      required: [site_id, family]
      additionalProperties: false
      properties:
        site_id:
          type: string
          pattern: '^[0-9a-f]{32}$'
          description: |
            This service's own 32-character hex Datastream site id
            (`ds_config.sites._id`), the same value `GET /v1/sites` returns as
            `site_id`. Not an MBO numeric site id.
          example: 194b29112cf18b58d8a387198cfdc0db
        family:
          type: string
          enum: [ClientFamily, ClassFamily, TransactionFamily, SalesFamily]
          description: |
            The data family to sync. ClassFamily always includes enrollments
            (no separate toggle).
        date_start:
          type: string
          format: date
          description: Optional. YYYY-MM-DD.
        date_end:
          type: string
          format: date
          description: Optional. YYYY-MM-DD.
        only_promos:
          type: boolean
          description: >
            ClassFamily only. Pulls `ds_mbo.promo_code` from Mindbody and
            skips classes, visits, staff, enrollments, and rooms. Used when
            a newly created code needs to land in the mirror before the next
            cron. Invalid on any other family.

    SyncTriggerResult:
      type: object
      properties:
        ok:
          type: boolean
          description: Whether the job was successfully dispatched — NOT whether it finished.
        site_id: { type: integer }
        family:
          type: string
          enum: [ClientFamily, ClassFamily, TransactionFamily, SalesFamily]
        message: { type: string }

    SyncExecution:
      type: object
      properties:
        executionId: { type: string }
        status: { type: string }
        dateStart: { type: string, format: date, nullable: true }
        dateEnd: { type: string, format: date, nullable: true }
        startedAt: { type: string, nullable: true }
        finishedAt: { type: string, nullable: true }
        errorMessage: { type: string }

    SyncHistoryResult:
      type: object
      properties:
        ok: { type: boolean }
        site_id: { type: string }
        family:
          type: string
          enum: [ClientFamily, ClassFamily, TransactionFamily, SalesFamily]
        sync_control_id: { type: string }
        executions:
          type: array
          items: { $ref: '#/components/schemas/SyncExecution' }

    SyncNextRangeResult:
      type: object
      properties:
        site_id:
          type: string
          pattern: '^[0-9a-f]{32}$'
        family:
          type: string
          enum: [ClientFamily, ClassFamily, TransactionFamily, SalesFamily]
        date_start: { type: string, format: date, description: YYYY-MM-DD }
        date_end: { type: string, format: date, description: YYYY-MM-DD }

    NewClassScheduleRequest:
      type: object
      required: [class_description_id, location_id, staff_id, staff_pay_rate, booking_status, days_of_week, start_date, start_time, end_time]
      additionalProperties: false
      properties:
        class_description_id:
          type: string
          description: The Mindbody class type to schedule.
        location_id:
          type: string
          description: |
            Mindbody's own numeric location id, as returned by
            `GET /v1/locations` — not the Datastream `site_id`. A site can hold
            several Mindbody locations, so this cannot be derived from
            `site_id`.
          example: '3'
        staff_id:
          type: string
          description: The permanent teacher — every generated instance defaults to this.
        staff_pay_rate:
          type: string
          description: |
            The teacher's Mindbody pay rate **slot** — an integer 1–21, not a
            dollar amount. It indexes the pay rates a studio configures in the
            Mindbody back office ("Rate 1 - In-person Class" and so on).

            Nothing in Mindbody's Public API lists those slots or their names —
            not the Payroll API (earnings only), not `/site/*`, not `/staff/*` —
            so a caller has to know the number.

            Required, even though Mindbody's own spec marks `StaffPayRate`
            optional: it binds an absent value to 0 and then rejects it as out
            of range. Demanding it here makes that a 400 naming the field
            instead of a relayed 422.
          example: '1'
        booking_status:
          type: string
          enum: [PaymentRequired, BookAndPayLater, Free]
          description: |
            Whether a student must pay to book into the class. Mindbody's own
            back office spells the same three states as two checkboxes:
            "Unpaid online signups" is `BookAndPayLater`, "Free Class" is
            `Free`, and neither is `PaymentRequired`.

            Required, and deliberately **not** defaulted: Mindbody marks
            `BookingStatus` optional but answers "BookingStatus cannot be null"
            without it, and guessing on the caller's behalf would silently
            create either a class nobody is charged for or one nobody can book.
          example: PaymentRequired
        days_of_week:
          type: array
          minItems: 1
          items:
            type: string
            enum: [Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday]
          description: |
            Mindbody day names, e.g. ["Monday", "Wednesday"]. Rejected with a
            400 if any name is not one of the seven above: Mindbody takes seven
            separate day booleans, so an unrecognised name would silently
            publish a schedule that generates no classes at all.
        start_date: { type: string, format: date }
        end_date:
          type: string
          format: date
          description: |
            Optional. Omitted means Mindbody keeps generating instances for this
            schedule rather than stopping on a date.
        start_time: { type: string, description: 'HH:MM, 24-hour.' }
        end_time: { type: string, description: 'HH:MM, 24-hour.' }
        room_id:
          type: string
          description: |
            The room, Mindbody's `ResourceId`. Optional — a class needs no room.
            `GET /v1/rooms?location_id=` lists the candidates for the
            `location_id` above.

            Worth knowing before building a reschedule flow: Mindbody's own back
            office requires a room to be **removed** before the time, date or
            days of week of an existing class are changed. So changing a class
            with a room is clear-room → change-dates → reassign, not one call.
          example: '13'
        max_capacity: { type: integer, minimum: 1 }
        show_to_public:
          type: boolean
          description: |
            Whether the class appears on the public schedule — Mindbody's "Show
            to public". Omitted leaves Mindbody's own default in place rather
            than this API choosing one.
        actor_staff_id: { type: string }
        actor_name: { type: string }

    NewEnrollmentScheduleRequest:
      type: object
      required: [class_description_id, location_id, staff_id, staff_pay_rate, booking_status, days_of_week, start_date, end_date, start_time, end_time]
      additionalProperties: false
      description: |
        The body `POST /v1/enrollment-definitions` takes.

        Field names and types follow the handler that has been creating these
        in Flow's production site for years, not Mindbody's published spec.
        That is deliberate: the sibling class-schedule call was written from
        the spec and every one of its four field-shape guesses was wrong.
      properties:
        class_description_id:
          type: string
          description: The Mindbody class type to schedule the enrollment from.
        location_id:
          type: string
          description: |
            Mindbody's own numeric location id, as returned by
            `GET /v1/locations` — not the Datastream `site_id`. A site can hold
            several Mindbody locations, so this cannot be derived from
            `site_id`.
          example: '3'
        staff_id:
          type: string
          description: The teacher every generated occurrence defaults to.
        staff_pay_rate:
          type: string
          description: |
            The teacher's Mindbody pay rate **slot** — an integer 1–21, not a
            dollar amount. It indexes the pay rates a studio configures in the
            Mindbody back office ("Rate 1 - In-person Class" and so on), and
            nothing in Mindbody's Public API lists those slots or their names,
            so a caller has to know the number.

            Required, even though Mindbody's spec marks `StaffPayRate`
            optional: it binds an absent value to 0 and then rejects it as out
            of range. Demanding it here makes that a 400 naming the field
            instead of a relayed 422.
          example: '1'
        booking_status:
          type: string
          enum: [PaymentRequired, BookAndPayLater, Free]
          description: |
            Whether a student must pay to reserve a place. Required, and
            deliberately **not** defaulted: guessing would silently create
            either an enrollment nobody is charged for or one nobody can book.
          example: PaymentRequired
        days_of_week:
          type: array
          minItems: 1
          items:
            type: string
            enum: [Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday]
          description: |
            Mindbody day names, e.g. ["Saturday"]. Rejected with a 400 if any
            name is not one of the seven above: Mindbody takes seven separate
            day booleans, so an unrecognised name would silently publish an
            enrollment that runs on no days at all.
        start_date: { type: string, format: date }
        end_date:
          type: string
          format: date
          description: |
            **Required here**, unlike on `POST /v1/schedule-definitions` where
            omitting it means "keep generating instances indefinitely". An
            enrollment is a bounded run by definition. Whether Mindbody would
            accept it absent is unverified.
        start_time: { type: string, description: 'HH:MM, 24-hour.' }
        end_time: { type: string, description: 'HH:MM, 24-hour.' }
        room_id:
          type: string
          description: |
            The room, Mindbody's `ResourceId`. Optional — an enrollment needs
            no room. `GET /v1/rooms?location_id=` lists the candidates for the
            `location_id` above.
          example: '13'
        max_capacity:
          type: integer
          minimum: 1
          description: |
            Seats. Sent to Mindbody as **both** `MaxCapacity` and
            `WebCapacity`, because Mindbody caps online signups on a separate
            field: setting only the former leaves a 20-seat workshop capped
            online at Mindbody's own default, which surfaces as students unable
            to book an enrollment that has places free.
        waitlist_capacity:
          type: integer
          minimum: 0
          description: |
            How many may wait for a returned place. `0` is meaningful — no
            waitlist — and is preserved rather than dropped.
        pricing_option_ids:
          type: array
          minItems: 1
          items: { type: string }
          description: |
            The Mindbody product ids a student may pay for a place with. Sent
            as strings rather than integers, which is the form the production
            handler has always used and Mindbody accepts.
          example: ['1201', '1202']

    EditEnrollmentScheduleRequest:
      type: object
      additionalProperties: false
      minProperties: 1
      description: |
        The body `PATCH /v1/enrollment-definitions/{class_schedule_id}` takes.
        Every field is optional, but **at least one is required** — an empty
        body is a 400 rather than a no-op, because a write that quietly does
        nothing reads as success.

        Field meanings are identical to `NewEnrollmentScheduleRequest`. Two are
        missing — `pricing_option_ids` and `class_description_id` — because
        Mindbody cannot change either on an update. See the endpoint
        description.
      properties:
        location_id:
          type: string
          description: Mindbody's own numeric location id, not the Datastream `site_id`.
          example: '3'
        staff_id: { type: string }
        staff_pay_rate:
          type: string
          description: |
            Pay rate **slot** 1–21, not a dollar amount. Sent only when you
            include it: Mindbody does not return this field on an enrollment, so
            this API cannot know its current value and must not invent one.
          example: '1'
        booking_status:
          type: string
          enum: [PaymentRequired, BookAndPayLater, Free]
          description: |
            Sent only when you include it, for the same reason as
            `staff_pay_rate` — and with more at stake: this decides whether
            students must pay to book.
        days_of_week:
          type: array
          minItems: 1
          items:
            type: string
            enum: [Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday]
          description: |
            **Send this whenever you change `start_date` or `end_date`.** Days
            and the range are coupled, and omitted fields are left alone — so
            moving a Monday enrollment into a Thu–Fri window without sending
            days leaves Mindbody holding Monday, which no longer occurs in the
            range. The write returns 200 and the enrollment generates **no
            sessions**. See `docs/VERIFY.md` §40.
        start_date:
          type: string
          format: date
          description: Changing this? Send `days_of_week` too — see that field.
        end_date:
          type: string
          format: date
          description: Changing this? Send `days_of_week` too — see that field.
        start_time: { type: string, description: 'HH:MM, 24-hour.' }
        end_time: { type: string, description: 'HH:MM, 24-hour.' }
        room_id:
          type: string
          description: |
            The room, Mindbody's `ResourceId`. **`0` clears the room** — omitting
            the field means "leave it alone", so a blank value can never be read
            as an instruction to strip one. Whether Mindbody accepts 0 as a clear
            or rejects it as out of range is unverified (`docs/VERIFY.md` §30).
          example: '15'
        max_capacity:
          type: integer
          minimum: 1
          description: |
            Sent to both `MaxCapacity` and `WebCapacity`, as on the publish
            route — confirmed working in §39.
        waitlist_capacity:
          type: integer
          minimum: 0
          description: |
            How many may wait for a returned place. `0` is meaningful — no
            waitlist — and is sent as 0 rather than dropped. Updatable, unlike
            pricing: Mindbody lists `WaitlistCapacity` in this endpoint's own
            request body.

    UpdatedEnrollmentSchedule:
      type: object
      description: What an enrollment edit reports back.
      properties:
        class_schedule_id:
          type: [integer, 'null']
          description: |
            The enrollment's id. Falls back to the id from the path when
            Mindbody's reply does not carry one in a field this API recognises.
        class_instance_ids:
          type: array
          items: { type: integer }
          description: |
            Occurrences Mindbody regenerated, if the change moved the dates or
            days. Empty is normal for an edit that did not.
        updated_fields:
          type: array
          items: { type: string }
          description: |
            The fields actually sent to Mindbody, so a caller can confirm an
            omitted one was omitted rather than silently defaulted.
        site_id: { type: string }

    CreatedEnrollmentSchedule:
      type: object
      description: |
        What Mindbody returns from `addenrollmentschedule`: the new
        enrollment's id plus the occurrences it generated, not a full record.

        **Confirmed against production 2026-09-02** — a real publish returned
        `class_schedule_id: 11666` with `class_instance_ids: [263457]`. A
        single-day Monday range produced exactly one occurrence, which also
        confirms the day translation. See api-datastream `docs/VERIFY.md` §39
        for what remains unverified (chiefly which Mindbody field name carries
        the id, since only this API's mapped output was recorded).
      properties:
        class_schedule_id:
          type: [integer, 'null']
          description: The new enrollment's schedule id.
        class_instance_ids:
          type: array
          items: { type: integer }
          description: |
            The individual occurrences Mindbody created. Each becomes readable
            through `GET /v1/events` once the mirror syncs — not before.
        site_id: { type: string }

    ContractPurchaseRequest:
      type: object
      required: [client_id, location_id]
      additionalProperties: false
      properties:
        client_id:
          type: string
          description: The Mindbody client the membership is for.
        location_id:
          type: string
          description: |
            Must be one of the contract's `location_restriction_ids` — Mindbody
            refuses anything else, and so does this endpoint, with the allowed
            list in the message.
        use_account_credit:
          type: boolean
          default: false
          description: Draw the first payment from the client's Mindbody account balance.
        stored_card:
          type: boolean
          default: false
          description: Charge the client's card on file.
        stored_card_last_four:
          type: string
          description: Picks which card on file, when the client has more than one.
        confirm_amount:
          type: number
          description: |
            **Required when `test: false`.** The first payment you expect to
            charge; a mismatch with the catalog is a `409`. There is no dry run
            at Mindbody, so this is the only guard against a stale catalog
            charging the wrong amount.
        promo_code:
          type: string
        first_month_discount:
          type: number
          description: |
            Dollars off the first payment only. Mints a one-use promo with
            `NumberOfAutopays: 1`, applies it on `purchasecontract`, then
            deactivates. `confirm_amount` must be catalog first payment minus
            this figure.
        first_month_discount_reason:
          type: string
          maxLength: 60
          description: |
            Names the minted promo on the Mindbody sale, e.g. "Day pass credit".
            Defaults to "First month discount".
        start_date:
          type: string
          format: date
          description: Defaults to Mindbody's own (today).
        send_email:
          type: boolean
          default: false
        test:
          type: boolean
          default: true
          description: |
            Defaults ON, and is **never forwarded to Mindbody** — it has no dry
            run here. `true` returns a local preflight; `false` sells the
            membership for real.

    ContractPreflight:
      type: object
      description: "What `test: true` returns. Nothing was sent to Mindbody."
      properties:
        contract_id: { type: string }
        contract_name: { type: [string, 'null'] }
        client_id: { type: string }
        location_id: { type: integer }
        purchasable_at_location:
          type: boolean
          description: Checked against the contract's own location restrictions.
        sold_online: { type: boolean }
        first_payment_total:
          type: [number, 'null']
          description: |
            Mindbody `FirstPaymentAmountTotal`, verbatim. PRE-discount on contracts
            with a built-in first-autopay discount ("30 Days for $30" reads 90.19
            here and 30.00 in Mindbody). Price a sale from `first_charge_total`.
        first_charge_amount:
          type: [number, 'null']
          description: Day-one charge before tax, from the contract items net of `built_in_discount`.
        first_charge_tax: { type: [number, 'null'] }
        first_charge_total:
          type: [number, 'null']
          description: What to expect on the card on day one. Exact when `first_charge_estimated` is false.
        built_in_discount:
          type: number
          description: Mindbody `discountAmount` — the contract's own first-autopay discount, 0 when none.
        first_charge_estimated:
          type: boolean
          description: True when a built-in discount forced the tax to be scaled rather than read.
        recurring_payment_total: { type: [number, 'null'] }
        total_contract_amount: { type: [number, 'null'] }
        autopay_enabled: { type: boolean }
        number_of_autopays: { type: [integer, 'null'] }
        autopay_frequency_unit: { type: [string, 'null'] }
        autopay_frequency_value: { type: [integer, 'null'] }
        payment_source: { type: string, enum: [account_credit, stored_card, none] }
        start_date: { type: [string, 'null'] }
        site_id: { type: string }
        validated_locally_only:
          const: true
          description: |
            Always true. A consumer that reads `test` as "Mindbody approved this"
            will sell a membership on a check that never reached Mindbody.
        validation_note: { type: string }
        test: { const: true }

    ContractPurchase:
      type: object
      properties:
        sale_id:
          type: [integer, 'null']
          description: |
            Mindbody's sale id when it returns one. The response envelope for this
            endpoint is unverified (see docs/VERIFY.md) — a null here means the
            membership was created but not identified, not that it failed.
        client_contract_id: { type: [integer, string, 'null'] }
        contract_id: { type: string }
        contract_name: { type: [string, 'null'] }
        client_id: { type: string }
        location_id: { type: integer }
        agreement_date: { type: [string, 'null'] }
        start_date: { type: [string, 'null'] }
        end_date: { type: [string, 'null'] }
        autopay_status: { type: [string, 'null'] }
        first_payment_total:
          type: [number, 'null']
          description: |
            Mindbody `FirstPaymentAmountTotal`, verbatim. PRE-discount on contracts
            with a built-in first-autopay discount ("30 Days for $30" reads 90.19
            here and 30.00 in Mindbody). Price a sale from `first_charge_total`.
        first_charge_amount:
          type: [number, 'null']
          description: Day-one charge before tax, from the contract items net of `built_in_discount`.
        first_charge_tax: { type: [number, 'null'] }
        first_charge_total:
          type: [number, 'null']
          description: What to expect on the card on day one. Exact when `first_charge_estimated` is false.
        built_in_discount:
          type: number
          description: Mindbody `discountAmount` — the contract's own first-autopay discount, 0 when none.
        first_charge_estimated:
          type: boolean
          description: True when a built-in discount forced the tax to be scaled rather than read.
        recurring_payment_total: { type: [number, 'null'] }
        total_contract_amount: { type: [number, 'null'] }
        charged_total:
          type: [number, 'null']
          description: Read back from the sale Mindbody recorded; null when it could not be read.
        charge_verified: { type: boolean }
        charge_mismatch:
          type: boolean
          description: |
            True when `charged_total` differs from `confirm_amount` by more than a
            few dollars. Also logged and alerted through flow-notify.
        first_month_promo_reused:
          type: boolean
          description: Present when first_month_discount was given; true when an existing promo for this contract and amount was used.
        number_of_autopays: { type: [integer, 'null'] }
        payment_source: { type: string }
        site_id: { type: string }
        test: { const: false }

    FreePackageRequest:
      type: object
      required: [client_id, package_id, location_id]
      additionalProperties: false
      properties:
        client_id:
          type: string
          description: The Mindbody client id receiving the comp.
        package_id:
          type: string
          description: |
            The `package_id` from `/v1/packages`. Its price is re-read from the
            mirror and must be exactly `0` — see the endpoint description.
        location_id:
          type: string
          description: |
            Mindbody location the comp is rung up at. Location ids are per-site
            sequential and collide across sites, so this must be a location of
            the same site the pricing option belongs to.
        send_email:
          type: boolean
          default: false
          description: |
            Have Mindbody email the client its own receipt. Defaults to false —
            a platform API does not email a studio's client as a side effect the
            caller did not request.
        test:
          type: boolean
          default: false
          description: |
            Validate the whole cart without creating a sale. Defaults to
            **false**, unlike `POST /v1/purchases` — no money moves here, so the
            default is to actually hand over the pass.
      example:
        client_id: '100090755'
        package_id: '11416'
        location_id: '1'

    FreePackageGrant:
      type: object
      properties:
        sale_id:
          type: integer
          nullable: true
          description: |
            Mindbody's sale id. Always null on a dry run. Rarely null on a
            commit, when Mindbody creates the sale without returning one — the
            grant still happened and is still recorded.
        client_id: { type: string }
        package_id: { type: string }
        package_name: { type: string, nullable: true }
        program:
          type: string
          nullable: true
          description: |
            Mindbody's program name for the option — `Event Single-Day`,
            `Classes`, `Promos`, `Retreats`. Studio-configured, so read it as a
            label rather than an enum.
        repeat_limit_applied:
          type: boolean
          description: |
            Whether the 6-month repeat check ran. `false` means the option is on
            an event program and was exempt; `true` means the check ran and this
            client was eligible.
        quantity: { type: integer, description: Always 1. }
        location_id: { type: integer }
        sub_total: { type: number, nullable: true }
        tax_total: { type: number, nullable: true }
        grand_total:
          type: number
          nullable: true
          description: |
            What Mindbody said the cart came to. Expected `0`; anything else
            means Mindbody priced a $0 option above zero and is worth
            investigating.
        payment_type:
          type: string
          enum: [Comp]
          description: |
            Always `Comp`. Flat rather than a payment method id, because nothing
            was tendered — no method, no card, no processor.
        site_id: { type: string }
        test:
          type: boolean
          description: True when this was a dry run and nothing was created.

    PurchaseRequest:
      type: object
      required: [client_id, item_id, location_id]
      additionalProperties: false
      properties:
        client_id:
          type: string
          description: The Mindbody client id buying the item.
        item_type:
          type: string
          enum: [service, product]
          default: service
          description: |
            `service` is a package/drop-in from `/v1/packages`; `product` is
            retail from `/v1/products`. Memberships (contracts) are not sold
            here — Mindbody uses a different endpoint for those.
        item_id:
          type: string
          description: The `package_id` or `product_id` from the catalog.
        location_id:
          type: string
          description: Mindbody location the sale is rung up at. Tax follows it.
        quantity:
          type: integer
          default: 1
          minimum: 1
        discount_amount:
          type: number
          default: 0
          description: |
            Dollar amount off. Mindbody derives the percent itself — only send
            the amount.
        payment_method_id:
          type: integer
          description: |
            The Mindbody **custom payment method** the sale is recorded against
            (e.g. 19 = "Square" on the Flow site). A label in Mindbody's books,
            not a processor — no card is charged. Send exactly one of this or
            `stored_card_last_four`.
        stored_card_last_four:
          type: string
          description: |
            Charge the card Mindbody already has on file for this client,
            identified by its last four digits. Send exactly one of this or
            `payment_method_id`.
        payment_amount:
          type: number
          description: |
            Must equal Mindbody's calculated total (tax included) to the cent.
            Omit to have the endpoint resolve the authoritative total itself.
        promo_code:
          type: string
        send_email:
          type: boolean
          default: false
        test:
          type: boolean
          default: true
          description: |
            Defaults ON. Mindbody validates and prices the cart without
            creating a sale; pass `false` to commit.

    Purchase:
      type: object
      properties:
        sale_id:
          type: [integer, 'null']
          description: Mindbody's sale id. `null` on a dry run.
        client_id: { type: string }
        item_type: { type: string, enum: [service, product] }
        item_id: { type: string }
        item_name: { type: [string, 'null'] }
        quantity: { type: integer }
        location_id: { type: integer }
        sub_total: { type: [number, 'null'] }
        discount_total: { type: [number, 'null'] }
        tax_total: { type: [number, 'null'] }
        grand_total:
          type: [number, 'null']
          description: Mindbody's calculated total, tax included.
        payment_method_id: { type: integer }
        payment_amount: { type: number }
        site_id: { type: string }
        test:
          type: boolean
          description: |
            Echoed loud — a consumer that ignores it will read a dry run as a
            sale.

    GiftCard:
      type: object
      properties:
        gift_card_id: { type: string }
        description: { type: [string, 'null'] }
        card_value:
          type: [number, 'null']
          description: What the recipient can spend.
        sale_price:
          type: [number, 'null']
          description: |
            What the purchaser is charged, and what
            `/v1/gift-cards/purchases` will charge regardless of what the
            caller claims. Differs from `card_value` under a discount promotion.
        terms: { type: [string, 'null'] }
        contact_info: { type: [string, 'null'] }
        sold_online: { type: [boolean, 'null'] }
        layouts:
          type: array
          description: |
            The email designs Mindbody itself can render this card in. Mostly
            informational — Flow suppresses Mindbody's receipt and sends its own
            email — but a `layout_id` passed to the purchase endpoint is
            validated against this list.
          items:
            type: object
            properties:
              layout_id: { type: [string, 'null'] }
              name: { type: [string, 'null'] }
              url: { type: [string, 'null'] }
        site_id: { type: string }
        source: { type: string, enum: [mbo] }

    GiftCardPurchaseRequest:
      type: object
      required: [client_id, location_id, gift_card_id, recipient_name, recipient_email, payment_type]
      additionalProperties: false
      properties:
        client_id:
          type: string
          description: The Mindbody client id of the **purchaser**, not the recipient.
        location_id:
          type: string
          description: Mindbody location the sale is rung up at.
        gift_card_id:
          type: string
          description: A `gift_card_id` from `/v1/gift-cards`.
        recipient_name: { type: string }
        recipient_email:
          type: string
          format: email
          description: |
            Validated for shape. The legacy endpoint declared this a plain
            string and checked nothing, so a typo bought a gift card that went
            nowhere and still charged the card.
        recipient_message:
          type: string
          description: Optional. The legacy endpoint required it, which was an accident of validation.
        design_id:
          type: string
          default: '1'
          description: |
            Which storefront card design the purchaser chose. Never reaches
            Mindbody — it selects the image in Flow's own email.
        layout_id:
          type: integer
          default: 101
          description: |
            Mindbody's own email design. Near-inert because Mindbody's receipt
            is suppressed, but Mindbody rejects the call without a valid one.
            Validated against the card's `layouts` when supplied.
        payment_type:
          type: string
          enum: [stored_card, custom]
          description: |
            `stored_card` charges the card Mindbody holds for the purchaser.
            `custom` records the sale against a Mindbody custom payment method
            without charging anything — something else took the money first.
            There is deliberately no raw-card-number option.
        last_four:
          type: string
          pattern: '^\d{4}$'
          description: Required when `payment_type` is `stored_card`.
        payment_method_id:
          type: integer
          description: |
            Required when `payment_type` is `custom` — the Mindbody custom
            payment method from `/v1/payments/methods`.
        amount:
          type: number
          description: |
            Optional assertion of the price. The charge always comes from the
            catalog's `sale_price`; a value that disagrees is a `400`.
        send_email:
          type: boolean
          default: true
          description: Flow's branded gift email. Never sent on a dry run.
        test:
          type: boolean
          default: true
          description: |
            Defaults ON. Mindbody validates the purchase and creates nothing;
            pass `false` to commit.

    GiftCardPurchase:
      type: object
      properties:
        barcode_id:
          type: [string, 'null']
          description: |
            The redemption code Mindbody issued. `null` on a dry run — its
            presence is what makes the purchase real. Mindbody does return a
            barcode on a `test: true` run, but it is a reserved id against
            nothing, so it is suppressed here rather than passed on.
        gift_card_id: { type: string }
        description: { type: [string, 'null'] }
        card_value: { type: [number, 'null'] }
        amount_charged:
          type: number
          description: The catalog's `sale_price`. Never the caller's number.
        purchaser_client_id: { type: string }
        recipient_name: { type: string }
        recipient_email: { type: string }
        location_id: { type: integer }
        layout_id: { type: integer }
        design_id: { type: string }
        delivery_date:
          type: string
          format: date
          description: The studio's own date, not UTC's.
        payment_type: { type: string, enum: [stored_card, custom] }
        email_sent:
          type: boolean
          description: |
            Reported rather than assumed. The send is best-effort — the money is
            already taken by the time it runs — so a caller showing "sent!" on a
            `false` is lying to the purchaser.
        site_id: { type: string }
        test:
          type: boolean
          description: Echoed loud — a consumer that ignores it reads a dry run as a sale.
        source: { type: string, enum: [mbo] }

    SquareConfig:
      type: object
      properties:
        environment: { type: string, enum: [sandbox, production] }
        application_id: { type: string }
        square_location_id:
          type: string
          description: Square's own location id — NOT the `location_id` query param, which is Mindbody's.
        square_location_name:
          type: [string, 'null']
          description: From `ds_api.square_location_map`; a human label for the mapping, not Square's own name.
        js_sdk_url:
          type: string
          description: '`<script src>` for the Web Payments SDK — sandbox or production, matching `environment`.'
        api_base_url:
          type: string
          description: For reference only; the browser never calls this directly.

    SquareChargeRequest:
      type: object
      required: [client_id, item_id, location_id]
      additionalProperties: false
      properties:
        client_id:
          type: string
          description: The Mindbody client id the sale — and the charge — is for.
        item_type:
          type: string
          enum: [service, product]
          default: service
        item_id:
          type: string
          description: The `package_id` or `product_id` from the catalog.
        location_id:
          type: string
          description: Mindbody location the sale is rung up at. Tax follows it.
        quantity:
          type: integer
          default: 1
          minimum: 1
        discount_amount:
          type: number
          default: 0
        promo_code:
          type: string
        source_id:
          type: string
          description: |
            The Web Payments SDK's single-use payment token, from
            `payments.card().tokenize()` in the browser. Send exactly one of
            this or `card_id`. Never a raw card number — accepting one would
            place this service in PCI DSS scope.
        card_id:
          type: string
          description: |
            A Square card already on file for this client (`ccof:…`), from
            `GET /v1/payments/square/cards`. Send exactly one of this or
            `source_id`. The handler looks up the Square customer
            (`<site_id>:<client_id>`) and refuses a card that is not theirs.
        send_email:
          type: boolean
          default: false
          description: Applies to the Mindbody sale only; there is no dry run here to email.

    SquarePurchase:
      type: object
      properties:
        sale_id: { type: [integer, 'null'] }
        client_id: { type: string }
        item_type: { type: string, enum: [service, product] }
        item_id: { type: string }
        item_name: { type: [string, 'null'] }
        quantity: { type: integer }
        location_id: { type: integer }
        sub_total: { type: [number, 'null'] }
        discount_total: { type: [number, 'null'] }
        tax_total: { type: [number, 'null'] }
        grand_total:
          type: [number, 'null']
          description: Mindbody's calculated total, tax included — what the card was charged.
        site_id: { type: string }
        square_payment_id: { type: string }
        square_status: { type: string, description: 'Square''s own payment status, e.g. COMPLETED.' }
        square_receipt_url: { type: [string, 'null'] }

    SquarePlanVariation:
      type: object
      properties:
        plan_variation_id: { type: string }
        plan_id: { type: [string, 'null'] }
        plan_name: { type: [string, 'null'] }
        variation_name: { type: [string, 'null'] }
        cadence:
          type: [string, 'null']
          description: |
            Square's billing cadence — `MONTHLY`, `ANNUAL`, `EVERY_TWO_WEEKS`
            and so on. Flow's own contracts only need `MONTHLY` and `ANNUAL`.
        amount:
          type: [number, 'null']
          description: Dollars, tax inclusive. Square's own API uses cents; this does not.
        currency: { type: [string, 'null'] }
        periods:
          type: [integer, 'null']
          description: Billing periods before the plan ends. `null` means it renews indefinitely.

    SquarePlanRequest:
      type: object
      required: [plan_name, amount]
      properties:
        plan_name: { type: string }
        variation_name:
          type: string
          description: Defaults to `plan_name` when omitted.
        cadence:
          type: string
          default: MONTHLY
          enum: [DAILY, WEEKLY, EVERY_TWO_WEEKS, THIRTY_DAYS, SIXTY_DAYS, NINETY_DAYS, MONTHLY, EVERY_TWO_MONTHS, QUARTERLY, EVERY_FOUR_MONTHS, EVERY_SIX_MONTHS, ANNUAL, EVERY_TWO_YEARS]
        amount:
          type: number
          description: Dollars per period, tax inclusive.
        periods:
          type: integer
          minimum: 1
          description: Omit for a plan that never ends.

    SquareCardOnFile:
      type: object
      required: [card_id, source]
      properties:
        card_id: { type: string, description: Square card id (`ccof:…`). }
        brand: { type: [string, 'null'], description: Square card brand, e.g. VISA. }
        last4: { type: [string, 'null'] }
        exp_month: { type: [integer, 'null'] }
        exp_year: { type: [integer, 'null'] }
        cardholder_name: { type: [string, 'null'] }
        source: { type: string, enum: [square] }

    SquareSubscribeRequest:
      type: object
      required: [client_id, location_id, plan_variation_id]
      properties:
        client_id: { type: string }
        location_id: { type: string, description: Mindbody's location id, not Square's. }
        plan_variation_id: { type: string }
        source_id:
          type: string
          description: |
            A Web Payments SDK card token to save. Mutually exclusive with
            `card_id`; exactly one is required. Wallet and ACH tokens are
            rejected by Square — only cards can be stored on file.
        card_id:
          type: string
          description: A card already on file for this client. Verified against the customer's own cards.
        cardholder_name:
          type: string
          description: Required with `source_id` — Square's CreateCard demands it.
        postal_code:
          type: string
          description: |
            Required with `source_id`. Written on the Square Customer (invoice
            recipient) and the card `billing_address`. Street/city/state come
            from the Mindbody client when present.
        verification_token:
          type: string
          description: |
            From the Web Payments SDK's `payments.verifyBuyer(token, …)` with a
            `STORE` intent. Optional in the US, required for SCA markets, and it
            measurably reduces declines either way.
        start_date:
          type: string
          format: date
          description: |
            `YYYY-MM-DD`. Omitted or today means Square charges the first
            period immediately.
        mbo_contract_id:
          type: string
          description: |
            Which Mindbody catalog contract this subscription stands in for,
            recorded for later reconciliation. Nothing is created in Mindbody.
        promo_code:
          type: string
          description: |
            Mindbody promo code applied to the first-period pack sale
            (`checkoutshoppingcart` `PromotionCode`). The Square charge is
            the discounted cart total. Later subscription invoices stay on
            the plan amount. Same field as `/v1/purchases` and
            `/v1/payments/square/charge`.

    SquareSubscription:
      type: object
      properties:
        square_subscription_id: { type: string }
        status:
          type: string
          description: 'Square''s own status: PENDING, ACTIVE, CANCELED, DEACTIVATED, PAUSED.'
        square_status:
          type: [string, 'null']
          description: Live status from Square on the list endpoint. `null` means Square has no such subscription.
        recorded_status:
          type: [string, 'null']
          description: Status recorded at enrolment. `not_recorded` means Square has it and this service does not.
        plan_variation_id: { type: [string, 'null'] }
        square_customer_id: { type: [string, 'null'] }
        square_card_id: { type: [string, 'null'] }
        square_location_id: { type: [string, 'null'] }
        square_location_name: { type: [string, 'null'] }
        client_id: { type: [string, 'null'] }
        location_id: { type: [integer, 'null'] }
        mbo_contract_id: { type: [string, 'null'] }
        site_id: { type: string }
        amount: { type: [number, 'null'], description: Dollars per period, tax inclusive. }
        cadence: { type: [string, 'null'] }
        start_date: { type: [string, 'null'] }
        created_at:
          type: [string, 'null']
          format: date-time
          description: |
            When the subscription was purchased. UTC instant from
            `ds_api.square_subscription.created_at`, or Square's own
            `created_at` for a subscription this service never recorded.
        charged_through_date: { type: [string, 'null'] }
        canceled_date: { type: [string, 'null'] }
        is_winding_down:
          type: [boolean, 'null']
          description: |
            `true` when Square still reports `ACTIVE` but a cancellation is
            pending at period end. Read this rather than `status`.
        mbo_entitlement:
          type: string
          enum: [none, sold, failed]
          description: |
            `sold` when a Mindbody contract was stored and the standing pack
            was written in this request. `failed` when Square is billing but
            the pack sale did not land — retry mbo-entitle with
            `already_paid: true`. `none` when no contract was stored.
        mbo_entitlement_note: { type: string }
        mbo_sale_id: { type: [string, number, 'null'] }
        cancel_note: { type: string }
        pause_note: { type: string }
        pause_starts:
          type: [string, 'null']
          description: |
            When the pause Square just scheduled actually starts, which may be
            later than the `pause_date` requested — Square rounds up to the end
            of the billing period the date falls in. This is the field to show a
            member; the requested date may be a day no payment stops on.
        resumes_on: { type: [string, 'null'] }
        anchor_note: { type: string }
        actions:
          type: array
          description: |
            Changes Square has accepted but not applied yet. Present on the
            member read and on every member write. Square omits these unless the
            request asks for them, so an absent array on some other endpoint
            means "not asked for", not "none scheduled".
          items: { $ref: '#/components/schemas/SquareSubscriptionAction' }

    SquareSubscriptionAction:
      type: object
      properties:
        action_id:
          type: string
          description: Delete this to undo the change — `DELETE .../actions/{action_id}`.
        type:
          type: string
          description: CANCEL, PAUSE, RESUME, SWAP_PLAN or CHANGE_BILLING_ANCHOR_DATE.
        effective_date: { type: [string, 'null'] }
        monthly_anchor_day:
          type: [integer, 'null']
          description: Day of the month a monthly cadence will renew on. Null for every other cadence.

    SquareSubscriptionEvent:
      type: object
      properties:
        event_id: { type: string }
        type:
          type: string
          description: |
            Square's own name: START_SUBSCRIPTION, PLAN_CHANGE,
            STOP_SUBSCRIPTION, DEACTIVATE_SUBSCRIPTION, PAUSE_SUBSCRIPTION,
            RESUME_SUBSCRIPTION, BILLING_ANCHOR_DATE_CHANGED.
        effective_date: { type: [string, 'null'] }
        monthly_anchor_day: { type: [integer, 'null'] }
        plan_variation_id: { type: [string, 'null'] }
        detail:
          type: [string, 'null']
          description: Square's reason, where it gives one — why a subscription was deactivated, say.

    MemberSubscription:
      type: object
      description: |
        Everything the member landing page renders, in one response. Read
        `is_winding_down` / `is_paused` / `ends_on` / `resumes_on` rather than
        `status`: Square keeps a cancelled subscription `ACTIVE` until the paid
        period ends.
      properties:
        square_subscription_id: { type: string }
        site_id: { type: string }
        status: { type: string }
        is_winding_down:
          type: boolean
          description: Still ACTIVE at Square, but billing stops on `ends_on`.
        is_paused: { type: boolean }
        plan_name:
          type: [string, 'null']
          description: The Mindbody contract's name — what the member bought.
        amount: { type: [number, 'null'], description: Dollars per period, tax inclusive. }
        cadence: { type: [string, 'null'] }
        start_date: { type: [string, 'null'] }
        charged_through_date: { type: [string, 'null'] }
        next_billing_date:
          type: [string, 'null']
          description: |
            When the card is next charged. Usually Square's
            `charged_through_date` — the date the next invoice generates — but
            **not** when a pause is scheduled: a pause leaves `status` on
            `ACTIVE` and `charged_through_date` untouched, so the naive answer
            names the one date that will *not* be charged. Once a PAUSE lands on
            or before that date, this is the paired RESUME instead, or `null`
            for an open-ended pause. Also `null` when winding down, cancelled or
            deactivated. Never derived from the cadence locally.
        ends_on:
          type: [string, 'null']
          description: |
            The last day billing covers, when a cancel is set. Only ever set by
            a real Square cancel — an open-ended pause reports
            `payments_stop_after` instead, because it is not a cancellation.
        paused_from: { type: [string, 'null'] }
        resumes_on: { type: [string, 'null'] }
        payments_stop_after:
          type: [string, 'null']
          description: |
            A pause with no resume behind it: no payment is taken on or after
            this date. This is what "stop billing me after X" looks like, since
            Square has no scheduled cancel — and it is a **pause**, so the
            subscription survives and can be resumed. Do not render it as
            "cancelled".
        anchor_moves_to: { type: [string, 'null'] }
        card:
          type: [object, 'null']
          description: The card on file for this subscription. Null when Square has none enabled.
          properties:
            brand: { type: [string, 'null'] }
            last4: { type: [string, 'null'] }
        client:
          type: object
          properties:
            client_id: { type: string }
            first_name: { type: [string, 'null'] }
            last_name: { type: [string, 'null'] }
            email: { type: [string, 'null'] }
        location:
          type: object
          properties:
            id: { type: integer }
            name: { type: [string, 'null'] }
        mbo_contract_id: { type: [string, 'null'] }
        today:
          type: [string, 'null']
          description: |
            The studio's date in the studio's zone. A date picker must be bounded
            by this, not by the visitor's clock — a member abroad must not be
            offered yesterday as a cancellation date.
        timezone: { type: [string, 'null'] }
        actions:
          type: array
          items: { $ref: '#/components/schemas/SquareSubscriptionAction' }
        test_accelerate_available:
          type: boolean
          description: |
            True only while `ENABLE_SUBSCRIPTION_TEST_ACCELERATE` is on and this
            subscription can be swapped or recreated (ACTIVE, card on file,
            contract stored). The public member page must not render an
            Accelerate button from this flag.

    SquareWebhookEvent:
      type: object
      properties:
        event_id: { type: string }
        event_type:
          type: string
          description: |
            Square's own name, e.g. `invoice.payment_made`,
            `invoice.scheduled_charge_failed`, `subscription.updated`.
        signature_verified:
          type: boolean
          description: False means the delivery was recorded but rejected and never acted on.
        occurred_at:
          type: [string, 'null']
          description: Square's own timestamp from the envelope, UTC. Unlike Datastream datetimes, this is not site-local wall clock.
        received_at: { type: [string, 'null'] }
        processed_at: { type: [string, 'null'] }
        error: { type: [string, 'null'] }
        payload: { type: [string, 'null'], description: The raw delivery body, truncated at 65,000 characters. }

    Booking:
      type: object
      properties:
        visit_id:
          type: [integer, 'null']
          description: |
            Mindbody's visit id. `0` on a dry run — check `test`, not this, to
            tell a dry run from a booking.
        class_id: { type: integer }
        client_id: { type: [string, 'null'] }
        class_name: { type: [string, 'null'] }
        start_time: { type: [string, 'null'] }
        end_time: { type: [string, 'null'] }
        service_id: { type: [integer, 'null'] }
        service_name: { type: [string, 'null'], description: The package the visit was paid from. }
        location_id: { type: [integer, 'null'] }
        staff_id: { type: [integer, 'null'] }
        signed_in: { type: boolean }
        late_cancelled: { type: boolean }
        web_signup: { type: boolean }
        site_id: { type: string }
        test:
          type: boolean
          description: True when nothing was created. Check it before treating this as a booking.

    EventTicketTier:
      type: object
      description: One price a place at an event can be bought at. Live from Mindbody.
      properties:
        service_id: { type: [string, 'null'] }
        name: { type: [string, 'null'] }
        price: { type: [number, 'null'] }
        online_price: { type: [number, 'null'] }
        count: { type: [integer, 'null'] }
        program_id: { type: [string, 'null'] }
        is_free: { type: boolean }
        event_id: { type: string }
        site_id: { type: string }
        source: { type: string, enum: [mbo] }

    ClientHasCredits:
      type: object
      description: >-
        Everything the client can pay with, valid on `check_date`, sorted the
        way the legacy flowsite's `hasCredit()` sorted it. This is the BROADER
        verdict: `msg: HAVE_CREDITS` here alongside `eligible: false` above is
        the "holds credit, but not credit this event accepts" case, which is the
        distinction a human needs in order to fix a refusal.
      properties:
        status:
          type: integer
          enum: [0, 1]
        msg:
          type: string
          enum: [HAVE_CREDITS, NO_CREDITS]
          description: >-
            `HAVE_CREDITS` when the client holds ANY pack valid on the date,
            including one in a category none of the arrays below covers.
        client_services:
          type: array
          description: Class-based packs, by client-service id.
          items: { type: string }
        client_event_services:
          type: array
          description: >-
            Event-based packs, by catalog product id — the ids intersected with
            an event's pricing options. Note this differs from `client_services`,
            which carries the client's own service ids.
          items: { type: string }
        pilates_services:
          type: array
          description: >-
            **Always empty.** Mindbody's `ScheduleType` cannot distinguish a
            pilates program from any other class program, so there is no rule for
            this bucket yet and a pilates pack appears in `client_services`.
            Present for shape parity with the legacy handler.
          items: { type: string }
        client_services_site_details:
          type: object
          description: >-
            Each collected id mapped to the Mindbody site its pack was sold at.
            Keyed by whichever id its own bucket collected — product id for
            events, service id for the rest.
          additionalProperties: { type: string }
        client_id: { type: string }
        check_date: { type: string }
        site_id: { type: string }
        source: { type: string, enum: [mbo] }

    EventEnrollment:
      type: object
      properties:
        event_id: { type: string }
        event_name: { type: [string, 'null'] }
        client_id: { type: string }
        visit_id: { type: [string, 'null'] }
        enroll_date_forward: { type: string }
        final_service_id:
          type: [string, 'null']
          description: >-
            The pricing option that authorised this enrolment. Reported, never
            sent — `addclienttoenrollment` has no `ClientServiceId` field, so
            Mindbody picks which pack to draw down. Not a receipt.
        final_service_name: { type: [string, 'null'] }
        client_service_id: { type: [string, 'null'] }
        dates:
          type: array
          description: Every occurrence this enrolment covers.
          items:
            type: object
            properties:
              class_id: { type: [string, 'null'] }
              start_time: { type: [string, 'null'] }
              end_time: { type: [string, 'null'] }
              location_id: { type: [string, 'null'] }
              location_name: { type: [string, 'null'] }
        site_id: { type: string }
        source: { type: string, enum: [mbo] }
        test: { type: boolean }

    ClassService:
      type: object
      description: >-
        One pricing option that can pay for a given class. Read live from
        Mindbody, so `source` is always `mbo` — there is no mirror row behind it.
      properties:
        service_id: { type: [string, 'null'] }
        name: { type: [string, 'null'] }
        price:
          type: [number, 'null']
          description: List price in dollars. Zero means the option can be claimed for nothing.
        online_price: { type: [number, 'null'] }
        count:
          type: [integer, 'null']
          description: Visits the option is worth. A free-class option is normally 1.
        program_id: { type: [string, 'null'] }
        is_free:
          type: boolean
          description: >-
            `price === 0`. The field this endpoint exists for — a consumer that
            cannot see it will sell a pass for a class that costs nothing.
        class_id: { type: string }
        site_id: { type: string }
        source: { type: string, enum: [mbo] }

    CheckIn:
      type: object
      properties:
        visit_id: { type: integer }
        class_id: { type: integer }
        client_id: { type: [string, 'null'] }
        signed_in: { type: boolean }
        site_id: { type: string }

    ClassInstance:
      type: object
      description: Mindbody's updated single class instance, after a teacher/sub assignment.
      properties:
        class_id: { type: [integer, 'null'] }
        staff_id: { type: [integer, 'null'] }
        start_time: { type: [string, 'null'] }
        end_time: { type: [string, 'null'] }
        name: { type: [string, 'null'] }
        substitute: { type: boolean }
        site_id: { type: string }

    Substitution:
      type: object
      description: |
        The substitution as carried out. Mindbody's own response to
        `substituteclassteacher` echoes nothing worth reshaping — no class and no
        visit — so this is the request as performed, not an upstream record. The
        mirror is the read side, on its next sync.
      properties:
        class_id: { type: integer }
        staff_id: { type: integer }
        override_conflicts:
          type: boolean
          description: >-
            Whether Mindbody's booking-conflict check was bypassed. Echoed back
            so a log records which of the two behaviours actually ran.
        test:
          type: boolean
          description: >-
            Always `false`. There is no dry run for this verb — see the request
            schema. Echoed back so the response carries proof of that.
        site_id: { type: string }

    CancelledClass:
      type: object
      description: Mindbody's class record after the cancellation.
      properties:
        class_id: { type: [integer, 'null'] }
        staff_id: { type: [integer, 'null'] }
        start_time: { type: [string, 'null'] }
        end_time: { type: [string, 'null'] }
        name: { type: [string, 'null'] }
        is_cancelled:
          type: boolean
          description: Always `true` on a successful response — the call succeeded.
        send_client_email:
          type: boolean
          description: >-
            Echoed back so a log records whether students were e-mailed. There is
            no way to ask Mindbody afterwards.
        hide_cancel: { type: boolean }
        site_id: { type: string }

    LiveClass:
      type: object
      description: >-
        One class as Mindbody currently has it. Read live, so `source` is always
        `mbo` — there is no mirror row behind this response.
      properties:
        class_id: { type: integer }
        staff_id:
          type: [integer, 'null']
          description: Who is teaching it right now, substitutes included.
        staff_name: { type: [string, 'null'] }
        class_name: { type: [string, 'null'] }
        is_cancelled:
          type: boolean
          description: |
            Mindbody spells this `IsCanceled`, with one `l`; the house spelling
            is kept so a consumer reading this and the mirror does not have to
            hold two. **This is the field `GET /v1/schedule/{class_id}` cannot
            give you** — that route strips it.
        start_time:
          type: [string, 'null']
          description: Studio-local wall clock, no `Z`, as everywhere else in this API.
        end_time: { type: [string, 'null'] }
        site_id: { type: string }
        source: { type: string, enum: [mbo] }

    CreatedClassSchedule:
      type: object
      description: |
        What Mindbody returns from `addclassschedule` — its own
        `WrittenClassSchedulesInfo`, which is the new schedule's id plus the
        instances it generated, not a full schedule record. Mindbody calls the
        schedule id `ClassId` in that response; it is the value this API and the
        mirror both call `class_schedule_id`.
      properties:
        class_schedule_id:
          type: [integer, 'null']
          description: The new recurring schedule's id.
        class_instance_ids:
          type: array
          items: { type: integer }
          description: |
            The individual class occurrences Mindbody created. Each becomes a
            `class_id` on `GET /v1/schedule` once the mirror syncs — not before.
        site_id: { type: string }

    ClassSchedule:
      type: object
      description: Mindbody's recurring class schedule (the definition, not an instance).
      properties:
        class_schedule_id: { type: [integer, 'null'] }
        class_description_id: { type: [integer, 'null'] }
        location_id: { type: [integer, 'null'] }
        staff_id: { type: [integer, 'null'] }
        days_of_week: { type: [array, 'null'], items: { type: string } }
        start_date: { type: [string, 'null'] }
        end_date: { type: [string, 'null'] }
        start_time: { type: [string, 'null'] }
        end_time: { type: [string, 'null'] }
        max_capacity: { type: [integer, 'null'] }
        site_id: { type: string }

    ItemEnvelope:
      type: object
      required: [status, data]
      properties:
        status: { type: string, enum: [success] }
        data: { type: object }

    MboStoredCard:
      type: object
      required: [last4, card_type, source]
      properties:
        last4:
          type: string
          description: Last four digits. Empty when Mindbody has no card on file.
        card_type:
          type: string
          description: Brand label from ClientCreditCard.CardType (e.g. Visa).
        source: { type: string, enum: [mbo] }

    ErrorEnvelope:
      type: object
      required: [status, error]
      properties:
        status: { type: string, enum: [error] }
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum:
                - bad_request
                - unauthorized
                - forbidden
                - not_found
                - site_scope_violation
                - conflict
                - rate_limited
                - upstream_rejected
                - class_full
                - client_suspended
                - payment_required
                - upstream_unavailable
                - internal_error
            message: { type: string }

    StatusResponse:
      type: object
      properties:
        status: { type: string, enum: [success, error] }
        data:
          type: object
          properties:
            service: { type: string }
            version: { type: string }
            uptime_seconds: { type: integer }
            database:
              type: object
              properties:
                ok: { type: boolean }
                latency_ms: { type: number }
                error: { type: string }
            request_log_sink: { type: string, enum: [stdout, database, none] }
            unverified_field_count: { type: integer }

    ClassSummary:
      type: object
      properties:
        class_id: { type: integer }
        class_name: { type: [string, 'null'] }
        start_time: { type: [string, 'null'], format: date-time }
        end_time: { type: [string, 'null'], format: date-time }
        staff_id: { type: [integer, 'null'] }
        staff_name: { type: [string, 'null'] }
        staff_image_url:
          type: [string, 'null']
          description: Not yet populated — absent from the mirror.
        location_id: { type: [integer, 'null'] }
        location_name: { type: [string, 'null'] }
        room_id:
          type: [string, 'null']
          description: MBO resource id for the room the class runs in.
        room_name:
          type: [string, 'null']
          description: >
            Room within the studio ("SA", "Maha"). Carried inline on the class,
            unlike staff and location which hold ids only. Null where MBO has no
            resource assigned to the class.
        max_capacity: { type: [integer, 'null'] }
        web_capacity: { type: [integer, 'null'] }
        total_booked: { type: [integer, 'null'] }
        total_signed_in: { type: [integer, 'null'] }
        total_booked_waitlist: { type: [integer, 'null'] }
        is_waitlist_available: { type: [boolean, 'null'] }
        is_cancelled:
          type: [boolean, 'null']
          description: House spelling. MBO's source field is `isCanceled`.
        is_canceled:
          type: [boolean, 'null']
          deprecated: true
          description: Legacy alias of `is_cancelled`. Will be removed once no consumer reads it.
        is_available_online: { type: [boolean, 'null'] }
        category: { type: [string, 'null'] }
        class_schedule_id:
          type: [integer, 'null']
          description: >
            Key for the class type's description — fetch it on demand from
            `GET /v1/class-descriptions/{class_schedule_id}` rather than
            expecting it inline. The description text repeats verbatim across
            every instance of a class type and was ~52% of this endpoint's
            payload by weight; it was split out and is no longer returned here.
        is_substitute: { type: [boolean, 'null'] }
        source:
          type: string
          description: >
            Provenance of the row — `ds_enriched.classes.source`, default
            `"mbo"`. Datastream is source-agnostic by design (DS-ENRICHED-V2.md):
            a future non-MBO mirror (Momence/Arketa) would populate this rather
            than growing a parallel set of prefixed columns.
        site_id: { type: string }

    ClassDescription:
      type: object
      properties:
        class_schedule_id: { type: [integer, 'null'] }
        class_name: { type: [string, 'null'] }
        category: { type: [string, 'null'] }
        class_description: { type: [string, 'null'] }

    ContractDescription:
      type: object
      properties:
        contract_id: { type: [string, 'null'] }
        contract_name: { type: [string, 'null'] }
        description: { type: [string, 'null'] }

    RosterEntry:
      type: object
      properties:
        booking_id: { type: [string, 'null'] }
        class_id: { type: [integer, 'null'] }
        client_id: { type: [string, 'null'] }
        client_unique_id: { type: [string, 'null'] }
        client_name: { type: [string, 'null'] }
        client_email:
          type: [string, 'null']
          description: Unverified path — may read null pending reconciliation.
        client_phone:
          type: [string, 'null']
          description: Unverified path — may read null pending reconciliation.
        client_photo_url: { type: [string, 'null'] }
        signed_in: { type: [boolean, 'null'] }
        late_cancelled: { type: [boolean, 'null'] }
        service_name: { type: [string, 'null'] }
        web_signup: { type: [boolean, 'null'] }
        source:
          type: string
          description: >
            Provenance of the row — `ds_enriched.class_visit.source`, default
            `"mbo"`. See DS-ENRICHED-V2.md.
        site_id: { type: string }

    NewStaffRequest:
      type: object
      required: [first_name, last_name]
      properties:
        first_name: { type: string, example: Check-in }
        last_name: { type: string, example: App }
        email: { type: string }
        class_teacher:
          type: boolean
          default: false
          description: >
            Whether this staff member can teach classes. Defaults to false —
            a staff member who can teach appears in every teacher picker in
            the business, which is wrong for a consumer's identity and right
            for a person.
        appointment_instructor: { type: boolean, default: false }
        independent_contractor: { type: boolean, default: false }
        emp_id:
          type: string
          description: The business's own staff id, if it keeps one.

    CreatedStaffMember:
      type: object
      properties:
        staff_id: { type: [string, 'null'] }
        first_name: { type: [string, 'null'] }
        last_name: { type: [string, 'null'] }
        email: { type: [string, 'null'] }
        mobile_phone: { type: [string, 'null'] }
        bio: { type: [string, 'null'] }
        class_teacher: { type: [boolean, 'null'] }
        appointment_instructor: { type: [boolean, 'null'] }
        independent_contractor: { type: [boolean, 'null'] }
        emp_id: { type: [string, 'null'] }
        site_id: { type: string }
        login_required:
          type: boolean
          description: >
            Always true. The created record has no Mindbody login and no API
            can give it one — see `next_step`.
        next_step:
          type: string
          description: The manual step between this call and assigning permissions.

    StaffSessionRequest:
      type: object
      required: [username, password]
      properties:
        username:
          type: string
          description: Mindbody staff email (same as Portal).
        password:
          type: string
          description: Mindbody staff password. Write-only; never returned.
        site_id:
          type: string
          description: >
            Optional. When omitted, each site in the key's scope is tried
            until one accepts the login.

    StaffSession:
      type: object
      description: A verified Mindbody staff login. No token is returned.
      properties:
        staff_id: { type: [string, 'null'] }
        first_name: { type: [string, 'null'] }
        last_name: { type: [string, 'null'] }
        email: { type: string }
        permission_group: { type: [string, 'null'] }
        user_type: { type: [string, 'null'] }
        site_id: { type: string }
        source: { type: string, enum: [mbo] }

    LiveStaffMember:
      type: object
      description: >-
        One staff member from the live Mindbody roster. Read live, so `source` is
        always `mbo`.
      properties:
        staff_id: { type: [string, 'null'] }
        first_name: { type: [string, 'null'] }
        last_name: { type: [string, 'null'] }
        name:
          type: [string, 'null']
          description: >-
            Mindbody's `Name`, falling back to `DisplayName` and then to first +
            last. Present on every one of the 201 members measured.
        mobile_phone:
          type: [string, 'null']
          description: >-
            The field the mirror read has no equivalent for. Populated on 158 of
            201 members (site Flow, 2026-08-19).
        email: { type: [string, 'null'] }
        is_class_teacher: { type: boolean }
        is_active:
          type: boolean
          description: |
            Always `true` as Mindbody answers today — inactive staff are simply
            **absent** from this response rather than flagged. Carried through so
            a consumer never has to know that, and so the field does not read as
            missing. It is also the whole reason this endpoint cannot be a mirror
            read: the mirror's copy of this flag goes stale and never comes back.
        sort_order: { type: [integer, 'null'] }
        site_id: { type: string }
        source: { type: string, enum: [mbo] }

    StaffPermissionGroupName:
      type: object
      required: [permission_group_name, source]
      properties:
        permission_group_name:
          type: string
          description: >
            The name as it reads in Manager Tools → Staff → Role.
          example: Flow Manager
        source: { type: string, enum: [mindbody] }

    StaffPermissionsRequest:
      type: object
      required: [permission_group_name]
      properties:
        permission_group_name:
          type: string
          description: >
            A permission group that already exists in the business, by name as
            it reads in Manager Tools. Mindbody offers no endpoint that lists
            them, so a wrong name surfaces as a 422 rather than a 400.
          example: API Sales

    StaffPermissionGroup:
      type: object
      properties:
        permission_group_name: { type: [string, 'null'] }
        ip_restricted: { type: [boolean, 'null'] }
        allowed_permissions: { type: array, items: { type: string } }
        denied_permissions: { type: array, items: { type: string } }
        staff_id: { type: string }
        site_id: { type: string }

    KeyMboIdentity:
      type: object
      properties:
        key_id: { type: string }
        obfuscated_key: { type: [string, 'null'] }
        configured:
          type: boolean
          description: >
            False means this key's sales carry its site's shared API user, the
            default.
        username:
          type: [string, 'null']
          description: The Mindbody staff login. The password is never returned.
        configured_at:
          type: [string, 'null']
          description: >
            ISO-8601. Null for credentials set by hand before this endpoint
            existed.
        site_ids: { type: array, items: { type: string } }

    SetKeyMboIdentityRequest:
      type: object
      required: [site_id, username, password]
      properties:
        site_id:
          type: string
          description: >
            The site the login is verified against. Must be the key's one and
            only site — named explicitly rather than inferred, so a request
            about the wrong studio fails before it changes anything.
          example: 194b29112cf18b58d8a387198cfdc0db
        username: { type: string, example: _CheckinApp }
        password:
          type: string
          format: password
          description: Stored write-only. Never returned and never logged.
        replace:
          type: boolean
          default: false
          description: >
            Required to overwrite an existing identity, since doing so
            re-attributes every future sale. Without it, a key that already
            has one gets a 409.

    KeyMboIdentitySet:
      allOf:
        - $ref: '#/components/schemas/KeyMboIdentity'
        - type: object
          properties:
            site_id: { type: string }
            mbo_site_id: { type: string }
            mbo_user_id:
              type: string
              description: >
                The Mindbody user the login resolved to, from Mindbody's own
                token response — the staff member whose name will be on the
                sales, confirmed upstream rather than assumed.
            replaced:
              type: boolean
              description: Whether this call overwrote a previous identity.

    KeyMboIdentityCleared:
      type: object
      properties:
        key_id: { type: string }
        obfuscated_key: { type: [string, 'null'] }
        configured: { type: boolean, enum: [false] }
        cleared:
          type: boolean
          description: >
            Whether this call is what removed it. False means there was
            nothing set.
        previous_username: { type: [string, 'null'] }

    StaffMember:
      type: object
      properties:
        staff_id: { type: integer }
        first_name: { type: [string, 'null'] }
        last_name: { type: [string, 'null'] }
        full_name: { type: [string, 'null'] }
        image_url: { type: [string, 'null'] }
        email: { type: [string, 'null'] }
        slug: { type: [string, 'null'], description: Not yet populated. }
        is_class_teacher: { type: [boolean, 'null'] }
        is_active: { type: [boolean, 'null'] }
        sort_order: { type: [integer, 'null'] }
        source:
          type: string
          description: >
            Provenance of the row — `ds_mbo.staff.source`, default `"mbo"`.
            Datastream is source-agnostic by design (DS-ENRICHED-V2.md): a
            future non-MBO mirror (Momence/Arketa) would populate this rather
            than growing a parallel set of prefixed columns. Currently always
            `"mbo"`, since ds_mbo is exclusively written by the MBO sync.
        site_id: { type: string }

    StaffBio:
      type: object
      properties:
        staff_id: { type: [integer, 'null'] }
        full_name: { type: [string, 'null'] }
        bio: { type: [string, 'null'] }

    Location:
      type: object
      properties:
        location_id: { type: integer }
        name: { type: [string, 'null'] }
        slug: { type: [string, 'null'], description: Not yet populated. }
        address: { type: [string, 'null'] }
        address2: { type: [string, 'null'] }
        city: { type: [string, 'null'] }
        state: { type: [string, 'null'] }
        postal_code: { type: [string, 'null'] }
        latitude: { type: [number, 'null'] }
        longitude: { type: [number, 'null'] }
        phone: { type: [string, 'null'] }
        description: { type: [string, 'null'] }
        amenities:
          description: MBO's array of amenity objects, passed through verbatim.
          type: [array, 'null']
          items: { type: object }
        has_classes: { type: [boolean, 'null'] }
        average_rating: { type: [number, 'null'] }
        image_url: { type: [string, 'null'], description: Not yet populated. }
        source:
          type: string
          description: >
            Provenance of the row — `ds_mbo.location.source`, default `"mbo"`.
            Same reasoning as `ClassSummary.source` (DS-ENRICHED-V2.md).
            Currently always `"mbo"`, since ds_mbo is exclusively written by
            the MBO sync.
        site_id: { type: string }
        content:
          description: >
            Hand-authored content for this location — `ds_api.location_content`,
            added by sql/035. `null` when nobody has authored any for this
            (site, location) pair, and `null` on every row where sql/035 has not
            been applied: the read fails soft rather than failing the endpoint.


            Nested rather than flattened onto the row deliberately. Every field
            here was typed by a person and none has a Mindbody source, so a
            reader must be able to tell them apart from mirrored data — the
            row's own `source` is `"mbo"` while this object's is
            `"flow_authored"`.


            Note `slug` and `image_url` appear both here and on the row above.
            The top-level pair are the legacy contract's, mapped to MBO JSON
            paths that are empty on every row; these are the populated ones.
          oneOf:
            - type: 'null'
            - type: object
              properties:
                google_map_url:
                  type: [string, 'null']
                  description: >
                    The share link a human clicks — a shortened `goo.gl` /
                    `maps.app.goo.gl` URL. Mindbody has no field for it; the
                    values originate in `bolingr_flow.mb_flow_locations`.
                  example: https://maps.app.goo.gl/ZSstSJgujRJXpMBj8
                google_map_embed_code:
                  type: [string, 'null']
                  description: >
                    The iframe source, `https://www.google.com/maps/embed?pb=…`.
                    A separate field from `google_map_url` and not derivable
                    from it: a share link will not render in an iframe.


                    Named `_code` after the column it mirrors, but every
                    observed value is a URL rather than markup. Build your own
                    `<iframe>` around it — do not inject the value into a page
                    as HTML.
                  example: https://www.google.com/maps/embed?pb=!1m18!1m12!1m3
                slug: { type: [string, 'null'] }
                image_url: { type: [string, 'null'] }
                display_name:
                  type: [string, 'null']
                  description: >
                    Short label for a picker — `"Cedar Park"` where the Mindbody
                    name is `"Flow Yoga Cedar Park"`. `null` means no override;
                    fall back to `name`.
                sort_order:
                  type: [integer, 'null']
                  description: >
                    Display position. Applied by the caller, not by the API —
                    this is not a SQL `ORDER BY`, because the content is merged
                    after the query.
                is_publishable:
                  type: boolean
                  description: >
                    Whether this location should be offered when publishing an
                    event. False for real Mindbody locations that are not
                    bookable Flow studios. Filter on it in the caller: like
                    `sort_order` it is merged after the query, so it is not a
                    server-side filter and does not affect `total_count`.
                source:
                  type: string
                  enum: [flow_authored]

    EventSummary:
      type: object
      properties:
        event_id: { type: integer }
        class_description_id:
          type: [string, 'null']
          description: Join key to CMS content; one description, many enrollments.
        event_name: { type: [string, 'null'] }
        event_description: { type: [string, 'null'] }
        start_date: { type: [string, 'null'], format: date }
        end_date: { type: [string, 'null'], format: date }
        start_time:
          type: [string, 'null']
          description: Time of day on the enrollment, not a full timestamp.
        end_time: { type: [string, 'null'] }
        staff_id: { type: [integer, 'null'] }
        staff_name: { type: [string, 'null'] }
        location_id: { type: [integer, 'null'] }
        location_name: { type: [string, 'null'] }
        category: { type: [string, 'null'] }
        session_type: { type: [string, 'null'], description: MBO's session type name — Event, Community, Workshop, Retreat, Teacher Trainings, ... }
        program: { type: [string, 'null'], description: MBO program the description is filed under. }
        event_type: { type: string, enum: [event, training, retreat], description: Retreats and trainings folded out of session_type. }
        is_available_online: { type: [boolean, 'null'] }
        allow_open_enrollment: { type: [boolean, 'null'] }
        max_capacity: { type: [integer, 'null'], description: Not yet populated. }
        total_booked: { type: [integer, 'null'], description: Not yet populated. }
        image_url:
          type: [string, 'null']
          description: >
            Sourced from the Portal's `flow_cms_posts` content over its
            events-to-thirdparty HTTP API, joined on `class_description_id` —
            not a `ds_enriched.events` column. Null when the Portal has no
            matching image, or when the Portal integration is unconfigured.
        source:
          type: string
          description: >
            Provenance of the row — `ds_enriched.events.source`, default
            `"mbo"`. See DS-ENRICHED-V2.md.
        site_id: { type: string }

    Package:
      type: object
      properties:
        package_id: { type: integer }
        package_name: { type: [string, 'null'] }
        price: { type: [number, 'null'] }
        online_price: { type: [number, 'null'] }
        count: { type: [integer, 'null'] }
        expiration: { type: [integer, 'null'] }
        expiration_unit: { type: [string, 'null'] }
        type: { type: [string, 'null'] }
        program: { type: [string, 'null'] }
        program_id:
          type: [integer, 'null']
          description: |
            The program's numeric id. Prefer it over `program` whenever a
            consumer is *deciding* something — which options may be given away,
            which are events — because it survives a studio renaming the
            program, and display `program` to a human. Per-site like every other
            Mindbody id, so it means nothing without the `site_id` beside it.
        description: { type: [string, 'null'], description: Not yet populated. }
        location_ids:
          type: [array, 'null']
          items: {}
          description: MBO `sellAtLocationIds`, verbatim.
        use_at_location_ids: { type: [array, 'null'], items: {} }
        sell_online: { type: [boolean, 'null'] }
        is_intro_offer: { type: [boolean, 'null'] }
        discontinued: { type: [boolean, 'null'] }
        sale_in_contract_only:
          type: [boolean, 'null']
          description: |
            Mindbody locks this pricing option to contract sales; the cart 422s it.
            `POST /v1/purchases` refuses it with 409 and names the contract that
            carries it — sell that via `POST /v1/contracts/{contract_id}/purchases`.
        is_auto_renewing: { type: [boolean, 'null'], description: Not yet populated. }
        source:
          type: string
          description: >
            Provenance of the row — `ds_mbo.service.source`, default `"mbo"`.
            Same reasoning as `ClassSummary.source` (DS-ENRICHED-V2.md).
            Currently always `"mbo"`, since ds_mbo is exclusively written by
            the MBO sync.
        site_id: { type: string }

    Site:
      type: object
      properties:
        site_id: { type: string }
        site_name: { type: [string, 'null'] }
        mbo_site_id:
          type: [string, 'null']
          description: MBO-side numeric site id. Populated on all 9 rows as of 2026-08-02.
        status: { type: [string, 'null'] }
        plan: { type: [string, 'null'] }
        timezone:
          type: [string, 'null']
          description: |
            IANA zone the studio keeps ("America/Chicago"). Every wall-clock
            datetime this API serves for the site is in this zone. Null means
            America/Chicago — the zone of every current tenant. Set at
            onboarding for studios outside Central.
