> ## Documentation Index
> Fetch the complete documentation index at: https://docs.theroyalglow.in/llms.txt
> Use this file to discover all available pages before exploring further.

# Memberships

> Customer SPA membership view plus the admin endpoints to create, list, inspect, cancel, and record sessions against memberships.

# Memberships

SPA memberships are hour-based: a customer buys a tier (e.g. Silver 8hrs, Gold
15hrs) valid for a fixed number of days, and each SPA session deducts its
duration from the remaining hours. There is one customer-facing read endpoint
(`theroyalglow.in/api/membership`, singular); everything else is an admin
endpoint served from `admin.theroyalglow.in` (e.g.
`admin.theroyalglow.in/api/memberships`, plural) and is role-gated.

<Info>
  **Base URLs:** the customer read endpoint is `https://theroyalglow.in`; the
  admin endpoints are served from `https://admin.theroyalglow.in` with **no
  `/api/admin/` segment** — the subdomain is the namespace (e.g.
  `admin.theroyalglow.in/api/memberships`, `.../api/membership-tiers`). Roles
  are hierarchical: `customer` \< `staff` \<
  `receptionist` \< `manager` \< `owner` \< `developer`. Money is an integer in
  **paise** (₹10,000 = `1000000`). **Hours are stored as minutes** — the
  `*HoursMinutes` fields hold minutes, so Silver 8hrs = `480` and Gold 15hrs =
  `900`. Dates in request bodies are `YYYY-MM-DD`; timestamps in responses are
  UTC ISO strings. Membership status is one of `active`, `expired`, `cancelled`.
  Membership numbers use the format `RG-MEM-{YY}-{branchNumber}-{5 random}`.
</Info>

## GET `theroyalglow.in/api/membership`

Returns the signed-in customer's own membership data: the single `active`
membership (if any), an array of `past` (expired/cancelled) memberships, and the
`sessions` history for the active membership. Strictly scoped to the
authenticated customer — it never exposes another customer's data. When there is
no active membership, `active` is `null` and `sessions` is an empty array.

**Minimum role:** `customer` (`requireSession`)

```http theme={null}
GET /api/membership
```

### Response

```json theme={null}
{
  "success": true,
  "data": {
    "active": {
      "id": "mem_9aZ2bYc31",
      "membershipNumber": "RG-MEM-26-1-90872",
      "customerId": "usr_abc123",
      "tierId": "tier_gold",
      "tierNameSnapshot": "Gold",
      "totalHoursMinutes": 900,
      "usedHoursMinutes": 120,
      "pricePaidPaise": 1500000,
      "startsAt": "2026-06-01T00:00:00.000Z",
      "expiresAt": "2026-08-30T18:29:59.999Z",
      "status": "active",
      "createdBy": "usr_reception01",
      "invoiceId": "inv_3kP9qWx20",
      "notes": null,
      "createdAt": "2026-06-01T05:30:00.000Z",
      "updatedAt": "2026-06-10T07:00:00.000Z"
    },
    "past": [],
    "sessions": [
      {
        "id": "bk_77aa11bb22",
        "bookingNumber": "BK-RS-2606-S-44120-M",
        "branchId": "branch_rayasandra",
        "customerId": "usr_abc123",
        "status": "completed",
        "serviceType": "spa",
        "bookingDate": "2026-06-10",
        "startTime": "15:30:00",
        "endTime": "17:30:00",
        "totalAmountPaise": 0,
        "totalDurationMinutes": 120,
        "isMembershipSession": true,
        "spaMembershipId": "mem_9aZ2bYc31",
        "completedAt": "2026-06-10T07:00:00.000Z",
        "createdAt": "2026-06-10T07:00:00.000Z",
        "services": [
          {
            "bookingId": "bk_77aa11bb22",
            "serviceId": "svc_aromamassage",
            "serviceNameSnapshot": "Aromatherapy Massage",
            "durationMinutes": 120,
            "staffId": "stf_therapist01",
            "staffName": "Anita R.",
            "displayOrder": 0
          }
        ]
      }
    ]
  }
}
```

### Errors

<AccordionGroup>
  <Accordion title="UNAUTHENTICATED — 401">
    No active session.
  </Accordion>

  <Accordion title="INTERNAL_ERROR — 500">
    Unexpected/transient failure. `retryable: true`.
  </Accordion>
</AccordionGroup>

***

## GET `admin.theroyalglow.in/api/membership-tiers`

