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:

  1. Browse campaigns
  2. Enter amount
  3. Choose payment method
  4. Confirm details
  5. 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.


6. Magpie redirects back

OutcomeMagpie redirects toBackend then redirects to
SuccessGET /api/v1/payments/magpie/success?id={reference_number}{FRONTEND_URL}/donations/success?id={reference_number}
CancelGET /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

TriggerNew status
checkout.session.completed webhookcompleted
charge.succeeded webhook (saved card)completed
charge.failed webhookfailed
User clicks cancel on Magpie checkoutcancelled
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

RouteDescription
/donateCampaign browser + 5-step donation flow
/donate/:idJump directly to the amount step for a specific campaign
/donations/successPost-checkout success page — reads ?id= reference number
/donations/cancelledPost-checkout cancellation page

Was this page helpful?