Donation Flow
Complete lifecycle of a one-time donation from the moment a user clicks "Donate" to the webhook confirmation.
This page walks through the flow using Maya as the worked example. PayMongo follows the same shape — pending record → hosted checkout redirect → API-verified return → authoritative webhook — but with gateway-specific endpoints and payloads. See PayMongo Checkout for those specifics.
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 posts to the API
// Frontend — Donate.tsx
const res = await api.post('/donations', {
campaign_id: 1,
amount: 1000,
payment_gateway: 'maya',
message: 'Keep up the good work!',
})
const redirectUrl: string = res.data.data.redirectUrl
// Validate destination before redirecting — prevents open redirect exploitation
const allowedHosts = ['maya.ph', 'paymaya.com']
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
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. Maya Checkout session is created
// app/Services/MayaTransactionService.php
public function executeCheckout(Donation $donation): string
{
$fees = FeeCalculator::calculate('maya', $donation->amount, 'unknown');
$checkout = $this->mayaService->createCheckout([
'totalAmount' => [
'value' => $fees['total_amount'], // checkout API uses 'value'
'currency' => 'PHP',
],
'requestReferenceNumber' => $donation->reference_number,
'redirectUrl' => [
'success' => route('maya.success', ['id' => $donation->reference_number]),
'failure' => route('maya.failure', ['id' => $donation->reference_number]),
'cancel' => route('maya.cancel', ['id' => $donation->reference_number]),
],
]);
$donation->update(['transaction_id' => $checkout['checkoutId']]);
return $checkout['redirectUrl'];
}
The response contains a hosted checkout URL. The backend returns it as redirectUrl.
5. User pays on Maya's hosted page
Supported methods: GCash, Maya wallet, credit/debit card.
Maya's 3DS and wallet 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. Maya redirects back
| Outcome | Maya redirects to | Backend then redirects to |
|---|---|---|
| Success | GET /api/v1/payments/maya/success?id={reference_number} | {FRONTEND_URL}/donations/success?id={reference_number} |
| Failure | GET /api/v1/payments/maya/failure?id={reference_number} | {FRONTEND_URL}/donations/cancelled |
| Cancel | GET /api/v1/payments/maya/cancel?id={reference_number} | {FRONTEND_URL}/donations/cancelled |
The ?id= parameter is the donation's reference_number. The success handler calls verifyAndComplete() to confirm payment status with Maya's API before marking the donation complete. The cancel handler marks the donation cancelled — but only if its status is still pending. A completed or failed donation cannot be cancelled via this redirect.
The frontend /donations/success page reads the ?id= query param (the reference_number UUID) to display the right donation.
7. Maya fires a webhook
Regardless of the redirect, Maya sends a server-to-server POST to /api/v1/payments/maya/webhook. 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/MayaTransactionService.php
private function markCompleted(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 |
|---|---|
PAYMENT_SUCCESS webhook (Maya) / checkout_session.payment.paid (PayMongo) | completed |
Saved-card charge succeeds (Maya) / payment.paid (PayMongo) | completed |
PAYMENT_FAILED webhook (Maya) / payment.failed (PayMongo) | failed |
| User clicks cancel on the hosted checkout | cancelled |
Abandoned checkout — donations:expire scheduler (runs every 30 min) | expired |
Event names differ per gateway (Maya PAYMENT_*, PayMongo checkout_session.payment.paid / payment.*), but each maps to the same donation status. The cancelled and expired transitions are gateway-agnostic.
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 bulk-updates any pending donation older than 30 minutes to expired.
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 |