# Memberships (Manage)

**Portal:** Manage · **Routes:** `manage.memberships.*` · **Nav:** Sales & Marketing → **Transaction**, the *Memberships* hub tab (one sidebar entry fronting Memberships / Property Booking / Rental + three unbuilt lines; [Components/SalesTabs.vue](/resources/js/Components/SalesTabs.vue) over the shared [HubTabs](/resources/js/Components/HubTabs.vue)). The Memberships stage has two pivots — the tier list and **Function access** (`/manage/memberships/access`) — switched by [Partials/MembershipViewSwitch.vue](/resources/js/Pages/Manage/Membership/Partials/MembershipViewSwitch.vue), a segmented control under the rail (a pivot, not a stage)

## What it does
Manages the company's **memberships** and their terms (price, description, T&C, benefits). A membership is
a single **flat, editable record** — there is no versioning and there are no upsells/add-ons; editing a
membership updates it in place. A membership does two things: leads **enrol** into it (becoming members),
and it **grants access to LMS courses**.

Seeded memberships (all admin-editable): **Legacy** (no price, inactive — kept for labelling early members),
**Elite** (RM 2388), **AI Basic** (RM 299), **AI Coaching** (RM 7899).

## How it works
- A **`Membership`** (`Src\Membership\Membership`, key model — uuid + blame + soft delete) holds the stable
  identity (`code`, `name`, `status`, `sort_order`) **and** its terms (`price`, `currency`, `description`,
  `terms`, `benefits` JSON) directly.
- **Status:** `Active` = open for enrolment; `Inactive` = closed (e.g. Legacy). Toggle via activate/deactivate.
- **Benefits** are a JSON array (entered one-per-line in the form, shown as a bulleted list).
- All writes go through `MembershipRepository` inside `DB::transaction`; the controller maps input explicitly
  and returns `Inertia::render`.
- **Members (enrolment):** a lead enrols via a **`MemberSubscription`** (`subscriptions()` 1-to-many on
  `membership_id`) — built in the [Members](/docs/modules_handbook/manage/membership/members/readMe.md)
  module. The Show page's **Members** tab lists them (bulk-add by lead search, CSV import, cancel). Each
  member row also shows its **account manager** — a **read-only** single value read off the lead
  (`subscriptions.lead.assignedAdmin`, emitted as `account_manager` by `transformSubscription`). The
  manager is set in the Leads module, not here.
- **Courses (access):** a membership grants access to LMS courses via **`courses()`** — a many-to-many over
  the **`lms_course_membership`** pivot, the inverse of `Src\Lms\Course::memberships()` (same pivot, editable
  from either side). The Show page's **Courses** tab manages this (`syncCourses`). Only **"Members"-visibility**
  courses are assignable (Public courses are open to everyone; Internal are staff-only); a Members course with
  **no** membership assigned is open to **all** members — see `Course::isAccessibleTo()` in the
  [Courses / LMS](/docs/modules_handbook/manage/lms/readMe.md) handbook.

> **Legacy `TYPE_TIER` / `TYPE_UPSELL` constants** still exist on the model only because the original
> (master-committed) `create_memberships_table` migration references `TYPE_TIER` as a default; the `type`
> column is dropped by `2026_06_16_000003_simplify_membership_schema`. They are not used by the flat model.

## Function access — which portal FUNCTION a membership unlocks (2026-09-09)

`/manage/memberships/access` ([Pages/Manage/Membership/Access.vue](/resources/js/Pages/Manage/Membership/Access.vue)) is a
matrix: rows are the portal functions the code can gate, columns are **Everyone** + every active membership.

- **The rows are a registry, not a table.** [`Src\Membership\Feature`](/src/Membership/Feature.php) declares every gateable
  function (constant, label, band, what URLs it covers, and its DEFAULT visibility). A feature only exists where a route or
  controller asks about it, so the list lives beside the enforcement. Adding one = a constant + its `FEATURES` entry + the
  `feature:` middleware on its routes (or an inline `FeatureAccess::allows()` where there is no route, as the Area Guide tab).
