> For the complete documentation index, see [llms.txt](https://docs.kula.digital/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.kula.digital/whats-in-your-data/classpass.md).

# ClassPass — ontology map

**Source id:** `com.classpass` · **Role:** aggregator revenue only · **Access:** operator-uploaded CSV — **there is no ClassPass API for studios**

ClassPass is unlike every other source here. It has no studio API and no connector service. What a studio has is a **reservation report** they download from ClassPass and upload to Kula, and what that report carries is **the payout side only**.

The attendance itself is not missing — ClassPass bookings flow into the studio's booking system (Mindbody, Wix, GymMaster), so `bookings.attendance` already has the visit. What was missing was the money: how much ClassPass actually paid the studio for it. That is the entire job of this source.

## Coverage at a glance

The export must carry these 12 columns. ClassPass ships more than one shape of this report and has added columns over time, so columns are matched **by name**: extra columns are carried into the raw record untouched, and any column order works. A file MISSING one of the twelve is rejected whole-file, naming what was absent — that means "not a ClassPass reservation report", not a mapping to solve.

Because the idempotency key is built from the twelve named columns only, the same reservation exported in the narrow and the wider shape hashes to the same reference and dedupes against itself. Importing both variants of an overlapping period does not double-count the payout.

```
venue, location, class_name, class_date, start_time, uid,
first_name, last_name, earnings, payout_reason, status, instructor_name
```

| CSV column                                           | Canonical home                                             |
| ---------------------------------------------------- | ---------------------------------------------------------- |
| `earnings`                                           | `commerce.sale.amount` / `.total`                          |
| `class_date` + `start_time`                          | `commerce.sale.occurred_at` (studio-local)                 |
| `first_name` + `last_name`                           | used for matching; not written as a member                 |
| `uid`                                                | ClassPass member id, retained in `source_extras`           |
| `status`                                             | `source_extras.status` — also drives the payout kind       |
| `payout_reason`                                      | `source_extras` (e.g. "Reactivate past members (25% Off)") |
| `class_name`, `instructor_name`, `venue`, `location` | `source_extras`, and used for matching                     |

**One `commerce.sale` per CSV row.** No members are created, no attendance is created, no plans, no products. `class_date` is `DD/MM/YYYY` and `start_time` is 24-hour local.

## What we cannot get

**Anything ClassPass didn't put in the export.** There is no API, so the CSV is the complete universe of ClassPass data. Concretely, that means:

* **No ClassPass member profile.** `uid` is stable, but there's no email, phone, join date or history. ClassPass attendees are **not** created as `people.member` rows — they exist as members only if the booking system also recorded them.
* **No ClassPass-side booking or cancellation timeline.** Only the final `status` per reservation.
* **No forward view.** The report is historical; there's nothing to poll, so there is no "ClassPass bookings for next week".
* **No refunds or adjustments as their own rows.** Corrections appear as further reservation rows in a later export.

**Freshness is manual.** There is no recurring sync. The data is as current as the last file an operator uploaded — which is why a ClassPass revenue figure should always be qualified by the period the uploads actually cover.

**Coverage is whatever was uploaded.** Gaps between exports are invisible. A missing month looks like a month with no ClassPass revenue.

## How the payout links back to attendance

Stage 2 runs set-based in SQL and matches each CSV row to an existing attendance record:

1. **Find the member by normalised name.** Two keys, matching on either: *name\_full* (lower-cased, diacritics folded, punctuation stripped, first+last concatenated — so `José` matches `Jose` and `O'Leary` matches `OLeary`), and *name\_sort* (same fold, but tokens sorted — so `Zhang, Wei` matches `Wei Zhang`).
2. **Find that member's attendance within ±1 day** of the class time. The slack absorbs timezone skew between the CSV's studio-local times and the booking system's timestamps.
3. **Pick among candidates** by exact class-name-and-time, then exact time, then class name, then closest time.

**The sale is created either way.** An unmatched row still becomes revenue, flagged `source_extras.match_status = 'unmatched'`. The attendance link is fill-only — an attendance row already pointing at a sale (paid through the booking system) is never overwritten.

There is a **rematch** path: unmatched sales are re-run against current member and attendance data and promoted when they now match. So a studio that uploads ClassPass before finishing their booking-system import will see match rates improve after a rematch rather than being stuck.

Idempotency is `sha256(uid | class_date | start_time | class_name | status)`. Overlapping exports collide per-row and count as duplicates. `status` is part of the key on purpose — a "Late Cancel" and a "Late Cancel Rebooked" for the same slot are genuinely different payouts.

## Counting traps

**Match rate is a real, reportable number.** Query `source_extras->>'match_status'` and say what proportion of ClassPass revenue could be attributed to a known member. Reporting per-member ClassPass spend without that denominator overstates confidence.

**No booking data means no matches at all.** If the studio's booking system hasn't been imported yet, every row lands unmatched. That's expected — say so rather than reporting it as a data quality problem.

**Cancellation-fee payouts are mixed in.** Rows whose `status` contains "cancel" are cancellation-fee payouts, not attended classes. Splitting attended-class revenue from cancellation fees matters for any per-visit economics.

**ClassPass revenue is not studio revenue at the same rate.** A ClassPass visit typically pays a fraction of a direct booking, and `payout_reason` shows why (promotions, reactivation discounts). Keep it reported separately from headline membership revenue — the canonical glossary treats aggregator flows as an excluded bucket for exactly this reason.

**The booking system also recorded these visits.** ClassPass attendance exists in `bookings.attendance` with `source = 'com.mindbody'` (or Wix, GymMaster). Don't count the ClassPass sale as an extra visit — it's the money for a visit already counted.

## Questions this source can and can't answer

**Can answer well**

* What ClassPass actually paid, per reservation and in total, for the uploaded periods
* Effective revenue per ClassPass visit, and how it compares to a direct booking
* Which promotions and payout reasons are driving the rate
* Which instructors and class times attract the most aggregator volume
* Cancellation-fee income

**Cannot answer**

* Who ClassPass attendees are, beyond a name and an opaque uid
* Whether a ClassPass attendee converted to a direct member *(only inferable via a name match to the booking system)*
* Anything about periods not covered by an upload
* Forward bookings
* ClassPass-side cancellations, waitlists or member behaviour

## Importing an export from the chat screen

An operator does not have to open the console to load a ClassPass export. Three tools cover the whole cycle:

| Tool                          | What it does                                                                                                     |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `import_classpass_export`     | Takes the CSV as text, lands every row, creates the sales and links them to attendance — both stages in one call |
| `rematch_classpass_sales`     | Re-evaluates sales still flagged unmatched, after more members or booking history arrive                         |
| `get_classpass_import_status` | What is already loaded, what is unmatched and why, and the per-day reconciliation against the booking roster     |

**Pass the file through verbatim.** `csv_text` must be a byte-for-byte copy of the export: the header line and every data row, in the original order. Do not reformat, requote, sample, collapse similar rows or stop early — each row is a separate payout, so an omitted row is revenue the studio never sees. Set `expected_rows` to the number of data rows you believe the file has; the tool compares it against what arrived and returns `row_count_warning` when they disagree, which is how an incomplete paste gets caught. If that warning comes back, say so plainly and re-import the full file rather than reporting the import as done.

The ceiling is **512 KB of CSV** (roughly 4,500 reservations — comfortably a month, plausibly a quarter). Above that the tool refuses and names the console upload, which has no practical limit; that is the right path for a multi-year backfill, and it is not a failure to say so.

Re-importing is safe. Rows dedupe on a content hash of `uid | class_date | start_time | class_name | status`, so an overlapping export adds only what is genuinely new. Note that `earnings` is deliberately **not** part of that hash: a corrected re-export updates nothing and double-counts nothing.

After the import, read `unmatched_breakdown` before characterising the result. `late_cancel` and `privacy_erased` are expected and need no action — see [Counting traps](#counting-traps). Only `unlinked` is worth chasing, and `rematch_classpass_sales` is the thing to run once more members or more booking history have loaded.

## Recipes — what works well

For reading the data back there is no ClassPass-specific tool — everything here is SQL over `commerce.sale` filtered to `source = 'com.classpass'`. Three things are worth doing every time.

### Always establish coverage and match rate first

```sql
SELECT min(occurred_at) AS earliest, max(occurred_at) AS latest,
       count(*) AS reservations,
       count(*) FILTER (WHERE source_extras->>'match_status' = 'matched') AS matched
FROM commerce.sale
WHERE source = 'com.classpass';
```

Two numbers to quote alongside any ClassPass figure: the period the uploads actually cover, and the proportion matched to a known member. A gap between exports looks identical to a month with no ClassPass revenue.

### Effective revenue per visit, and how it compares

```sql
SELECT date_trunc('month', occurred_at) AS month,
       count(*) AS reservations,
       sum(total) AS payout,
       round(avg(total), 2) AS avg_per_visit
FROM commerce.sale
WHERE source = 'com.classpass'
  AND coalesce(source_extras->>'status','') NOT ILIKE '%cancel%'
GROUP BY 1 ORDER BY 1;
```

The `NOT ILIKE '%cancel%'` matters — cancellation-fee payouts are mixed in and are not attended classes. Run the inverse to report them separately.

### What's driving the rate

```sql
SELECT coalesce(nullif(source_extras->>'payout_reason',''), '(none)') AS reason,
       count(*), round(avg(total), 2) AS avg_payout
FROM commerce.sale
WHERE source = 'com.classpass'
GROUP BY 1 ORDER BY 2 DESC;
```

Promotions and reactivation discounts show up here, and they explain most of the variance in what ClassPass pays.

### Which classes and instructors attract aggregator volume

`class_name` and `instructor_name` ride in `source_extras`. Cross-reference with `get_class_utilisation` on the booking source to see whether ClassPass is filling classes that were already full — which is a very different conclusion from filling empty ones.

### Keep it out of headline revenue

The canonical glossary treats aggregator flows as an excluded bucket. Report ClassPass payout as its own line, not folded into membership revenue, and never count a ClassPass sale as an extra visit — the visit is already in `bookings.attendance` under the booking system's source.

### What doesn't work

No purpose-built tool applies. `list_at_risk_members` deliberately excludes ClassPass members — they aren't expected to return, so treating them as lapsing is noise. Per-member ClassPass analysis is limited to whatever matched; the unmatched remainder is real revenue with no known member and should be reported as its own bucket.

## Where this lives in the code

| Concern                                                                            | Path                                                                 |
| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| The engine — parsing, Stage 1 landing, Stage 2 projection, rematch, reconciliation | `services/intelligence/internal/classpass/`                          |
| Console front door (multipart upload, Kinde-gated)                                 | `services/intelligence/internal/admin/httpapi/ingestor_classpass.go` |
| Chat front door (MCP tools, on mcp-runtime)                                        | `services/intelligence/internal/tools/classpass_v2.go`               |
| Operator UI                                                                        | `/ingestors/classpass` in `apps/kula-org-admin`                      |
| Sample export shape                                                                | `services/ingestors/classpass/demodata/README.md`                    |
| Operator-facing connect guide                                                      | [CSV & ClassPass connector](/your-data-sources/csv.md)               |
