{
  "openapi": "3.1.0",
  "info": {
    "title": "perFIT App and PerFit One ERP integration",
    "version": "0.1.0-draft",
    "summary": "Every integration point between the perFIT mobile app backend and the PerFit One ERP, in both directions, with field level detail, ownership, sync semantics and the open questions that still need a decision.",
    "contact": {
      "name": "Futovia (perFIT app backend)",
      "email": "joao@futovia.com"
    },
    "license": {
      "name": "Confidential. Prepared for perFIT and the PerFit One team.",
      "identifier": "LicenseRef-Confidential"
    },
    "description": "# Purpose\n\nThis document is a proposal and a request. It exists to answer, in one\nplace, what the perFIT app and the PerFit One ERP need from each other.\nIt was prepared in response to the request to \"ensure full coverage of all\nthe integration points between the perFIT App backend and the ERP\".\n\nIt is derived from the shipped perFIT app backend and its published\nOpenAPI contract, not from memory. Anything that is a proposal rather than\nexisting behaviour is labelled `(proposed)`.\n\n**Nothing here is built against the ERP yet.** The perFIT app runs today\nwith an ERP client interface that has a null implementation: the seams\nexist, the adapter does not, because the ERP API is not yet documented and\nno sandbox has been made available. See Open question 14.\n\n# How to read this\n\n- **Paths tagged `ERP: read API`** are endpoints we are asking PerFit One\n  to expose. We call them.\n- **Paths tagged `ERP: change notifications`** are endpoints the perFIT\n  app exposes for PerFit One to call when ERP-owned data changes. We host\n  them.\n- **Webhooks** (the separate section at the end) are events the perFIT app\n  sends out to the ERP when app-owned data changes. PerFit One hosts the\n  receiver.\n- Each operation and schema carries its own sync semantics: direction,\n  trigger, frequency, idempotency key, conflict rule, and what happens\n  when the other side is unreachable.\n\nServer URLs are placeholders. PerFit One supplies the ERP base URL; we\nsupply ours.\n\n# Ownership, stated once\n\nEvery field has exactly one owning system. No field is written by both\nsides. This is deliberate: two writers on one field produces silent\ndisagreement, and on money data that becomes a dispute with a member or a\ncoach.\n\n| # | Entity | Direction | Trigger (proposed) | Frequency |\n|---|---|---|---|---|\n| 1 | Members | ERP to app, plus lookup on demand | Webhook on change, plus lookup by mobile at first login | On change |\n| 2 | Memberships and entitlements | ERP to app | Webhook on change, nightly reconciliation | On change, nightly full |\n| 3 | Coaches | ERP to app | Webhook on change, nightly reconciliation | On change |\n| 4 | Coach to member assignments | ERP to app | Webhook on change | On change |\n| 5 | PT packages | ERP to app | Webhook on change, nightly reconciliation | On change, nightly full |\n| 6 | PT sessions (bookings) | App to ERP | Webhook from app on booking | Immediate |\n| 7 | PT check-ins and signatures | App to ERP | Webhook from app on check-in | Immediate |\n| 8 | PT invoices | App to ERP | Webhook from app at booking | Immediate |\n| 9 | Coach revenue | Neither, derived | n/a | n/a |\n| 10 | Branches and hours | ERP to app | Nightly batch, closures immediate | Daily |\n| 11 | Class schedule | ERP to app | Webhook on change, nightly reconciliation | On change |\n| 12 | Class attendance | App to ERP | Webhook from app on marking | Immediate |\n| 13 | Payments and receipts | App to ERP | Webhook from app on settlement | Immediate |\n| 14 | Reviews and support requests | App to ERP | Webhook from app, or ERP polls | Immediate or daily |\n\n## What stays authoritative in the perFIT app\n\nThese are not ERP-owned and must not be written by the ERP. Most still\nneed to be **reported to** the ERP.\n\n| Data | Why it stays with the app | How it reaches the ERP |\n|---|---|---|\n| PT check-in signature and timestamp | Captured on the coach's device at the point of delivery; it is the evidence a paid session happened | Pushed on check-in |\n| Class attendance marked in the app | Marked by the coach at the door, including by QR scan | Pushed on marking |\n| Reviews and ratings | Written by members in the app, 1 to 5 stars with optional comment and tags | Daily export or webhook |\n| Support requests | Submitted in the app with a reference number (`SR-000042`) | Daily export or webhook |\n| Member app profile fields | Height, weight, goals, training frequency, preferred branches, language, notification preferences, self-uploaded photo | Not pushed unless PerFit One wants them |\n| Legal consent records | T&C, privacy and biometric consent versions with timestamps, held for PDPO | Available on request |\n| Authentication | Passwords, OTP challenges, sessions and devices | Never shared in either direction |\n\nA review of 3 stars or fewer automatically opens a customer service\nfollow-up item and is held back from public display until staff have\nlooked at it. If that queue should live in the ERP rather than in our\nstaff tooling, we need the feed direction agreed (Open question 11).\n\n# Transport and operational requirements (proposed)\n\nThese apply to every operation below. They are the practical things that\ndecide whether an integration is reliable or a source of daily incidents.\n\n1. **Authentication.** Both directions authenticated. OAuth 2.0 client\n   credentials or a long-lived API key over TLS, with separate credentials\n   per environment. Webhooks in both directions signed (HMAC-SHA256 over\n   the raw body with a shared secret) so the receiver can verify origin.\n2. **Environments.** A sandbox with representative data, separate from\n   production, is required before we can build against the API. This is\n   the single longest-standing blocker on our side.\n3. **Idempotency.** Every write endpoint and every webhook must be safe to\n   replay. We send an idempotency key on every push and ask the ERP to do\n   the same. Networks fail mid-request; the only safe assumption is that\n   every message may arrive twice.\n4. **Delivery and retries.** Webhooks retried with exponential backoff for\n   at least 24 hours, both ways. A webhook channel alone is not enough: we\n   also need a **delta query endpoint** per entity\n   (`GET /members?updated_since=...`) so a missed webhook is recoverable\n   without a full reload.\n5. **Reconciliation.** A nightly full or delta reconciliation per entity.\n   Silence must never be interpreted as deletion. Deletions and\n   deactivations must be explicit events.\n6. **Pagination.** Cursor based, with a documented maximum page size, on\n   every list endpoint.\n7. **Time and time zone.** All timestamps ISO 8601 with an explicit offset\n   or in UTC. Calendar days (membership expiry, package expiry, join date)\n   as `YYYY-MM-DD`. The app treats calendar days as Hong Kong days\n   throughout; Hong Kong has no daylight saving, so the offset is a fixed\n   +08:00.\n8. **Money.** All amounts in HKD as decimal numbers with 2 decimal places.\n   Never as floats in a locale-formatted string.\n9. **Error format.** A machine-readable error code plus a human-readable\n   message on every non-2xx response, so the app can react to the specific\n   failure rather than showing \"something went wrong\".\n10. **Rate limits.** Documented, with the limit and reset communicated in\n    response headers.\n11. **Versioning.** A version in the path or a header, with a deprecation\n    policy. Breaking changes must not ship without notice.\n12. **Contact and escalation.** A named technical contact and a channel\n    for integration issues.\n\n# OPEN QUESTIONS FOR THE PERFIT ONE TEAM\n\nThese are the decisions we cannot make alone, in rough order of how much\nthey block the build. The three that block the most are 1, 6 and 14.\n\n1. **Member identifier.** What is the stable, immutable member identifier\n   we should store and join on, and is it guaranteed never to change on\n   renewal, freeze, branch change or phone number change?\n2. **Coach identifiers.** Does the ERP own the coach login code and the\n   coach ID, or should the app keep generating them? If the ERP owns them,\n   are they immutable?\n3. **Invoices.** Does the ERP treat the app's PT invoice as the accounting\n   record, or does it issue its own invoice number? If the ERP issues its\n   own, will it return that number to us so the coach and member see the\n   same reference?\n4. **Remaining sessions.** Do you agree that the ERP holds the master\n   value for `remaining_sessions` and the app reports consumption deltas?\n   If not, what is the rule when the two disagree?\n5. **Assignment revocation.** Is it acceptable that an unassignment takes\n   effect in the app only when the webhook arrives (typically seconds, but\n   during an ERP outage, later)? The alternative is a live check on every\n   coach action, which makes the coach app depend on ERP availability.\n6. **Class schedule ownership.** Will classes and rosters be fed from the\n   ERP, from a separate CMS, or entered in some other system? Coaches\n   cannot create classes in the app, so something must own this before\n   launch.\n7. **Acceptable staleness.** What is the maximum acceptable age for cached\n   membership entitlement during an ERP outage before we should start\n   refusing bookings? Our default proposal is to keep serving the cache\n   indefinitely and alert internally.\n8. **Door access and the QR badge.** The app generates a QR code that\n   expires after one minute. How does the branch hardware verify our\n   token? We can expose a verification endpoint, publish the signing key,\n   or switch to an ERP-issued credential. Which do you want, and who is\n   the contact on the hardware side?\n9. **Signature retention and PDPO.** Who is the data controller for the\n   member signatures captured at PT check-in, how long must they be\n   retained, and does the ERP need the image itself or only a reference?\n10. **Refund completion.** When a refund is processed in the shop, how\n    will the app be told? Today this is manual, and until it is automated\n    a member's app can show \"Submitted\" indefinitely.\n11. **Reviews and support queue.** Should low-star review follow-ups and\n    support requests be worked in the ERP (we feed them to you), or in our\n    staff tooling (you need nothing)?\n12. **Membership expiry reminders.** The app sends a reminder 14 days\n    before expiry. Does the ERP also send one? If so, who should own it so\n    members do not receive two?\n13. **Duplicate account merge.** The signed answer places account merges\n    in the back office or ERP, with the app reflecting the result. We need\n    a merge event to react to, or a decision that merges will be performed\n    in our tooling instead. Today the app tells members staff can merge\n    their accounts at a branch, and no system can actually complete that.\n14. **Sandbox.** When can we have sandbox credentials and representative\n    test data? This is the item that gates everything else.\n\n# What we will build once this is agreed\n\nOn our side this is one work package. The seams already exist:\nprovisioning, assignments, packages and memberships are all repository\ndriven, and the ERP client is an interface with a null implementation in\nproduction today. The work is the adapter against the real API, the sync\njobs, the outbound push with its retry queue, and the reconciliation and\nalerting. We estimate two to three pull requests once the documentation\nand sandbox are in hand.\n"
  },
  "externalDocs": {
    "description": "The perFIT mobile API contract (what our backend already exposes)",
    "url": "https://perfit-api-docs.app.futovia.com/mobile/"
  },
  "servers": [
    {
      "url": "https://erp.example.perfit.hk/api/v1",
      "description": "PerFit One ERP, placeholder. PerFit One to supply the real base URL and a sandbox equivalent (Open question 14). Used by every operation tagged \"ERP: read API\"."
    },
    {
      "url": "https://api.perfit.hk",
      "description": "perFIT app backend, production. Used by every operation tagged \"ERP: change notifications\", which are endpoints we host for the ERP to call."
    }
  ],
  "tags": [
    {
      "name": "ERP: read API",
      "description": "Endpoints we are asking PerFit One to expose so the app can pull and reconcile ERP-owned data. Every list endpoint should support `updated_since` for delta sync and cursor pagination, so a missed webhook is recoverable without a full reload."
    },
    {
      "name": "ERP: change notifications",
      "description": "Endpoints the perFIT app hosts for the ERP to call when ERP-owned data changes. All are idempotent on the identifiers named in each schema and all are HMAC signed. If the ERP prefers a single multiplexed endpoint with an event type field, we can do that instead; these are split by entity because it makes retries and failures easier to reason about."
    },
    {
      "name": "App to ERP events",
      "description": "Events the perFIT app sends to the ERP when app-owned data changes. These appear in the \"webhooks\" section. PerFit One hosts the receiver and tells us the URL and the signing secret."
    },
    {
      "name": "Reference",
      "description": "Entities documented for completeness that need no feed, and the shared error and pagination shapes."
    }
  ],
  "paths": {
    "/members": {
      "get": {
        "tags": [
          "ERP: read API"
        ],
        "operationId": "listMembers",
        "summary": "Members changed since a timestamp",
        "description": "**Direction:** ERP to app.\n**Trigger:** nightly reconciliation sweep, and recovery after a missed\nchange notification.\n**Frequency:** nightly full pass, plus ad hoc delta pulls.\n**Idempotency key:** `member_id`. Replaying the same payload must be\nsafe and must not create a second member.\n**Conflict rule:** the ERP wins for ERP-owned fields, the app wins for\napp-owned fields (credentials, push tokens and preferences, language,\nmember-entered height and weight, self-uploaded photo, onboarding\nanswers, consent records). If both sides changed since the last sync,\nwe take the ERP value for its fields and log the overwrite.\n**If the ERP is unreachable:** the app serves the last synced values.\nA registered member logs in and uses the app normally.\n\nOmitting `updated_since` must return the full active set, so we can\ncold start.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/UpdatedSince"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of members.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Member"
                      }
                    },
                    "page": {
                      "$ref": "#/components/schemas/PageInfo"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/members/{member_id}": {
      "get": {
        "tags": [
          "ERP: read API"
        ],
        "operationId": "getMember",
        "summary": "One member by stable identifier",
        "description": "Used on demand when the app holds a `member_id` and needs current\ntruth, for example when a support agent opens a member in staff\ntooling.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/MemberIdPath"
          }
        ],
        "responses": {
          "200": {
            "description": "The member.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Member"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/members/lookup": {
      "get": {
        "tags": [
          "ERP: read API"
        ],
        "operationId": "lookupMemberByMobile",
        "summary": "Find a member by mobile number, for first login",
        "description": "The only lookup that is not keyed on `member_id`, because at first\nlogin the app does not yet know it.\n\nThe app asks this once, at the moment a person completes phone\nverification and has no ERP link stored. On a hit we write the\nreturned `member_id` into the member's record and the account is\nlinked from then on. **Mobile number is not the permanent key.**\nMembers change numbers; see Open question 1.\n\n**If the ERP is unreachable or returns no match:** the account is\ncreated unlinked and the member can use the app. The nightly sweep\nlinks it when the ERP returns, or staff link it by hand.\n",
        "parameters": [
          {
            "name": "mobile",
            "in": "query",
            "required": true,
            "description": "Mobile number in E.164.",
            "schema": {
              "type": "string",
              "pattern": "^\\+[1-9][0-9]{6,14}$"
            },
            "example": "+85261234567"
          }
        ],
        "responses": {
          "200": {
            "description": "The matching member.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Member"
                }
              }
            }
          },
          "404": {
            "description": "No membership found for that number. The app treats this as a normal outcome, not an error: the person gets an app account with no ERP link.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/memberships": {
      "get": {
        "tags": [
          "ERP: read API"
        ],
        "operationId": "listMemberships",
        "summary": "Membership status and entitlements changed since a timestamp",
        "description": "**Direction:** ERP to app. The ERP owns this entirely. The app holds a\nread cache and is never the system of record.\n**Trigger:** nightly full reconciliation of all active members, plus\ndelta pulls to recover missed notifications.\n**Idempotency key:** `membership_id` plus `updated_at`. An\n`updated_at` older than the one we hold is ignored.\n**Conflict rule:** the ERP always wins. The only local write is an\nin-app renewal (see the `payment.settled` webhook), which extends\n`expiry_date` locally and is pushed to the ERP immediately. If the ERP\nthen sends a different expiry we take the ERP value and raise an\nalert, rather than silently shortening a membership the member paid to\nextend.\n**If the ERP is unreachable:** the last synced entitlement continues\nto govern booking. Failing closed would lock every paying member out\nduring an ERP outage; failing open would let lapsed members book\nindefinitely. See Open question 7 on maximum acceptable staleness.\n\nTwo app behaviours the ERP team should know, because they change what\na member sees:\n\n1. The app derives an effective entitlement at read time. If\n   `expiry_date` has passed but `entitlement_status` still says\n   `active` because no sync has run at the instant of expiry, the app\n   treats it as expired and blocks booking. We never silently correct\n   the stored ERP value, only what the member sees.\n2. The app sends a membership expiry reminder 14 days before expiry,\n   once per expiry date. If the ERP also sends renewal reminders we\n   should agree who owns it (Open question 12).\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/UpdatedSince"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "name": "member_id",
            "in": "query",
            "required": false,
            "description": "Restrict to one member.",
            "schema": {
              "type": "string"
            },
            "example": "PF-000123"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of memberships.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Membership"
                      }
                    },
                    "page": {
                      "$ref": "#/components/schemas/PageInfo"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/coaches": {
      "get": {
        "tags": [
          "ERP: read API"
        ],
        "operationId": "listCoaches",
        "summary": "Coaches changed since a timestamp",
        "description": "**Direction:** ERP to app.\n**Trigger:** nightly reconciliation, plus delta pulls.\n**Idempotency key:** `coach_id`.\n**Conflict rule:** the ERP wins on every field in the schema. The\ncoach's password is app owned and is never supplied by, or sent to,\nthe ERP.\n**If the ERP is unreachable:** existing coaches log in and work\nnormally. New coaches cannot be created until the ERP returns, or we\nprovision them by hand as we do today.\n\n**Deactivation must be explicit.** A coach record that stops appearing\nin a feed is treated as a feed problem, not a deactivation. We need\n`is_active: false` or an explicit deletion event, never silence.\n\nCoaches never self-register: the app has no coach signup, by signed\nrequirement. Today Futovia provisions coach accounts by hand with an\nops script. In production the ERP should own this.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/UpdatedSince"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of coaches.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Coach"
                      }
                    },
                    "page": {
                      "$ref": "#/components/schemas/PageInfo"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/assignments": {
      "get": {
        "tags": [
          "ERP: read API"
        ],
        "operationId": "listAssignments",
        "summary": "Coach to member assignments changed since a timestamp",
        "description": "**This is a permission boundary, not a display convenience.** A coach\ncan only view a member, book a PT session, read invoices, or mark\nattendance if the assignment exists.\n\n**Direction:** ERP to app.\n**Trigger:** change notification on assign or unassign, plus nightly\nreconciliation of the full assignment set per coach.\n**Frequency:** on change. Timeliness matters here: an unassignment is\na permission revocation, so it should reach us in minutes, not\novernight.\n**Idempotency key:** the (`coach_id`, `member_id`) pair. In our\ndatabase that pair is the primary key, so an assignment cannot be\nduplicated.\n**Conflict rule:** the ERP wins. The app never creates or deletes an\nassignment.\n**If the ERP is unreachable:** the last synced assignments continue to\napply, so a revocation made during an outage takes effect late. The\nalternative is a live check on every coach action, which makes every\ncoach screen depend on ERP availability. We recommend the cache with a\nfast notification; see Open question 5.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/UpdatedSince"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "name": "coach_id",
            "in": "query",
            "required": false,
            "description": "Restrict to one coach.",
            "schema": {
              "type": "string"
            },
            "example": "C00007"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of assignments.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Assignment"
                      }
                    },
                    "page": {
                      "$ref": "#/components/schemas/PageInfo"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/packages": {
      "get": {
        "tags": [
          "ERP: read API"
        ],
        "operationId": "listPackages",
        "summary": "PT packages, pricing and remaining sessions",
        "description": "**The single most commercially sensitive feed in this integration.**\nCoach revenue and every PT invoice are derived from it.\n\n**Direction:** ERP to app for the record; app to ERP for consumption\ndeltas (see the `package.consumption_delta` webhook).\n**Trigger:** change notification on package create, change, expiry or\nrefund, plus nightly full reconciliation of active packages.\n**Idempotency key:** `package_id` for the record; the perFIT PT\nsession id for each consumption delta.\n**Conflict rule:** the ERP wins on all fields including\n`remaining_sessions`. Discrepancies are logged and reported, not\nsilently accepted.\n**If the ERP is unreachable:** coaches can still book against the last\nsynced package, down to 0 remaining. Consumption deltas queue and are\nreplayed when the ERP returns; they are idempotent, so a replay after\na partial outage cannot double-deduct.\n\n## Why price matters more than it looks\n\nThe app does not store a per-session price. It computes\n`price_hkd / total_sessions` at the moment a session is booked and\nfreezes that number onto the invoice. Coach revenue is the sum of\nthose per-session values over delivered sessions. Three consequences:\n\n1. If `price_hkd` or `total_sessions` is missing, the app **excludes**\n   those sessions from revenue rather than guessing, and shows the\n   coach a count of excluded sessions. Today, because packages are\n   seeded with stand-in prices, that is exactly what a coach sees.\n   Real ERP pricing removes it.\n2. Re-pricing a package in the ERP changes future invoices but never\n   rewrites an invoice already issued. An issued invoice is a record\n   of what was charged.\n3. Revenue reads package pricing at request time, so a price\n   correction in the ERP will shift previously displayed revenue\n   totals for sessions that have no invoice snapshot.\n\n## Remaining sessions: the one field that needs a rule\n\nBoth systems have a legitimate claim. The app decrements it when a\ncoach books (inside the same database transaction as the booking, so\nit cannot oversell). The ERP decrements it when a package is sold,\nrefunded, or adjusted by staff.\n\nOur proposal: **the ERP is the master value, the app reports deltas.**\nThe app never sets `remaining_sessions` from its own arithmetic when a\nsync arrives; it applies the ERP value. Each booking sends a delta\n(`-1`, with the session id as the idempotency key). A booking is\nrefused locally at 0 remaining, so a lagging ERP feed cannot cause an\noversell in the app. See Open question 4.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/UpdatedSince"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "name": "member_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "example": "PF-000123"
          },
          {
            "name": "coach_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "example": "C00007"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of packages.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/PtPackage"
                      }
                    },
                    "page": {
                      "$ref": "#/components/schemas/PageInfo"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/branches": {
      "get": {
        "tags": [
          "ERP: read API"
        ],
        "operationId": "listBranches",
        "summary": "Branches, opening hours, holiday overrides, closures and notices",
        "description": "**Direction:** ERP or the perFIT CMS to app, whichever perFIT prefers.\nNot the app. Today these are seeded by hand.\n**Trigger:** a nightly batch is sufficient for hours and addresses.\n**Closures are the exception:** a typhoon closure is useless if it\narrives tomorrow, so closures need an immediate push (see\n`POST /erp/branches/closure`) or a short poll interval.\n**Frequency:** daily for the record, immediate for closures.\n**Idempotency key:** `branch_id`; for closures, the closure id.\n**Conflict rule:** the source system wins.\n**If the ERP is unreachable:** the last synced branch data is served.\nA closure that cannot be delivered is the one genuinely risky case,\nwhich is why we recommend a push channel for it.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/UpdatedSince"
          }
        ],
        "responses": {
          "200": {
            "description": "All branches with their hours.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Branch"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/classes": {
      "get": {
        "tags": [
          "ERP: read API"
        ],
        "operationId": "listClasses",
        "summary": "Group class schedule",
        "description": "**Worth stating clearly, because it surprises people: coaches cannot\ncreate or edit classes in the app.** The app only reads the class\nschedule. That was a signed scope decision. So if the ERP does not\nfeed classes, they have to be entered somewhere else, and today that\nis a hand-run seed. See Open question 6.\n\n**Direction:** ERP or CMS to app for the schedule.\n**Trigger:** change notification, plus nightly reconciliation of the\nforward schedule.\n**Idempotency key:** `class_id`.\n**Conflict rule:** the ERP wins on the class record.\n**If the ERP is unreachable:** the last synced schedule is served and\nmembers can still book against it; bookings queue for push.\n\nA class moving to `cancelled` must notify every booked member, so\ncancellations need to arrive as promptly as closures.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/UpdatedSince"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "name": "starts_after",
            "in": "query",
            "required": false,
            "description": "Only classes starting after this instant.",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "example": "2026-09-20T00:00:00+08:00"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of classes.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/GymClass"
                      }
                    },
                    "page": {
                      "$ref": "#/components/schemas/PageInfo"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/classes/{class_id}/roster": {
      "get": {
        "tags": [
          "ERP: read API"
        ],
        "operationId": "getClassRoster",
        "summary": "Who the ERP has enrolled in a class",
        "description": "**Direction:** ERP to app for ERP-made enrolments; app to ERP for\nmember self-bookings, cancellations, waitlist promotions and\nattendance (see the `class.*` webhooks).\n**Idempotency key:** the (`class_id`, `member_id`) pair.\n**Conflict rule:** whichever system made the booking owns that row.\nCapacity is enforced by the app at book time.\n\nClass bookings are membership-covered in phase 1: no credits are\ndeducted and no fee is charged for a late cancellation, though a\ncancellation inside 24 hours is recorded and surfaced. The signed\nanswer on cancellation says the class fee is forfeit inside 24 hours;\nin a membership-covered model there is no per-class fee to forfeit, so\nthe app records the late cancellation rather than charging for it. If\nPerFit One intends an actual charge or credit deduction, that is a\nchange on both sides and needs to be agreed.\n",
        "parameters": [
          {
            "name": "class_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "example": "cls_20260922_hiit1"
          }
        ],
        "responses": {
          "200": {
            "description": "The roster.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/RosterEntry"
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/erp/members/changed": {
      "post": {
        "tags": [
          "ERP: change notifications"
        ],
        "operationId": "notifyMemberChanged",
        "servers": [
          {
            "url": "https://api.perfit.hk",
            "description": "perFIT app backend"
          }
        ],
        "summary": "A member was created or changed in the ERP",
        "description": "Call this on member create or change. We apply the ERP-owned fields\nand leave the app-owned ones alone.\n\n**Idempotency key:** `member_id`. Safe to replay.\n**Conflict rule:** ERP wins for its fields, app wins for credentials,\npush tokens and preferences, language, member-entered height and\nweight, self-uploaded photo, onboarding answers and consent records.\n**Ordering:** we use `updated_at` to discard an out-of-order replay,\nso a notification older than the state we hold is accepted with 200\nand ignored.\n",
        "security": [
          {
            "erpWebhookSignature": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Member"
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/Accepted"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/erp/memberships/changed": {
      "post": {
        "tags": [
          "ERP: change notifications"
        ],
        "operationId": "notifyMembershipChanged",
        "servers": [
          {
            "url": "https://api.perfit.hk"
          }
        ],
        "summary": "A membership status, date or branch entitlement changed",
        "description": "Call this on any change to status, entitlement, dates or branches,\nincluding freezes and cancellations.\n\n**Idempotency key:** `membership_id` plus `updated_at`.\n**Conflict rule:** the ERP always wins.\n**Why this one matters:** `entitlement_status` gates class booking. A\nfreeze that does not reach us lets a frozen member keep booking.\n",
        "security": [
          {
            "erpWebhookSignature": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Membership"
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/Accepted"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/erp/coaches/changed": {
      "post": {
        "tags": [
          "ERP: change notifications"
        ],
        "operationId": "notifyCoachChanged",
        "servers": [
          {
            "url": "https://api.perfit.hk"
          }
        ],
        "summary": "A coach was created, changed or deactivated",
        "description": "**Idempotency key:** `coach_id`.\n**Deactivation must be explicit:** send `is_active: false`. We never\ninfer deactivation from absence in a feed.\n**On a new coach:** the app generates a first password and it is\nhanded over out of band. If the ERP prefers to trigger a \"set your\npassword\" SMS instead, that is a small change and we are happy to make\nit. The password is never supplied by, or sent to, the ERP.\n",
        "security": [
          {
            "erpWebhookSignature": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Coach"
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/Accepted"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/erp/assignments/changed": {
      "post": {
        "tags": [
          "ERP: change notifications"
        ],
        "operationId": "notifyAssignmentChanged",
        "servers": [
          {
            "url": "https://api.perfit.hk"
          }
        ],
        "summary": "A coach to member assignment was made or revoked",
        "description": "**Send this promptly.** An unassignment is a permission revocation:\nuntil it arrives, the coach can still open that member, book sessions\nfor them and read their invoices.\n\n**Idempotency key:** the (`coach_id`, `member_id`) pair.\n**Revocation:** send `active: false`. We remove the assignment.\n",
        "security": [
          {
            "erpWebhookSignature": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Assignment"
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/Accepted"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/erp/packages/changed": {
      "post": {
        "tags": [
          "ERP: change notifications"
        ],
        "operationId": "notifyPackageChanged",
        "servers": [
          {
            "url": "https://api.perfit.hk"
          }
        ],
        "summary": "A PT package was sold, re-priced, adjusted, expired or refunded",
        "description": "**Idempotency key:** `package_id`.\n**Conflict rule:** the ERP value wins, including `remaining_sessions`.\nIf it disagrees with our local count we apply the ERP value and log\nthe discrepancy for reconciliation.\n**Pricing:** send `price_hkd` and `total_sessions` on every package.\nWithout them the app cannot value the sessions and excludes them from\ncoach revenue rather than guessing.\n",
        "security": [
          {
            "erpWebhookSignature": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PtPackage"
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/Accepted"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/erp/branches/closure": {
      "post": {
        "tags": [
          "ERP: change notifications"
        ],
        "operationId": "notifyBranchClosure",
        "servers": [
          {
            "url": "https://api.perfit.hk"
          }
        ],
        "summary": "A branch closed or reopened (typhoon, rainstorm, maintenance)",
        "description": "The one branch change that cannot wait for the nightly batch. While a\nclosure is active it overrides all opening hours in the app, and the\nbranch shows as closed with the reason.\n\n**Idempotency key:** `closure_id`.\n**To reopen early:** send the same `closure_id` with `ends_at` set to\nthe moment it ended.\n",
        "security": [
          {
            "erpWebhookSignature": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BranchClosure"
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/Accepted"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/erp/classes/changed": {
      "post": {
        "tags": [
          "ERP: change notifications"
        ],
        "operationId": "notifyClassChanged",
        "servers": [
          {
            "url": "https://api.perfit.hk"
          }
        ],
        "summary": "A class was scheduled, moved or cancelled",
        "description": "**Idempotency key:** `class_id`.\n**Cancellation:** send `status: cancelled`. The app notifies every\nbooked member; that notification is the member's only warning, so a\nlate cancellation notice is worse than none.\n",
        "security": [
          {
            "erpWebhookSignature": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/GymClass"
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/Accepted"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/erp/classes/enrolment": {
      "post": {
        "tags": [
          "ERP: change notifications"
        ],
        "operationId": "notifyClassEnrolment",
        "servers": [
          {
            "url": "https://api.perfit.hk"
          }
        ],
        "summary": "The ERP enrolled or removed a member from a class",
        "description": "Only needed if members can be enrolled in the ERP as well as\nself-booking in the app.\n\n**Idempotency key:** the (`class_id`, `member_id`) pair.\n**Capacity:** the app enforces capacity at book time for app-side\nbookings. An ERP enrolment that exceeds capacity is accepted and\nflagged rather than rejected, on the assumption that staff meant it.\n",
        "security": [
          {
            "erpWebhookSignature": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RosterEntry"
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/Accepted"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/erp/members/merged": {
      "post": {
        "tags": [
          "ERP: change notifications"
        ],
        "operationId": "notifyAccountMerge",
        "servers": [
          {
            "url": "https://api.perfit.hk"
          }
        ],
        "summary": "Two member records were merged in the ERP",
        "description": "**This closes a real gap, and it is the one place where the app\ncurrently promises the member something no system can deliver.**\n\nThe same person ends up with two app accounts, typically by\nregistering with an email and later doing a first login by phone. The\napp detects the collision and refuses to attach a phone number that\nalready belongs to another account, returning `PHONE_TAKEN`, and tells\nthe member that staff can merge their accounts at any branch. But\nthere is no merge API in either direction today, so a merge performed\nin the ERP has no effect on the app: the member still has two app\naccounts and each shows only its own history.\n\nOn receipt of this event the app will re-point the absorbed account's\napp-side records (bookings, PT history, reviews, support requests,\nnotifications) onto the surviving account and retire the duplicate\nlogin. **That app work is not built yet**, because until there is a\nmerge event to react to there is nothing to trigger it.\n\nIf perFIT would rather staff performed merges in our tooling instead,\nthat also works, but it needs to be decided. See Open question 13.\n\n**Idempotency key:** the (`surviving_member_id`, `merged_member_id`)\npair. A merge is applied once; a replay returns 200 and changes\nnothing.\n",
        "security": [
          {
            "erpWebhookSignature": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AccountMergeEvent"
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/Accepted"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/erp/refunds/completed": {
      "post": {
        "tags": [
          "ERP: change notifications"
        ],
        "operationId": "notifyRefundCompleted",
        "servers": [
          {
            "url": "https://api.perfit.hk"
          }
        ],
        "summary": "A refund was processed in the shop or the ERP",
        "description": "Refund requests are submitted in the app and completed in the shop,\nper the signed answer. The app records the request and shows the\nmember \"Submitted\", then \"Completed\".\n\n**Without this call the member's app shows \"Submitted\" forever.**\nThat is currently a manual step and is one of the more user-visible\ngaps in the product. See Open question 10.\n\n**Idempotency key:** `refund_request_id`.\n",
        "security": [
          {
            "erpWebhookSignature": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RefundCompletion"
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/Accepted"
          },
          "404": {
            "description": "No refund request with that identifier.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/erp/qr-badge/verify": {
      "post": {
        "tags": [
          "ERP: change notifications"
        ],
        "operationId": "verifyQrBadge",
        "servers": [
          {
            "url": "https://api.perfit.hk"
          }
        ],
        "summary": "Verify a member QR badge token (proposed, for door hardware)",
        "description": "**Proposed, not built.** Offered as one of the three options in Open\nquestion 8.\n\nThe app renders a short-lived signed token as a QR code, used today by\ncoaches to check members into classes and intended for branch entry\nhardware later. It is the app-side stand-in for facial recognition at\nthe door, per the signed scope. The token lasts 60 seconds, is signed\nwith a server-side key, is self-contained and stores nothing.\n\nIf PerFit One wants door hardware to accept it, the options are: this\nverification endpoint, publishing the signing key so the hardware\nverifies offline, or the hardware using an ERP-issued credential\ninstead. We need a decision and a contact on the hardware side.\n",
        "security": [
          {
            "erpApiKey": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "token"
                ],
                "properties": {
                  "token": {
                    "type": "string",
                    "description": "The token encoded in the member's QR code.",
                    "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJQRi0wMDAxMjMifQ.sig"
                  },
                  "branch_id": {
                    "type": "string",
                    "description": "Where the scan happened, for the audit trail.",
                    "example": "tsuen-wan"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Token valid.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "valid",
                    "member_id"
                  ],
                  "properties": {
                    "valid": {
                      "type": "boolean",
                      "const": true
                    },
                    "member_id": {
                      "type": "string",
                      "example": "PF-000123"
                    },
                    "name": {
                      "type": "string",
                      "example": "Chan Tai Man"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Token invalid or expired. Tokens live for 60 seconds.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/erp/reconciliation/coach-revenue": {
      "get": {
        "tags": [
          "Reference"
        ],
        "operationId": "getCoachRevenueForReconciliation",
        "servers": [
          {
            "url": "https://api.perfit.hk"
          }
        ],
        "summary": "The app's coach revenue figure, for reconciliation during integration testing",
        "description": "**Proposed, for integration testing only. No production feed is\nrequired in either direction.**\n\nCoach revenue in the app is derived, not stored and not fed: see the\n`CoachRevenue` schema. If the ERP also calculates coach commission,\nthe two figures must be reconciled from the same package data, and the\nquickest way to prove the package feed is correct is to compare the\ntwo outputs over the same window. This endpoint exists so that\ncomparison can be automated during integration testing.\n\nIf PerFit One does not calculate commission, nothing here is needed.\n",
        "security": [
          {
            "erpApiKey": []
          }
        ],
        "parameters": [
          {
            "name": "coach_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "example": "C00007"
          },
          {
            "name": "from",
            "in": "query",
            "required": true,
            "description": "Hong Kong calendar day, inclusive.",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "example": "2026-08-20"
          },
          {
            "name": "to",
            "in": "query",
            "required": true,
            "description": "Hong Kong calendar day, inclusive.",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "example": "2026-09-20"
          }
        ],
        "responses": {
          "200": {
            "description": "The app's figure for that coach and window.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CoachRevenue"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    }
  },
  "webhooks": {
    "ptSessionBooked": {
      "post": {
        "tags": [
          "App to ERP events"
        ],
        "operationId": "eventPtSessionBooked",
        "summary": "A coach booked a PT session",
        "description": "**Direction:** app to ERP.\n**Trigger:** immediately on booking.\n**Frequency:** immediate, typically a handful per coach per day.\n**Idempotency key:** `session_id`. A repeated `session_id` is the same\nbooking: return success, do not create a duplicate.\n**Conflict rule:** the app is authoritative for the booking record. If\nthe ERP rejects a booking, for example because the package was\nrefunded in the ERP and our cache had not caught up, return a clear\nerror code and we will surface it to the coach and reverse the local\nbooking.\n**If the ERP is unreachable:** the booking succeeds locally, the coach\nand member see it, and the push is queued with exponential backoff.\nNothing about a coach's day depends on the ERP being up.\n\nBooking rules already enforced in the app, which the ERP can rely on:\na coach cannot hold two sessions that overlap in time, a member cannot\nbe booked into two overlapping sessions even by different coaches, and\na booking against a package with 0 remaining sessions is refused.\n\nThis event is sent together with `ptInvoiceIssued` and\n`packageConsumptionDelta`, which describe the same booking from the\nmoney side.\n",
        "security": [
          {
            "perfitWebhookSignature": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PtSession"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Accepted (or already known, for a replay)."
          },
          "409": {
            "description": "Rejected. Return a machine-readable code; we surface it to the coach and reverse the local booking.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "ptSessionChanged": {
      "post": {
        "tags": [
          "App to ERP events"
        ],
        "operationId": "eventPtSessionChanged",
        "summary": "A booked PT session was moved or cancelled",
        "description": "**Direction:** app to ERP.\n**Trigger:** when a coach reschedules a session (time, length or\nvenue) or cancels it.\n**Idempotency key:** `session_id` plus `status`.\n**On cancellation** the app returns the session to the member's\npackage and removes the invoice: expect a matching\n`packageConsumptionDelta` of `+1` and treat the earlier invoice as\nvoid. A session that has been checked in cannot be cancelled.\n",
        "security": [
          {
            "perfitWebhookSignature": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PtSession"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Accepted."
          },
          "400": {
            "description": "Malformed or unverifiable payload. We retry 5xx with backoff; a 400 is logged and raised internally rather than retried forever.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "ptSessionCheckedIn": {
      "post": {
        "tags": [
          "App to ERP events"
        ],
        "operationId": "eventPtSessionCheckedIn",
        "summary": "A member signed in to a PT session",
        "description": "At the session the member signs on the coach's device to confirm\nattendance. The app renders the signature to an image server side and\nstores the path against the session.\n\n**Ownership: the app, and it must stay that way.** This is the\nevidence of delivery for a paid session, it is captured on our device,\nand the member's signature must not be re-drawn or re-interpreted by\nanother system.\n\n**Direction:** app to ERP.\n**Trigger:** immediately on check-in.\n**Idempotency key:** `session_id`. A session has exactly one check-in.\n**Conflict rule:** the app is authoritative. Store the check-in\ntimestamp and a reference to the signature, not a copy that could\ndiverge. If the ERP needs the image itself for a finance pack we can\npush the bytes, but we need to agree retention and who is the PDPO\ncontroller (Open question 9).\n**If the ERP is unreachable:** the check-in stands locally and queues.\n\nRules already enforced: check-in opens 60 minutes before the session\nstarts and closes 24 hours after it ends, a session can only be\nchecked in once (concurrent taps resolve to a single check-in), and\nthe signature image is rendered server side from validated coordinates\nso no user-supplied content is stored verbatim. Access to the image is\nrestricted to the coach and the member on that session, so the URL\nrequires our authentication.\n",
        "security": [
          {
            "perfitWebhookSignature": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PtCheckIn"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Accepted."
          },
          "400": {
            "description": "Malformed or unverifiable payload. We retry 5xx with backoff; a 400 is logged and raised internally rather than retried forever.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "ptInvoiceIssued": {
      "post": {
        "tags": [
          "App to ERP events"
        ],
        "operationId": "eventPtInvoiceIssued",
        "summary": "A PT invoice was issued for a booked session",
        "description": "One invoice record per booked PT session, written in the same database\ntransaction as the booking. It carries the per-session rate frozen at\nbooking time.\n\n**Direction:** app to ERP.\n**Trigger:** in the same push as the booking.\n**Idempotency key:** `invoice_id`, with `session_id` as a secondary\nuniqueness guarantee (one invoice per session, enforced by a unique\nconstraint).\n**Conflict rule:** an issued invoice is immutable in the app. A\ncorrection is a new record, never an edit. If the ERP needs invoice\nnumbering in its own series, return its number and we will store it\nalongside ours (Open question 3).\n**If the ERP is unreachable:** queued with the booking.\n",
        "security": [
          {
            "perfitWebhookSignature": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PtInvoice"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Accepted. Optionally return the ERP's own invoice number and we will store it alongside ours.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "erp_invoice_no": {
                      "type": "string",
                      "description": "The ERP's own invoice reference, if it issues one.",
                      "example": "INV-2026-00871"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "packageConsumptionDelta": {
      "post": {
        "tags": [
          "App to ERP events"
        ],
        "operationId": "eventPackageConsumptionDelta",
        "summary": "The app consumed or returned a session on a package",
        "description": "The app reports consumption as a **delta**, never as an absolute\ncount, because the ERP holds the master value (Open question 4).\n\n**Direction:** app to ERP.\n**Trigger:** on booking (`-1`) and on cancellation of a booked session\n(`+1`).\n**Idempotency key:** `session_id` plus `delta`. This is what makes a\nretry after a network failure safe: replaying the same session's\ndeduction must not deduct twice.\n**If the ERP is unreachable:** deltas queue and replay on recovery.\n",
        "security": [
          {
            "perfitWebhookSignature": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PackageConsumptionDelta"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Accepted. Optionally return the ERP's authoritative `remaining_sessions` after applying the delta; we will apply it.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "remaining_sessions": {
                      "type": "integer",
                      "minimum": 0,
                      "example": 7
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "classAttendanceMarked": {
      "post": {
        "tags": [
          "App to ERP events"
        ],
        "operationId": "eventClassAttendanceMarked",
        "summary": "A coach marked a member present, absent or on leave",
        "description": "Coaches mark attendance in the app, either by tapping a roster row or\nby scanning the member's in-app QR badge.\n\n**Direction:** app to ERP.\n**Trigger:** immediately on marking.\n**Idempotency key:** the (`class_id`, `member_id`) pair plus\n`marked_at`. The latest `marked_at` wins: a coach correcting a\nmis-tap sends a second event.\n",
        "security": [
          {
            "perfitWebhookSignature": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AttendanceEvent"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Accepted."
          },
          "400": {
            "description": "Malformed or unverifiable payload. We retry 5xx with backoff; a 400 is logged and raised internally rather than retried forever.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "classBookingChanged": {
      "post": {
        "tags": [
          "App to ERP events"
        ],
        "operationId": "eventClassBookingChanged",
        "summary": "A member booked, cancelled, or was promoted from the waitlist",
        "description": "**Direction:** app to ERP, for bookings made in the app.\n**Idempotency key:** the (`class_id`, `member_id`) pair plus `status`.\n**Late cancellation:** a cancellation inside 24 hours is recorded and\nsurfaced but not charged, because class bookings are\nmembership-covered in phase 1. `late` is set so the ERP can see it.\n",
        "security": [
          {
            "perfitWebhookSignature": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ClassBookingEvent"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Accepted."
          },
          "400": {
            "description": "Malformed or unverifiable payload. We retry 5xx with backoff; a 400 is logged and raised internally rather than retried forever.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "paymentSettled": {
      "post": {
        "tags": [
          "App to ERP events"
        ],
        "operationId": "eventPaymentSettled",
        "summary": "An in-app purchase settled (credit top-up, membership purchase or renewal)",
        "description": "In-app purchases are taken through Payment Asia. The ERP is not in the\npayment path, but its finance records need the outcome.\n\n**Direction:** app to ERP.\n**Trigger:** on settlement, from the provider's verified webhook.\n**Idempotency key:** `checkout_id`.\n**Conflict rule:** the payment provider's verified webhook is the\ntruth about whether money moved. Neither the app nor the ERP overrides\nit.\n**If the ERP is unreachable:** settlement is unaffected, it does not\ndepend on the ERP; the report queues.\n\nA membership purchase or renewal bought in the app extends the local\nmembership record and must be reflected in the ERP, which otherwise\nholds a shorter expiry than the member paid for.\n",
        "security": [
          {
            "perfitWebhookSignature": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PaymentEvent"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Accepted."
          },
          "400": {
            "description": "Malformed or unverifiable payload. We retry 5xx with backoff; a 400 is logged and raised internally rather than retried forever.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "reviewSubmitted": {
      "post": {
        "tags": [
          "App to ERP events"
        ],
        "operationId": "eventReviewSubmitted",
        "summary": "A member left a review (optional feed)",
        "description": "Only needed if the customer service queue lives in the ERP rather than\nin our staff tooling (Open question 11).\n\nA review of 3 stars or fewer automatically opens a customer service\nfollow-up item and is held back from public display until staff have\nlooked at it.\n\n**Idempotency key:** `review_id`.\n",
        "security": [
          {
            "perfitWebhookSignature": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Review"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Accepted."
          },
          "400": {
            "description": "Malformed or unverifiable payload. We retry 5xx with backoff; a 400 is logged and raised internally rather than retried forever.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "supportRequestSubmitted": {
      "post": {
        "tags": [
          "App to ERP events"
        ],
        "operationId": "eventSupportRequestSubmitted",
        "summary": "A member submitted a support request (optional feed)",
        "description": "Only needed if support requests are worked in the ERP rather than in\nour staff tooling (Open question 11). Staff respond through our ops\ntooling today.\n\n**Idempotency key:** `support_request_id`.\n",
        "security": [
          {
            "perfitWebhookSignature": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SupportRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Accepted."
          },
          "400": {
            "description": "Malformed or unverifiable payload. We retry 5xx with backoff; a 400 is logged and raised internally rather than retried forever.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "erpOAuth": {
        "type": "oauth2",
        "description": "Preferred for the ERP read API. Separate client credentials per environment (sandbox and production).",
        "flows": {
          "clientCredentials": {
            "tokenUrl": "https://erp.example.perfit.hk/oauth/token",
            "scopes": {
              "members.read": "Read members and memberships",
              "coaches.read": "Read coaches and assignments",
              "packages.read": "Read PT packages and pricing",
              "schedule.read": "Read branches, classes and rosters"
            }
          }
        }
      },
      "erpApiKey": {
        "type": "apiKey",
        "in": "header",
        "name": "X-API-Key",
        "description": "Acceptable alternative to OAuth for the read API. Separate keys per environment, rotatable without a deploy."
      },
      "erpWebhookSignature": {
        "type": "apiKey",
        "in": "header",
        "name": "X-PerfitOne-Signature",
        "description": "HMAC-SHA256 over the raw request body, hex encoded, using a shared\nsecret, on every call the ERP makes to the perFIT app. We verify it\nbefore parsing the body and reject anything unsigned.\n\nSend `X-PerfitOne-Timestamp` (RFC 3339) alongside it and include the\ntimestamp in the signed payload so an intercepted call cannot be\nreplayed indefinitely; we reject a timestamp more than 5 minutes old.\n"
      },
      "perfitWebhookSignature": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Perfit-Signature",
        "description": "The matching header on every event the perFIT app sends to the ERP:\nHMAC-SHA256 over the raw body, hex encoded, with a shared secret the\nERP supplies. We also send `X-Perfit-Idempotency-Key` (the value named\nin each event's description) and `X-Perfit-Timestamp`.\n"
      }
    },
    "parameters": {
      "UpdatedSince": {
        "name": "updated_since",
        "in": "query",
        "required": false,
        "description": "Return only records whose `updated_at` is at or after this instant. Omit for a full set (cold start and nightly full reconciliation).",
        "schema": {
          "type": "string",
          "format": "date-time"
        },
        "example": "2026-09-19T08:14:02Z"
      },
      "Cursor": {
        "name": "cursor",
        "in": "query",
        "required": false,
        "description": "Opaque cursor from the previous page's `page.next_cursor`.",
        "schema": {
          "type": "string"
        }
      },
      "Limit": {
        "name": "limit",
        "in": "query",
        "required": false,
        "description": "Page size. The ERP should document its maximum.",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 500,
          "default": 100
        }
      },
      "MemberIdPath": {
        "name": "member_id",
        "in": "path",
        "required": true,
        "description": "The ERP stable member identifier.",
        "schema": {
          "type": "string"
        },
        "example": "PF-000123"
      }
    },
    "responses": {
      "Accepted": {
        "description": "Processed, or already applied (a replay). The app answers 200 to any well-formed, correctly signed notification it can apply or safely ignore, so the ERP's retry queue drains.",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": [
                "ok"
              ],
              "properties": {
                "ok": {
                  "type": "boolean",
                  "const": true
                },
                "applied": {
                  "type": "boolean",
                  "description": "False when the payload was older than the state we hold and was deliberately ignored.",
                  "example": true
                }
              }
            }
          }
        }
      },
      "BadRequest": {
        "description": "The request was malformed: a missing required field, an unparseable timestamp, or a value outside its documented range. The `code` says which.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Missing or invalid credentials, or a bad signature.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "No record with that identifier.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "RateLimited": {
        "description": "Too many requests.",
        "headers": {
          "Retry-After": {
            "description": "Seconds to wait before retrying.",
            "schema": {
              "type": "integer"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Error": {
        "description": "Any other error.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "description": "Requirement 9: a machine-readable code plus a human-readable message on every non-2xx response, so the caller can react to the specific failure rather than showing \"something went wrong\".",
        "required": [
          "code",
          "message"
        ],
        "properties": {
          "code": {
            "type": "string",
            "description": "Stable, machine-readable. Never localised.",
            "example": "MEMBER_NOT_FOUND"
          },
          "message": {
            "type": "string",
            "description": "Human-readable, safe to log. Not shown to members verbatim.",
            "example": "No member with identifier PF-000999"
          },
          "details": {
            "type": "object",
            "additionalProperties": true,
            "description": "Optional field-level detail for validation failures."
          }
        },
        "examples": [
          {
            "code": "MEMBER_NOT_FOUND",
            "message": "No member with identifier PF-000999"
          }
        ]
      },
      "PageInfo": {
        "type": "object",
        "description": "Cursor pagination (requirement 6).",
        "properties": {
          "next_cursor": {
            "type": [
              "string",
              "null"
            ],
            "description": "Pass as `cursor` to fetch the next page. Null on the last page.",
            "example": "eyJvZmZzZXQiOjEwMH0"
          },
          "has_more": {
            "type": "boolean",
            "example": true
          }
        }
      },
      "Member": {
        "type": "object",
        "title": "Member",
        "description": "A person who trains at perFIT and uses the app.\n\n**Ownership: shared, split by field.** The app owns authentication and\nthe app-only profile. The ERP owns the membership identity and status.\n\n**Stable identifier.** The app allocates its own member code on\nregistration (`M00001`, `M00002`, from a database sequence). That is\nwhat the member sees and what our API exposes. The link to the ERP is\na separate, nullable, unique column holding the ERP's `member_id`.\n\n**We are asking PerFit One for a stable, immutable member identifier**\nthat never changes when a member renews, freezes, changes branch, or\nchanges phone number. Mobile number is not acceptable as the permanent\nkey; it is used only for the first-login match (Open question 1).\n\n**Fields the app owns and the ERP must not overwrite:** password and\nlogin credentials, push notification tokens and preferences, app\nlanguage, height and weight (member-entered), self-uploaded profile\nphoto, onboarding answers (goals, training frequency, preferred\nbranches), and legal consent records (T&C, privacy and biometric\nversions with timestamps, held for PDPO).\n",
        "required": [
          "member_id",
          "mobile",
          "name",
          "is_active",
          "updated_at"
        ],
        "properties": {
          "member_id": {
            "type": "string",
            "description": "ERP stable member identifier, immutable. The join key for every other entity.",
            "example": "PF-000123"
          },
          "mobile": {
            "type": "string",
            "description": "Mobile number in E.164. Used for first-login matching only.",
            "pattern": "^\\+[1-9][0-9]{6,14}$",
            "example": "+85261234567"
          },
          "name": {
            "type": "string",
            "description": "Full name as held in the ERP. Shown in the app, to the assigned coach, and on class rosters.",
            "example": "Chan Tai Man"
          },
          "email": {
            "type": [
              "string",
              "null"
            ],
            "format": "email",
            "description": "Email if held.",
            "example": "taiman@example.com"
          },
          "date_of_birth": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Calendar day. Used for age display.",
            "example": "1990-04-12"
          },
          "gender": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "female",
              "male",
              null
            ],
            "description": "Shown to the assigned coach only.",
            "example": "male"
          },
          "is_active": {
            "type": "boolean",
            "description": "Whether the member record is active in the ERP. An inactive member keeps read access in the app but is flagged.",
            "example": true
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "description": "Last change in the ERP. Drives delta sync and discards out-of-order replays.",
            "example": "2026-09-19T08:14:02Z"
          }
        },
        "examples": [
          {
            "member_id": "PF-000123",
            "mobile": "+85261234567",
            "name": "Chan Tai Man",
            "email": "taiman@example.com",
            "date_of_birth": "1990-04-12",
            "gender": "male",
            "is_active": true,
            "updated_at": "2026-09-19T08:14:02Z"
          }
        ]
      },
      "Membership": {
        "type": "object",
        "title": "Membership and entitlement",
        "description": "Whether a member is currently entitled to train, until when, and at\nwhich branches. The app shows this and uses it to allow or block class\nbooking.\n\n**Ownership: the ERP, entirely.** The app holds a read cache and says\nso in the code. The one exception is a membership bought inside the\napp, which extends the local record and is reported back via the\n`paymentSettled` event.\n",
        "required": [
          "member_id",
          "membership_id",
          "status",
          "entitlement_status",
          "branch_ids",
          "updated_at"
        ],
        "properties": {
          "member_id": {
            "type": "string",
            "description": "ERP member identifier. Join key.",
            "example": "PF-000123"
          },
          "membership_id": {
            "type": "string",
            "description": "ERP identifier of this membership record, in case a member holds more than one over time.",
            "example": "MS-0099"
          },
          "status": {
            "type": "string",
            "enum": [
              "member",
              "non_member",
              "frozen"
            ],
            "description": "Coarse label shown to the member.",
            "example": "member"
          },
          "entitlement_status": {
            "type": "string",
            "enum": [
              "active",
              "pending",
              "frozen",
              "suspended",
              "expired"
            ],
            "description": "Finer access state. Only `active` permits class booking.",
            "example": "active"
          },
          "start_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "example": "2026-01-15"
          },
          "expiry_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Null means open ended. Read as a Hong Kong calendar day. Drives the membership screen, the 14-day expiry reminder and the booking gate.",
            "example": "2027-01-14"
          },
          "branch_ids": {
            "type": "array",
            "description": "Branches this membership is valid at. May be empty.",
            "items": {
              "type": "string"
            },
            "example": [
              "tsuen-wan",
              "kwun-tong"
            ]
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-09-19T08:14:02Z"
          }
        },
        "examples": [
          {
            "member_id": "PF-000123",
            "membership_id": "MS-0099",
            "status": "member",
            "entitlement_status": "active",
            "start_date": "2026-01-15",
            "expiry_date": "2027-01-14",
            "branch_ids": [
              "tsuen-wan",
              "kwun-tong"
            ],
            "updated_at": "2026-09-19T08:14:02Z"
          }
        ]
      },
      "CoachCertificate": {
        "type": "object",
        "title": "Coach certificate",
        "description": "A qualification. Read only in the app on purpose: review and approval of a coach's qualifications happen in the ERP, not in the app.",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "example": "NASM CPT"
          },
          "issuer": {
            "type": "string",
            "example": "NASM"
          },
          "issued_on": {
            "type": "string",
            "format": "date",
            "example": "2021-05-01"
          }
        }
      },
      "Coach": {
        "type": "object",
        "title": "Coach",
        "description": "A coach account.\n\n**Ownership: the ERP for identity and profile, the app for\ncredentials.**\n\n**Two identifiers exist and they are not the same thing**, which\nmatters: `login_code` is what the coach types on the login screen\n(`COACH07`), and `coach_id` is the displayed coach ID (`C00007`) that\nevery assignment, package, session and invoice references. We are\nasking PerFit One to own and supply both, or to tell us to keep\ngenerating them. If the ERP supplies them they must be immutable\n(Open question 2).\n",
        "required": [
          "coach_id",
          "login_code",
          "name",
          "is_active",
          "updated_at"
        ],
        "properties": {
          "coach_id": {
            "type": "string",
            "description": "ERP stable coach identifier. Referenced by every assignment, package, session and invoice.",
            "example": "C00007"
          },
          "login_code": {
            "type": "string",
            "description": "Coach login id, unique. Typed on the coach login screen.",
            "example": "COACH07"
          },
          "name": {
            "type": "string",
            "description": "Display name, shown in the coach profile, the member-facing directory, and on class and PT screens.",
            "example": "Joe Hon"
          },
          "mobile": {
            "type": [
              "string",
              "null"
            ],
            "description": "Contact and password reset channel, E.164.",
            "example": "+85261234567"
          },
          "email": {
            "type": [
              "string",
              "null"
            ],
            "format": "email",
            "description": "Contact and password reset channel.",
            "example": "joe@perfit.hk"
          },
          "join_date": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Shown read only in the coach profile.",
            "example": "2024-06-01"
          },
          "is_active": {
            "type": "boolean",
            "description": "Whether the coach may log in. Send `false` to deactivate; never rely on absence from a feed.",
            "example": true
          },
          "photo_url": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "Coach photo for the member-facing directory.",
            "example": "https://erp.example/img/c7.jpg"
          },
          "introduction": {
            "type": [
              "string",
              "null"
            ],
            "description": "Biography shown in the coach directory.",
            "example": "Ten years in strength and conditioning."
          },
          "specialties": {
            "type": "array",
            "description": "Filterable specialty tags in the coach directory.",
            "items": {
              "type": "string"
            },
            "example": [
              "Strength",
              "Rehab"
            ]
          },
          "branch_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Home branch, used by the directory branch filter.",
            "example": "tsuen-wan"
          },
          "certificates": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CoachCertificate"
            }
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-09-19T08:14:02Z"
          }
        },
        "examples": [
          {
            "coach_id": "C00007",
            "login_code": "COACH07",
            "name": "Joe Hon",
            "mobile": "+85261234567",
            "email": "joe@perfit.hk",
            "join_date": "2024-06-01",
            "is_active": true,
            "photo_url": "https://erp.example/img/c7.jpg",
            "introduction": "Ten years in strength and conditioning.",
            "specialties": [
              "Strength",
              "Rehab"
            ],
            "branch_id": "tsuen-wan",
            "certificates": [
              {
                "name": "NASM CPT",
                "issuer": "NASM",
                "issued_on": "2021-05-01"
              }
            ],
            "updated_at": "2026-09-19T08:14:02Z"
          }
        ]
      },
      "Assignment": {
        "type": "object",
        "title": "Coach to member assignment",
        "description": "Which members a coach is allowed to see and act on. **A permission\nboundary, not a display convenience.**\n\n**Stable identifier:** the (`coach_id`, `member_id`) pair, which is\nthe primary key in our database, so an assignment cannot be\nduplicated.\n",
        "required": [
          "coach_id",
          "member_id",
          "active",
          "updated_at"
        ],
        "properties": {
          "coach_id": {
            "type": "string",
            "example": "C00007"
          },
          "member_id": {
            "type": "string",
            "example": "PF-000123"
          },
          "active": {
            "type": "boolean",
            "description": "Whether the assignment currently stands. `false` removes it in the app.",
            "example": true
          },
          "assigned_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the assignment began. Audit only.",
            "example": "2026-02-01T00:00:00Z"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-09-19T08:14:02Z"
          }
        }
      },
      "PtPackage": {
        "type": "object",
        "title": "PT package",
        "description": "A block of personal training sessions a member has bought, tied to a\nspecific coach. It carries how many sessions remain and what it cost.\n\n**Ownership:** the ERP owns the package and its price.\n`remaining_sessions` is shared and needs the clearest rule of anything\nin this document; see the `/packages` operation and Open question 4.\n",
        "required": [
          "package_id",
          "member_id",
          "coach_id",
          "name",
          "total_sessions",
          "remaining_sessions",
          "price_hkd",
          "status",
          "updated_at"
        ],
        "properties": {
          "package_id": {
            "type": "string",
            "description": "ERP stable package identifier. Referenced by every PT session and invoice.",
            "example": "PKG-55012"
          },
          "member_id": {
            "type": "string",
            "description": "Owning member.",
            "example": "PF-000123"
          },
          "coach_id": {
            "type": "string",
            "description": "The coach this package is bookable with. Drives booking authorisation.",
            "example": "C00007"
          },
          "name": {
            "type": "string",
            "description": "Display name, shown on the coach booking screen and on the invoice line.",
            "example": "PT 10-Session Pack"
          },
          "total_sessions": {
            "type": "integer",
            "minimum": 1,
            "description": "Sessions the package was sold with. Denominator of the per-session rate.",
            "example": 10
          },
          "remaining_sessions": {
            "type": "integer",
            "minimum": 0,
            "description": "Sessions not yet delivered. The ERP value is master; the app reports deltas and refuses to book at 0.",
            "example": 8
          },
          "price_hkd": {
            "type": [
              "number",
              "null"
            ],
            "multipleOf": 0.01,
            "description": "Total price paid for the package, in HKD. The per-session rate is `price_hkd / total_sessions`. **Null or missing means the app cannot value these sessions and excludes them from coach revenue** rather than guessing.",
            "example": 6800.0
          },
          "expiry": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Hong Kong calendar day. Null means no expiry.",
            "example": "2027-03-31"
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "expired",
              "cancelled",
              "refunded"
            ],
            "description": "Whether the package can still be booked against.",
            "example": "active"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-09-19T08:14:02Z"
          }
        },
        "examples": [
          {
            "package_id": "PKG-55012",
            "member_id": "PF-000123",
            "coach_id": "C00007",
            "name": "PT 10-Session Pack",
            "total_sessions": 10,
            "remaining_sessions": 8,
            "price_hkd": 6800.0,
            "expiry": "2027-03-31",
            "status": "active",
            "updated_at": "2026-09-19T08:14:02Z"
          }
        ]
      },
      "PackageConsumptionDelta": {
        "type": "object",
        "title": "Package consumption delta",
        "description": "The app's report that it consumed or returned one session on a package. Never an absolute count: the ERP holds the master value.",
        "required": [
          "package_id",
          "session_id",
          "delta",
          "occurred_at"
        ],
        "properties": {
          "package_id": {
            "type": "string",
            "example": "PKG-55012"
          },
          "session_id": {
            "type": "string",
            "description": "The perFIT PT session that caused it. Part of the idempotency key.",
            "example": "pts_9f2c4a7e18b04c31"
          },
          "member_id": {
            "type": "string",
            "example": "PF-000123"
          },
          "coach_id": {
            "type": "string",
            "example": "C00007"
          },
          "delta": {
            "type": "integer",
            "enum": [
              -1,
              1
            ],
            "description": "-1 when a session is booked, +1 when a booked session is cancelled.",
            "example": -1
          },
          "reason": {
            "type": "string",
            "enum": [
              "booking",
              "cancellation"
            ],
            "example": "booking"
          },
          "occurred_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-09-20T03:11:42Z"
          }
        }
      },
      "PtSession": {
        "type": "object",
        "title": "PT session (booking)",
        "description": "A personal training appointment between one coach and one member,\nbooked in the app by the coach against a package.\n\n**Ownership: the app.** This is created in the app and the ERP needs\nto be told.\n\n**Stable identifier:** the perFIT app session id. There is also a\nnatural key enforced in the database: (`coach_id`, `member_id`,\n`package_id`, `starts_at`) is unique, which makes an identical\nduplicate submission an idempotent replay rather than a second\nbooking.\n",
        "required": [
          "session_id",
          "coach_id",
          "member_id",
          "package_id",
          "venue",
          "starts_at",
          "duration_minutes",
          "status",
          "created_at"
        ],
        "properties": {
          "session_id": {
            "type": "string",
            "description": "perFIT app session identifier. The idempotency key.",
            "example": "pts_9f2c4a7e18b04c31"
          },
          "coach_id": {
            "type": "string",
            "example": "C00007"
          },
          "member_id": {
            "type": "string",
            "example": "PF-000123"
          },
          "package_id": {
            "type": "string",
            "description": "The package the session is drawn from.",
            "example": "PKG-55012"
          },
          "venue": {
            "type": "string",
            "maxLength": 120,
            "description": "Where the session takes place.",
            "example": "Studio 1"
          },
          "starts_at": {
            "type": "string",
            "format": "date-time",
            "description": "Session start, with time zone offset.",
            "example": "2026-09-22T10:00:00+08:00"
          },
          "duration_minutes": {
            "type": "integer",
            "minimum": 15,
            "maximum": 240,
            "default": 60,
            "description": "Session length. The studio standard is 60.",
            "example": 60
          },
          "notes": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 1000,
            "description": "Coach notes.",
            "example": "Focus on deadlift form"
          },
          "status": {
            "type": "string",
            "enum": [
              "booked",
              "checked_in",
              "cancelled"
            ],
            "description": "Lifecycle state. `cancelled` arrives via the `ptSessionChanged` event and is accompanied by a `+1` consumption delta.",
            "example": "booked"
          },
          "cancelled_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Present when `status` is `cancelled`.",
            "example": null
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the booking was made.",
            "example": "2026-09-20T03:11:42Z"
          }
        },
        "examples": [
          {
            "session_id": "pts_9f2c4a7e18b04c31",
            "coach_id": "C00007",
            "member_id": "PF-000123",
            "package_id": "PKG-55012",
            "venue": "Studio 1",
            "starts_at": "2026-09-22T10:00:00+08:00",
            "duration_minutes": 60,
            "notes": "Focus on deadlift form",
            "status": "booked",
            "cancelled_at": null,
            "created_at": "2026-09-20T03:11:42Z"
          }
        ]
      },
      "PtCheckIn": {
        "type": "object",
        "title": "PT check-in and signature",
        "description": "The member's signed confirmation that a paid session was delivered. Captured on the coach's device, rendered to an image server side, and stored against the session.",
        "required": [
          "session_id",
          "coach_id",
          "member_id",
          "checked_in_at",
          "signature_url"
        ],
        "properties": {
          "session_id": {
            "type": "string",
            "example": "pts_9f2c4a7e18b04c31"
          },
          "coach_id": {
            "type": "string",
            "description": "Coach who took the check-in.",
            "example": "C00007"
          },
          "member_id": {
            "type": "string",
            "description": "Member who signed.",
            "example": "PF-000123"
          },
          "checked_in_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the member signed.",
            "example": "2026-09-22T10:04:11+08:00"
          },
          "signature_url": {
            "type": "string",
            "format": "uri",
            "description": "Authenticated URL of the stored signature image. Access is restricted to the coach and the member on that session, so fetching it requires our credentials.",
            "example": "https://api.perfit.hk/uploads/sig-pts9f2c-3a7f11c2e480.svg"
          }
        },
        "examples": [
          {
            "session_id": "pts_9f2c4a7e18b04c31",
            "coach_id": "C00007",
            "member_id": "PF-000123",
            "checked_in_at": "2026-09-22T10:04:11+08:00",
            "signature_url": "https://api.perfit.hk/uploads/sig-pts9f2c-3a7f11c2e480.svg"
          }
        ]
      },
      "PtInvoice": {
        "type": "object",
        "title": "PT invoice",
        "description": "One invoice record per booked PT session, written in the same database transaction as the booking, carrying the per-session rate frozen at booking time. Immutable: a correction is a new record, never an edit.",
        "required": [
          "invoice_id",
          "session_id",
          "coach_id",
          "member_id",
          "package_id",
          "package_name",
          "rate_hkd",
          "session_starts_at",
          "created_at"
        ],
        "properties": {
          "invoice_id": {
            "type": "string",
            "description": "perFIT app invoice identifier.",
            "example": "inv_71ab33c9d0e24f16"
          },
          "session_id": {
            "type": "string",
            "description": "The session invoiced. Unique: one invoice per session.",
            "example": "pts_9f2c4a7e18b04c31"
          },
          "coach_id": {
            "type": "string",
            "description": "Coach delivering.",
            "example": "C00007"
          },
          "member_id": {
            "type": "string",
            "description": "Member charged.",
            "example": "PF-000123"
          },
          "package_id": {
            "type": "string",
            "description": "Package drawn from.",
            "example": "PKG-55012"
          },
          "package_name": {
            "type": "string",
            "description": "Package name as it was at booking time.",
            "example": "PT 10-Session Pack"
          },
          "rate_hkd": {
            "type": [
              "number",
              "null"
            ],
            "multipleOf": 0.01,
            "description": "Per-session value frozen at booking (`price_hkd / total_sessions`). Null when the package was unpriced, which is exactly the case real ERP pricing removes.",
            "example": 680.0
          },
          "session_starts_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-09-22T10:00:00+08:00"
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the invoice was issued.",
            "example": "2026-09-20T03:11:42Z"
          }
        },
        "examples": [
          {
            "invoice_id": "inv_71ab33c9d0e24f16",
            "session_id": "pts_9f2c4a7e18b04c31",
            "coach_id": "C00007",
            "member_id": "PF-000123",
            "package_id": "PKG-55012",
            "package_name": "PT 10-Session Pack",
            "rate_hkd": 680.0,
            "session_starts_at": "2026-09-22T10:00:00+08:00",
            "created_at": "2026-09-20T03:11:42Z"
          }
        ]
      },
      "CoachRevenue": {
        "type": "object",
        "title": "Coach revenue (derived, no feed required)",
        "description": "**No integration is required for this and we want to be explicit about\nwhy.**\n\nCoach revenue in the app is not a stored figure and not a feed. It is\ncomputed on every request as: delivered PT sessions (start time in the\npast), each valued at its package's per-session rate, summed over the\nselected window. Sessions on packages without pricing are excluded and\nthe count of exclusions is shown to the coach.\n\nThe implication for PerFit One is simple: **the accuracy of coach\nrevenue in the app depends entirely on the accuracy of `price_hkd` and\n`total_sessions` in the package feed.** No separate revenue feed is\nneeded or wanted. If the ERP also calculates coach commission, the two\nfigures must be reconciled from the same package data, and we should\ncompare outputs during integration testing.\n\nThe app offers revenue by preset period (1 day, 1 week, 1 month, 3\nmonths, year to date) and by a custom date range. All day boundaries\nare Hong Kong days.\n\nThis schema documents the shape the app produces. It is shown for\nreconciliation during testing, not as something to send us.\n",
        "properties": {
          "coach_id": {
            "type": "string",
            "example": "C00007"
          },
          "period": {
            "type": "string",
            "description": "Preset window, or `custom` when a date range was picked.",
            "enum": [
              "1d",
              "1w",
              "1m",
              "3m",
              "ytd",
              "custom"
            ],
            "example": "1m"
          },
          "from": {
            "type": "string",
            "format": "date",
            "example": "2026-08-20"
          },
          "to": {
            "type": "string",
            "format": "date",
            "description": "Inclusive.",
            "example": "2026-09-20"
          },
          "total_hkd": {
            "type": "number",
            "multipleOf": 0.01,
            "example": 28720.0
          },
          "all_time_hkd": {
            "type": "number",
            "multipleOf": 0.01,
            "example": 33400.0
          },
          "unpriced_sessions": {
            "type": "integer",
            "description": "Delivered sessions excluded from the total because their package had no price. Shown to the coach so the number is never silently wrong.",
            "example": 0
          }
        }
      },
      "BranchWeeklyHours": {
        "type": "object",
        "title": "Weekly opening hours",
        "description": "One row per weekday. A weekday with no row is treated as closed. A closing time earlier than or equal to the opening time is read as spanning midnight.",
        "required": [
          "weekday",
          "closed"
        ],
        "properties": {
          "weekday": {
            "type": "integer",
            "minimum": 0,
            "maximum": 6,
            "description": "0 is Sunday.",
            "example": 1
          },
          "closed": {
            "type": "boolean",
            "example": false
          },
          "is_24h": {
            "type": "boolean",
            "default": false,
            "example": false
          },
          "open": {
            "type": [
              "string",
              "null"
            ],
            "pattern": "^[0-2][0-9]:[0-5][0-9]$",
            "example": "07:00"
          },
          "close": {
            "type": [
              "string",
              "null"
            ],
            "pattern": "^[0-2][0-9]:[0-5][0-9]$",
            "example": "23:00"
          }
        }
      },
      "BranchHolidayHours": {
        "type": "object",
        "title": "Holiday hours override",
        "description": "Date-specific override of the weekly hours.",
        "required": [
          "date",
          "closed"
        ],
        "properties": {
          "date": {
            "type": "string",
            "format": "date",
            "example": "2026-12-25"
          },
          "closed": {
            "type": "boolean",
            "example": true
          },
          "is_24h": {
            "type": "boolean",
            "default": false
          },
          "open": {
            "type": [
              "string",
              "null"
            ],
            "example": null
          },
          "close": {
            "type": [
              "string",
              "null"
            ],
            "example": null
          }
        }
      },
      "BranchClosure": {
        "type": "object",
        "title": "Temporary closure",
        "description": "Overrides all opening hours while active. Typhoon and rainstorm closures are the reason this needs a push channel rather than the nightly batch.",
        "required": [
          "closure_id",
          "branch_id",
          "starts_at",
          "reason",
          "kind"
        ],
        "properties": {
          "closure_id": {
            "type": "string",
            "description": "Stable identifier. Re-send it with `ends_at` to reopen early.",
            "example": "clo-2026-09-24-typhoon"
          },
          "branch_id": {
            "type": "string",
            "example": "tsuen-wan"
          },
          "starts_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-09-24T06:00:00+08:00"
          },
          "ends_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Null while the closure is open ended.",
            "example": "2026-09-24T20:00:00+08:00"
          },
          "reason": {
            "type": "string",
            "description": "Shown to members verbatim.",
            "example": "Typhoon signal 8"
          },
          "kind": {
            "type": "string",
            "enum": [
              "weather",
              "maintenance",
              "holiday",
              "other"
            ],
            "example": "weather"
          }
        }
      },
      "BranchAnnouncement": {
        "type": "object",
        "title": "Branch announcement",
        "description": "A notice shown on the branch detail screen.",
        "required": [
          "title",
          "body"
        ],
        "properties": {
          "announcement_id": {
            "type": "string",
            "example": "ann-refurb-2026-10"
          },
          "title": {
            "type": "string",
            "example": "Refurbishment"
          },
          "body": {
            "type": "string",
            "example": "The changing rooms are being refurbished from 1 October."
          },
          "starts_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "example": null
          },
          "ends_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "example": null
          }
        }
      },
      "Branch": {
        "type": "object",
        "title": "Branch",
        "description": "A gym location with its opening hours, holiday overrides, temporary\nclosures and announcements.\n\n**Ownership:** the ERP or the perFIT CMS, whichever perFIT prefers.\nNot the app. Today these are seeded by hand. Our branch identifiers\nare human-readable slugs (`tsuen-wan`); we will use the ERP identifier\nif one exists.\n",
        "required": [
          "branch_id",
          "name",
          "address",
          "timezone",
          "is_active",
          "weekly_hours"
        ],
        "properties": {
          "branch_id": {
            "type": "string",
            "description": "Stable branch identifier. Referenced by membership entitlement, the coach directory and support routing.",
            "example": "tsuen-wan"
          },
          "name": {
            "type": "string",
            "description": "English name.",
            "example": "Tsuen Wan"
          },
          "name_zh": {
            "type": [
              "string",
              "null"
            ],
            "description": "Chinese name.",
            "example": "荃灣"
          },
          "address": {
            "type": "string",
            "example": "Shop 5, 123 Castle Peak Road"
          },
          "phone": {
            "type": [
              "string",
              "null"
            ],
            "description": "Public line, E.164. Drives the call button in the app.",
            "example": "+85231234567"
          },
          "whatsapp": {
            "type": [
              "string",
              "null"
            ],
            "description": "WhatsApp number, E.164. Drives the message link.",
            "example": "+85291234567"
          },
          "timezone": {
            "type": "string",
            "description": "IANA zone. Defaults to Asia/Hong_Kong.",
            "default": "Asia/Hong_Kong",
            "example": "Asia/Hong_Kong"
          },
          "is_active": {
            "type": "boolean",
            "description": "Whether the branch is listed.",
            "example": true
          },
          "sort_order": {
            "type": "integer",
            "description": "Display order.",
            "example": 1
          },
          "weekly_hours": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/BranchWeeklyHours"
            }
          },
          "holiday_hours": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/BranchHolidayHours"
            }
          },
          "closures": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/BranchClosure"
            }
          },
          "announcements": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/BranchAnnouncement"
            }
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-09-19T08:14:02Z"
          }
        }
      },
      "GymClass": {
        "type": "object",
        "title": "Group class",
        "description": "A group class: what is running, when, where, with which coach, and\ncapacity.\n\n**Coaches cannot create or edit classes in the app** (signed scope),\nso something outside the app must own this before launch.\n",
        "required": [
          "class_id",
          "coach_id",
          "name",
          "venue",
          "starts_at",
          "ends_at",
          "capacity",
          "status",
          "updated_at"
        ],
        "properties": {
          "class_id": {
            "type": "string",
            "example": "cls_20260922_hiit1"
          },
          "coach_id": {
            "type": "string",
            "description": "Coach running it.",
            "example": "C00007"
          },
          "name": {
            "type": "string",
            "example": "HIIT 45"
          },
          "venue": {
            "type": "string",
            "description": "Room or branch label.",
            "example": "Studio 1, Tsuen Wan"
          },
          "starts_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-09-22T19:00:00+08:00"
          },
          "ends_at": {
            "type": "string",
            "format": "date-time",
            "description": "Must be after the start.",
            "example": "2026-09-22T19:45:00+08:00"
          },
          "capacity": {
            "type": "integer",
            "minimum": 0,
            "description": "Maximum places. Enforced by the app at book time, and drives the waitlist.",
            "example": 16
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "description": "Member-facing.",
            "example": "High intensity intervals."
          },
          "notes": {
            "type": [
              "string",
              "null"
            ],
            "description": "Coach-facing.",
            "example": "Bring skipping ropes"
          },
          "status": {
            "type": "string",
            "enum": [
              "scheduled",
              "cancelled"
            ],
            "description": "A cancelled class notifies every booked member.",
            "example": "scheduled"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-09-19T08:14:02Z"
          }
        }
      },
      "RosterEntry": {
        "type": "object",
        "title": "Class roster entry",
        "description": "One member's place on one class.",
        "required": [
          "class_id",
          "member_id",
          "status"
        ],
        "properties": {
          "class_id": {
            "type": "string",
            "example": "cls_20260922_hiit1"
          },
          "member_id": {
            "type": "string",
            "example": "PF-000123"
          },
          "status": {
            "type": "string",
            "enum": [
              "booked",
              "attended",
              "absent",
              "leave",
              "cancelled"
            ],
            "example": "booked"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-09-19T08:14:02Z"
          }
        }
      },
      "AttendanceEvent": {
        "type": "object",
        "title": "Class attendance marking",
        "description": "A coach marked a member present, absent or on leave, at the door. App-owned, pushed to the ERP.",
        "required": [
          "class_id",
          "member_id",
          "status",
          "marked_at",
          "coach_id",
          "method"
        ],
        "properties": {
          "class_id": {
            "type": "string",
            "example": "cls_20260922_hiit1"
          },
          "member_id": {
            "type": "string",
            "example": "PF-000123"
          },
          "coach_id": {
            "type": "string",
            "example": "C00007"
          },
          "status": {
            "type": "string",
            "enum": [
              "booked",
              "attended",
              "absent",
              "leave"
            ],
            "example": "attended"
          },
          "marked_at": {
            "type": "string",
            "format": "date-time",
            "description": "Part of the idempotency key. The latest marking wins.",
            "example": "2026-09-22T19:02:44+08:00"
          },
          "method": {
            "type": "string",
            "enum": [
              "manual",
              "qr_scan"
            ],
            "description": "Whether the coach tapped the roster row or scanned the member's QR badge.",
            "example": "qr_scan"
          }
        }
      },
      "ClassBookingEvent": {
        "type": "object",
        "title": "Class booking change made in the app",
        "description": "A member booked, cancelled, or was promoted from the waitlist.",
        "required": [
          "class_id",
          "member_id",
          "status",
          "occurred_at"
        ],
        "properties": {
          "class_id": {
            "type": "string",
            "example": "cls_20260922_hiit1"
          },
          "member_id": {
            "type": "string",
            "example": "PF-000123"
          },
          "status": {
            "type": "string",
            "enum": [
              "booked",
              "cancelled",
              "waitlisted",
              "waitlist_promoted"
            ],
            "example": "cancelled"
          },
          "late": {
            "type": "boolean",
            "description": "True when a cancellation fell inside the 24-hour window. Recorded and surfaced, not charged: class bookings are membership-covered in phase 1.",
            "example": true
          },
          "occurred_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-09-22T09:30:00+08:00"
          }
        }
      },
      "PaymentEvent": {
        "type": "object",
        "title": "In-app payment outcome",
        "description": "A credit top-up, membership purchase or renewal taken through Payment Asia. The ERP is not in the payment path, but its finance records need the outcome.",
        "required": [
          "checkout_id",
          "member_id",
          "type",
          "description",
          "amount_hkd",
          "currency",
          "status",
          "provider_ref"
        ],
        "properties": {
          "checkout_id": {
            "type": "string",
            "description": "perFIT app checkout session. Idempotency key.",
            "example": "co_8812f0a3b7d9"
          },
          "member_id": {
            "type": "string",
            "example": "PF-000123"
          },
          "type": {
            "type": "string",
            "enum": [
              "credit_top_up",
              "membership_purchase",
              "membership_renewal"
            ],
            "example": "membership_renewal"
          },
          "description": {
            "type": "string",
            "description": "Receipt description.",
            "example": "12-month membership"
          },
          "amount_hkd": {
            "type": "number",
            "multipleOf": 0.01,
            "example": 4800.0
          },
          "currency": {
            "type": "string",
            "example": "HKD"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "succeeded",
              "failed",
              "cancelled",
              "expired"
            ],
            "example": "succeeded"
          },
          "provider_ref": {
            "type": "string",
            "description": "Payment Asia reference.",
            "example": "PA-99120031"
          },
          "receipt_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Receipt issued on success.",
            "example": "rcpt_3391cc02"
          },
          "settled_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "example": "2026-09-20T04:02:10Z"
          },
          "credits": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Credits granted, snapshotted at purchase. Present for a top-up.",
            "example": null
          },
          "duration_months": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Membership months granted, snapshotted at purchase.",
            "example": 12
          }
        }
      },
      "RefundCompletion": {
        "type": "object",
        "title": "Refund completion",
        "description": "Sent by the ERP or shop when a refund is actually processed. Until the app receives this, the member's app shows \"Submitted\" indefinitely.",
        "required": [
          "refund_request_id",
          "status",
          "completed_at"
        ],
        "properties": {
          "refund_request_id": {
            "type": "string",
            "description": "The identifier the app issued when the member submitted the request. Idempotency key.",
            "example": "rr_55a1c8e0"
          },
          "reference_type": {
            "type": "string",
            "enum": [
              "booking",
              "top_up",
              "membership"
            ],
            "description": "What the refund was against.",
            "example": "top_up"
          },
          "reference_id": {
            "type": "string",
            "example": "co_8812f0a3b7d9"
          },
          "status": {
            "type": "string",
            "enum": [
              "completed",
              "rejected"
            ],
            "example": "completed"
          },
          "amount_hkd": {
            "type": [
              "number",
              "null"
            ],
            "multipleOf": 0.01,
            "example": 500.0
          },
          "note": {
            "type": [
              "string",
              "null"
            ],
            "description": "Optional staff note. Shown to the member if present, so write it for them.",
            "example": "Refunded in cash at Tsuen Wan."
          },
          "completed_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-09-21T02:00:00Z"
          }
        }
      },
      "AccountMergeEvent": {
        "type": "object",
        "title": "Duplicate account merge",
        "description": "Two member records were merged in the ERP. The app re-points the absorbed account's records onto the surviving account and retires the duplicate login.",
        "required": [
          "surviving_member_id",
          "merged_member_id",
          "effective_at"
        ],
        "properties": {
          "surviving_member_id": {
            "type": "string",
            "description": "The ERP member identifier that remains.",
            "example": "PF-000123"
          },
          "merged_member_id": {
            "type": "string",
            "description": "The ERP member identifier absorbed and retired.",
            "example": "PF-000456"
          },
          "effective_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-09-20T06:00:00Z"
          }
        }
      },
      "Review": {
        "type": "object",
        "title": "Member review",
        "description": "A member's rating of a coach, a venue or customer service. Optional feed; see Open question 11.",
        "required": [
          "review_id",
          "member_id",
          "subject_type",
          "stars",
          "status",
          "created_at"
        ],
        "properties": {
          "review_id": {
            "type": "string",
            "example": "rv_7712ab"
          },
          "member_id": {
            "type": "string",
            "example": "PF-000123"
          },
          "subject_type": {
            "type": "string",
            "enum": [
              "coach",
              "venue",
              "customer_service"
            ],
            "example": "coach"
          },
          "subject_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "The coach or branch reviewed.",
            "example": "C00007"
          },
          "stars": {
            "type": "integer",
            "minimum": 1,
            "maximum": 5,
            "example": 2
          },
          "comment": {
            "type": [
              "string",
              "null"
            ],
            "example": "Session started late."
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "example": [
              "punctuality"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "published",
              "suppressed",
              "hidden",
              "rejected"
            ],
            "description": "A review of 3 stars or fewer is held back from public display and opens a customer service follow-up item.",
            "example": "suppressed"
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-09-19T12:00:00Z"
          }
        }
      },
      "SupportRequest": {
        "type": "object",
        "title": "Support request",
        "description": "A member's message to the studio, with a human reference number. Optional feed; see Open question 11.",
        "required": [
          "support_request_id",
          "ref_no",
          "member_id",
          "kind",
          "subject",
          "body",
          "status",
          "created_at"
        ],
        "properties": {
          "support_request_id": {
            "type": "string",
            "example": "sr_0091ac"
          },
          "ref_no": {
            "type": "string",
            "description": "Human reference shown to the member and quoted in replies.",
            "pattern": "^SR-[0-9]{6}$",
            "example": "SR-000042"
          },
          "member_id": {
            "type": "string",
            "example": "PF-000123"
          },
          "kind": {
            "type": "string",
            "enum": [
              "complaint",
              "compliment",
              "support",
              "attendance"
            ],
            "example": "attendance"
          },
          "branch_id": {
            "type": [
              "string",
              "null"
            ],
            "example": "tsuen-wan"
          },
          "subject": {
            "type": "string",
            "example": "Missing attendance record"
          },
          "body": {
            "type": "string",
            "example": "I attended the 19:00 class on 12 September but it is not in my history."
          },
          "image_url": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "Optional attachment. Fetching it requires our authentication.",
            "example": null
          },
          "status": {
            "type": "string",
            "enum": [
              "open",
              "in_progress",
              "resolved"
            ],
            "example": "open"
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-09-19T12:00:00Z"
          }
        }
      }
    }
  },
  "security": [
    {
      "erpOAuth": []
    },
    {
      "erpApiKey": []
    }
  ]
}