# Members (Manage)

**Portal:** Manage · **Routes:** `manage.memberships.members.store`, `manage.members.cancel`, `manage.leads.convert` · **Entry points:** a Lead's detail page · a Membership's detail page

## What it does
Turns a [Lead](/docs/modules_handbook/manage/leads/readMe.md) into a **Member** by enrolling it into a
[Membership](/docs/modules_handbook/manage/membership/memberships/readMe.md). A member's subscription
belongs to the **Lead** (`member_subscriptions.lead_id`), not a user account.

Two flows write the same `member_subscription` record:
- **Lead → member** (single) — from a lead's detail page, pick a membership (+ optional amount paid / paid date).
- **Membership → add many** (bulk) — from a membership's detail page, **search and pick leads** to enrol.

A lead may hold **several subscriptions at once** (different memberships). A lead becomes a Member when
it holds at least one subscription; existing members are never demoted by a new enrolment.

A member's **account manager** is a **single** manage-portal staff member, and it lives on the **Lead**
(`leads.assigned_admin_id`) — **not** on the subscription. One person looks after that member across
**all** their memberships; enrolling into a second tier never creates a second manager. Assigning is done
in the **Leads** module (the Lead's detail page or the Leads list), so the pool + group-scope rules are
the lead-assignment ones — see the [Leads](/docs/modules_handbook/manage/leads/readMe.md) handbook. The
Members tab here shows each member's manager **read-only**; there is no per-enrolment manager and no
manager picker on the enrol/import flows.

> **History:** until 2026-07-18 each subscription carried its **own** many-to-many manager set
> (`member_subscription_managers` pivot). That was collapsed to one manager per lead — the pivot was
> backfilled into `leads.assigned_admin_id` and dropped.

> **Portal role is kept in sync with subscriptions.** Enrolling **and** cancelling run
> `App\Actions\SyncMembershipRoleAction::forLead`, which promotes the lead's login account to the
> `Member` role when it holds an active subscription and demotes it back to `Non-Member` when it holds
> none (never touching admins). This gates the main user portal (`member` middleware / `User::isMember()`).

## How it works
- A **`MemberSubscription`** links `lead_id` → `membership_id`, with `status` (Active / Cancelled /
  Expired) and a light-touch payment record (`price_paid`, `currency`, `paid_at`). When a gateway payment
  bought it, `purchase_history_id` records WHICH one (null = granted by hand — convert, admin enrol, CSV
  import). That link is what makes the payment lane's fulfilment idempotent **per purchase** (see the
  [Payments ledger](/docs/modules_handbook/manage/payments/purchase-histories/readMe.md)); the manual
  lanes here still guard with `alreadyActive()`, which is per-state and exactly right for a human
  clicking Enrol twice.
- **`EnrollMemberAction`** orchestrates the enrolment: it records the subscription against the lead via
  `MemberSubscriptionRepository`, then re-syncs the lead's portal role via `SyncMembershipRoleAction::forLead`
  (a **two-way** sync — Member when it holds an active subscription, Non-Member when it holds none; never
  touches admins). It also exposes `isStaff()` (used to report a staff account) and `ensureMainUser()`
  (stamps a role-less customer Non-Member so it is enrollable). It never touches the account manager —
  that is set separately on the Lead.
- **Single (lead):** `LeadsController@convert` validates the chosen membership (`ConvertRequest`),
  guards (account exists, not an admin, not already a member of it), then calls the action.
- **Bulk (membership):** `MembersController@store` receives a list of lead uuids (`BulkEnrollRequest`,
  chosen via the lead search picker) and enrols each — leads with **no account**, **admin** accounts, or
  accounts **already a member** of that membership are **skipped and reported** in the flash message.
- **Cancel:** `MembersController@cancel` sets the subscription to Cancelled (kept for history), then
  re-syncs the lead's role via `SyncMembershipRoleAction::forLead` — so if it was their **last** active
  subscription they are demoted back to Non-Member.
- **Export:** `MembershipsController@export` (route `manage.memberships.export`, `GET /manage/memberships/{id}/export?format=xlsx|csv`) streams that membership's members as Excel or CSV via the shared
  `ExportsResource` trait + `App\Exports\MembershipMembersExport` (Name, Email, Phone, Status, Account
  Manager, Currency, Price Paid, Paid At, Joined). It rebuilds the Members-tab query directly on
  `MemberSubscription` (scoped to the membership, `latest()` order, **all** statuses) and honours the tab's
  `search` box — which is client-side, so the Vue `<ExportMenu>` passes it through the new `params` prop.
  Read-only: gated by the group's `view-memberships` permission, so anyone who can see the page can export.