Lists active membership tiers, ordered by `displayOrder` — used to populate the
create-membership form / tier picker. Hours and validity prefill the create
form but remain overridable.

**Minimum role:** `receptionist` (`requireRole('receptionist')`)

```http theme={null}
GET /api/membership-tiers
```

### Response

```json theme={null}
{
  "success": true,
  "data": [
    {
      "id": "tier_silver",
      "name": "Silver",
      "slug": "silver",
      "description": "8 hours of SPA services, valid 90 days.",
      "defaultHoursMinutes": 480,
      "defaultPricePaise": 1000000,
      "defaultValidityDays": 90,
      "isActive": true,
      "displayOrder": 0,
      "createdAt": "2026-05-01T00:00:00.000Z",
      "updatedAt": "2026-05-01T00:00:00.000Z"
    },
    {
      "id": "tier_gold",
      "name": "Gold",
      "slug": "gold",
      "description": "15 hours of SPA services, valid 90 days.",
      "defaultHoursMinutes": 900,
      "defaultPricePaise": 1500000,
      "defaultValidityDays": 90,
      "isActive": true,
      "displayOrder": 1
    }
  ]
}
```

### Errors

<AccordionGroup>
  <Accordion title="UNAUTHENTICATED — 401">
    No active session.
  </Accordion>

  <Accordion title="FORBIDDEN — 403">
    Session role is below `receptionist`.
  </Accordion>

  <Accordion title="INTERNAL_ERROR — 500">
    Unexpected/transient failure. `retryable: true`.
  </Accordion>
</AccordionGroup>

***

## GET `admin.theroyalglow.in/api/memberships`

Lists memberships, newest first, each flattened with the customer name and tier
name. Optional query filters narrow by tier id and/or status. The payload is the
array itself (no wrapping object).

**Minimum role:** `receptionist` (`requireRole('receptionist')`)

```http theme={null}
GET /api/memberships?status=active&tier=tier_gold
```

### Query parameters

| Name     | Type     | Required | Description                        |
| -------- | -------- | -------- | ---------------------------------- |
| `tier`   | `string` | No       | A tier id to filter by.            |
| `status` | `string` | No       | One of active, expired, cancelled. |

### Response

```json theme={null}
{
  "success": true,
  "data": [
    {
      "id": "mem_9aZ2bYc31",
      "membershipNumber": "RG-MEM-26-1-90872",
      "customerId": "usr_abc123",
      "tierId": "tier_gold",
      "tierNameSnapshot": "Gold",
      "totalHoursMinutes": 900,
      "usedHoursMinutes": 120,
      "pricePaidPaise": 1500000,
      "startsAt": "2026-06-01T00:00:00.000Z",
      "expiresAt": "2026-08-30T18:29:59.999Z",
      "status": "active",
      "createdBy": "usr_reception01",
      "invoiceId": "inv_3kP9qWx20",
      "notes": null,
      "createdAt": "2026-06-01T05:30:00.000Z",
      "updatedAt": "2026-06-10T07:00:00.000Z",
      "customerName": "Priya Sharma",
      "tierName": "Gold"
    }
  ]
}
```

### Errors

<AccordionGroup>
  <Accordion title="UNAUTHENTICATED — 401">
    No active session.
  </Accordion>

  <Accordion title="FORBIDDEN — 403">
    Session role is below `receptionist`.
  </Accordion>

  <Accordion title="INTERNAL_ERROR — 500">
    Unexpected/transient failure. `retryable: true`.
  </Accordion>
</AccordionGroup>

***

## POST `admin.theroyalglow.in/api/memberships`

Creates a SPA membership and its `membership_purchase` invoice atomically. The
handler enforces one active membership per customer (a friendly 409 ahead of the
DB partial-unique index), resolves the tier for its name snapshot, computes the
expiry from `startDate` + `validityDays`, and splits the GST-inclusive price into
base + GST on the invoice. No gems are earned on purchase. A best-effort expiry
notice job is scheduled but never affects the response.

**Minimum role:** `receptionist` (`requireRole('receptionist')`)

```http theme={null}
POST /api/memberships
Content-Type: application/json
```

### Request body

Validated by `createMembershipSchema` (`@rgss/types`).

