> 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/wix.md).

# Wix — ontology map

**Source id:** `com.wix` · **Role:** all-in-one — website, bookings, pricing plans and payments · **Access:** Wix REST APIs, API-key auth, per-site

## Scope: Wix Bookings, not all of Wix

Read this first — it prevents the most common confusion on this source.

**Wix is a platform, not a product.** A studio's Wix account can run a website, an online store, blogs, events, forms, email marketing and more. **We ingest the Bookings side only** — the schedule, the people, the plans, and the money that flows through Cashier. Everything else the studio does on Wix is outside what Kula sees.

This matters because Wix's own interface and API show the operator *all* of it. So an operator looking at their Wix dashboard — or anyone querying Wix directly — sees totals that will not match ours, and neither number is wrong: they are counting different things. Store revenue, site traffic and non-bookings activity are theirs, not ours.

When a Wix figure doesn't reconcile, **check the scope before assuming a sync problem.** Say "that includes your Wix store, which Kula doesn't ingest" rather than treating the difference as missing data.

## What makes Wix awkward

Almost nothing is denormalised. A session carries a schedule id and a resource list; a participation carries an event id and a contact id. Every human-meaningful field — class name, category, instructor, class time, the plan that covered the visit — is resolved by joining across other entities at transform time.

That means Wix data quality depends on **which entities were pulled together**. A session ingested without its services cache gets a schedule id where a class name should be.

## Coverage at a glance

| Wix entity                   | Canonical home                                | Grain                            | Notes                                          |
| ---------------------------- | --------------------------------------------- | -------------------------------- | ---------------------------------------------- |
| `locations`                  | `people.location`                             | one per location                 | single page, no paging                         |
| `staff` (staff-members)      | `people.staff`                                | one per staff member             | `resourceId` retained for session joins        |
| `resources`                  | **no canonical table**                        | —                                | cache only: resolves session → instructor      |
| `services`                   | **no canonical table**                        | —                                | cache only: class name + category per schedule |
| `categories`                 | `bookings.category`                           | one per category                 | `kind = service`                               |
| `pricing_plans`              | `commerce.plan`                               | one per plan                     |                                                |
| `products` (Stores)          | `commerce.product`                            | one per product                  | offset-paged                                   |
| `contacts`                   | `people.member`                               | one per contact                  | offset-paged — see traps                       |
| `pricing_orders`             | `commerce.plan` stub + member plan-state stub | one per membership/pass purchase | **not a revenue source**                       |
| `ecom_orders`                | **landed raw, not mapped**                    | one per class booking            | the booking + cancellation record — see below  |
| `transactions` (Cashier)     | `commerce.sale` + `commerce.refund`           | one per settled charge           | **the canonical revenue source**               |
| `sessions` (calendar events) | `bookings.class_session`                      | one per session                  | date-windowed                                  |
| `participations`             | `bookings.attendance`                         | one per participation            |                                                |

## The booking lifecycle — check-ins, cancellations, late cancels

Wix records more of the booking lifecycle than a first read suggests.

**`CONFIRMED` means the member checked in to the class.** It is a record of attendance, not merely of a confirmed reservation — so `bookings.attendance.booking_status = 'attended'` on a Wix row reflects a real check-in.

**A booking is an order, and it can be cancelled before the class.** When a member books a class an order is created; cancelling it before the class start produces a cancellation with its own timestamp. That lands on `bookings.attendance.cancelled_at`, alongside the session's `starts_at`.

**Late cancel is derivable — cancelled within 2 hours of class start.** The canonical `latecancel` status is **not** written at ingest: Wix cancellations land as `cancelled`, and the two-hour rule is applied at **read** time from `cancelled_at` versus the session's `starts_at`. The [Recipes](#recipes--what-works-well) section has the query. Treat a stored `booking_status = 'cancelled'` as "cancelled at some point", and derive the late-cancel split yourself.

**No-show behaviour is derivable from the orders, but it's a heavy call.** The ecom orders — the booking records — carry what's needed to separate someone who booked and didn't show from someone who checked in. Those rows land raw in `ingest.raw_record` and are **not mapped canonically**, so answering it means reading raw orders rather than querying a table. It is possible; it is expensive. Say so before committing to it, and prefer the `CONFIRMED` check-in signal on `bookings.attendance` where that suffices.

## What we cannot get

