Donation Flow — Payment Intents + Stripe Elements POC

POC  ·  Not yet implemented  ·  July 2026

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

AspectCurrent (Checkout Sessions)Proposed (Payment Intents + Elements)
Payment UIStripe-hosted page (redirect)Custom UI on our donation page (embedded)
Payment method selectionOn Stripe's pageOn our page (custom selector + Payment Element)
Guest checkoutNot supported (JWT required)Supported (email + name, no account needed)
Return URL handlingsuccess_url / cancel_url redirectsFrontend handles confirmation result inline
3DS / SCAStripe handles on hosted pageStripe.js handles client-side (auto-redirect)
Card dataNever touches our server (Stripe-hosted)Never touches our server (Elements iframe)
Response from backendpayment_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

changes to the Midtrans flow.

current POST /donate and POST /donate-direct (Checkout Session flow)

remain untouched and functional. This is zero-risk to the existing flow.

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:


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

1
Donor Opens Donation Page
The donor arrives at a campaign or NGO profile page. The page renders the donation form with amount input, tip toggle, anonymous switch, and a payment method selector.
🌐
2
Fetch Available Payment Methods
Frontend calls 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.
📋
3
Donor Fills In Donation Details
Donor enters: donation amount, optional tip, optional message, selects a payment method from the list, toggles anonymous mode. Guests also provide email & name (no account required).
4
Backend Creates PaymentIntent
Frontend calls 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.
Backend Response →  { client_secret, donation_uid, payment_intent_id }
5
Frontend Mounts Stripe Payment Element
The frontend initializes Stripe.js with the 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.
🛡
6
Donor Enters Payment Details
The donor fills in their card number, expiry, and CVC inside the Stripe-hosted iframe. For non-card methods (PayNow, GrabPay), Stripe renders the appropriate redirect or QR interface.
💳
7
Frontend Confirms Payment
The frontend calls 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.
Confirmation Outcomes
Payment Succeeded
No 3DS needed. Payment confirmed inline. Show success screen. Stripe fires payment_intent.succeeded webhook.
🔒
3D Secure Required
Stripe.js auto-redirects donor to their bank’s 3DS page. Donor authenticates. Redirected back to our page with the result.
Payment Failed
Card declined or validation error. Error shown inline. Donation row stays pending. Donor can retry with a different card.
8
Webhook Fulfillment (Async)
Stripe sends 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.
📨
✔   Donation Complete — Frontend polls 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:

1
Identify Donor
Read req.user from OptionalAuth. JWT present → authenticated. No JWT → guest (require email+name).
2
Identify Target
campaign_uid → resolve campaign → NGO. ngo_uid → resolve NGO directly.
3
Validate Amounts
Truncate to integers. Total = donation + tip + fee. Max 500k donation, 100k tip (non-ID).
4
Route Provider
NGO ID → Midtrans (IDR). NGO AU → Stripe AU (AUD). NGO SG → Stripe SG (SGD).
5
Tax Deductibility
Check NGO eligibility, donor government ID, country match, min amount. Defaults to false.
6
Increment Counter
Atomically increment ngo_donation_counter for the NGO. Stored as sequence number.
7
Build Data
Generate 6-char UID, compute referral fee, payout amount, impact statement. Build DonationData object.
8
Insert Row
Insert donation into donor_user_donates with status pending. Guests: dud_u_id = null.
9a. Stripe (SG / AU)

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 }

