TC-PAY-009: Treasury Verify/Dispute — Maker-Checker Separation of Duties (Detailed)
URLs for this test:
Frontend:
Admin treasury reconciliation:
Admin billing payments:
Admin Roles/Permissions matrix:
MailHog:
Frontend:
https://stg-rcme.membervu.com/loginAdmin treasury reconciliation:
https://stg-rcme.membervu.com/admin/treasuryAdmin billing payments:
https://stg-rcme.membervu.com/admin/paymentsAdmin Roles/Permissions matrix:
https://stg-rcme.membervu.com/admin/rolesMailHog:
https://stg-webmail.membervu.com
This is an authorization test, not a UI-happy-path test. The point of #528 is that
FINANCE:VERIFY:treasury (checker) and FINANCE:EDIT:payments (maker) are now two separate permission keys. Assert the exact HTTP status code and error code field on every negative case below — a redirect, a hidden button, or a generic error toast is NOT sufficient evidence of correct RBAC enforcement. Where possible, drive the negative cases directly against the API (browser dev tools / curl with the role's token) rather than relying on the UI hiding the button, since a UI omission is not the same as a server-side 403.
No-regression baseline: out of the box, both
admin@rcme.membervu.com (ADMIN, via the FINANCE:*:* wildcard) and treasurer@demo.membervu.com (FINANCE_MANAGER, explicit grant of both keys) hold BOTH permissions — so testing with only these two accounts will show no behavior change at all. To prove the SoD split (PAY9-04/05) you must first create a single-permission custom role via Step 0 below.
Step 0: Create a maker-only role and a checker-only role (prerequisite for PAY9-04/05)
| Step | Action (EXACT clicks/typing) | Expected Result (EXACT text/behavior) | Test Value |
|---|---|---|---|
| 1 | Log in as admin@rcme.membervu.com / Admin123!. Go to Settings → Roles & Permissions (/admin/roles). | Roles list panel loads with default roles (ADMIN, FINANCE_MANAGER, etc.) plus any existing custom roles. | — |
| 2 | Click "+ Create Role". Name the role, submit. | CreateRoleModal creates a new custom role with no permissions granted yet. It appears in the role list. | Name QA Maker-Only |
| 3 | Select the new role, open its detail panel, find the Finance module in the PermissionMatrix. Locate the Payments resource row. | The matrix shows columns VIEW / CREATE / EDIT / DELETE / VERIFY (the VERIFY column is new for #528). For resources with no VERIFY slot (e.g. most non-treasury resources) that cell renders as a disabled "—". | — |
| 4 | Toggle ON EDIT for the Payments resource row only (grants FINANCE:EDIT:payments). Leave the Treasury resource row's VERIFY cell OFF. Save. | Role QA Maker-Only now holds exactly FINANCE:EDIT:payments and nothing else. | — |
| 5 | Repeat steps 2–4 for a second role: create it, then toggle ON only the VERIFY cell on the Treasury resource row (grants FINANCE:VERIFY:treasury). Leave Payments EDIT OFF. Save. | Role QA Checker-Only now holds exactly FINANCE:VERIFY:treasury and nothing else. | — |
| 6 | Assign QA Maker-Only to one throwaway/test user and QA Checker-Only to another (via the user's role assignment UI, or reuse two seeded non-finance accounts you can safely re-role for this test). Log in as each to obtain their session token. | Two distinct sessions/tokens, each holding exactly one of the two permissions. | — |
Shortcut if role admin UI is unavailable:
events@demo.membervu.com / Events123! holds neither FINANCE:EDIT:payments nor FINANCE:VERIFY:treasury by default — it is sufficient on its own to prove the treasury verify 403 half of PAY9-04 (a non-finance role can't verify), but it does NOT prove the maker-vs-checker split (that a role with ONE finance permission is blocked from the OTHER finance action). Step 0 is still required for the full PAY9-04/05 proof.
Step 1: Checker verifies a manual (offline) payment (PAY9-01)
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | As treasurer@demo.membervu.com / Treasurer123!, go to Finance → Treasury (/admin/treasury). Filter/find a manual (non-PAYNAMICS) payment with externalVerificationStatus: PENDING_VERIFICATION. | A treasury row is shown with a Verify and Dispute action. | Any settled manual payment, e.g. channel MANUAL_BANK |
| 2 | Click Verify, confirm. | PATCH /api/treasury/payments/:id/verify → 200 OK. Response body is exactly { "externalVerificationStatus": "VERIFIED", "updatedAt": "<timestamp>" } — no wrapping payment object. | — |
| 3 | Refresh the treasury list / re-open the row. | Status now shows VERIFIED. The Verify action is no longer offered (or is now a no-op affordance); Dispute remains available (VERIFIED is not terminal — see Step 2). | — |
| 4 | If an audit/history view is exposed for the payment, check it. | A new audit entry exists: PaymentAuditAction.STATUS_CHANGED, previousStatus: PENDING_VERIFICATION, newStatus: VERIFIED, notes Treasury verify action, performedBy = the treasurer's userId (not their email). | — |
Step 2: Dispute, then re-verify — VERIFIED is not terminal (PAY9-02)
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | On the entry that is now VERIFIED from Step 1, click Dispute, confirm. | PATCH /api/treasury/payments/:id/dispute → 200 OK. externalVerificationStatus: VERIFIED → DISPUTED. This is the error-correction path — a verified entry is not locked forever. | — |
| 2 | Refresh, confirm status shows DISPUTED. | Confirmed in the UI and (if inspectable) via GET /api/treasury/payments. | — |
| 3 | Click Verify again on this now-DISPUTED entry. | PATCH .../verify → 200 OK, externalVerificationStatus: DISPUTED → VERIFIED. Confirms both directions of the correction cycle work: PENDING_VERIFICATION|DISPUTED → VERIFIED and PENDING_VERIFICATION|VERIFIED → DISPUTED. | — |
Step 3: Idempotent no-ops (PAY9-03)
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | With the entry currently VERIFIED (end state of Step 2), call PATCH .../verify again. | 200 OK, externalVerificationStatus: "VERIFIED" unchanged, updatedAt unchanged from before this call (no-op — service logs "already VERIFIED — no-op" and returns early without writing a new audit entry or bumping updatedAt). | — |
| 2 | Dispute the entry again (.../dispute) so it is DISPUTED, then call .../dispute a second time. | Second call → 200 OK, externalVerificationStatus: "DISPUTED" unchanged, no duplicate transition/audit entry. | — |
Step 4: SoD negative — maker-only role cannot verify/dispute (PAY9-04)
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | Using the QA Maker-Only session/token from Step 0 (holds ONLY FINANCE:EDIT:payments), attempt PATCH /api/treasury/payments/:id/verify on any treasury payment. | 403 Forbidden. The requirePermission("FINANCE:VERIFY:treasury") route guard rejects the request before it reaches verifyPaymentHandler — the maker's FINANCE:EDIT:payments grant does NOT satisfy this check. | — |
| 2 | Same session, attempt PATCH .../dispute. | 403 Forbidden — same reasoning. | — |
| 3 | (UI corroboration) Log in to the browser as the maker-only user, navigate to /admin/treasury. | Either the page/actions are hidden, or clicking Verify/Dispute surfaces the 403 from step 1/2 — confirm the UI does not silently succeed or show a misleading "success" state. | — |
Step 5: SoD negative — checker-only role cannot approve/reject billing payments (PAY9-05)
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | Using the QA Checker-Only session/token from Step 0 (holds ONLY FINANCE:VERIFY:treasury), attempt POST /api/billing/admin/payments/:paymentId/approve on any pending manual payment. | 403 Forbidden. This route is guarded by requirePermission("FINANCE:EDIT:payments"), which the checker-only role does not hold — proving the split is enforced in both directions. | — |
| 2 | Same session, attempt POST .../reject. | 403 Forbidden — same reasoning. | — |
| 3 | (UI corroboration) Log in to the browser as the checker-only user, navigate to /admin/payments. | Approve/Reject actions are hidden or 403 when attempted — the checker cannot act as a maker despite having a finance-adjacent permission. | — |
Step 6: Online-payment gate — auto-verified channel rejects treasury action (PAY9-06)
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | As treasurer@demo.membervu.com, find (or create via a seeded/simulated gateway flow) a payment with channel: PAYNAMICS — i.e. an online payment, which carries externalVerificationStatus: null (auto-verified, not part of the manual reconciliation queue). | Payment exists with a null/absent verification status; it should NOT appear as actionable in the treasury Verify/Dispute queue. | — |
| 2 | Attempt PATCH /api/treasury/payments/:id/verify directly against this payment's id (bypass the UI if it doesn't expose the action). | 400 Bad Request: { "error": { "code": "ONLINE_PAYMENT_GATE", "message": "Online payments are verified automatically." } }. | — |
| 3 | Repeat with .../dispute on the same payment. | Same 400 ONLINE_PAYMENT_GATE response. | — |
Step 7: Nonexistent payment (PAY9-07)
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | As treasurer@demo.membervu.com, call PATCH /api/treasury/payments/:id/verify with a random/non-existent UUID (or a payment id from a different tenant). | 404 Not Found: { "error": { "code": "PAYMENT_NOT_FOUND", "message": "Payment not found." } }. | Id 00000000-0000-0000-0000-000000000000 |
| 2 | Repeat with .../dispute. | Same 404 PAYMENT_NOT_FOUND response. | — |
Step 8: No-regression — default ADMIN and FINANCE_MANAGER keep both grants (PAY9-08)
| Step | Action | Expected Result |
|---|---|---|
| 1 | As admin@rcme.membervu.com / Admin123!, perform a treasury verify (Step 1 pattern) AND a billing payment approve (from TC-PAY-003) on separate test payments. | Both succeed — 200 OK each. ADMIN's FINANCE:*:* wildcard covers FINANCE:VERIFY:treasury automatically. |
| 2 | As treasurer@demo.membervu.com / Treasurer123!, perform the same two actions on separate test payments. | Both succeed — 200 OK each. FINANCE_MANAGER's role template explicitly lists both FINANCE:EDIT:payments and FINANCE:VERIFY:treasury. |
| 3 | Open /admin/roles, inspect the built-in ADMIN and FINANCE_MANAGER rows in the Permission Matrix's Treasury resource, VERIFY column. | Both show VERIFY granted, alongside Payments/EDIT — confirming the split is opt-in per tenant, not a breaking default-role change. |
CLEANUP
- Delete or deactivate the QA Maker-Only and QA Checker-Only custom roles created in Step 0, and revert any test user's role assignment back to its original role.
- Leave the treasury entries from Steps 1–3 in whatever final verification state they ended at (VERIFIED/DISPUTED) — do not attempt to "reset" them to PENDING_VERIFICATION; there is no such reverse transition, and forcing one is out of scope.
- If a throwaway PAYNAMICS-channel payment was created in Step 6, note it in your run log so it isn't mistaken for a real gateway transaction in later regression passes.
- Confirm no payment used in Step 7's not-found probe accidentally matches a real seeded payment id in another tenant (it shouldn't, given the random UUID).