- **Account manager:** there is **no** manager write path in this module. The manager is the Lead's single
  `assigned_admin_id`, set via `LeadsController@assign` (route `manage.leads.assign`) in the Leads module.
  The enrol/import flows carry **no** `managers[]` field, and there is no `/manage/members/{id}/managers`
  endpoint. To surface the manager here, `MembershipsController@show` eager-loads
  `subscriptions.lead.assignedAdmin.profile` and `transformSubscription()` emits a single `account_manager`
  (`{ uuid, name }` or `null`) per member row.

## Related files

**Backend — Model**
- [src/Membership/MemberSubscription.php](/src/Membership/MemberSubscription.php) — `lead_id` + `membership_id` + status + payment; STATUSES; `lead()` / `membership()`. (No manager relation — the manager lives on the Lead.)

**Backend — Repository**
- [src/Membership/Repositories/MemberSubscriptionRepository.php](/src/Membership/Repositories/MemberSubscriptionRepository.php) — `create()`, `cancel()`, `delete()`.

**Backend — Role sync (shared)**
- [app/Actions/SyncMembershipRoleAction.php](/app/Actions/SyncMembershipRoleAction.php) — `forLead()`: two-way sync of the lead's login role (Member ⇄ Non-Member) to match its active subscriptions. Called on enrol **and** cancel.
- [src/People/Repositories/UserRepository.php](/src/People/Repositories/UserRepository.php) — `changeRole()` (called only from inside `SyncMembershipRoleAction`).

**Backend — Action (orchestration)**
- [app/Actions/EnrollMemberAction.php](/app/Actions/EnrollMemberAction.php) — enrol + role sync; `canEnroll()` / `alreadyActive()` / `isStaff()` / `ensureMainUser()` guards. No manager handling.

**Backend — Export**
- [app/Exports/MembershipMembersExport.php](/app/Exports/MembershipMembersExport.php) — `FromCollection` + `WithHeadings` + `WithMapping` + `ShouldAutoSize`; one row per subscription, resolving name/email/phone through `lead.user.profile` and the manager through `lead.assignedAdmin`.
- [app/Http/Controllers/Concerns/ExportsResource.php](/app/Http/Controllers/Concerns/ExportsResource.php) — shared `downloadExport()` (xlsx/csv by `?format`, timestamped filename).