9b. Midtrans (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

Frontend
Stripe
Backend (Donor-API)
Receive client_secret { client_secret, donation_uid, pi_id }
Init Stripe.js loadStripe(pubKey)elements({ clientSecret })
Mount Payment Element Render card input iframe in #payment-element
Donor enters card details Card data goes directly to Stripe (iframe)
Confirm payment stripe.confirmPayment({ elements, redirect: 'if_required' })
PaymentIntent Created Status: requires_payment_method
Payment Method Attached Status: processing
3DS / SCA (if needed) Redirect donor to bank auth page
Charge Authorized Status: succeeded
Fire Webhook Event: payment_intent.succeeded
Donation row already inserted Status: pending (step 8)
Waiting for webhook...
Webhook received POST /stripe-webhook/donate-payment-webhook
Find donation by PI ID repo.getDonationByPaymentIntent(pi.id)
Idempotency check Skip if already succeeded
↓ Fulfillment continues ↓
Webhook App — Fulfillment (Existing Handler, No Changes)
1. Retrieve PI
Expand charges.data.balance_transaction
2. Extract Details
Card brand, last4, fees, net, billing address
3. Update Donation
Status → payment_intent.succeeded
4. Send Receipt
Mailjet email (region-specific template)
5. Pusher Events
Donation, leaderboard, sum-of-donations
6. Notify NGO
In-app + push notification (if not anonymous)
7. Notify Followers
In-app + push (if not anonymous)
8. Invalidate Cache
Scoped Redis patterns
✔   Donation Complete

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:

ParamTypeRequiredDescription
country_codestringYesSG 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:

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.

purposes only if provided.

a specific method (e.g. grabpay, card, paynow). See

[4.3 Payment Method Restriction](#43-payment-method-restriction).

Routing rules:

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 requestBackend creates PI withPayment 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:

AspectRestrict to selected methodShow all methods
UXClean — only what donor selectedDonor might see too many options
FallbackIf selected method is down, donor is stuckDonor can switch to another method
ComplexityBackend branches between two PI creation modesOne code path
Payment ElementShows only the selected methodShows 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):

TypeLabelNotes
cardCredit / Debit CardVisa, Mastercard, Amex
paynowPayNowQR code — Singapore's national payment method
grabpayGrabPayPopular e-wallet in SEA
alipayAlipayChinese e-wallet
wechat_payWeChat PayChinese e-wallet

Australia (AU):

TypeLabelNotes
cardCredit / Debit CardVisa, Mastercard, Amex
au_becs_debitBECS Direct DebitAustralian bank account debit
afterpay_clearpayAfterpay / ClearpayBNPL — 4 interest-free installments
zipZipBNPL
paypalPayPalE-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:

Target
↓ Donor Type
Authenticated
(JWT present)
Guest
(No JWT)
Campaign
(campaign_uid)
Direct NGO
(ngo_uid)
👥
Campaign Donation
Resolve campaign → NGO.
Resolve existing Stripe Customer.
dud_u_id = userId
🌐
Guest Campaign
Resolve campaign → NGO.
Create Stripe Customer (single region).
dud_u_id = null
👥
Direct NGO Donation
Resolve NGO directly.
Resolve existing Stripe Customer.
dud_u_id = userId
🌐
Guest Direct NGO
Resolve NGO directly.
Create Stripe Customer (single region).
dud_u_id = null

Provider Routing

The NGO's base country determines which payment provider handles the transaction:

🇲🇨
Midtrans Snap
Indonesia → IDR → returns payment_url
🇦🇺
Stripe (AU)
Australia → AUD → returns client_secret
🇸🇬
Stripe (SG)
Singapore / other → SGD → returns client_secret

Internal Routing Logic

The use case's execute() method processes these 11 steps:

1
Donor Type
donorType = currentUser?.id ? 'authenticated' : 'guest'
2
Target Type
targetType = dto.campaign_uid ? 'campaign' : 'direct'
3
Guest Validation
IF guest && !email → throw BadRequestException
4
Resolve Target
campaign ? resolveCampaign→NGO : resolveNgo
5
Provider & Currency
determineCurrency(ngo.baseCountry) → provider
6
Validate Amounts
Truncate integers. Max 500k donation, 100k tip
7
Tax Deductibility
Check NGO eligibility, tax ID, country, min amount
8
Increment Counter
repo.incrementAndGetNgoDonationCounter(ngoId)
9
Build DonationData
uid, userId(=null for guest), amounts, impact stmt
10
Insert Row
repo.insertDonation(donationData) → status: pending
11a
Stripe (SG/AU)
resolve Customer (auth only)
IF payment_method_type → restrict
ELSE → automatic_payment_methods
no customer for guests
return { client_secret, ... }
11b
Midtrans (ID)
create Snap transaction
return { payment_url, ... }

Why a Single Endpoint

Single EndpointFour Endpoints
One use case to maintainFour use cases with duplicated logic
One DTO (with optional fields)Four DTOs with overlapping fields
One controller methodFour controller methods
Frontend always calls the same URLFrontend must choose the right endpoint
Routing logic is centralizedRouting 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

FieldRequiredPurpose
emailOptionalIf provided, used for receipt email. If not, extracted from webhook billing_details.email. Non-card methods may not provide email.
nameOptionalDisplay purposes only.
donation_amountYesThe donation amount
chariot_tipNoOptional tip
platform_chargeYesPlatform fee (calculated by frontend)
country_codeYesSG / AU / ID
is_anonymousYesWhether to hide donor identity
message_from_donorNoOptional message to NGO
payment_method_typeOptionalRestrict 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

1
Identify Donor guest path
OptionalAuth guard runs — req.user is undefined (no JWT). Set donorType = 'guest' and donorUserId = null. Email and name are optional — no validation required.
2
Resolve Target
Resolve the campaign or NGO from the request — same logic as the authenticated flow. Gets NGO details: country, charge percentage, Stripe account ID.
3
Validate Amounts & Tax
Parse amounts, truncate to integers, enforce maximums. Tax deductibility automatically returns false — guests have no user account with a verified government ID.
4
Build DonationData
Construct the DonationData object with userId = null. Generate 6-char UID, compute referral fee, payout amount, and program impact statement.
5
Insert Donation Row
Insert into 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.
Webhook handler will populate dud_guest_email from billing_details.email when available.
6
Create PaymentIntent no customer
Create PaymentIntent without a Stripe Customer. If payment_method_type provided → payment_method_types: [type]. If not → automatic_payment_methods: { enabled: true }. Set receipt_email: email || undefined.
No Stripe Customer object is created for guests. No saved cards, no portal, no future subscriptions. If the guest later signs up, the PI's customer can be retroactively associated.
7
Return Response
Return { client_secret: paymentIntent.client_secret, donation_uid: donationData.uid, payment_intent_id: paymentIntent.id } to the frontend.

Guest Flow End-to-End

Donor (Guest)

Opens donation page (no account, no JWT)

Fills in: amount, payment method

Email / name optional — only if donor provides them

Clicks "Donate"

Frontend
POST /donor/donate-payment-intent

No Authorization header

{ campaign_uid, donation_amount,
country_code, ...
payment_method_type: 'grabpay',
email: 'guest@...' (optional) }
Donor-API Backend
  1. No JWT → donorType = 'guest'
  2. Resolve campaign → NGO
  3. Validate amounts
  4. Insert donation: dud_u_id = null, dud_guest_email = email || null
  5. Create PaymentIntent (no Customer)
  6. If payment_method_type → restrict to that method
Backend Response
{ client_secret, donation_uid, payment_intent_id }
Frontend — Payment Element
  1. Mount Payment Element with client_secret
  2. If restricted: only selected method shows
  3. Guest enters card details (iframe → Stripe)
  4. stripe.confirmPayment()
  5. Show success / failure
Stripe

Processes payment

Handles 3DS if needed

Captures billing_details.email (card only)

Fires payment_intent.succeeded

Webhook App (Async)
  1. Find donation by PI ID
  2. Update to 'succeeded'
  3. If dud_guest_email is null:
    extract billing_details.email → update row
  4. Send receipt email (if email present)
  5. If not anonymous: notify NGO + followers
  6. If anonymous: skip notifications
Complete
Non-card methods (PayNow, GrabPay) may not provide email — dud_guest_email stays null, no receipt sent

Anonymous guest donations

If a guest sets is_anonymous: true:

the NGO and followers — so anonymous guests don't trigger notifications.

didn't provide an email and the payment method didn't capture one (e.g.

PayNow, GrabPay), no receipt email is sent.


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:

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:

event without any linking step.

Sessions in the new flow).

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