- **A row is one of two things.** *Everyone* (`FeatureGate::VISIBILITY_OPEN`): any signed-in portal user. *Restricted*
  (`VISIBILITY_MEMBERS`): only the ticked memberships — and **no tick means NOBODY** (staff excepted). The gate fails closed,
  the same shape as `AiLearningAccess`; the page prints an amber line on every such row.
- **Defaults change nothing.** Every function a member could reach before the matrix existed defaults to Everyone; only the
  three paid AI surfaces (Vibe Coding, Investment Prompt, Grant Application) default to Restricted with no tier. A saved row
  is the only thing that overrides a default — `feature_gates` holds decisions, not the registry.
- **Storage:** `feature_gates` (feature key, visibility, blame) + `feature_gate_membership` (mirrors `lms_course_membership`).
  Model [`FeatureGate`](/src/Membership/FeatureGate.php); writes through
  [`FeatureGateRepository::sync()`](/src/Membership/Repositories/FeatureGateRepository.php) (one PUT saves the whole matrix,
  ticks are kept when a row is switched to Everyone); reads through
  [`FeatureAccess`](/src/Membership/Support/FeatureAccess.php) — `allows($user, $feature)`, `membershipNames($feature)`,
  `matrix()`.
- **Enforcement:** the `feature:{key}` route middleware
  ([`EnsureFeatureAccess`](/app/Http/Middleware/EnsureFeatureAccess.php)) on 44 portal routes in `routes/main.php`. A refused
  PAGE renders [`Main/Portal/FeatureLocked`](/resources/js/Pages/Main/Portal/FeatureLocked.vue) — an upsell naming the tier
  and linking `/membership/plans`, never a 403; a refused JSON fetch or WRITE is a 403. Any Manage user passes, and
  `?preview=locked` shows staff the locked page. The three AI Coach surfaces keep their controller gate
  (`AiLearningAccess`, staff bypass on the `view-ai-learning` PERMISSION, not the role — see that class for why) but read the
  same rows; Investment Prompt's market groups (stocks / crypto / gold / options / forex) have a row of their own, refused
  in `run()` and `chat()` and dimmed on the page.
- **What it replaced:** the single `ai_learning.membership_ids` settings row (Setting → AI E-Learning), which decided all
  three AI surfaces at once. Migration `2026_09_09_100002` carried it over into three MEMBERS rows; that page now holds
  only the community's reply-email switch and links here.
- **Courses are NOT here** — they stay gated per course on each membership's Courses tab. Quotas (reports per year, AI
  credit) are not here either: this matrix is boolean access; the credit ledger is the next piece.
