Step-by-Step Guide — Follow each step exactly as written. All values are provided — do not improvise.

TC-PAY-009: Treasury Verify/Dispute — Maker-Checker Separation of Duties (Detailed)

Module
Payments — Treasury Reconciliation / RBAC
Staging Frontend
https://stg-rcme.membervu.com
Staging API
https://stg-api.membervu.com
Tenant
RCME (Rotary Club of Manila Expats)
Test Users
admin@rcme.membervu.com / Admin123! · treasurer@demo.membervu.com / Treasurer123! · events@demo.membervu.com / Events123!
Priority
P1
URLs for this test:
Frontend: https://stg-rcme.membervu.com/login
Admin treasury reconciliation: https://stg-rcme.membervu.com/admin/treasury
Admin billing payments: https://stg-rcme.membervu.com/admin/payments
Admin Roles/Permissions matrix: https://stg-rcme.membervu.com/admin/roles
MailHog: 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)

StepAction (EXACT clicks/typing)Expected Result (EXACT text/behavior)Test Value
1Log 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.
2Click "+ 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
3Select 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 "—".
4Toggle 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.
5Repeat 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.
6Assign 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)

StepActionExpected ResultTest Value
1As 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
2Click Verify, confirm.PATCH /api/treasury/payments/:id/verify200 OK. Response body is exactly { "externalVerificationStatus": "VERIFIED", "updatedAt": "<timestamp>" } — no wrapping payment object.
3Refresh 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).
4If 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)

StepActionExpected ResultTest Value
1On the entry that is now VERIFIED from Step 1, click Dispute, confirm.PATCH /api/treasury/payments/:id/dispute200 OK. externalVerificationStatus: VERIFIED → DISPUTED. This is the error-correction path — a verified entry is not locked forever.
2Refresh, confirm status shows DISPUTED.Confirmed in the UI and (if inspectable) via GET /api/treasury/payments.
3Click Verify again on this now-DISPUTED entry.PATCH .../verify200 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)

StepActionExpected ResultTest Value
1With 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).
2Dispute 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)

StepActionExpected ResultTest Value
1Using 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.
2Same 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)

StepActionExpected ResultTest Value
1Using 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.
2Same 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)

StepActionExpected ResultTest Value
1As 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.
2Attempt 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." } }.
3Repeat with .../dispute on the same payment.Same 400 ONLINE_PAYMENT_GATE response.

Step 7: Nonexistent payment (PAY9-07)

StepActionExpected ResultTest Value
1As 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
2Repeat with .../dispute.Same 404 PAYMENT_NOT_FOUND response.

Step 8: No-regression — default ADMIN and FINANCE_MANAGER keep both grants (PAY9-08)

StepActionExpected Result
1As 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.
2As 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.
3Open /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