EventCurrent flowNew flowAction
payment_intent.succeededHandledHandled (same code)Fulfill donation
payment_intent.payment_failedHandledHandled (same code)Mark as failed
payment_intent.canceledHandledHandled (same code)Mark as canceled
payment_intent.processingHandledHandled (same code)Update status
payment_intent.requires_actionHandledHandled (same code)Update status
checkout.session.completedHandled (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_***).

Propertyclient_secretsk_*** (API key)
Who sees itFrontend + backendBackend only
What it can doConfirm one specific PaymentIntentEverything on the Stripe account
ScopeBound to one PI's amount + currencyAll operations
LifetimeExpires when PI succeeds/cancels (24h max)Permanent (until rotated)
Can it create charges?No — it can only confirm the PI it belongs toYes
Can it access other PIs?NoYes

Key point: Even if an attacker intercepts the client_secret, they cannot:

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

ProtectionWhyHow
HTTPSTransport-layer encryption. The client_secret is only safe over HTTPS.Production already uses Apache + TLS.
Rate limiting on POST /donate-payment-intentPrevents 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 validationThe 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 creationPrevents 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_secretIt'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 activePrevents 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:

RiskMitigation
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 abuseConsider 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:

(or redirects to the bank's authentication page).

requires a full redirect (e.g. some bank redirects). Card 3DS is handled

inline.

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

ComponentStatusNotes
POST /donor/donateUnchangedOld Checkout Session flow — still works
POST /donor/donate-directUnchangedOld Checkout Session flow — still works
POST /donor/donate-ios / -androidUnchangedPlatform variants — still work
Midtrans flow (ID)UnchangedIndonesia continues using Snap redirect
Webhook appUnchangedExisting payment_intent.succeeded handler works
StripeService.createCheckoutSession()UnchangedStill used by old endpoints
buildStripeDonationRedirectUrls()UnchangedStill used by old endpoints
DonationTaxServiceUnchangedSame tax check logic
StripeCustomerResolverServiceUnchangedUsed for authenticated donors in new flow
DonationHydratorServiceUnchangedRead endpoints unaffected
Donation read endpointsUnchangedGET /donation, GET /donations, etc.
donor_user_donates schemaOne new columndud_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;
ColumnTypeNullablePurpose
dud_guest_emailVARCHAR(255)YesGuest 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?

guests set it to NULL.

in the request. The email is sufficient for receipts.

Note: dud_guest_email may be null at insertion time for guests who don't
provide an email in the request. The webhook handler populates it from
charges.data[0].billing_details.email when 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


13. Architecture Diagram

Donor App (Frontend / Mobile)
Old Flow
POST /donor/donate
(Checkout Session)
New Flow
GET /donor/payment-methods-elements
New Flow
POST /donor/donate-payment-intent
(PaymentIntent)
Donor-API (NestJS)
DonateUseCase
existing
routeStripe() → Checkout Session
routeMidtrans() → Snap URL
→ returns payment_url
+
DonatePaymentIntentUseCase
new
routeStripePaymentIntent() → PaymentIntent
→ returns client_secret
Handles: campaign/direct, auth/guest, Stripe/Midtrans
Shared Services
DonationTaxService StripeCustomerResolver ProgramImpactService DonationRepository
both flows create donation rows with dud_stripe_payment_intent_id
Webhook App (NestJS) — Shared, No Changes
POST /stripe-webhook/donate-payment-webhook
(SG)
POST /stripe-webhook/donate-payment-webhook-au
(AU)
POST /midtrans/notification
(ID)
payment_intent.succeeded
  1. Find donation by PI ID
  2. Update status → 'succeeded'
  3. Send email (Mailjet)
  4. Pusher events
  5. Notify NGO + followers
  6. Invalidate cache
Database (MySQL)
donor_user_donates
dud_id PK
dud_uid 6-char
dud_u_id FK → users, NULL for guests
dud_guest_email NEW — nullable
dud_stripe_payment_intent_id pi_***
dud_stripe_status 'pending' → 'succeeded'
dud_midtrans_payment_reference
...

14. Risks & Mitigations

#RiskSeverityMitigation
1Breaking existing donation flowLowNew endpoint coexists with old ones. Old endpoints are untouched. Both share the same webhook handler.
2Guest endpoint abuse (spam PI creation)MediumRate limiting per IP (e.g. 10/min for guests). Consider Cloudflare Turnstile for bot protection. Stripe Radar catches fraud.
33DS handling complexityLowStripe.js handles 3DS automatically client-side. No backend involvement needed. redirect: 'if_required' keeps donor on-page for card 3DS.
4Payment method mismatch (curated list vs Dashboard)LowCurated list is a UI hint. Payment Element only renders Dashboard-enabled methods. Keep both in sync. Document the dependency.
5Guest Stripe Customer accumulationLowGuest 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.
6delByPattern('*') wipes RedisHighExisting issue, inherited by new flow. Fix as part of this work: replace with scoped patterns. See docs/donation-flow.md section 15.6.
7Frontend complexity (Elements integration)MediumMore 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.
8Donation row stays 'pending' if donor abandonsLowSame 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.
9Idempotency (double-click "Donate")MediumPass 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.
10Guest email typos → receipt not deliveredLowValidate 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.