- Tests: [`tests/Feature/Membership/FeatureAccessTest.php`](/tests/Feature/Membership/FeatureAccessTest.php) (defaults, the
  fail-closed row, the middleware's three answers, the page) and `AiLearningAccessTest` (the AI view over the same rows).

## Revenue collected (2026-09-16)

The list now answers *"how much did this actually make?"*, over a window that **defaults to the current month** and takes `?from=&to=`.

**It counts `purchase_histories`, not `member_subscriptions.price_paid`, and the difference is not academic.** This install carries 486 subscriptions holding **RM 786,047** of `price_paid` — but only **284 of them are linked to a payment at all**. The rest were granted or imported, so their `price_paid` is a tier's price sitting on a row nobody was ever charged for. Counting it as revenue would overstate the figure by RM 132,018. Only `STATUS_ACTIVE` receipts count, so a payment stops being revenue the moment it is refunded; refunds are shown **beside** the total rather than folded into it, so the headline can never be read as net when it is gross.

**The dates ARE the state** — there is no `period=this_month` token. *This month* / *Last month* are buttons that set `from`/`to`, and the page decides which one is "selected" by comparing the dates back. A token would let a shared link mean something different tomorrow; dates always mean the same window.

**The window filters the MONEY, not the rows.** It deliberately does not reuse `useResourceIndex`'s `dateRange` (`date_from`/`date_to`), which narrows which rows appear: a membership tier exists whether or not it sold anything this month, and hiding it would answer a question nobody asked.

**The comparison line is load-bearing.** The default window is the current month, which early in a month is legitimately near zero — on 16 Sep 2026 membership revenue this month is genuinely **RM 0**, because the newest membership payment is 26 Aug. Without *"vs 1 Aug – 30 Aug"* beside it, a correct zero reads as a broken page. The previous window is shifted by **whole months** (`subMonthNoOverflow`), so this-month-to-date compares against last-month-to-the-same-point rather than against a raw day count.

Per-tier revenue appears as a **Collected** column, and sums to the headline: Elite Member RM 647,148 + AI Active Blueprint RM 3,887 + Implementation Day RM 2,994 = RM 654,029 all-time.

## Related files

**Backend — Model**
- [src/Membership/Membership.php](/src/Membership/Membership.php) — identity + terms; STATUSES; `subscriptions()`; **`courses()`** (LMS pivot).

**Backend — Repository**
- [src/Membership/Repositories/MembershipRepository.php](/src/Membership/Repositories/MembershipRepository.php) — create / update (in place), activate/deactivate, **`syncCourses()`**, delete (cascades subscriptions).

**Backend — Controller**
- [app/Http/Controllers/Manage/Membership/MembershipsController.php](/app/Http/Controllers/Manage/Membership/MembershipsController.php) — index/store/show/update/activate/deactivate/**syncCourses**/destroy (+ `assignableCourses` helper).

**Backend — Form Requests**
- [app/Http/Requests/Manage/Membership/StoreRequest.php](/app/Http/Requests/Manage/Membership/StoreRequest.php) · [UpdateRequest.php](/app/Http/Requests/Manage/Membership/UpdateRequest.php) · [MembershipQueryRequest.php](/app/Http/Requests/Manage/Membership/MembershipQueryRequest.php) · [SyncCoursesRequest.php](/app/Http/Requests/Manage/Membership/SyncCoursesRequest.php)

**Frontend (Vue)**
- [resources/js/Pages/Manage/Membership/Index.vue](/resources/js/Pages/Manage/Membership/Index.vue) — membership rows (price, status, actions).
- [resources/js/Pages/Manage/Membership/Show.vue](/resources/js/Pages/Manage/Membership/Show.vue) — identity header + **Members** & **Courses** tabs.
- [resources/js/Pages/Manage/Membership/Partials/MembershipForm.vue](/resources/js/Pages/Manage/Membership/Partials/MembershipForm.vue) + [MembershipFormModal.vue](/resources/js/Pages/Manage/Membership/Partials/MembershipFormModal.vue) — create / edit.
- [resources/js/Pages/Manage/Membership/Partials/Tabs/MembersTab.vue](/resources/js/Pages/Manage/Membership/Partials/Tabs/MembersTab.vue) · [CoursesTab.vue](/resources/js/Pages/Manage/Membership/Partials/Tabs/CoursesTab.vue) (assign courses).
- [resources/js/Layouts/ManageLayout.vue](/resources/js/Layouts/ManageLayout.vue) — sidebar nav entry.

**Migrations**
- [database/migrations/2020_05_25_042763_create_memberships_table.php](/database/migrations/2020_05_25_042763_create_memberships_table.php) — original table.
- [database/migrations/2026_06_16_000003_simplify_membership_schema.php](/database/migrations/2026_06_16_000003_simplify_membership_schema.php) — flatten: terms onto the membership; versions + upsell columns dropped.
- The `lms_course_membership` pivot is created by the LMS migration `2026_06_16_000007_create_lms_course_membership_table.php`.

**Seeder**
- [database/seeds/MembershipsSeeder.php](/database/seeds/MembershipsSeeder.php) — seeds Legacy / Elite / AI Basic / AI Coaching with sample terms.

**Routes**
- [routes/web.php](/routes/web.php) — `manage.memberships.*` (incl. `manage.memberships.courses.sync`).
