TC-FUND-001: Donation Money-Flow (Detailed) — #651 / #569 / #607

Step-by-Step Guide — Follow each step exactly as written. All values are provided — do not improvise.
ASSERT ON DATA, NOT "IT WORKED". This is a G9 money-parity flow — every step below checks a concrete DB field/enum value (Donation.approvalStatus, Invoice.status, Payment.status/verificationStatus), never just a toast message. Approve/Reject act on the same Payment/Donation row — a passing test that silently created a duplicate row is a FAIL even if the UI looked fine.
Module
Fundraising & Campaigns — Donation Money-Flow
Staging Frontend
https://stg-rcme.membervu.com
Staging Admin
https://stg-admin.membervu.com
Staging API
https://stg-api.membervu.com
Staging MailHog
https://stg-webmail.membervu.com
Tenant
RCME (Rotary Club of Manila Expats)
Target Campaign
Any ACTIVE fundraising campaign (create one via /admin/campaigns/new if none is ACTIVE)
Priority
P1

TC-FUND-MONEY-01: Happy Path — Donate → Proof → Approve (G9)

StepActionExpected ResultTest Value
1 Log in as the donor member. Dashboard loads. testmember@rcme.membervu.com / Member123!
2 Navigate to the public/member campaign page for an ACTIVE campaign and donate a fixed amount. POST /api/campaigns/:campaignId/donations returns 200/201. Toast confirms the donation. 500.00 PHP
3 Note the donation's linked invoice ID (visible in the response / "View Invoice" link / My Donations list). Assert on data: Donation.approvalStatus = PENDING; a new Invoice exists with status = ISSUED, source = DONATION, invoice number prefixed DONATION, due date = today + 7 days. No Payment row exists for this invoice yet.
4 Open MailHog. A "Donation Received" email is present, addressed to testmember@rcme.membervu.com. https://stg-webmail.membervu.com
5 Back in the app, open the donation's invoice (/invoices/:id?pay=now) and upload a payment proof image for the FULL donation amount. A Payment row is created: status: PENDING, verificationStatus: PENDING_VERIFICATION, proofUrl populated. any JPG/PNG receipt image
6 Log out. Log in as the finance admin. Admin portal loads. admin@rcme.membervu.com / Admin123!
7 Navigate to Pending Proof Review (/admin/campaigns/donations/pending) and find this donation. The donation appears in the queue with the proof image visible. /admin/campaigns/donations/pending
8 Click Approve. POST /api/campaigns/admin/donations/:donationId/approve returns 200. Assert on data: the SAME Payment row (from step 5) flips to SUCCEEDED/APPROVED — do NOT see a second Payment row for this invoice. An Allocation now covers the invoice. Invoice.status → PAID. A money-flow audit entry with action APPROVED exists. Donation.approvalStatus → APPROVED.
9 Check MailHog again. "Donation Approved" email sent to the donor. https://stg-webmail.membervu.com
10 Reload the campaign page (public or admin detail) and note raisedAmount before/after. raisedAmount increases by exactly the donation amount (500.00).

TC-FUND-MONEY-02: Under-Covered Proof Blocks Approval (Rollback)

StepActionExpected ResultTest Value
1 As donor, make a new donation on the ACTIVE campaign. Donation PENDING + Invoice ISSUED created. 1000.00 PHP
2 Upload a proof, but for LESS than the invoice amount (use a payment note/annotation indicating a partial amount, or use a proof-amount field if the UI has one). Payment row created, status: PENDING. e.g. proof for 600.00 against a 1000.00 invoice
3 As admin, attempt to Approve this donation. 409 response: {"error":{"code":"PAYMENT_AMOUNT_INSUFFICIENT"}}.
4 Re-check the Donation, Invoice, and Payment records. Assert everything rolled back: Payment.status still PENDING; Invoice.status still ISSUED; Donation.approvalStatus still PENDING. Nothing was partially settled.
5 (Contrast case) make another donation and upload proof for MORE than the invoice amount, then approve. Approval succeeds — Invoice.status → PAID. The overpaid excess is issued as a Credit with status: AVAILABLE for the donor. e.g. proof for 1200.00 against a 1000.00 invoice