| Name            | Type      | Required | Description                                                         |
| --------------- | --------- | -------- | ------------------------------------------------------------------- |
| `customerId`    | `string`  | Yes      | Non-empty user id of the customer.                                  |
| `tierId`        | `string`  | Yes      | Non-empty tier id; must resolve to an active tier.                  |
| `hoursMinutes`  | `integer` | Yes      | Positive. Total membership hours in minutes (Silver 480, Gold 900). |
| `pricePaise`    | `integer` | Yes      | Non-negative. GST-inclusive price in paise.                         |
| `startDate`     | `string`  | Yes      | YYYY-MM-DD.                                                         |
| `validityDays`  | `integer` | Yes      | Positive. Days from startDate until expiry.                         |
| `paymentMethod` | `enum`    | Yes      | cash, upi, or card.                                                 |
| `notes`         | `string`  | No       | Max 500 characters.                                                 |

```json theme={null}
{
  "customerId": "usr_abc123",
  "tierId": "tier_gold",
  "hoursMinutes": 900,
  "pricePaise": 1500000,
  "startDate": "2026-06-01",
  "validityDays": 90,
  "paymentMethod": "upi",
  "notes": "Anniversary gift package."
}
```

### Response

Returns `201 Created` with the created membership and its purchase invoice.

```json theme={null}
{
  "success": true,
  "data": {
    "membership": {
      "id": "mem_9aZ2bYc31",
      "membershipNumber": "RG-MEM-26-1-90872",
      "customerId": "usr_abc123",
      "tierId": "tier_gold",
      "tierNameSnapshot": "Gold",
      "totalHoursMinutes": 900,
      "usedHoursMinutes": 0,
      "pricePaidPaise": 1500000,
      "startsAt": "2026-06-01T00:00:00.000Z",
      "expiresAt": "2026-08-30T18:29:59.999Z",
      "status": "active",
      "createdBy": "usr_reception01",
      "invoiceId": "inv_3kP9qWx20",
      "notes": "Anniversary gift package.",
      "createdAt": "2026-06-01T05:30:00.000Z",
      "updatedAt": "2026-06-01T05:30:00.000Z"
    },
    "invoice": {
      "id": "inv_3kP9qWx20",
      "invoiceNumber": "INV-1-2627-92921",
      "branchId": "branch_rayasandra",
      "bookingId": null,
      "customerId": "usr_abc123",
      "subtotalPaise": 1271186,
      "taxableValuePaise": 1271186,
      "gstAmountPaise": 228814,
      "totalAmountPaise": 1500000,
      "invoiceType": "membership_purchase",
      "paymentMethod": "upi",
      "paymentStatus": "paid",
      "gemsEarned": 0,
      "paidAt": "2026-06-01T05:30:00.000Z"
    }
  }
}
```

### Errors

<AccordionGroup>
  <Accordion title="UNAUTHENTICATED — 401">
    No active session.
  </Accordion>

  <Accordion title="FORBIDDEN — 403">
    Session role is below `receptionist`.
  </Accordion>

  <Accordion title="VALIDATION_ERROR — 400">
    Body fails `createMembershipSchema`, or the selected tier does not exist / is
    inactive.
  </Accordion>

  <Accordion title="MEMBERSHIP_ALREADY_ACTIVE — 409">
    The customer already has an active membership.
  </Accordion>

  <Accordion title="NOT_FOUND — 404">
    The primary branch could not be resolved.
  </Accordion>

  <Accordion title="INTERNAL_ERROR — 500">
    Unexpected/transient failure. `retryable: true`.
  </Accordion>
</AccordionGroup>

***

## GET `admin.theroyalglow.in/api/memberships/[id]`

Returns a single membership enriched with `customerName`, `customerEmail`, the
full `tier` object, and its completed-session history (`sessions`, each with its
`booking_service` snapshot rows).

**Minimum role:** `receptionist` (`requireRole('receptionist')`)

```http theme={null}
GET /api/memberships/mem_9aZ2bYc31
```

### Path parameters

| Name | Type     | Required | Description              |
| ---- | -------- | -------- | ------------------------ |
| `id` | `string` | Yes      | Path. The membership id. |

### Response

