URLs: Frontend https://stg-rcme.membervu.com · MailHog https://stg-webmail.membervu.com · Membership types live at Members → Settings → Membership Types tab (/admin/members/settings?tab=types, the default tab). Status changes live at Members → All Members → (click a member) → Actions ▾ → Change Status. Payment approval lives on an invoice's Payments tab at Finance → Invoices → (open invoice).
Payment gateway is INERT on staging (Paynamics). Nothing in this TC triggers a live charge — the manual/bank-transfer "Record Payment" + "Approve" flow only creates Invoice/Payment records.
SCEN-01: Create a QUARTERLY membership type
Purpose: confirm QUARTERLY is a selectable, persisted MembershipPeriod (#504, enum migration 20260630000002_add_quarterly_lifetime_membership_periods).
Step
Action
Expected Result
Test Value
1
Log in as admin. Go to /admin/members/settings?tab=types (Members sidebar → Settings → Membership Types tab).
"Create Membership Type" form is visible with a "Billing Period" <select> offering Monthly / Quarterly / Annual / Lifetime.
-
2
Fill: Name QA Quarterly Plan, Billing Period Quarterly, Dues Amount 1500. Submit.
Toast "Membership type created". Type appears in the list with period = Quarterly.
QA Quarterly Plan / 1500
3
Call GET /api/membership/types (or refresh the list) and locate the new type.
Response/UI shows period: "quarterly" (API lowercases on read) and amountCents: 150000. Data assertion, not just "it saved."
-
PASS if: QUARTERLY type is created, persists, and round-trips with the correct amount and period value.
SCEN-02: Create a LIFETIME membership type
Step
Action
Expected Result
Test Value
1
On the same Membership Types tab, fill: Name QA Lifetime Plan, Billing Period Lifetime.
The price field label changes from "Dues Amount" to "One-time Price" and its helper text reads "One-time charge collected at join. Never re-invoiced."
QA Lifetime Plan
2
Enter Price 25000. Submit.
Toast "Membership type created". List row shows period = Lifetime.
25000
3
Verify persistence via GET /api/membership/types or reload.
period: "lifetime", amountCents: 2500000. Both QA Quarterly Plan and QA Lifetime Plan coexist with the pre-existing MONTHLY/ANNUAL types — none of the existing types changed period or amount.
-
PASS if: LIFETIME type is created and persists; existing MONTHLY/ANNUAL types are unaffected.
SCEN-03: LIFETIME member activation — null expiry does not error
Purpose: confirm computeMembershipExpiry() returns null for LIFETIME and downstream code (member record, activation email) handles it cleanly — no crash, no blank/garbled date.
Setup: this scenario needs a member on the QA Lifetime Plan created in SCEN-02, in PENDING_APPROVAL or PENDING_PAYMENT. Register a fresh applicant and have an admin assign the Lifetime type + approve, OR change an existing pending member's Membership Type to "QA Lifetime Plan" via Actions ▾ → Change Membership Type before approving.
Step
Action
Expected Result
Test Value
1
In an incognito window, go to https://stg-rcme.membervu.com/register and register: First Lifetime, Last QATest, Email qa-lifetime-member@test.membervu.com, plus required fields. Submit.
Application submitted; status = PENDING_APPROVAL (if tenant requires approval).
qa-lifetime-member@test.membervu.com
2
As admin, open the new member on Members → All Members. Use Actions ▾ → Change Membership Type → select QA Lifetime Plan → save.
Member's membership type is now "QA Lifetime Plan" (period LIFETIME).
-
3
Click inline "Approve" on the member header (or use the PENDING filter to find them).
If tenant has autoCreateDuesInvoice off (skip-dues path): status → ACTIVE directly, and computeMembershipExpiry() is called with membershipPeriod: "LIFETIME" → returns null. If dues are required: status → PENDING_PAYMENT, continue to step 4.
-
4 (conditional)
If PENDING_PAYMENT: as admin, open the auto-created dues invoice (Finance → Invoices) → Record Payment (method Bank Transfer, amount = invoice total) → confirm.
Open the member's detail panel and check the membership-expiry field.
Expiry shows as no expiry / blank / "Lifetime" in the UI — not an error, not Invalid Date, not a fallback date. membershipExpiresAt is null in the data.
-
6
Check MailHog (https://stg-webmail.membervu.com) for the membership-activation email sent to qa-lifetime-member@test.membervu.com.
Email renders successfully. The "Valid Until" field reads exactly Lifetime (no expiry) (from buildMembershipActivationEmail's membershipExpiresAt ? formatDateForEmail(...) : "Lifetime (no expiry)" branch) — not blank, not a garbled date string.
-
PASS if: the LIFETIME member activates without error, membershipExpiresAt is null, and the activation email shows "Lifetime (no expiry)" rather than erroring or showing a blank/garbled date.
SCEN-04: QUARTERLY member activation — expiry = +3 calendar months
Step
Action
Expected Result
Test Value
1
Repeat SCEN-03 steps 1-4, but assign QA Quarterly Plan instead of Lifetime, for a new applicant qa-quarterly-member@test.membervu.com. Note the exact date/time the invoice is marked PAID (or approval completes on the skip-dues path).
Member activates to ACTIVE.
qa-quarterly-member@test.membervu.com
2
Open the member's detail panel and read membershipExpiresAt (or check via admin member-detail API response if visible in devtools Network tab).
Expiry date = activation date + exactly 3 calendar months (e.g. activated Jul 1, 2026 → expires Oct 1, 2026), per computeMembershipExpiry()'s expiry.setMonth(expiry.getMonth() + 3) logic — not a generic +90 days, not +1 year.
-
PASS if: the computed expiry is activation-date + 3 calendar months, exactly.
SCEN-05: Valid transitions — drive the full lifecycle
Step
Action
Expected Result
Test Value
1
As admin, open Maria Cruz (testmember@rcme.membervu.com, ACTIVE). Actions ▾ → Change Status → Suspend Member. Reason: QA test - suspension. Confirm.
Status → SUSPENDED. Her User.tokenVersion is bumped (any existing session token now fails auth) and a suspension email is sent (check MailHog).
QA test - suspension
2
Actions ▾ → Change Status → Reactivate Member. Confirm.
Status → ACTIVE. New membershipExpiresAt is set via computeMembershipExpiry() for Maria's period (see SCEN-07 for the cross-path comparison).
-
3
Actions ▾ → Change Status → Deactivate Member (only offered while ACTIVE).
Status → INACTIVE. User.tokenVersion bumped again (#446) — Maria's next authenticated request returns 401. Confirm by hitting any authenticated endpoint with her old token (or just observing she's logged out on next page load).
-
4
Actions ▾ → Change Status → Reactivate Member (INACTIVE → ACTIVE is allowed per the guard table).
Status → ACTIVE again; Maria can log back in.
-
5
Open Antonio Dela Cruz (expired@demo.membervu.com, EXPIRED). Actions ▾ → Change Status → Reactivate Member.
Status → ACTIVE (EXPIRED is in the reactivatable-status list). New expiry set per §2a formula.
-
PASS if: every transition in this table succeeds and produces the documented side-effect (tokenVersion bump, email, new expiry).
SCEN-06: Invalid transitions are blocked
Purpose: confirm the source-state guards in membershipStatus.service.ts reject illegal transitions rather than silently applying them.
Step
Action
Expected Result
Test Value
1
Reject an applicant first: register qa-status-block@test.membervu.com, then as admin click inline Reject with reason QA test - rejection.
Status → REJECTED.
qa-status-block@test.membervu.com
2
Attempt to suspend the now-REJECTED member: POST /members/:id/suspend (or check whether "Change Status" even offers Suspend for this member in the UI).
The UI should not offer Suspend for a REJECTED member. If invoked directly via API, the response is success:false with message "Cannot suspend member with status REJECTED" — no status change occurs.
-
3
Attempt to approve an already-ACTIVE member (e.g. Maria Cruz): POST /members/:id/approve.
Rejected — "Cannot approve member with status ACTIVE" (only PENDING_APPROVAL, or GUEST_ATTENDEE via the prospect extension, is accepted). Maria's status is unchanged.
-
4
Attempt to deactivate a SUSPENDED member (suspend Maria again first if needed, via Change Status → Suspend).
Rejected — "Cannot deactivate member with status SUSPENDED. Only ACTIVE members can be deactivated." The "Deactivate Member" option should not even appear in the Change Status modal for a SUSPENDED member.
-
5
Attempt to reject a member who is not PENDING_APPROVAL (e.g. Antonio, ACTIVE after SCEN-05 step 5): POST /members/:id/reject.
Rejected — "Cannot reject member with status ACTIVE. Only PENDING_APPROVAL members can be rejected."
-
PASS if: every illegal transition attempted above is rejected with a clear "Cannot <action>..." message and the member's status is unchanged — data-verify the status field didn't move, don't just trust a UI toast.
SCEN-07: Expiry identical across activation paths
Purpose: confirm computeMembershipExpiry() produces the same expiry for equivalent inputs regardless of which code path calls it.
Step
Action
Expected Result
Test Value
1
Create two fresh applicants on the same membership type (pick the tenant's default ANNUAL type). Call them qa-expiry-a@test.membervu.com and qa-expiry-b@test.membervu.com.
Both submitted, PENDING_APPROVAL.
qa-expiry-a / qa-expiry-b
2
For A: if the tenant has autoCreateDuesInvoice OFF, approve directly (skip-dues path, uses approveMembership's own computeMembershipExpiry call). If ON, approve → pay the resulting dues invoice via Record Payment (uses onDuesInvoicePaid's call) — pick whichever path the tenant supports and note which one you used.
A → ACTIVE with an expiry date. Record the exact membershipExpiresAt value and the activation timestamp.
-
3
For B: reject the application instead (Reject, reason setup for reactivate test) — this is just to get a non-ACTIVE record; then manually flip B to SUSPENDED isn't possible from REJECTED, so instead: approve B the same way as A within the same minute, then immediately suspend B (Change Status → Suspend), then immediately Reactivate B (Change Status → Reactivate).
B ends ACTIVE via the reactivate path (reactivateMembership's computeMembershipExpiry call), activated within ~1 minute of A.
-
4
Compare A's and B's membershipExpiresAt.
Both dates land on the same calendar day per the formula in index §2a (same period, same effective "now" window, same duesPeriod-or-fallback branch) — confirming approval/dues-paid/reactivate all agree. A trivial second-level timestamp difference is expected (different requests); a different date is a bug.
-
PASS if: members activated via different paths (approval, dues-paid, reactivate) on the same membership type land on the same computed expiry date.
SCEN-08: Manual bank-transfer dues approval activates the member (#527)
Purpose: confirm approving a PENDING bank-transfer payment on a DUES invoice flips the member to ACTIVE in the same request — no separate activation action needed.
Step
Action
Expected Result
Test Value
1
Register a fresh applicant qa-manualdues-activate@test.membervu.com and have admin approve them with dues required (tenant autoCreateDuesInvoice ON) — or use any existing member already in PENDING_PAYMENT with an unpaid DUES invoice (seed user pending-payment@demo.membervu.com / Isabella Ramos works if not already consumed by another test).
Member status = PENDING_PAYMENT; a DUES-source invoice exists and is unpaid (OUTSTANDING).
qa-manualdues-activate@test.membervu.com
2
As the member (log in), or as admin on their behalf: submit a manual bank-transfer payment with proof against that dues invoice — "Submit for Approval" button on the invoice/payment page (SubmitPaymentProofModal), method Bank Transfer, amount = full invoice total, upload any proof image, reference BT-2026-DUES-001.
Payment record created with status PENDING. Invoice remains OUTSTANDING (unsettled). Member status is still PENDING_PAYMENT at this point — no activation yet.
BT-2026-DUES-001
3
As admin or treasurer (treasurer@demo.membervu.com / Treasurer123!), open Finance → Invoices → find this dues invoice → open its Payments tab → click "Approve" next to the pending payment.
Request hits POST /api/billing/admin/payments/:paymentId/approve. Response 200 with status: "SUCCEEDED", verificationStatus: "APPROVED".
-
4
Immediately check the invoice.
Invoice status → PAID.
-
5
Immediately check the member's detail panel (Members → All Members → open the member) — in the same test pass, do not wait for any cron/background job.
Member status field now reads ACTIVE — flipped in the same approval step, no separate "Activate" click was needed. This is the #527 behavior: the approve handler checks payment.invoice?.source === 'DUES' after the invoice reaches PAID and calls onDuesInvoicePaid() inline.
-
6
Check the member's membershipStartDate and membershipExpiresAt fields on the same detail panel.
Both dates are populated (start = now or existing join date; expiry = per the §2a formula for this member's period/duesPeriod) — assert the dates changed, not just the status label.
-
7
Check MailHog for a membership-activation email to the member.
Email received with "Valid Until" reflecting the same expiry date read in step 6 (or "Lifetime (no expiry)" if their type is LIFETIME).
-
PASS if: approving the manual bank-transfer dues payment (1) marks the invoice PAID, (2) flips the member status field to ACTIVE in the SAME step (no separate activation action), and (3) sets both membershipStartDate and membershipExpiresAt. All three must be verified as data, not inferred from a success toast.
Common false-pass: don't stop at "payment approved successfully" toast. A tester who only checks the payment/invoice status and not the member status would miss a regression where #527's activation call silently fails (e.g. exception swallowed) while the payment itself still shows approved.
CLEANUP — Read Before You Stop
Maria Cruz must end ACTIVE — if any scenario left her SUSPENDED/INACTIVE, reactivate via Actions ▾ → Change Status → Reactivate.
Antonio Dela Cruz — if reactivated in SCEN-05 step 5, reseed to restore EXPIRED: docker exec membervu-backend npm run seed:rcme.
Isabella Ramos (pending-payment@demo.membervu.com) — if consumed in SCEN-08, reseed to restore her PENDING_PAYMENT + unpaid dues invoice state.
All qa-*@test.membervu.com test applicants/members and the QA Quarterly Plan / QA Lifetime Plan membership types are cleared by a reseed: docker exec membervu-backend npm run seed:rcme.
If you created the QA Quarterly/Lifetime types and want to keep the environment clean without a full reseed, delete them via the Membership Types tab (only allowed when no members are assigned).