**Anything outside Wix Bookings.** Store orders, site analytics, blog, events, forms — a studio may run all of them on Wix and none of it reaches Kula. See [Scope](#scope-wix-bookings-not-all-of-wix).

**Door access / facility entry.** No concept in Wix. `bookings.facility_entry` is empty.

**Member status history before we started capturing it.** Wix reports a pricing order's *current* state only, and the historical and superseded orders behind it are collapsed to one snapshot at ingest — so there is no lapse, pause or cancellation timeline to read back. Kula synthesises the change by detecting it itself, so **from the date capture began** every status, membership-type, plan and suspension change is recorded in `people.member_status_event` — read it with `list_member_status_changes`, or the `status_history` block on `get_member_context`. Each member also carries one `is_baseline = true` origin row recording what we first saw; that is an observation, not a change, so filter it out before counting anything. Establish the capture start before charting a series:

```sql
SELECT min(detected_at) AS capture_started FROM people.member_status_event;
```

**Accounting / general ledger.** Wix Cashier gives settled payments, not a chart of accounts. `accounting.*` requires Xero or QuickBooks.

**A single revenue endpoint that covers everything historically.** Cashier transactions are date-windowed, so revenue history is only as deep as the windows that have been pulled. Older revenue that predates the pull window is simply absent — the pricing-orders endpoint cannot substitute (see the next section).

**Most list endpoints are not date-filterable.** Only calendar sessions and Cashier transactions honour a `[from, to]` window. Everything else re-pulls in full, which means a "just fetch what changed since Tuesday" question has no answer for contacts, plans, staff or products.

**Appointments as a distinct concept.** Wix bookings all land as calendar sessions; there is no separate 1:1 appointment feed mapped. `bookings.appointment` is empty for Wix.

## The revenue story — read this before quoting Wix revenue

Wix has **two order systems and a payments system**, and they are not interchangeable.

* **Pricing orders** are membership/pass *purchases*. An order records the purchase, **not the stream of recurring charges against it**. A weekly membership billed 59 times appears as **one order**. Using orders as revenue under-counts recurring income massively — historically it reported roughly a quarter of true revenue.
* **eCommerce orders** are checkout records. They overlap Cashier and are landed raw but map to nothing.
* **Cashier transactions** are every settled payment — recurring cycles and one-off purchases alike. **This is the canonical revenue source**, and the only one that maps to `commerce.sale`.

So: `commerce.sale` for a Wix studio comes from Cashier transactions. Pricing orders survive only as plan and member-plan-state stubs that keep the commerce graph linked. If a revenue figure looks implausibly low, check whether the transactions entity has actually been pulled for the period in question.

Refunds ride on the parent SALE transaction's `refunds[]` array and become `commerce.refund` rows. Standalone REFUND and CHARGEBACK transaction rows are **skipped** so each gateway refund is counted exactly once — don't add them back in.

## Identity and join keys

| Thing         | Wix id                             | Canonical                                   |
| ------------- | ---------------------------------- | ------------------------------------------- |
| Contact       | `id` / `_id`                       | `people.member.source_external_id`          |
| Staff         | `id`, plus `resourceId`            | `people.staff.source_external_id`           |
| Session       | `id`                               | `bookings.class_session.source_external_id` |
| Participation | `id`, with `eventId` + `contactId` | `bookings.attendance`                       |
| Transaction   | `transactionId` (**not** `id`)     | `commerce.sale.source_external_id`          |
| Pricing plan  | `id`                               | `commerce.plan.source_external_id`          |

The cross-links that matter:

* **Session → class name and category** goes `session.scheduleId` → service → category. Without the services pull, a session's `class_template_id` falls back to the raw schedule id and the class name is missing.
* **Session → instructor** goes `resources[0].id` → staff via `staff.resourceId`. Wix convention is that the first resource is the instructor; further mapped resources become secondary staff.
* **Transaction → member and plan** rides inline on the transaction (`order.description.items[]` and `wixAppBuyerId`), with `is_recurring` and the membership category resolved from the pricing-orders cache.

## Counting traps

**Wix totals include products Kula doesn't ingest.** The most frequent "discrepancy" on this source isn't a data problem at all — it's a Wix dashboard number that spans the store or the site alongside bookings. Check the scope before investigating a sync.

**Order-based revenue under-counts.** Restated from above because it is the single most expensive mistake available on Wix data. Sales come from Cashier transactions; a pricing order is one purchase, not its stream of recurring charges.

**`cancelled` is not the same as late cancel.** Wix cancellations all land as `booking_status = 'cancelled'`. The late-cancel split is a read-time derivation from `cancelled_at` against the session's `starts_at` — see the Recipes. Reporting all cancellations as late cancels overstates the problem substantially.

**Pricing-plan events can arrive slightly out of order.** Order by `occurred_at`, not by ingest order, when reconstructing plan state.

## Questions this source can and can't answer

**Can answer well**

* Class schedule, capacity, instructor, category, room
* Who was booked on which session, who **checked in** (`CONFIRMED`), and who cancelled
* **Late-cancel rate** — derived at read time from the cancellation timestamp against class start
* True recurring revenue per member and per plan (from transactions)
* Membership/pass plans, prices, intervals
* Refund totals and net revenue
* Retention and at-risk analysis, on booking history

**Answerable, but expensive**

* Full no-show behaviour — derivable from the raw ecom orders, which land unmapped. Flag the cost before starting; the check-in signal on `bookings.attendance` covers most questions more cheaply.

**Cannot answer from Wix alone**

* Anything outside Wix Bookings — store, site, blog, events *(out of scope)*
* Walk-ins / door access *(no concept)*
* General-ledger reporting, P\&L, expenses *(needs Xero/QuickBooks)*
* Revenue before the earliest pulled transaction window
* "What changed since yesterday" for contacts, plans, staff or products *(endpoints aren't date-filterable)*

## Recipes — what works well

### Who's lapsing → `list_at_risk_members`

Pause-aware, bucketed by days since last visit. On Wix a "visit" means an attended class — there's no door data — and because `CONFIRMED` records a real check-in, the recency signal is genuine attendance rather than a booking that may never have been honoured.

### Schedule performance → `get_class_utilisation` → `get_time_slot_detail`

Wix is strong here: sessions resolve to a real class name, category, instructor and room through the cross-entity caches. The heatmap first, then the per-class drill-down for a chosen weekday and hour.

If class names come back looking like opaque ids, the services entity hasn't been pulled — that's a connector problem worth flagging rather than a naming quirk.

### Instructor performance → `get_teacher_performance`

Pass `staff_ref` and `include_summary: true`. Wix resolves the instructor via the session's first resource, so an instructor who only ever appears as a secondary resource needs `include_subs: true`.

### Revenue → SQL on Cashier transactions

This is the recipe that most often goes wrong. Sales come from transactions; pricing orders are **not** revenue.

```sql
-- Recurring vs one-off revenue by month
SELECT date_trunc('month', occurred_at) AS month,
       sum(total) FILTER (WHERE is_recurring) AS recurring,
       sum(total) FILTER (WHERE NOT is_recurring) AS one_off,
       sum(total) AS gross
FROM commerce.sale_guarded
WHERE source = 'com.wix'
GROUP BY 1 ORDER BY 1;
```

Net of refunds subtracts `commerce.refund` — and remember standalone REFUND and CHARGEBACK transaction rows are deliberately skipped, so don't add them back.

Before quoting any revenue figure, confirm the transactions entity actually covers the period. An implausibly low month is usually an unpulled window, not a bad month:

```sql
SELECT min(occurred_at), max(occurred_at), count(*)
FROM commerce.sale WHERE source = 'com.wix';
```

### Retention → `get_retention_curve`

Works well on Wix, because Cashier transactions give real per-cycle recurring charges — which is exactly what the curve measures (billing continuity). This is the tool that would have been wrong under the old order-based revenue model.

### One member's money → `get_member_payments`

Combines sales with unlinked gateway payments, with a `record_kind` column discriminating them.

### Late cancels → SQL on `cancelled_at` vs class start

Not stored — derived. A cancellation inside 2 hours of the class start is a late cancel:

```sql
-- Late-cancel rate by month
WITH c AS (
  SELECT a.occurred_at,
         a.cancelled_at,
         s.starts_at,
         a.cancelled_at > s.starts_at - interval '2 hours' AS is_late
  FROM bookings.attendance_guarded a
  JOIN bookings.class_session_guarded s
    ON s.source = a.source
   AND s.source_external_id = a.class_session_id
  WHERE a.source = 'com.wix'
    AND a.booking_status = 'cancelled'
    AND a.cancelled_at IS NOT NULL
)
SELECT date_trunc('month', starts_at) AS month,
       count(*)                        AS cancellations,
       count(*) FILTER (WHERE is_late) AS late_cancels,
       round(100.0 * count(*) FILTER (WHERE is_late) / nullif(count(*),0), 1)
         AS late_pct
FROM c
GROUP BY 1 ORDER BY 1;
```

Swap `date_trunc` for `a.member_id` to find the members who repeatedly late-cancel — usually a more actionable list than the aggregate.

Two hours is the studio-agnostic default. If the operator runs a different late-cancel window, use theirs and say which you applied.

### Attendance and check-in rate → `bookings.attendance_guarded`

`booking_status = 'attended'` means the member checked in, so an attendance rate is a straightforward count — no inference caveat needed. Splitting booked-but-never-honoured out fully needs the raw ecom orders, which is the expensive path; flag the cost before starting it.

### What doesn't work on Wix

Door access and walk-ins (no concept in Wix), general-ledger reporting (needs Xero or QuickBooks), and anything about the studio's Wix store, site or other Wix products — out of scope, not missing. `get_cac_by_cohort` needs a marketing source connected for spend.

## Where this lives in the code

| Concern                                    | Path                                                        |
| ------------------------------------------ | ----------------------------------------------------------- |
| Entity catalogue, endpoints, paging shapes | `services/ingestors/wix/internal/wixclient/specs.go`        |
| Transforms + the cross-entity caches       | `services/ingestors/wix/internal/transform/`                |
| Revenue (Cashier transactions)             | `services/ingestors/wix/internal/transform/transactions.go` |
| Sessions + participations                  | `services/ingestors/wix/internal/transform/bookings.go`     |
| Operator-facing connect guide              | [Wix connector](/your-data-sources/wix.md)                  |
