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 posts to the API
// Frontend — Donate.tsx
const res = await api.post('/donations', {
campaign_id: 1,
amount: 1000,
payment_gateway: 'magpie',
message: 'Keep up the good work!',
})
const redirectUrl: string = res.data.data.redirectUrl
// Validate destination before redirecting — prevents open redirect exploitation
const allowedHosts = ['magpie.im', 'checkout.magpie.im', 'pay.magpie.im']
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. Magpie Checkout session is created
// app/Services/MagpieTransactionService.php
public function initiateCheckoutSession(Donation $donation): string
{
$session = $this->magpieService->createCheckoutSession([
'amount' => $donation->total_amount * 100, // cents
'currency' => 'php',
'reference_number' => $donation->reference_number,
'success_url' => route('magpie.success'),
'cancel_url' => route('magpie.cancel'),
]);
return $session['url'];
}
The response contains a hosted checkout URL. The backend returns it as redirectUrl.
5. User pays on Magpie's hosted page
Supported methods: GCash, Maya, credit/debit card.
In Magpie test mode, GCash and Maya sources do not return an action.url. You cannot fully test the wallet redirect flow without live keys.
6. Magpie redirects back
| Outcome | Magpie redirects to | Backend then redirects to |
|---|---|---|
| Success | GET /api/v1/payments/magpie/success?id={reference_number} | {FRONTEND_URL}/donations/success?id={reference_number} |
| Cancel | GET /api/v1/payments/magpie/cancel?id={reference_number} | {FRONTEND_URL}/donations/cancelled |
The ?id= parameter on the cancel URL is the donation's reference_number. The backend uses it to look up and mark 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. Magpie fires a webhook
Regardless of the redirect, Magpie sends a server-to-server POST to /api/v1/payments/magpie/webhook. This is the authoritative signal — never trust the redirect alone.
See Webhooks for signature verification and event handling.
8. Donation status is updated
// app/Services/MagpieTransactionService.php
public function updateStatus(array $event): void
{
match ($event['type']) {
'checkout.session.completed' => $this->markCompleted($event['data']),
'checkout.session.expired' => $this->markExpired($event['data']),
};
}
private function markCompleted(array $data): void
{
$donation = Donation::where('reference_number', $data['metadata']['reference_number'])->firstOrFail();
$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.completed webhook | completed |
charge.succeeded webhook (saved card) | completed |
charge.failed webhook | failed |
| User clicks cancel on Magpie checkout | cancelled |
Abandoned checkout — donations:expire scheduler (runs every 30 min) | expired |
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 |