TC-FUND-MONEY-03: Approve With No Proof Uploaded

StepActionExpected ResultTest Value
1 As donor, make a new donation. Do NOT upload any proof. Donation PENDING, no Payment row exists for the invoice. 250.00 PHP
2 As admin, attempt to Approve this donation from the pending queue (or via direct API call if the UI hides the button for no-proof donations — note either behavior). 409 response: {"error":{"code":"NO_PROOF_TO_APPROVE"}}.

TC-FUND-MONEY-04: Reject Retains Proof + Donor-Safe Copy (#569)

StepActionExpected ResultTest Value
1 As donor, make a new donation and upload a proof. Payment PENDING, proofUrl set. 300.00 PHP
2 Note the proofUrl value (e.g. from admin queue image src or API response) before rejecting. URL recorded for comparison in step 4.
3 As admin, click Reject and try submitting with an EMPTY reason field. 400 — reason is required (trimmed non-empty, ≤1000 chars). (blank)
4 Now submit Reject with a real reason. POST /api/campaigns/admin/donations/:donationId/reject returns 200. Assert on data: Payment.status → FAILED/REJECTED (terminal); proofUrl is STILL present on the Payment record (compare to step 2 — the S3 object is NOT deleted); Donation.approvalStatus → REJECTED; Invoice.status reverts to unsettled (not PAID). Amount does not match uploaded proof
5 Check MailHog. "Donation Rejected" email sent, body includes the reason text from step 4. https://stg-webmail.membervu.com
6 As donor, reopen the invoice for the rejected donation. The DonationActionNeededCard is displayed. Assert its copy NEVER contains the words "Invoice", "Receipt", or "OR" — only donor-safe terms ("gift", "donation", "proof of payment"). The upload field is labeled "Gift reference", not "Invoice Number" / "OR Number".

TC-FUND-MONEY-05: Resubmit After Rejection Reuses Same Row (G13)

StepActionExpected ResultTest Value
1 Continuing from TC-FUND-MONEY-04's rejected donation, note the donationId and the invoice's total Donation row count. Donation.count({invoiceId}) = 1.
2 As donor, click Resubmit on the DonationActionNeededCard. POST /api/campaigns/donations/:donationId/resubmit returns 200. Assert on data: Donation.approvalStatus REJECTED → PENDING on the SAME row (same donationId as step 1). Re-check Donation.count({invoiceId}) — must STILL be 1 (not 2).
3 Call resubmit a second time while the donation is already PENDING (re-click the button, or repeat the API call). Idempotent 200 no-op — NOT an error, no state change beyond staying PENDING.
4 Upload a fresh proof for the resubmitted donation. A NEW Payment row is created for the invoice (the old rejected Payment stays FAILED — the duplicate-payment guard does not block this because the prior payment is terminal). any JPG/PNG receipt image
5 As admin, approve the new proof. Same assertions as TC-FUND-MONEY-01 step 8: the new Payment → SUCCEEDED, Invoice.status → PAID, Donation.approvalStatus → APPROVED. Still only ONE Donation row for this invoice throughout the entire flow.

CLEANUP

Data created during this test:

  1. 5 donations by testmember@rcme.membervu.com on the target ACTIVE campaign (500, 1000, 1000/overpay-1200, 250, 300 PHP) — safe to leave; they exercise real money-flow states useful for TC-FUND-002/003 regression.
  2. One APPROVED donation with a Credit (from the overpay contrast case) — note the Credit ID if you plan to test credit application elsewhere.
  3. Campaign raisedAmount was increased — if you need a clean baseline for TC-FUND-003 aggregate checks, use a fresh campaign instead of resetting this one.