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: '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.


6. Maya redirects back

OutcomeMaya redirects toBackend then redirects to
SuccessGET /api/v1/payments/maya/success?id={reference_number}{FRONTEND_URL}/donations/success?id={reference_number}
FailureGET /api/v1/payments/maya/failure?id={reference_number}{FRONTEND_URL}/donations/cancelled
CancelGET /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

TriggerNew 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 checkoutcancelled
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

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?