Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate donate Netlify function to Cloudflare (#proj-donor-page v2 option 1) #2998

Merged
merged 31 commits into from
Jan 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
76d0695
🚧 (donate) re-structure code as cloudflare function
mlbrgl Nov 29, 2023
f431bd0
🚧 (donate) upgrade stripe API version
mlbrgl Nov 30, 2023
97e22bc
🚧 (donate) handle API secrets
mlbrgl Nov 30, 2023
0fe72ae
🚧 (donate) handle stripe subscriptions
mlbrgl Nov 30, 2023
3151c07
🐛 (donate) include subscription_data metadata in the right place for …
mlbrgl Dec 4, 2023
3a28a26
♻️ (donate) better types and abstraction for Stripe metadata
mlbrgl Dec 4, 2023
6f4af7c
♻️ (donate): deprecated stripe redirectToCheckout
mlbrgl Dec 5, 2023
a951703
💬 (donate) update donation custom text
mlbrgl Dec 5, 2023
7f22f1b
✨ (donate) handle one-time donations
mlbrgl Dec 5, 2023
b1436cf
fix (donate) CORS headers
mlbrgl Dec 5, 2023
b798f4c
wip (donate) add preflight request handler
mlbrgl Dec 5, 2023
7636c9e
💬 (donate) show "Donate" on stripe payment button
mlbrgl Dec 6, 2023
4bb704c
♻️ (donate) share donation types between client and cf function
mlbrgl Dec 6, 2023
b58c06a
♻️ (donate) remove obsolete currency handling code
mlbrgl Dec 6, 2023
1012edc
🥅 (donate): more isomorphic error handling
mlbrgl Dec 8, 2023
1117d5f
🐛 (donate): fix reCAPTCHA validation
mlbrgl Dec 8, 2023
f301460
🦺 (donate) reset form errors
mlbrgl Dec 8, 2023
0b41a78
📝 (donate) add docs for donate cf function
mlbrgl Dec 8, 2023
72da26a
♻️ (donate) check for donation type earlier
mlbrgl Dec 12, 2023
27fe391
📦 (donate) add missing stripe package
mlbrgl Dec 12, 2023
6180e86
💬 (donate) update custom text on stripe checkout
mlbrgl Dec 12, 2023
bd01b65
✨ (donate) add image to stripe checkout
mlbrgl Dec 12, 2023
e4714af
🚚 (donate) rename stripe -> checkout
mlbrgl Dec 13, 2023
72d28da
♻️ (donate) rename env var fn
mlbrgl Dec 13, 2023
a88527a
🩹 (donate) correct header response error
mlbrgl Dec 13, 2023
f6a1b57
🚚 (donate) move donate form
mlbrgl Dec 14, 2023
8b44c32
🚚 (donate) change base folder to "donation"
mlbrgl Dec 14, 2023
5227079
📝 (donate) add testing instructions
mlbrgl Dec 15, 2023
35001ce
✏️ (donate) add missing quote
mlbrgl Jan 3, 2024
ad1dfcc
🩹 (donate) correct error message for "0" donations
mlbrgl Jan 3, 2024
98022de
🐛 (donate) do not send name if not on list
owidbot Jan 11, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .dev.vars.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# env vars for local development of /functions/donation/donate.ts cloudflare pages function
# rename to .dev.vars and fill in the values from the Stripe and Recaptcha dashboards
# For testing, you can use the Stripe API and Recaptcha test keys saved in 1Password.

# https://dashboard.stripe.com/test/apikeys
# required
STRIPE_SECRET_KEY=

# https://www.google.com/recaptcha/admin/site/345413385
#required
RECAPTCHA_SECRET_KEY=
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ wpmigration
dist/
.wrangler/
.nx/cache
.dev.vars
77 changes: 77 additions & 0 deletions functions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,83 @@ In addition, there's a [`_routes.json`](../_routes.json) file that specifies whi

# Our dynamic routes

## `/donation/donate`

This route is used to create a Stripe Checkout session for a donation.

When a user clicks the Donate button on our donate page, they send a request to this function, which verifies that they've passed the CAPTCHA challenge and validates their donation parameters (amount, interval, etc.).

If all goes well, this function will respond with the URL of a Stripe Checkout form, where the donor's browser will be redirected to. From there, Stripe deals with the donation – collecting card & address info. Stripe has success and cancel URLs configured to redirect users after completion.

```mermaid
sequenceDiagram
box Purple Donor flow
participant Donor
participant Donation Form
participant Recaptcha
participant Cloud Functions
participant Stripe Checkout
participant "Thank you" page
end
box Green Udate public donors list
participant Lars
participant Donors sheet
participant Valerie
participant Wordpress
end
Donor ->>+ Donation Form: Visits
Donation Form ->> Donation Form: Activates donate button
Donor ->> Donation Form: Fills in and submits
Donation Form ->> Donation Form: Validates submission
break when donation parameters invalid
Donation Form -->> Donor: Show error
end
Donation Form ->>+ Recaptcha: is human?
Recaptcha -->>- Donation Form: yes
break when bot suspected
Recaptcha -->> Donor: show challenge
end
Donation Form ->>+ Cloud Functions: submits
Cloud Functions ->> Recaptcha: is token valid?
Recaptcha -->> Cloud Functions: yes
break when token invalid or donation parameters invalid
Cloud Functions -->> Donor: Show error
end
Cloud Functions ->> Stripe Checkout: Requests Stripe checkout session
Stripe Checkout -->> Cloud Functions: Generates Stripe checkout session
break when session creation failed
Cloud Functions -->> Donor: Show error
end
Cloud Functions -->>- Donation Form: Send session URL
Donation Form ->>- Stripe Checkout: Redirects
Donor ->> Stripe Checkout: Proceeds with payment
Stripe Checkout -->> Cloud Functions: Confirms payment
Cloud Functions ->> Donor: Sends confirmation email via Mailgun
Stripe Checkout ->> "Thank you" page: Redirects
Note right of "Thank you" page: A few weeks/months later
Lars ->> Donors sheet: ✍️ Exports new donors
Valerie ->> Donors sheet: ✍️ Edits/Deletes donors
Valerie ->> Wordpress: ✍️ Pastes updated donors list
```

### Development

1. Copy `.dev.vars.example` to `.dev.vars` and fill in the required variables.

2. Start the Cloudflare function development server with either:

- (preferred) `yarn make up.full`: starts the whole local development stack, including the functions development server
- `yarn startLocalCloudflareFunctions`: only starts the functions development server

The route is available at `http://localhost:8788/donation/donate`.

Note: compatibility dates between local development and production environments should be kept in sync:

- local: defined in `package.json` -> `startLocalCloudflareFunctions`
- production: see https://dash.cloudflare.com/078fcdfed9955087315dd86792e71a7e/pages/view/owid/settings/functions

3. Go to `http://localhost:3030/donate` and fill in the form. You should be redirected to a Stripe Checkout page. You should use the Stripe VISA test card saved in 1Password (or any other test payment method from https://stripe.com/docs/testing) to complete the donation. Do not use a real card.

## `/grapher/:slug`

Our grapher pages are (slightly) dynamic!
Expand Down
148 changes: 148 additions & 0 deletions functions/donation/checkout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import Stripe from "stripe"
import {
DonationRequest,
getErrorMessageDonation,
JsonError,
} from "@ourworldindata/utils"

function getPaymentMethodTypes(
donation: DonationRequest
): Stripe.Checkout.SessionCreateParams.PaymentMethodType[] {
if (donation.interval === "once" && donation.currency === "EUR") {
return [
"card",
"sepa_debit",
"giropay",
"ideal",
"bancontact",
"eps",
"sofort",
]
}
return ["card"]
}

export async function createCheckoutSession(
donation: DonationRequest,
key: string
) {
const stripe = new Stripe(key, {
apiVersion: "2023-10-16",
maxNetworkRetries: 2,
})

// We check that the donation parameters are within the allowed range. If
// not, we send a helpful error message. This step should never fail when
// the request is coming from the client since we are running the same
// validation code there before sending it over to the server.
const errorMessage = getErrorMessageDonation(donation)
if (errorMessage) throw new JsonError(errorMessage)

const {
name,
amount,
currency,
showOnList,
interval,
successUrl,
cancelUrl,
} = donation

const amountRoundedCents = Math.floor(amount) * 100

const metadata: Stripe.Metadata = {
name,
// showOnList is not strictly necessary since we could just rely on the
// presence of a name to indicate the willingness to be shown on the
// list (a name can only be filled in if showOnList is true). It might
// however be useful to have the explicit boolean in the Stripe portal
// for auditing purposes. Note: Stripe metadata are key-value pairs of
// strings, hence the (voluntarily explicit) conversion.
showOnList: showOnList.toString(),
}

const options: Stripe.Checkout.SessionCreateParams = {
success_url: successUrl,
cancel_url: cancelUrl,
payment_method_types: getPaymentMethodTypes(donation),
}

const messageInterval =
interval === "monthly"
? "You will be charged monthly and can cancel any time by writing to us at [email protected]."
: "You will only be charged once."
const message = showOnList
? `You chose for your donation to be publicly listed as "${metadata.name}". Your name will appear on our list of donors next time we update it. The donation amount will not be disclosed. ${messageInterval}`
: `You chose to remain anonymous, your name won't be shown on our list of supporters. ${messageInterval}`

if (interval === "monthly") {
options.mode = "subscription"
options.subscription_data = {
metadata,
}
options.line_items = [
{
price_data: {
currency,
product_data: {
name: "Monthly donation",
images: [
"https://ourworldindata.org/default-thumbnail.jpg",
],
},
recurring: {
interval: "month",
interval_count: 1,
},
unit_amount: amountRoundedCents,
},
quantity: 1,
},
]
options.custom_text = {
submit: {
message,
},
}
} else if (interval === "once") {
options.submit_type = "donate"
options.mode = "payment"
// Create a customer for one-time payments. Without this, payments are
// associated with guest customers, which are not surfaced when exporting
// donors in owid-donors. Note: this doesn't apply to subscriptions, where
// customers are always created.
options.customer_creation = "always"
options.payment_intent_data = {
metadata,
}
options.custom_text = {
submit: {
message,
},
}
options.line_items = [
{
price_data: {
currency,
product_data: {
name: "One-time donation",
images: [
"https://ourworldindata.org/default-thumbnail.jpg",
],
},
unit_amount: amountRoundedCents,
},
quantity: 1,
},
]
}

try {
return await stripe.checkout.sessions.create(options)
} catch (error) {
throw new JsonError(
`Error from our payments processor: ${error.message}`,
500
)
}
}
115 changes: 115 additions & 0 deletions functions/donation/donate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import fetch from "node-fetch"
import { createCheckoutSession } from "./checkout.js"
import {
DonateSessionResponse,
DonationRequestTypeObject,
JsonError,
PLEASE_TRY_AGAIN,
stringifyUnknownError,
} from "@ourworldindata/utils"
import { Value } from "@sinclair/typebox/value"

interface DonateEnvVars {
ASSETS: Fetcher
STRIPE_SECRET_KEY: string
RECAPTCHA_SECRET_KEY: string
}

const hasDonateEnvVars = (env: any): env is DonateEnvVars => {
return !!env.ASSETS && !!env.STRIPE_SECRET_KEY && !!env.RECAPTCHA_SECRET_KEY
}

// CORS headers need to be sent in responses to both preflight ("OPTIONS") and
// actual requests.
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
// The Content-Type header is required to allow requests to be sent with a
// Content-Type of "application/json". This is because "application/json" is
// not an allowed value for Content-Type to be considered a CORS-safelisted
// header.
// - https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_request_header
"Access-Control-Allow-Headers": "Content-Type",
}

const DEFAULT_HEADERS = { ...CORS_HEADERS, "Content-Type": "application/json" }

// This function is called when the request is a preflight request ("OPTIONS").
export const onRequestOptions: PagesFunction = async () => {
return new Response(null, {
headers: CORS_HEADERS,
status: 200,
})
}

export const onRequestPost: PagesFunction = async ({
request,
env,
}: {
request: Request
env
}) => {
if (!hasDonateEnvVars(env))
// This error is not being caught and surfaced to the client voluntarily.
throw new Error(
"Missing environment variables. Please check that both STRIPE_SECRET_KEY and RECAPTCHA_SECRET_KEY are set."
)

// Parse the body of the request as JSON
const donation = await request.json()

try {
// Check that the received donation object has the right type. Given that we
// use the same types in the client and the server, this should never fail
// when the request is coming from the client. However, it could happen if a
// request is manually crafted. In this case, we select the first error and
// send TypeBox's default error message.
if (!Value.Check(DonationRequestTypeObject, donation)) {
const { message, path } = Value.Errors(
DonationRequestTypeObject,
donation
).First()
throw new JsonError(`${message} (${path})`)
}

if (
!(await isCaptchaValid(
donation.captchaToken,
env.RECAPTCHA_SECRET_KEY
))
)
throw new JsonError(
`The CAPTCHA challenge failed. ${PLEASE_TRY_AGAIN}`
)

const session = await createCheckoutSession(
donation,
env.STRIPE_SECRET_KEY
)
const sessionResponse: DonateSessionResponse = { url: session.url }

return new Response(JSON.stringify(sessionResponse), {
headers: DEFAULT_HEADERS,
status: 200,
})
} catch (error) {
return new Response(
JSON.stringify({ error: stringifyUnknownError(error) }),
{
headers: DEFAULT_HEADERS,
status: +error.status || 500,
}
)
}
}

async function isCaptchaValid(token: string, key: string): Promise<boolean> {
const response = await fetch(
`https://www.google.com/recaptcha/api/siteverify?secret=${key}&response=${token}`,
{
method: "POST",
}
)
const json = (await response.json()) as { success: boolean }
return json.success
}
1 change: 1 addition & 0 deletions functions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"@ourworldindata/grapher": "workspace:^",
"@ourworldindata/utils": "workspace:^",
"itty-router": "^4.0.23",
"stripe": "^14.5.0",
"svg2png-wasm": "^1.4.1"
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@
"simple-git": "^3.16.1",
"simple-statistics": "^7.3.2",
"string-pixel-width": "^1.10.0",
"stripe": "^14.8.0",
"striptags": "^3.2.0",
"svgo": "^3.0.2",
"timezone-mock": "^1.0.18",
Expand Down
Loading
Loading