**Backend — Controllers**
- [app/Http/Controllers/Manage/Membership/MembersController.php](/app/Http/Controllers/Manage/Membership/MembersController.php) — bulk `store` (by lead), CSV `importPreview` / `import`, `cancel`.
- [app/Http/Controllers/Manage/Leads/LeadsController.php](/app/Http/Controllers/Manage/Leads/LeadsController.php) — `convert` (single, from a lead) + membership panel in `show`; also **`assign`** (the lead's single account manager) — see the Leads handbook.
- [app/Http/Controllers/Manage/Membership/MembershipsController.php](/app/Http/Controllers/Manage/Membership/MembershipsController.php) — members panel in `show` (eager-loads `subscriptions.lead.assignedAdmin.profile`, emits a single `account_manager` per member).

**Backend — Form Requests**
- [app/Http/Requests/Manage/Leads/ConvertRequest.php](/app/Http/Requests/Manage/Leads/ConvertRequest.php) — membership + amount paid + paid date.
- [app/Http/Requests/Manage/Members/BulkEnrollRequest.php](/app/Http/Requests/Manage/Members/BulkEnrollRequest.php) — lead_ids + amount paid + paid date.
- [app/Http/Requests/Manage/Members/ImportRequest.php](/app/Http/Requests/Manage/Members/ImportRequest.php) — the CSV/Excel upload (preview + apply).

**Account manager** — the assign endpoint (`AssignRequest`, `manage.leads.assign`, `AssignLeadManagerModal.vue`) lives in the [Leads](/docs/modules_handbook/manage/leads/readMe.md) module; this module only **reads** the lead's `assigned_admin` for display.

**Frontend (Vue)**
- [resources/js/Pages/Manage/Leads/Partials/Tabs/MembershipTab.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/MembershipTab.vue) — the lead's Memberships panel: each subscription's tier + payment + status + cancel, plus the convert trigger. (The single account manager is shown in the Lead Show identity header, not here.)
- [resources/js/Pages/Manage/Leads/Partials/ConvertMemberModal.vue](/resources/js/Pages/Manage/Leads/Partials/ConvertMemberModal.vue) — membership picker + payment.
- [resources/js/Pages/Manage/Membership/Partials/Tabs/MembersTab.vue](/resources/js/Pages/Manage/Membership/Partials/Tabs/MembersTab.vue) — Members list (each row shows the member's single manager **read-only**) + export / add-members / import triggers.
- [resources/js/Components/ExportMenu.vue](/resources/js/Components/ExportMenu.vue) — shared Excel/CSV dropdown. Two additive props were introduced here: **`params`** (extra filters that don't live in the URL, e.g. this tab's client-side `search`) and **`compact`** (the small card/tab-header button). Both default off, so the Leads / Portal-users / Attendance call sites are unchanged.
- [resources/js/Pages/Manage/Membership/Partials/AddMembersModal.vue](/resources/js/Pages/Manage/Membership/Partials/AddMembersModal.vue) — lead search picker + payment.
- [resources/js/Components/ImportCsvModal.vue](/resources/js/Components/ImportCsvModal.vue) — the shared CSV/XLSX import dialog (wide `4xl`); preview counts are split into four groups — **Will be imported** (`Matched` / `Unmatched`), **Needs your decision** (`Needs review` — a per-row **checkbox** + **Select all**: ticked = *yes, same person* → enrolled **using the email/phone already in the system** (labelled *"in the system · will be used"*), with the CSV's differing value struck-through and labelled *"in your CSV · ignored"*; unticked = skipped. **An import never edits a lead's email/phone** — that is a per-person edit on the lead, and the row's CSV download is the worklist), **Already in the system** (`Already members`), **Skipped — will not be imported** (mismatch / no-email-phone / skip). Each count is an expandable section with a one-line meaning, listing its rows (file line, name, email, phone, amount; first 300 per category) and a **Download CSV** button exporting all of that category's rows (a `Needs review` cell reads `old → new`). **Two independent confirmation toggles** gate the write: **"Enrol N existing leads"** (`enrol_matched`) and **"Create M new leads and enrol them"** (`create_unmatched`) — both default on, untick either to exclude that group; the confirm button count and label follow the ticked toggles ("Enrol 108 members"), and is disabled with an explanation when nothing is selected / actionable. A **`Needs review`** row is one that matched an existing person by ONE key but whose OTHER key (phone/email) in the file **differs** from what is on file (and belongs to nobody else) — it is **skipped**, never enrolled or overwritten, and the change is shown inline (stored value struck-through → the file's value in amber) for a human to reconcile. It carries an optional single **account-manager** picker, but only when an `assignableAdmins` prop is passed — the **Leads-index** import does (assign the batch to one manager, fill-blank-only); this **membership enrol** import does **not** (enrolling never sets a manager).

**Migrations**
- [database/migrations/2026_06_03_000005_create_member_subscriptions_table.php](/database/migrations/2026_06_03_000005_create_member_subscriptions_table.php) — original table.
- [database/migrations/2026_06_13_000005_rekey_member_subscriptions_to_lead.php](/database/migrations/2026_06_13_000005_rekey_member_subscriptions_to_lead.php) — re-key to the Lead hub: add `lead_id` (indexed), drop `user_id`.
- [database/migrations/2026_06_16_000004_drop_membership_version_from_member_subscriptions.php](/database/migrations/2026_06_16_000004_drop_membership_version_from_member_subscriptions.php) — drop the leftover `membership_version_id` snapshot key (the `membership_versions` table itself is dropped by `2026_06_16_000003_simplify_membership_schema`). The lead re-key and the de-versioning shipped as **two** migrations, not one.
- `2026_07_14_000010_create_member_subscription_managers_table` (the old per-subscription managers pivot), `2026_07_18_000001_backfill_lead_account_manager_from_subscriptions` (collapse each lead's managers into `leads.assigned_admin_id`), and `2026_07_18_000002_drop_member_subscription_managers_table` (drop the pivot). Net: the pivot no longer exists.

**Seeder**
- [database/seeds/MemberSubscriptionsSeeder.php](/database/seeds/MemberSubscriptionsSeeder.php) — enrols a few seeded Non-Member leads into the AI / Elite memberships.

**Tests**
- [tests/Feature/Lead/LeadAccountManagerTest.php](/tests/Feature/Lead/LeadAccountManagerTest.php) — assign / unassign the lead's single manager, and that the Membership Members list surfaces it.
- [tests/Feature/Manage/Membership/MembershipMembersExportTest.php](/tests/Feature/Manage/Membership/MembershipMembersExportTest.php) — the export downloads every member, honours `?search=`, and never leaks another membership's members.

**Routes**
- [routes/web.php](/routes/web.php) — `manage.memberships.members.store`, `manage.memberships.members.import(-preview)`, `manage.memberships.export` (`{id}/export` — a two-segment URI, so it never collides with the `GET {id}` show route), `manage.members.cancel`, `manage.leads.convert`. (The account manager is set via `manage.leads.assign`.)