```json theme={null}
{
  "success": true,
  "data": {
    "id": "mem_9aZ2bYc31",
    "membershipNumber": "RG-MEM-26-1-90872",
    "customerId": "usr_abc123",
    "tierId": "tier_gold",
    "tierNameSnapshot": "Gold",
    "totalHoursMinutes": 900,
    "usedHoursMinutes": 120,
    "pricePaidPaise": 1500000,
    "startsAt": "2026-06-01T00:00:00.000Z",
    "expiresAt": "2026-08-30T18:29:59.999Z",
    "status": "active",
    "createdBy": "usr_reception01",
    "invoiceId": "inv_3kP9qWx20",
    "notes": null,
    "createdAt": "2026-06-01T05:30:00.000Z",
    "updatedAt": "2026-06-10T07:00:00.000Z",
    "customerName": "Priya Sharma",
    "customerEmail": "priya@example.com",
    "tier": {
      "id": "tier_gold",
      "name": "Gold",
      "slug": "gold",
      "description": "15 hours of SPA services, valid 90 days.",
      "defaultHoursMinutes": 900,
      "defaultPricePaise": 1500000,
      "defaultValidityDays": 90,
      "isActive": true,
      "displayOrder": 1
    },
    "sessions": [
      {
        "id": "bk_77aa11bb22",
        "bookingNumber": "BK-RS-2606-S-44120-M",
        "status": "completed",
        "serviceType": "spa",
        "bookingDate": "2026-06-10",
        "startTime": "15:30:00",
        "endTime": "17:30:00",
        "totalAmountPaise": 0,
        "totalDurationMinutes": 120,
        "isMembershipSession": true,
        "spaMembershipId": "mem_9aZ2bYc31",
        "services": [
          {
            "bookingId": "bk_77aa11bb22",
            "serviceId": "svc_aromamassage",
            "serviceNameSnapshot": "Aromatherapy Massage",
            "durationMinutes": 120,
            "staffId": "stf_therapist01",
            "staffName": "Anita R.",
            "displayOrder": 0
          }
        ]
      }
    ]
  }
}
```

### Errors

<AccordionGroup>
  <Accordion title="UNAUTHENTICATED — 401">
    No active session.
  </Accordion>

  <Accordion title="FORBIDDEN — 403">
    Session role is below `receptionist`.
  </Accordion>

  <Accordion title="NOT_FOUND — 404">
    No membership with that id.
  </Accordion>

  <Accordion title="INTERNAL_ERROR — 500">
    Unexpected/transient failure. `retryable: true`.
  </Accordion>
</AccordionGroup>

***

## POST `admin.theroyalglow.in/api/memberships/[id]/sessions`

Records a SPA session against a membership. Each service line deducts its
duration from the remaining hours. The handler guards membership state and
remaining hours **before** any writes, validates that every service id exists,
snapshots service and staff names, then atomically creates a completed ₹0
booking flagged as a membership session (booking number suffixed `-M`), its
`booking_service` rows, a ₹0 `membership_session` invoice (zero gems), and
increments the membership's used minutes. `bookingDate` defaults to today (IST).

**Minimum role:** `receptionist` (`requireRole('receptionist')`)

```http theme={null}
POST /api/memberships/mem_9aZ2bYc31/sessions
Content-Type: application/json
```

### Path parameters

| Name | Type     | Required | Description              |
| ---- | -------- | -------- | ------------------------ |
| `id` | `string` | Yes      | Path. The membership id. |

### Request body

Validated by `recordSessionSchema` (`@rgss/types`).

| Name                         | Type      | Required | Description                                   |
| ---------------------------- | --------- | -------- | --------------------------------------------- |
| `services`                   | `array`   | Yes      | At least one service line (see fields below). |
| `services[].serviceId`       | `string`  | Yes      | Non-empty; must be an existing service.       |
| `services[].staffId`         | `string`  | No       | Staff member assigned to this line.           |
| `services[].durationMinutes` | `integer` | Yes      | Positive. Minutes to deduct for this line.    |
| `bookingDate`                | `string`  | No       | YYYY-MM-DD. Defaults to today (IST).          |

```json theme={null}
{
  "services": [
    {
      "serviceId": "svc_aromamassage",
      "staffId": "stf_therapist01",
      "durationMinutes": 120
    }
  ],
  "bookingDate": "2026-06-10"
}
```

### Response

Returns `201 Created` with the created booking, the ₹0 session invoice, and the
membership's remaining minutes after the deduction.

```json theme={null}
{
  "success": true,
  "data": {
    "booking": {
      "id": "bk_77aa11bb22",
      "bookingNumber": "BK-RS-2606-S-44120-M",
      "branchId": "branch_rayasandra",
      "customerId": "usr_abc123",
      "status": "completed",
      "serviceType": "spa",
      "bookingDate": "2026-06-10",
      "startTime": "15:30:00",
      "endTime": "17:30:00",
      "totalAmountPaise": 0,
      "totalDurationMinutes": 120,
      "isMembershipSession": true,
      "spaMembershipId": "mem_9aZ2bYc31",
      "completedAt": "2026-06-10T07:00:00.000Z"
    },
    "invoice": {
      "id": "inv_55cc66dd77",
      "invoiceNumber": "INV-1-2627-71044",
      "branchId": "branch_rayasandra",
      "bookingId": "bk_77aa11bb22",
      "customerId": "usr_abc123",
      "subtotalPaise": 0,
      "taxableValuePaise": 0,
      "gstAmountPaise": 0,
      "totalAmountPaise": 0,
      "invoiceType": "membership_session",
      "paymentStatus": "paid",
      "gemsEarned": 0,
      "paidAt": "2026-06-10T07:00:00.000Z"
    },
    "remainingMinutes": 780
  }
}
```

