Donation Flow — Payment Intents + Stripe Elements POC
Proposal for discussion with tech lead. Documents a new donation payment flow using Stripe Payment Intents + Stripe Elements, replacing the Stripe-hosted Checkout Session page with a custom payment UI on our frontend.
# Donation Flow — Payment Intents + Stripe Elements POC
Purpose: Proposal for discussion with tech lead. Documents a new donation
payment flow using Stripe Payment Intents + Stripe Elements, replacing the
Stripe-hosted Checkout Session page with a custom payment UI on our frontend.
>
Status: POC — not yet implemented.
Date: July 2026
Table of Contents
1. [Executive Summary](#1-executive-summary)
2. [Current Flow (As-Is)](#2-current-flow-as-is)
3. [Proposed Flow (To-Be) — End-to-End](#3-proposed-flow-to-be--end-to-end)
4. [API Endpoints](#4-api-endpoints)
5. [Payment Methods](#5-payment-methods)
6. [Single Endpoint Routing Logic](#6-single-endpoint-routing-logic)
7. [Guest Checkout Flow](#7-guest-checkout-flow)
8. [Webhook Fulfillment](#8-webhook-fulfillment)
9. [Security Considerations](#9-security-considerations)
10. [Frontend Integration (High-Level)](#10-frontend-integration-high-level)
11. [What Stays Unchanged](#11-what-stays-unchanged)
12. [Database Changes](#12-database-changes)
13. [Architecture Diagram](#13-architecture-diagram)
14. [Risks & Mitigations](#14-risks--mitigations)
15. [Decision Points for Lead](#15-decision-points-for-lead)
1. Executive Summary
What we're proposing
Shift the donation payment experience from Stripe Checkout Sessions (a
Stripe-hosted payment page that the donor redirects to) to **Stripe Payment
Intents + Stripe Elements** (a custom payment form embedded in our own donation
page).
| Aspect | Current (Checkout Sessions) | Proposed (Payment Intents + Elements) |
|---|---|---|
| Payment UI | Stripe-hosted page (redirect) | Custom UI on our donation page (embedded) |
| Payment method selection | On Stripe's page | On our page (custom selector + Payment Element) |
| Guest checkout | Not supported (JWT required) | Supported (email + name, no account needed) |
| Return URL handling | success_url / cancel_url redirects | Frontend handles confirmation result inline |
| 3DS / SCA | Stripe handles on hosted page | Stripe.js handles client-side (auto-redirect) |
| Card data | Never touches our server (Stripe-hosted) | Never touches our server (Elements iframe) |
| Response from backend | payment_url (redirect URL) | client_secret (for Stripe.js) |
Why
1. Payment method selection on our page — the donation page needs to show
available payment methods (card, PayNow, GrabPay, etc.) as part of the UI,
not on a separate Stripe-hosted page. This gives us control over the layout,
branding, and user experience.
2. Guest checkout — donors who don't have a 3Degrees account should be able
to donate without signing up. Currently all donation endpoints require JWT
auth.
3. Better UX — no redirect to Stripe's domain. The entire donation flow
stays on our page. The donor enters card details in an embedded Stripe
Elements iframe (PCI-compliant) and sees success/failure inline.
Scope
- Regions: SG and AU (Stripe) only. Indonesia stays on Midtrans Snap — no
changes to the Midtrans flow.
- Coexistence: new endpoints are added alongside the existing ones. The
current POST /donate and POST /donate-direct (Checkout Session flow)
remain untouched and functional. This is zero-risk to the existing flow.
- Webhook: no changes needed. The existing
payment_intent.succeeded
webhook handler already fulfills donations by looking up the PI ID.
Single endpoint
Instead of 4 separate endpoints (auth/guest × campaign/direct), we propose
one endpoint (POST /donor/donate-payment-intent) that handles all cases
internally. This reduces maintenance burden and keeps the API surface clean.
2. Current Flow (As-Is)
Full detail: see docs/donation-flow.md.
Donor (authenticated)
│
│ POST /donor/donate
│ { campaign_uid, donation_amount, chariot_tip, platform_charge, ... }
│
▼
donor-api: DonateUseCase
├── Validate amounts, resolve campaign + NGO
├── Check tax deductibility
├── Insert donation row (status: pending)
├── Create Stripe Checkout Session (mode: 'payment')
│ → Stripe returns a hosted checkout URL
├── Update donation row with session + PI ID
└── Return { payment_url: "https://checkout.stripe.com/...", donation_uid }
│
▼
Frontend redirects donor to payment_url (Stripe-hosted page)
│
│ Donor selects payment method + enters card on Stripe's page
│ Stripe processes payment
│
▼
Stripe fires webhook → webhook app fulfills donation
├── Update donation to 'payment_intent.succeeded'
├── Send email, Pusher events, notifications
└── Invalidate cache
Limitations:
- Donor must be authenticated (JWT) — no guest checkout.
- Payment method selection happens on Stripe's page, not ours.
- Full-page redirect to
checkout.stripe.com— breaks the in-app experience. - No control over the payment form layout or branding.
3. Proposed Flow (To-Be) — End-to-End
This is the core of the new flow. The entire donor journey from opening the donation page to donation completion.
3.1 The Donor Journey
GET /donor/payment-methods-elements?country_code=SG. Backend returns a curated list of Stripe payment methods for the region: Card, PayNow, GrabPay, etc. The frontend renders a custom payment method selector UI.POST /donor/donate-payment-intent. Backend resolves the campaign/NGO, validates amounts, checks tax deductibility, resolves (or creates) the Stripe Customer, inserts a pending donation row, creates a PaymentIntent with Stripe, and returns the client_secret.{ client_secret, donation_uid, payment_intent_id }
client_secret, mounts a Payment Element into the page. Stripe renders a secure iframe with payment method input fields (card number, expiry, CVC). Card data goes directly to Stripe — never touches our server.stripe.confirmPayment() with redirect: 'if_required'. Stripe processes the payment client-side. If 3D Secure is required, Stripe.js handles it automatically (modal or redirect to bank). The frontend receives a success/error result.payment_intent.succeeded to our webhook app. The existing handler finds the donation by dud_stripe_payment_intent_id, updates the status, sends the receipt email (Mailjet), triggers Pusher realtime events, and notifies the NGO + followers.GET /donor/donation?donation_uid=... to confirm status
3.2 Backend Perspective — Step-by-Step
When POST /donor/donate-payment-intent arrives, the backend processes these 9 steps before returning a response:
req.user from OptionalAuth. JWT present → authenticated. No JWT → guest (require email+name).campaign_uid → resolve campaign → NGO. ngo_uid → resolve NGO directly.ngo_donation_counter for the NGO. Stored as sequence number.donor_user_donates with status pending. Guests: dud_u_id = null.Resolve/Create Customer
Authenticated: resolve dual-region customer ID from users.u_stripe_customer_id. Guest: create a single-region Stripe Customer with email + name.
Create PaymentIntent
Call stripe.paymentIntents.create() with amount, currency, customer, metadata (donation_uid, donation_id, ngo_id), automatic_payment_methods: { enabled: true }, and setup_future_usage: 'off_session'.
Update Row
Store dud_stripe_approval_object (PI JSON), dud_stripe_payment_intent_id (pi_***), and dud_stripe_status = 'pending'.
Return: { client_secret, donation_uid, payment_intent_id }
Create Snap Transaction
Call midtransService.createSnapTransaction() with order ID (= donation UID), gross amount, item details (split: donation + fee + tip), customer details, callbacks, and enabled payments.
Insert Row
Insert donation with dud_midtrans_payment_reference = Snap response JSON.
Return: { payment_url, donation_uid }
For Midtrans, the donor is redirected to the Snap URL (same as today’s flow). No Payment Element is used.
3.3 After the Backend Responds — Swimlane View
{ client_secret, donation_uid, pi_id }
loadStripe(pubKey) → elements({ clientSecret })
#payment-element
stripe.confirmPayment({ elements, redirect: 'if_required' })
requires_payment_method
processing
succeeded
payment_intent.succeeded
pending (step 8)
POST /stripe-webhook/donate-payment-webhook
repo.getDonationByPaymentIntent(pi.id)
succeeded
Expand
charges.data.balance_transaction
Card brand, last4, fees, net, billing address
Status →
payment_intent.succeeded
Mailjet email (region-specific template)
Donation, leaderboard, sum-of-donations
In-app + push notification (if not anonymous)
In-app + push (if not anonymous)
Scoped Redis patterns
Note on Midtrans (Indonesia): When the NGO is in Indonesia, the flow differs at step 9 — the backend creates a Midtrans Snap transaction and returns a payment_url instead of client_secret. The donor is redirected to the Midtrans-hosted Snap page (same as today’s flow). Midtrans sends an async notification to POST /midtrans/notification which the existing webhook handler processes.
4. API Endpoints
4.1 GET /donor/payment-methods-elements
Returns the list of available Stripe payment methods for a given region/currency.
The frontend uses this to render a custom payment method selector before
mounting the Payment Element.
Auth: @Public() (no JWT required — needed before guest donors donate)
Query params:
| Param | Type | Required | Description |
|---|---|---|---|
country_code | string | Yes | SG or AU — determines which payment methods are available |
Request example:
GET /donor/payment-methods-elements?country_code=SG
Response (200):
{
"data": [
{
"type": "card",
"label": "Credit / Debit Card",
"icon": "card",
"description": "Visa, Mastercard, Amex"
},
{
"type": "paynow",
"label": "PayNow",
"icon": "paynow",
"description": "Scan QR code to pay"
},
{
"type": "grabpay",
"label": "GrabPay",
"icon": "grabpay",
"description": "Pay with GrabPay wallet"
},
{
"type": "alipay",
"label": "Alipay",
"icon": "alipay",
"description": "Pay with Alipay"
},
{
"type": "wechat_pay",
"label": "WeChat Pay",
"icon": "wechat_pay",
"description": "Pay with WeChat Pay"
}
],
"message": "Payment methods retrieved successfully"
}
AU response:
{
"data": [
{
"type": "card",
"label": "Credit / Debit Card",
"icon": "card",
"description": "Visa, Mastercard, Amex"
},
{
"type": "au_becs_debit",
"label": "BECS Direct Debit",
"icon": "au_becs_debit",
"description": "Australian bank account"
},
{
"type": "afterpay_clearpay",
"label": "Afterpay / Clearpay",
"icon": "afterpay_clearpay",
"description": "Buy now, pay later (4 installments)"
},
{
"type": "zip",
"label": "Zip",
"icon": "zip",
"description": "Buy now, pay later"
},
{
"type": "paypal",
"label": "PayPal",
"icon": "paypal",
"description": "Pay with PayPal account"
}
],
"message": "Payment methods retrieved successfully"
}
Note: The Payment Element will still only display methods that are
actually enabled in the Stripe Dashboard. This endpoint returns our curated
list — the frontend uses it for the selector UI, but the Payment Element is
the source of truth for what's actually rendered.
4.2 POST /donor/donate-payment-intent
Creates a donation and returns a PaymentIntent client_secret (for Stripe
regions) or a payment_url (for Midtrans/Indonesia).
Auth: OptionalAuth — accepts JWT if present, works without it for guests.
Request body:
campaign_idnumber— *(one of campaign/ngo)* Campaign ID to donate tocampaign_uidstring— *(one of campaign/ngo)* Campaign UID to donate tongo_idnumber— *(one of campaign/ngo)* NGO ID for direct donationngo_uidstring— *(one of campaign/ngo)* NGO UID for direct donationdonation_amountnumber— *(required)* Amount in cents (e.g. 50000 = S$500)chariot_tipnumber— *(optional, default 0)* Tip in centsplatform_chargenumber— *(required)* Platform fee in centscountry_codestring— *(required)*SG\|AU\|ID— donor's countryis_anonymousboolean— *(required)* Whether donation is anonymoustax_deductible_idstring— *(optional)* Donor's government tax IDmessage_from_donorstring— *(optional)* Message to NGOplatform_sourcestring— *(optional, default 'web')*web\|ios\|androidemailstring— *(optional, guests)* Guest donor's email. If not provided,
email is extracted from Stripe's webhook payload (billing_details.email) at
fulfillment time. Non-card methods (PayNow, GrabPay) may not provide an email —
in that case dud_guest_email stays null and no receipt email is sent.
namestring— *(optional, guests)* Guest donor's name. Used for display
purposes only if provided.
payment_method_typestring— *(optional)* Restrict the PaymentIntent to
a specific method (e.g. grabpay, card, paynow). See
[4.3 Payment Method Restriction](#43-payment-method-restriction).
Routing rules:
- If
campaign_idorcampaign_uidis provided → campaign donation. - If only
ngo_idorngo_uidis provided → direct NGO donation. - If no JWT → guest flow (email/name are optional, not required).
- If
payment_method_typeprovided → restrict PI to that method only. - If
payment_method_typeomitted → useautomatic_payment_methods(show all).
Request example — authenticated, campaign donation:
{
"campaign_uid": "abc123",
"donation_amount": 50000,
"chariot_tip": 5000,
"platform_charge": 2500,
"country_code": "SG",
"is_anonymous": false,
"tax_deductible_id": "S1234567A",
"message_from_donor": "Keep up the great work!",
"platform_source": "web"
}
Request example — guest, direct NGO donation:
{
"ngo_uid": "xyz789",
"donation_amount": 25000,
"chariot_tip": 0,
"platform_charge": 1250,
"country_code": "AU",
"is_anonymous": true,
"email": "guest@example.com",
"name": "John Doe",
"payment_method_type": "grabpay",
"platform_source": "web"
}
Response (200) — Stripe (SG/AU):
{
"data": {
"client_secret": "pi_3PqA..._secret_XyZ456...",
"donation_uid": "x7k9m2",
"payment_intent_id": "pi_3PqA..."
},
"message": "Payment intent created successfully"
}
Response (200) — Midtrans (ID):
{
"data": {
"payment_url": "https://app.sandbox.midtrans.com/snap/v3/redirection/abc123",
"donation_uid": "x7k9m2"
},
"message": "Payment intent created successfully"
}
Response (400) — invalid payment method type:
{
"statusCode": 400,
"message": "Invalid payment method type for this currency",
"error": "Bad Request"
}
4.3 Payment Method Restriction
When the donor selects a specific payment method on the donation page (e.g.
"GrabPay"), the frontend sends payment_method_type in the request body. The
backend restricts the PaymentIntent to that method only — the Payment Element
will render only the selected method, not all Dashboard-enabled methods.
How it works:
payment_method_type in request | Backend creates PI with | Payment Element shows |
|---|---|---|
'grabpay' | payment_method_types: ['grabpay'] | Only GrabPay |
'card' | payment_method_types: ['card'] | Only card |
'paynow' | payment_method_types: ['paynow'] | Only PayNow |
| *(not provided)* | automatic_payment_methods: { enabled: true } | All Dashboard-enabled methods |
Backend logic:
if (paymentMethodType) {
// Restrict to the selected method
paymentIntent = await stripe.paymentIntents.create({
amount,
currency,
payment_method_types: [paymentMethodType],
metadata: { donation_uid, donation_id, ngo_id },
...(customerId ? { customer: customerId } : {}),
...(email ? { receipt_email: email } : {}),
});
} else {
// Show all methods (default)
paymentIntent = await stripe.paymentIntents.create({
amount,
currency,
automatic_payment_methods: { enabled: true },
metadata: { donation_uid, donation_id, ngo_id },
...(customerId ? { customer: customerId } : {}),
...(email ? { receipt_email: email } : {}),
});
}
Trade-offs:
| Aspect | Restrict to selected method | Show all methods |
|---|---|---|
| UX | Clean — only what donor selected | Donor might see too many options |
| Fallback | If selected method is down, donor is stuck | Donor can switch to another method |
| Complexity | Backend branches between two PI creation modes | One code path |
| Payment Element | Shows only the selected method | Shows all Dashboard-enabled methods |
Recommendation: Use the hybrid approach — payment_method_type is optional.
If the frontend sends it → restrict. If not → show all. This lets the frontend
decide per page whether to restrict or show all methods.
5. Payment Methods
Curated lists per region
The GET /donor/payment-methods-elements endpoint returns a curated list based
on the donor's region. These are the payment methods we want to surface in our
custom selector UI.
Singapore (SG):
| Type | Label | Notes |
|---|---|---|
card | Credit / Debit Card | Visa, Mastercard, Amex |
paynow | PayNow | QR code — Singapore's national payment method |
grabpay | GrabPay | Popular e-wallet in SEA |
alipay | Alipay | Chinese e-wallet |
wechat_pay | WeChat Pay | Chinese e-wallet |
Australia (AU):
| Type | Label | Notes |
|---|---|---|
card | Credit / Debit Card | Visa, Mastercard, Amex |
au_becs_debit | BECS Direct Debit | Australian bank account debit |
afterpay_clearpay | Afterpay / Clearpay | BNPL — 4 interest-free installments |
zip | Zip | BNPL |
paypal | PayPal | E-wallet |
How it works with the Payment Element
1. Frontend fetches the curated list → renders custom selector buttons.
2. Donor selects a method (e.g. "PayNow").
3. Frontend calls POST /donate-payment-intent → gets client_secret.
4. Frontend mounts the Stripe Payment Element with client_secret.
5. The Payment Element renders the appropriate input fields for the selected
method (or shows the method selection itself if we don't pre-select).
6. The Payment Element only shows methods enabled in the Stripe Dashboard
for the given currency — our curated list is a UI hint, not a guarantee.
Important: To use these methods, they must also be enabled in the Stripe
Dashboard under Settings → Payment methods. If a method is in our curated
list but not enabled in the Dashboard, the Payment Element won't render it.
The curated list and Dashboard settings must be kept in sync.
6. Single Endpoint Routing Logic
One endpoint, four cases. The use case branches internally based on what's provided in the request and whether a JWT is present.
Decision Matrix
The single endpoint handles all four combinations — campaign/direct × authenticated/guest — by branching on two axes:
(JWT present)
(No JWT)
(campaign_uid)
(ngo_uid)
Resolve existing Stripe Customer.
dud_u_id = userId
Create Stripe Customer (single region).
dud_u_id = null
Resolve existing Stripe Customer.
dud_u_id = userId
Create Stripe Customer (single region).
dud_u_id = null
Provider Routing
The NGO's base country determines which payment provider handles the transaction:
payment_urlclient_secretclient_secretInternal Routing Logic
The use case's execute() method processes these 11 steps:
IF payment_method_type → restrict
ELSE → automatic_payment_methods
no customer for guests
return { client_secret, ... }
return { payment_url, ... }
Why a Single Endpoint
| Single Endpoint | Four Endpoints |
|---|---|
| One use case to maintain | Four use cases with duplicated logic |
| One DTO (with optional fields) | Four DTOs with overlapping fields |
| One controller method | Four controller methods |
| Frontend always calls the same URL | Frontend must choose the right endpoint |
| Routing logic is centralized | Routing logic is split across endpoints |
| Easier to test (one test suite) | Four test suites with similar tests |
7. Guest Checkout Flow
What a guest is
A guest is a donor who does not have a 3Degrees account and is not logged in.
They arrive at a donation page (e.g. via a shared campaign link) and want to
donate without signing up.
What the guest provides
| Field | Required | Purpose |
|---|---|---|
email | Optional | If provided, used for receipt email. If not, extracted from webhook billing_details.email. Non-card methods may not provide email. |
name | Optional | Display purposes only. |
donation_amount | Yes | The donation amount |
chariot_tip | No | Optional tip |
platform_charge | Yes | Platform fee (calculated by frontend) |
country_code | Yes | SG / AU / ID |
is_anonymous | Yes | Whether to hide donor identity |
message_from_donor | No | Optional message to NGO |
payment_method_type | Optional | Restrict payment to a specific method (e.g. grabpay). |
No tax_deductible_id for guests — tax deduction requires a user account with a
verified government ID. Guest donations are always isValidForTaxDeduction: false.
No Stripe Customer is created for guests. The PaymentIntent is created without a
customer field. The email (if provided) is set as receipt_email on the PI.
Backend Handling of a Guest Donation
req.user is undefined (no JWT). Set donorType = 'guest' and donorUserId = null. Email and name are optional — no validation required.false — guests have no user account with a verified government ID.DonationData object with userId = null. Generate 6-char UID, compute referral fee, payout amount, and program impact statement.donor_user_donates with dud_u_id = null and dud_guest_email = email || null new column. If email not provided, dud_guest_email is null at insert time.dud_guest_email from billing_details.email when available.payment_method_type provided → payment_method_types: [type]. If not → automatic_payment_methods: { enabled: true }. Set receipt_email: email || undefined.{ client_secret: paymentIntent.client_secret, donation_uid: donationData.uid, payment_intent_id: paymentIntent.id } to the frontend.Guest Flow End-to-End
Opens donation page (no account, no JWT)
Fills in: amount, payment method
Email / name optional — only if donor provides them
Clicks "Donate"
POST /donor/donate-payment-intent
No Authorization header
{ campaign_uid, donation_amount,
country_code, ...
payment_method_type: 'grabpay',
email: 'guest@...' (optional) }
- No JWT →
donorType = 'guest' - Resolve campaign → NGO
- Validate amounts
- Insert donation:
dud_u_id = null,dud_guest_email = email || null - Create PaymentIntent (no Customer)
- If
payment_method_type→ restrict to that method
{ client_secret, donation_uid, payment_intent_id }
- Mount Payment Element with
client_secret - If restricted: only selected method shows
- Guest enters card details (iframe → Stripe)
stripe.confirmPayment()- Show success / failure
Processes payment
Handles 3DS if needed
Captures billing_details.email (card only)
Fires payment_intent.succeeded
- Find donation by PI ID
- Update to
'succeeded' - If
dud_guest_emailis null:
extractbilling_details.email→ update row - Send receipt email (if email present)
- If not anonymous: notify NGO + followers
- If anonymous: skip notifications
dud_guest_email stays null, no receipt sent
Anonymous guest donations
If a guest sets is_anonymous: true:
- The donation row has
dud_is_anonymous = true. - The webhook handler checks
!donation.donation.isAnonymousbefore notifying
the NGO and followers — so anonymous guests don't trigger notifications.
- The receipt email is sent to
dud_guest_emailif present. If the guest
didn't provide an email and the payment method didn't capture one (e.g.
PayNow, GrabPay), no receipt email is sent.
- The NGO sees the donation in their feed but the donor shows as "Anonymous".
8. Webhook Fulfillment
Why no webhook changes are needed
The existing webhook handler at
apps/webhook/src/modules/payment/application/usecases/handle-stripe-donation-webhook.usecase.ts
already handles payment_intent.succeeded by:
1. Finding the donation via repo.getDonationByPaymentIntent(pi.id).
2. Looking up dud_stripe_payment_intent_id (which we set at PI creation time).
3. Fulfilling: update status, send email, trigger Pusher, notify NGO.
In the current Checkout Session flow, the PI ID is set in two phases:
routeStripe()sets it fromsession.payment_intentat creation time.- The
checkout.session.completedwebhook event links it as a fallback.
In the new Payment Intent flow, the PI ID is set **directly at creation
time** — we create the PI ourselves and store its ID immediately. This means:
- The webhook handler finds the donation on the first
payment_intent.succeeded
event without any linking step.
- The
checkout.session.completedevent is irrelevant (we don't use Checkout
Sessions in the new flow).
- No new webhook handler is needed.
Guest email extraction (new — added to existing handler)
For guest donations where dud_guest_email is null at insertion time (donor
didn't provide an email in the request), the webhook handler needs a small
addition to extract the email from Stripe's charge data:
On payment_intent.succeeded:
...existing steps (find donation, retrieve expanded PI)...
IF donation.dud_u_id is null (guest) AND donation.dud_guest_email is null:
charge = expandedPi.charges.data[0]
IF charge.billing_details.email:
repo.updateDonationGuestEmail(donation.dudId, charge.billing_details.email)
→ dud_guest_email is now populated
Send receipt email:
IF donation.dud_guest_email is present:
sendPostDonationEmail(donation, email = dud_guest_email)
ELSE:
skip receipt email (non-card methods like PayNow/GrabPay may not have email)
This is the only new logic added to the webhook handler. The rest of the
fulfillment (status update, Pusher, NGO notification, cache invalidation) is
unchanged.
Webhook events that matter
| Event | Current flow | New flow | Action |
|---|---|---|---|
payment_intent.succeeded | Handled | Handled (same code) | Fulfill donation |
payment_intent.payment_failed | Handled | Handled (same code) | Mark as failed |
payment_intent.canceled | Handled | Handled (same code) | Mark as canceled |
payment_intent.processing | Handled | Handled (same code) | Update status |
payment_intent.requires_action | Handled | Handled (same code) | Update status |
checkout.session.completed | Handled (links PI) | Not fired (no Checkout Session) | N/A |
What about the donation_reference_id metadata fallback?
The existing handler has a fallback path: if the donation isn't found by PI ID,
it looks up paymentIntent.metadata.donation_reference_id and links the PI ID
to the donation by UID. In the new flow, this fallback is still useful as a
safety net — we include donation_reference_id in the PI metadata, so if
anything goes wrong with the initial PI ID storage, the webhook can still
repair the link.
Existing delByPattern('*') issue
The current webhook handler calls redisService.delByPattern('*') which wipes
the entire Redis DB. This affects both the old and new flow. If we're touching
the donation flow, we should fix this as part of the work — replace with scoped
patterns:
// Instead of:
await redisService.delByPattern('*');
// Use:
await this.redisService.delByPattern('donation:*');
await this.redisService.delByPattern('leaderboard:*');
await this.redisService.delByPattern('ngo:donations:*');
await this.redisService.delByPattern('campaign:donations:*');
This is a known issue (documented in docs/donation-flow.md section 15.6) and
is called out in AGENTS.md. The new flow inherits this issue since it uses the
same webhook handler.
9. Security Considerations
Is client_secret safe to send to the frontend?
Yes. Stripe explicitly designs the client_secret to be client-facing. It
is not a server-side secret like the API key (sk_***).
| Property | client_secret | sk_*** (API key) |
|---|---|---|
| Who sees it | Frontend + backend | Backend only |
| What it can do | Confirm one specific PaymentIntent | Everything on the Stripe account |
| Scope | Bound to one PI's amount + currency | All operations |
| Lifetime | Expires when PI succeeds/cancels (24h max) | Permanent (until rotated) |
| Can it create charges? | No — it can only confirm the PI it belongs to | Yes |
| Can it access other PIs? | No | Yes |
Key point: Even if an attacker intercepts the client_secret, they cannot:
- Change the amount (set server-side at PI creation).
- Create new PaymentIntents.
- Access other donations.
- Refund charges.
- Access the Stripe account.
The client_secret can only confirm that one specific payment for **that
exact amount** — which is what the donor wants to do anyway.
Real protections we need
| Protection | Why | How |
|---|---|---|
| HTTPS | Transport-layer encryption. The client_secret is only safe over HTTPS. | Production already uses Apache + TLS. |
Rate limiting on POST /donate-payment-intent | Prevents abuse — creating thousands of PIs or Snap transactions. Especially important for guest donations (no JWT). | Use @nestjs/throttler or a Redis-based rate limiter. Suggest: 10 requests/minute per IP for guests, 30/minute for authenticated. |
| Server-side amount validation | The frontend can't manipulate the charge amount — the backend sets it on the PI. | Already done in the use case (max amount checks, truncation). |
| Idempotency key on PI creation | Prevents duplicate PaymentIntents if the donor double-clicks "Donate" or the request retries. | Pass Idempotency-Key: {donation_uid} header to stripe.paymentIntents.create(). |
Don't log client_secret | It's a credential, even if limited. Avoid leaking it in logs. | The new use case should log the PI ID + donation UID but NOT the full PI object (which contains client_secret). |
| Validate NGO/campaign exists + is active | Prevents donations to deleted/inactive NGOs. | Already done in the resolve step. |
PCI compliance
Stripe Elements renders card inputs in an iframe hosted on Stripe's domain.
Card data (PAN, expiry, CVC) goes directly from the donor's browser to Stripe's
servers — it never touches our backend, our frontend JavaScript, or our logs.
This means our PCI scope is SAQ A (the lowest burden) — same as the current
Checkout Session flow. Switching to Elements does not increase PCI scope.
Guest endpoint abuse
The guest endpoint is @Public() — no JWT required. Risks:
| Risk | Mitigation |
|---|---|
| Spam PI creation (DoS / cost) | Rate limiting per IP (e.g. 10/min) |
| Fraudulent donations (testing stolen cards) | Stripe Radar (built-in fraud detection) + monitor payment_intent.payment_failed events |
| Bot abuse | Consider Cloudflare Turnstile or reCAPTCHA on the frontend donation form |
10. Frontend Integration (High-Level)
Dependencies
# React
npm install @stripe/react-stripe-js @stripe/stripe-js
# Or vanilla JS
npm install @stripe/stripe-js
Flow (React example)
import { loadStripe } from '@stripe/stripe-js';
import { Elements, PaymentElement } from '@stripe/react-stripe-js';
const stripePromise = loadStripe(STRIPE_PUBLISHABLE_KEY);
function DonationPage() {
const [clientSecret, setClientSecret] = useState(null);
const [donationUid, setDonationUid] = useState(null);
// 1. Donor fills in amount, clicks "Donate"
async function handleDonate() {
const res = await fetch('/donor/donate-payment-intent', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(jwt ? { Authorization: `Bearer ${jwt}` } : {}),
},
body: JSON.stringify({
campaign_uid: 'abc123',
donation_amount: 50000,
chariot_tip: 5000,
platform_charge: 2500,
country_code: 'SG',
is_anonymous: false,
// Guests also send: email, name
}),
});
const { data } = await res.json();
setClientSecret(data.client_secret);
setDonationUid(data.donation_uid);
}
// 2. Once we have client_secret, render the Payment Element
if (clientSecret) {
const options = {
clientSecret,
appearance: { theme: 'stripe' },
};
return (
<Elements stripe={stripePromise} options={options}>
<CheckoutForm donationUid={donationUid} />
</Elements>
);
}
return <DonationForm onSubmit={handleDonate} />;
}
function CheckoutForm({ donationUid }) {
const stripe = useStripe();
const elements = useElements();
async function handleSubmit(e) {
e.preventDefault();
// 3. Confirm the payment
const { error } = await stripe.confirmPayment({
elements,
redirect: 'if_required',
});
if (error) {
// Show error (card declined, validation error, etc.)
console.error(error.message);
} else {
// Payment succeeded — webhook will fulfill async
// Poll GET /donor/donation?donation_uid=... to confirm
window.location.href = `/donation/success?uid=${donationUid}`;
}
}
return (
<form onSubmit={handleSubmit}>
<PaymentElement />
<button type="submit" disabled={!stripe}>Pay</button>
</form>
);
}
3DS / SCA handling
When stripe.confirmPayment() is called:
- If the card requires 3D Secure, Stripe.js automatically opens a 3DS modal
(or redirects to the bank's authentication page).
- With
redirect: 'if_required', Stripe only redirects if the payment method
requires a full redirect (e.g. some bank redirects). Card 3DS is handled
inline.
- After 3DS completes, the promise resolves with success or error.
- No backend involvement is needed for 3DS — it's entirely client-side.
Payment method pre-selection
If the frontend has a custom payment method selector (from
GET /payment-methods-elements), it can pass the selected method to the
Payment Element:
const paymentElement = elements.create('payment', {
defaultValues: {
// Pre-select the donor's chosen method
},
});
Or use the Payment Element's built-in method selector (simpler, less custom UI).
11. What Stays Unchanged
| Component | Status | Notes |
|---|---|---|
POST /donor/donate | Unchanged | Old Checkout Session flow — still works |
POST /donor/donate-direct | Unchanged | Old Checkout Session flow — still works |
POST /donor/donate-ios / -android | Unchanged | Platform variants — still work |
| Midtrans flow (ID) | Unchanged | Indonesia continues using Snap redirect |
| Webhook app | Unchanged | Existing payment_intent.succeeded handler works |
StripeService.createCheckoutSession() | Unchanged | Still used by old endpoints |
buildStripeDonationRedirectUrls() | Unchanged | Still used by old endpoints |
DonationTaxService | Unchanged | Same tax check logic |
StripeCustomerResolverService | Unchanged | Used for authenticated donors in new flow |
DonationHydratorService | Unchanged | Read endpoints unaffected |
| Donation read endpoints | Unchanged | GET /donation, GET /donations, etc. |
donor_user_donates schema | One new column | dud_guest_email (nullable) |
Migration path (if we later want to deprecate the old flow)
1. Ship the new endpoint alongside the old ones.
2. Frontend migrates to the new endpoint (per page or feature flag).
3. Monitor for issues — both flows share the same webhook, so fulfillment is
identical.
4. Once the frontend fully migrates, deprecate POST /donate and
POST /donate-direct (return 410 Gone or redirect).
5. Remove old endpoint code + createCheckoutSession if no longer used.
No rush — the old flow can coexist indefinitely.
12. Database Changes
New column
ALTER TABLE donor_user_donates
ADD COLUMN dud_guest_email VARCHAR(255) NULL AFTER dud_u_id;
| Column | Type | Nullable | Purpose |
|---|---|---|---|
dud_guest_email | VARCHAR(255) | Yes | Guest donor's email. NULL for authenticated donations. May also be NULL at insertion time for guests who don't provide an email — populated by webhook handler from billing_details.email when available. |
Why only one column?
dud_u_idis already nullable (int('dud_u_id')with no.notNull()) —
guests set it to NULL.
dud_is_anonymousalready exists — guests can be anonymous or not.dud_country_codealready exists — guest's country is stored.- Guest's name is not stored in our DB — it's only used for display if provided
in the request. The email is sufficient for receipts.
dud_stripe_payment_intent_idalready exists — stores the PI ID.dud_stripe_statusalready exists — tracks payment status.
Note:dud_guest_emailmay be null at insertion time for guests who don't
provide an email in the request. The webhook handler populates it fromcharges.data[0].billing_details.emailwhen the payment method captures email
(credit cards do; PayNow/GrabPay typically don't). If the payment method
doesn't provide an email, the column stays null and no receipt email is sent.
Migration
- Drizzle migration (forward-only, per project conventions).
- Generated via
pnpm db:generate. - Review the SQL before applying.
- No data backfill needed — the column is nullable, existing rows get
NULL.
13. Architecture Diagram
(Checkout Session)
(PaymentIntent)
routeMidtrans() → Snap URL
→ returns
payment_url→ returns
client_secretHandles: campaign/direct, auth/guest, Stripe/Midtrans
dud_stripe_payment_intent_id(SG)
(AU)
(ID)
- Find donation by PI ID
- Update status → 'succeeded'
- Send email (Mailjet)
- Pusher events
- Notify NGO + followers
- Invalidate cache
dud_id PKdud_uid 6-chardud_u_id FK → users, NULL for guestsdud_guest_email NEW — nullabledud_stripe_payment_intent_id pi_***dud_stripe_status 'pending' → 'succeeded'dud_midtrans_payment_reference...14. Risks & Mitigations
| # | Risk | Severity | Mitigation |
|---|---|---|---|
| 1 | Breaking existing donation flow | Low | New endpoint coexists with old ones. Old endpoints are untouched. Both share the same webhook handler. |
| 2 | Guest endpoint abuse (spam PI creation) | Medium | Rate limiting per IP (e.g. 10/min for guests). Consider Cloudflare Turnstile for bot protection. Stripe Radar catches fraud. |
| 3 | 3DS handling complexity | Low | Stripe.js handles 3DS automatically client-side. No backend involvement needed. redirect: 'if_required' keeps donor on-page for card 3DS. |
| 4 | Payment method mismatch (curated list vs Dashboard) | Low | Curated list is a UI hint. Payment Element only renders Dashboard-enabled methods. Keep both in sync. Document the dependency. |
| 5 | Guest Stripe Customer accumulation | Low | Guest customers are created on Stripe but not stored in users.u_stripe_customer_id. They accumulate on the Stripe account. Consider periodic cleanup or linking to user accounts if guests later sign up. |
| 6 | delByPattern('*') wipes Redis | High | Existing issue, inherited by new flow. Fix as part of this work: replace with scoped patterns. See docs/donation-flow.md section 15.6. |
| 7 | Frontend complexity (Elements integration) | Medium | More frontend work than redirect-to-Stripe. Stripe's React SDK (@stripe/react-stripe-js) simplifies this. Provide the code snippet in section 10 as a starting point. |
| 8 | Donation row stays 'pending' if donor abandons | Low | Same behavior as current flow. Pending rows don't appear in read endpoints (filtered by dud_stripe_status = 'payment_intent.succeeded'). Consider a cron job to expire stale 'pending' donations. |
| 9 | Idempotency (double-click "Donate") | Medium | Pass Idempotency-Key: {donation_uid} to stripe.paymentIntents.create(). The donation UID is generated server-side before the Stripe call, so retries with the same UID won't create duplicate PIs. |
| 10 | Guest email typos → receipt not delivered | Low | Validate email format (Zod). Consider a "confirm email" field on the frontend for guests. The donation still succeeds — only the receipt is affected. |
15. Decision Points for Lead
These are the open questions that need a decision before implementation:
15.1 Coexistence vs full replacement
Recommendation: Coexist. Ship the new endpoint alongside the old ones. Let
the frontend migrate at its own pace. Deprecate the old endpoints only after
full migration.
Alternative: Full replacement — modify the existing POST /donate and
POST /donate-direct to use the PI flow. Riskier (breaks current frontend
immediately) but cleaner long-term.
15.2 Guest Stripe Customer strategy
Recommendation: Don't create a Stripe Customer for guests. Create the
PaymentIntent without a customer field. Set receipt_email if the guest
provides an email. Extract email from the webhook's billing_details.email
for guests who don't provide one upfront.
Rationale: Simpler, no orphan Customer objects on Stripe, no dual-region
complexity. The trade-off is no saved cards / portal / subscriptions for
guests — acceptable for a POC. If the guest later signs up, the PaymentIntent's
customer can be retroactively associated.
Alternative A: Create a lightweight user record (with a is_guest flag)
+ dual-region Stripe Customers. Enables "claim your account" later. More work,
more schema changes.
Alternative B: Create a Stripe Customer without email (just metadata:
{ guest: 'true' }). Attach it to the PaymentIntent. Can update the customer's
email later from the webhook if billing_details.email is present. Middle
ground — customer association but no orphan email-less customers.
15.3 Curated payment method list vs automatic
Recommendation: Curated list via GET /payment-methods-elements (per
region). Frontend renders a custom selector. Payment Element still filters by
Dashboard settings.
Alternative: No curated list — just use automatic_payment_methods: { enabled: true }
on the PI and let the Payment Element auto-display all relevant methods.
Simpler backend, less custom UI, but less control over the payment method
selector layout.
15.4 Rate limiting
Recommendation: Add rate limiting to the guest endpoint now. Suggest 10
requests/minute per IP for guests, 30/minute for authenticated donors. Use
@nestjs/throttler or Redis-based limiter.
Alternative: Defer rate limiting — ship first, add later. Risk: guest
endpoint is public and could be abused.
15.5 delByPattern('*') fix
Recommendation: Fix as part of this work. Replace with scoped cache
patterns in all three webhook handlers (SG, AU, Midtrans). This is a P0 issue
that affects all donations — not just the new flow.
Alternative: Fix separately in a dedicated PR. Keeps this PR smaller but
leaves the risk in place longer.
15.6 Midtrans in the same endpoint?
Recommendation: Yes — POST /donate-payment-intent auto-routes to Midtrans
for ID NGOs and returns payment_url instead of client_secret. The frontend
checks which field is present and handles accordingly (redirect for Midtrans,
Elements for Stripe).
Alternative: Exclude Midtrans from the new endpoint — keep ID donations on
the existing POST /donate flow. Simpler, but means the frontend needs to call
different endpoints depending on the NGO's country.