♻️ Reconciled against current code — 2026-07-01. Corrections vs. prior version: full MemberStatus model; status changes go through the Actions ▾ → Change Status modal (not per-row buttons); the "pending" seed user is a prospect (approve via the application path, not the member-approve path); approval-workflow settings moved to /admin/members/settings?tab=workflow. Key correction this pass: the status-transition engine is now the single canonical source of truth — every activation path (approval skip-dues, dues-invoice-paid, admin reactivate) computes expiry via the same computeMembershipExpiry() helper in services/membership/membershipStatus.service.ts, so the earlier OQ-RET-16 caveat ("reactivate doesn't set a new expiry") is superseded — see the green banner below for the full #447/#453 rewrite. MembershipPeriod now has 4 values: MONTHLY, QUARTERLY, ANNUAL, LIFETIME.
🆕 New on stage — 2026-06-19 → 2026-07-01.
QUARTERLY + LIFETIME membership periods (#504, Jul 1 release):MembershipPeriod enum gained QUARTERLY and LIFETIME (additive migration 20260630000002_add_quarterly_lifetime_membership_periods). Both are selectable in the membership-type form (AdminMemberSettingsPage.tsx, Members Settings → Types tab, Billing Period <select>). computeMembershipExpiry() (membershipStatus.service.ts) is period-aware: LIFETIME → always null expiry (never expires); QUARTERLY → from + exactly 3 calendar months; other periods with a duesPeriod → Dec 31 of that year; otherwise from + 1 year. The dues-activation email (buildMembershipActivationEmail) renders "Lifetime (no expiry)" when membershipExpiresAt is null — confirmed no crash/blank field. Existing MONTHLY/ANNUAL types and members are unaffected. See TC-MEM-008.
Manual bank-transfer dues approval now activates the member in the same step (#527, Jul 1 release): approving a PENDING manual payment via POST /api/billing/admin/payments/:paymentId/approve (approveManualPaymentHandler, paymentHandlers.ts) now checks if (payment.invoice?.source === 'DUES') after the invoice flips to PAID and calls onDuesInvoicePaid() — the same activation path the online-payment webhook uses. A member who submitted a bank-transfer dues payment (proof via SubmitPaymentProofModal, billing module) and gets approved by an admin/treasurer is flipped PENDING_PAYMENT → ACTIVE immediately, with membershipStartDate/membershipExpiresAt set — no separate activation click needed. See TC-MEM-008 (cross-links Billing's Record/Approve Payment flow).
Unified status-transition engine + single expiry calculation (#447/#453, Jun 25 release):membershipStatus.service.ts is the sole writer of Member.status transitions, each with an explicit source-state guard (rejects illegal transitions with a message, not a silent no-op) — see the full transition table in §2 below, rewritten to match. computeMembershipExpiry() is shared by approveMembership (skip-dues path), onDuesInvoicePaid, and reactivateMembership, so the same inputs (duesPeriod / period / from-date) always produce the same expiry date regardless of which path triggered activation. This supersedes the old OQ-RET-16 finding — admin Reactivate now sets a real forward-looking expiry via the canonical helper, same as any other activation path.
Deactivate bumps tokenVersion (#446, security, Jun 25 release):deactivateMembership() now atomically increments the linked User.tokenVersion in the same transaction as the status change (mirrors suspendMembership). A deactivated member's pre-existing JWT fails authMiddleware's revocation check on the very next request → immediate 401, no waiting for token expiry.
Membership-type amount/currency validation hardened (#432, Jun 25 release):createMembershipTypeSchema/updateMembershipTypeSchema (utils/validation.ts) enforce price as a strict Zod number — a whitespace/non-numeric string fails type validation with a clean 400, not a silent NaN/coercion. Correction to the shipped release-note wording:currency is not a per-type field at all — the schema rejects any caller-supplied currency outright ("cannot be set per membership type; derived from the organization profile"), it isn't a format/whitespace check on a currency code.
Member status-history dedup (#431, Jun 25 release): this is a read-time display fix, not a DB write-time guard — there is no MemberStatusHistory table. GET /api/members/:id/audit (getMemberAuditLog) previously showed the same transition twice for members who have both a denormalized Member field (e.g. membershipApprovedAt) AND a real AuditLog row for the same event. It now dedupes synthetic entries against real AuditLog rows within a 60s window before returning the combined, sorted history.
Registration idempotency (#448): re-registering for an event the member already has an entry for reactivates the existing registration instead of creating a duplicate — Events module, cross-linked here since it touches Member/registration status.
APPROVED members counted in prospect pipeline (#366, Jun 19 release).
Default membership-type badge + "Set as Default" (#365, Jun 19 release):setDefaultMembershipType unsets the tenant's previous default in the same call — only one isDefault:true type survives per tenant. The approve-prospect modal pre-selects the default type.
Humanized membership status label in the member sidebar (#471, Jun 29 release):AppLayout.tsx (~line 644) renders {orgTypeLabel} • {viewLabel}, where viewLabel is getViewLabel() (utils/viewLabel.ts) — a role/status-derived label such as "Member View", "Expired Member View", "Pending Payment View" — never a raw MemberStatus enum string.
Tenant appDomain now required / fail-loud on unresolvable origin (#509, Jul 1 release):Tenant.appDomain is a required (non-null) column as of migration 20260630000001_tenant_appdomain_not_null. buildTenantOrigin() (utils/tenantUrl.ts) throws TenantOriginUnresolvableError when no appDomain and no valid request host can resolve an origin; interactive callers (invitations, prospect emails, membership emails) map this to HTTP 400 rather than emitting a malformed link like https://rcme.http/....
1. Scope
Profile Management — member self-service (details, avatar) + admin edit (incl. the "since/joined" date, #184).
Member Directory — approved-member search/listing.
Admin Member Controls — status changes (suspend / reactivate / deactivate), role assignment, audit.
Approval Workflow — pending applications (approve / reject) and the prospect pipeline.
MemberStatus values (current): ACTIVE, INACTIVE, SUSPENDED, PENDING_APPROVAL, PENDING_PAYMENT, EXPIRED, REJECTED, CANCELLED, GUEST_ATTENDEE (legacy PENDING_VERIFICATION still handled). Prospect pipeline uses a separate prospectStatus (NONE→INTERESTED→INVITED→APPLIED→UNDER_REVIEW→APPROVED/DECLINED) and membershipApplicationStatus (NOT_APPLIED / PENDING / APPROVED / REJECTED).
Since #447/#453, all transitions below are enforced by services/membership/membershipStatus.service.ts — each function loads the member's current status and checks it against an explicit allow-list ("source-state guard") before writing. An illegal call returns success:false with a message like Cannot suspend member with status X instead of silently no-op'ing or corrupting state.
Transition
Trigger
Allowed FROM statuses (guard)
(register) → PENDING_APPROVAL / PENDING_PAYMENT / ACTIVE
Self-registration, depending on tenant approval + auto-dues config
—
PENDING_APPROVAL → PENDING_PAYMENT or ACTIVE
approveMembership() — admin approve (member detail inline Approve); ACTIVE path sets membershipExpiresAt via computeMembershipExpiry()
PENDING_APPROVAL only (prospect flow extends with GUEST_ATTENDEE)
PENDING_APPROVAL → REJECTED
rejectMembership() — admin reject
PENDING_APPROVAL only
PENDING_PAYMENT → ACTIVE
onDuesInvoicePaid() — DUES invoice reaches PAID (online or manual-approved, #527). Sets membershipStartDate + membershipExpiresAt via computeMembershipExpiry(invoice.duesPeriod, now, period)
Invoice must be source:'DUES' and status:PAID
ACTIVE / INACTIVE / EXPIRED / PENDING_PAYMENT → SUSPENDED
deactivateMembership() — bumps User.tokenVersion atomically (#446) → next request 401
ACTIVE only
SUSPENDED / EXPIRED / INACTIVE → ACTIVE
reactivateMembership() — Actions ▾ → Change Status → Reactivate. Sets membershipExpiresAt via the samecomputeMembershipExpiry() call as approval/dues-paid
SUSPENDED, EXPIRED, INACTIVE
ACTIVE → EXPIRED
expireMembership() — membership-expiry cron
ACTIVE only
2a. Canonical expiry formula — computeMembershipExpiry(duesPeriod, from, membershipPeriod)
Single function, called identically by all three activation paths above (approval skip-dues, dues-invoice-paid, admin reactivate) — same inputs always produce the same expiry date, regardless of path:
Condition
Result
membershipPeriod === "LIFETIME"
null — no finite expiry, checked first (takes priority over duesPeriod)
membershipPeriod === "QUARTERLY"
from + exactly 3 calendar months
Otherwise, duesPeriod set (e.g. "2025" or "2024-2025")
Dec 31, 23:59:59.999 of the trailing 4-digit year parsed from duesPeriod
Otherwise (no duesPeriod, not QUARTERLY/LIFETIME — e.g. MONTHLY/ANNUAL with no period string)
from + 1 year (flat duration fallback)
OQ-RET-16 / #151 — SUPERSEDED by #447/#453 (2026-06-25): the earlier fix (admin Reactivate sets ≈ now+1yr flat) has been replaced by the canonical computeMembershipExpiry() call above — reactivate now honours the member's actual MembershipPeriod (LIFETIME → null, QUARTERLY → +3mo, else duesPeriod-year-end or +1yr fallback) instead of a hardcoded +1yr. Test: assert the reactivated expiry matches the formula in §2a for the member's period, not a blanket "+1 year".
3. Key routes & UI
Area
Route / how to reach
All Members
/admin/members — list + split-view detail panel (URL ?selected=<id>). Sidebar: Members → All Members.
Member full edit (incl. joined date)
/admin/members/:id/edit — "Edit Profile" button (ADMIN only). "Member Since (Joined Date)" field (#184).
Status change / roles / membership type
Member detail header → "Actions ▾" → Change Status / Manage Roles / Change Membership Type.
Executive Dashboard /admin/dashboard — only shows when signupNotifEnabled is on.
No "Add Member" button. Member intake is via the Prospects pipeline (Add Prospect / invite / approve application) or self-service /register. Legacy /admin/members/add, /new, /invite all redirect to /admin/prospects.
Critical:pending@demo.membervu.com (Miguel) is a prospect/applicant with status GUEST_ATTENDEE, notPENDING_APPROVAL. The member-approve action (POST /members/:id/approve) will reject him ("only PENDING_APPROVAL members can be approved"). Approve him via the application path on /admin/prospects (POST /admin/applications/:id/approve). There is no seeded PENDING_APPROVAL user — create one via /register when a true approval test is needed.
5. This-week changes folded in (stage = dev)
#184 — admin can edit a member's "since/joined" date (member edit page).
#185 — tenant-configurable registration form fields (Member Settings → Fields).
#186 / #217 / #243 — recent-signups admin widget + new-signup email and in-app bell. Default OFF — the signupNotifEnabled master switch must be turned on first at Settings → Notifications (/admin/settings/notifications); it was relocated there from the Approval Workflow page (#239). No notification fires until enabled — that's expected, not a bug.
#182 — international phone now persisted at registration.
#199 — member avatars stored as S3 keys (display fixed).
#151 (OQ-RET-16) — admin Reactivate now sets a future membershipExpiresAt. Superseded 2026-06-25 by #447/#453 — see §2a for the current canonical formula.
#504 — NEW 2026-07-01 — QUARTERLY + LIFETIME membership periods. See green banner + TC-MEM-008.
#527 — NEW 2026-07-01 — manual bank-transfer dues approval activates the member in the same step. See green banner + TC-MEM-008.
#447/#453 — NEW 2026-06-25 — unified status-transition engine + single canonical expiry calculation. See §2/§2a + TC-MEM-008.
#446 — NEW 2026-06-25 — deactivate bumps tokenVersion → immediate 401 on next request.
#432 — NEW 2026-06-25 — membership-type amount validated as a strict number; per-type currency rejected outright.
#431 — NEW 2026-06-25 — member audit-log read path dedupes synthetic vs. real AuditLog entries (no more duplicate rows in status history).
#471 — NEW 2026-06-29 — humanized membership status label in the member sidebar (role/status-derived viewLabel, never a raw enum).
#509 — NEW 2026-07-01 — Tenant.appDomain required; unresolvable tenant origin now fails loud (400) instead of emitting a malformed link.