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

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.


6. PayMongo redirects back

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

TriggerNew status
checkout_session.payment.paid / payment.paid webhookcompleted
Saved-card charge succeedscompleted
payment.failed webhookfailed
User backs out of the hosted checkout and PayMongo confirms no paymentcancelled
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 insteadcompleted

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:

  1. It first re-verifies against PayMongo directly. If PayMongo confirms the payment actually succeeded, the donation is rescued and marked completed instead of expired.
  2. If the donor is still mid-3DS challenge (awaiting_next_action) or PayMongo reports processing, the donation is left pending untouched, so a later webhook or the next scheduled run can resolve it.
  3. Only when PayMongo confirms no payment occurred (or the checkout session itself is expired) does the donation get marked expired.
  4. 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

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?