Donation Flow
Complete lifecycle of a one-time donation from the moment a user clicks "Donate" to the webhook confirmation.
Checkout Sequence
1. User selects campaign + amount
The donation UI lives at /donate (React, Donate.tsx). It is a 5-step flow:
- Browse campaigns
- Enter amount
- Choose payment method
- Confirm details
- Redirect to gateway
/donate/:id skips directly to the amount step for a specific campaign.
2. Frontend fetches a fee quote, then posts to the API
The frontend never computes fees itself — it calls POST /donations/quote with the chosen amount/method and displays the response verbatim before the donor confirms. See Fee Structure for the quote endpoint's request/response shape.
// Frontend — Donate.tsx
const res = await api.post('/donations', {
campaign_id: 1,
amount: 1000,
payment_gateway: 'paymongo',
payment_method: 'gcash',
message: 'Keep up the good work!',
})
const redirectUrl: string = res.data.data.redirectUrl
// Validate destination before redirecting — prevents open redirect exploitation
const allowedHosts = ['paymongo.com', 'checkout.paymongo.com', 'pm.link']
const parsed = new URL(redirectUrl)
if (!allowedHosts.some(h => parsed.hostname === h || parsed.hostname.endsWith('.' + h))) {
throw new Error('Unexpected payment redirect destination.')
}
window.location.href = redirectUrl
The request body must include payment_method (card | gcash | paymaya | grab_pay | qrph) — the donor picks this in Batchmates' own UI before checkout, and PayMongo's hosted page is then narrowed to that single method (payment_method_types: [method]) so the fee quoted matches what PayMongo will actually offer.
3. Backend creates a pending Donation
A Donation record is saved to the database with status: 'pending' before the gateway is called. This ensures the record exists even if the redirect fails.
4. PayMongo checkout session is created
// app/Services/PayMongoTransactionService.php
private function executeCheckout(Donation $donation): array
{
$fees = FeeCalculator::calculate('paymongo', $donation->amount, $donation->payment_method);
$session = $this->paymongo->createCheckoutSession([
'line_items' => [[
'currency' => 'PHP',
'amount' => (int) round($donation->total_amount * 100), // centavos
'name' => 'Donation — '.($campaign->title ?? 'Campaign'),
'quantity' => 1,
]],
'payment_method_types' => [$donation->payment_method],
'success_url' => config('app.url').'/api/v1/payments/paymongo/success?id='.urlencode($donation->reference_number),
'cancel_url' => config('app.url').'/api/v1/payments/paymongo/cancel?id='.urlencode($donation->reference_number),
'reference_number' => $donation->reference_number,
]);
$donation->update(['transaction_id' => $session['id']]);
return ['redirectUrl' => $session['attributes']['checkout_url']];
}
The response contains a hosted checkout URL. The backend returns it as redirectUrl.
5. User pays on PayMongo's hosted page
Supported methods: GCash, Maya wallet, card, GrabPay, QR Ph — narrowed to the single method the donor chose in step 2.
E-wallet and 3DS pages frequently block embedded WebViews. On native clients, open the redirectUrl in an in-app browser (ASWebAuthenticationSession / Chrome Custom Tabs) rather than a raw WebView.
6. PayMongo redirects back
| Outcome | PayMongo redirects to | Backend then redirects to |
|---|---|---|
| Success | GET /api/v1/payments/paymongo/success?id={reference_number} | {FRONTEND_URL}/donations/success?id={reference_number} |
| Cancel | GET /api/v1/payments/paymongo/cancel?id={reference_number} | {FRONTEND_URL}/donations/cancelled — or /donations/success?id={reference_number} when the gateway confirms payment |
The ?id= parameter is the donation's reference_number. Both handlers call verifyAndComplete() to confirm payment status with PayMongo's API before writing anything — PayMongo's post-payment screen has a back link pointing at cancel_url, so a paid checkout can arrive on the cancel redirect. The cancel handler only marks the donation cancelled if the gateway confirms no payment and the status is still pending; if the gateway confirms payment, the donation completes and the donor is redirected to the success page instead. A completed or failed donation is never cancelled via this redirect.
The frontend /donations/success page reads the ?id= query param (the reference_number UUID) to display the right donation.
7. PayMongo fires a webhook
Regardless of the redirect, PayMongo sends a server-to-server POST to /api/v1/payments/paymongo/webhook, signed with the Paymongo-Signature header. This is the authoritative signal — never trust the redirect alone.
See Webhooks for endpoint security and event handling.
8. Donation status is updated
// app/Services/PayMongoTransactionService.php
private function completeDonation(Donation $donation): void
{
$donation->update(['status' => 'completed', 'paid_at' => now()]);
$donation->campaign->increment('raised_amount', $donation->amount);
$donation->campaign->increment('available_amount', $donation->amount);
$donation->campaign->increment('supporter_count');
}
Campaign balances are incremented by the base donation amount, not the total_amount (which includes fees).
Status Transitions
| Trigger | New status |
|---|---|
checkout_session.payment.paid / payment.paid webhook | completed |
| Saved-card charge succeeds | completed |
payment.failed webhook | failed |
| User backs out of the hosted checkout and PayMongo confirms no payment | cancelled |
| Cancel redirect hit on an already-paid session (gateway confirms payment) | completed |
Abandoned checkout, confirmed unpaid by PayMongo — donations:expire scheduler (runs every 30 min) | expired |
| Abandoned checkout, but PayMongo confirms payment succeeded — scheduler rescues it instead | completed |
Abandoned Checkouts
When a user closes the browser tab or navigates away without hitting cancel, no redirect fires and the donation stays pending. The donations:expire artisan command runs every 30 minutes and sweeps pending PayMongo donations older than the cutoff, but it never expires blind:
- It first re-verifies against PayMongo directly. If PayMongo confirms the payment actually succeeded, the donation is rescued and marked
completedinstead ofexpired. - If the donor is still mid-3DS challenge (
awaiting_next_action) or PayMongo reportsprocessing, the donation is leftpendinguntouched, so a later webhook or the next scheduled run can resolve it. - Only when PayMongo confirms no payment occurred (or the checkout session itself is
expired) does the donation get markedexpired. - If PayMongo is unreachable when checked, the command skips that donation and logs a warning rather than expiring it blind.
You can also run it manually:
php artisan donations:expire # default: 30-minute window
php artisan donations:expire --minutes=60
Frontend Routes
| Route | Description |
|---|---|
/donate | Campaign browser + 5-step donation flow |
/donate/:id | Jump directly to the amount step for a specific campaign |
/donations/success | Post-checkout success page — reads ?id= reference number |
/donations/cancelled | Post-checkout cancellation page |