### Errors

<AccordionGroup>
  <Accordion title="UNAUTHENTICATED — 401">
    No active session.
  </Accordion>

  <Accordion title="FORBIDDEN — 403">
    Session role is below `receptionist`.
  </Accordion>

  <Accordion title="VALIDATION_ERROR — 400">
    Body fails `recordSessionSchema`, or one or more selected services do not exist.
  </Accordion>

  <Accordion title="NOT_FOUND — 404">
    No membership with that id, or the primary branch could not be resolved.
  </Accordion>

  <Accordion title="MEMBERSHIP_EXPIRED — 409">
    The membership is not `active` or is past its expiry.
  </Accordion>

  <Accordion title="MEMBERSHIP_INSUFFICIENT_HOURS — 409">
    The requested duration exceeds the remaining membership hours.
  </Accordion>

  <Accordion title="INTERNAL_ERROR — 500">
    Unexpected/transient failure. `retryable: true`.
  </Accordion>
</AccordionGroup>

***

## POST `admin.theroyalglow.in/api/memberships/[id]/cancel`

Cancels a membership: sets status to `cancelled` and appends the reason to the
membership's notes. This is the only membership endpoint that requires
`manager`.

**Minimum role:** `manager` (`requireRole('manager')`)

```http theme={null}
POST /api/memberships/mem_9aZ2bYc31/cancel
Content-Type: application/json
```

### Path parameters

| Name | Type     | Required | Description              |
| ---- | -------- | -------- | ------------------------ |
| `id` | `string` | Yes      | Path. The membership id. |

### Request body

Validated by `cancelMembershipSchema` (`@rgss/types`).

| Name     | Type     | Required | Description                                                                           |
| -------- | -------- | -------- | ------------------------------------------------------------------------------------- |
| `reason` | `string` | Yes      | Trimmed, 1–500 characters. Appended to the membership notes as "Cancelled: {reason}". |

```json theme={null}
{
  "reason": "Customer relocated out of the city."
}
```

### Response

Returns the updated membership row.

```json theme={null}
{
  "success": true,
  "data": {
    "id": "mem_9aZ2bYc31",
    "membershipNumber": "RG-MEM-26-1-90872",
    "customerId": "usr_abc123",
    "tierId": "tier_gold",
    "tierNameSnapshot": "Gold",
    "totalHoursMinutes": 900,
    "usedHoursMinutes": 120,
    "pricePaidPaise": 1500000,
    "startsAt": "2026-06-01T00:00:00.000Z",
    "expiresAt": "2026-08-30T18:29:59.999Z",
    "status": "cancelled",
    "createdBy": "usr_reception01",
    "invoiceId": "inv_3kP9qWx20",
    "notes": "Cancelled: Customer relocated out of the city.",
    "createdAt": "2026-06-01T05:30:00.000Z",
    "updatedAt": "2026-06-12T08:00:00.000Z"
  }
}
```

### Errors

<AccordionGroup>
  <Accordion title="UNAUTHENTICATED — 401">
    No active session.
  </Accordion>

  <Accordion title="FORBIDDEN — 403">
    Session role is below `manager`.
  </Accordion>

  <Accordion title="VALIDATION_ERROR — 400">
    Body fails `cancelMembershipSchema` (missing or over-long `reason`).
  </Accordion>

  <Accordion title="NOT_FOUND — 404">
    No membership with that id.
  </Accordion>

  <Accordion title="INTERNAL_ERROR — 500">
    Unexpected/transient failure. `retryable: true`.
  </Accordion>
</AccordionGroup>


## Related topics

- [SPA Memberships](/content/docs/features/memberships.md)
- [Billing & Invoicing](/content/docs/features/billing-invoicing.md)
- [Customer Guide](/content/docs/product/customer-guide.md)
- [Low-Level Design](/content/docs/system-design/low-level-design.md)
- [Offers & Combos](/content/docs/features/offers.md)
