Skip to content

Commit

Permalink
Migrate donate Netlify function to Cloudflare (#proj-donor-page v2 op…
Browse files Browse the repository at this point in the history
…tion 1) (#2998)

This is part of the migration of the donor flow from Netlify to Cloudflare, tracked in #2947.

This PR deals specifically with the migration of the `donate` function, which handles the creation of a Stripe checkout session upon clicking on "Donate" on /donate. See [README.md](https://github.com/owid/owid-grapher/blob/donate-cloudflare/functions/README.md) for a complete sequence diagram.

Additionally, it tackles:
- upgrading the Stripe API version from 2020-08-27 to 2023-10-16
- switching to [inline pricing](https://stripe.com/docs/products-prices/pricing-models#inline-pricing) for code-driven pricing (vs configuration in the Stripe dashboard)
- fixes reCAPTCHA validation
- a more isomorphic take on errors reported to the donor. Donation payloads are statically typed on the client and the server, and checked for proper type at runtime when received over the wire - all using a single type (thanks to Typebox). There is however no particular reporting of more general errors at this stage, beyond what might be surfaced in the Cloudflare panel. This needs to be addressed.

This PR also brings some reader-facing improvements:
- #2916
- a more customized checkout page (see [notion](https://www.notion.so/owid/2023-12-06-Donor-flow-repay-tech-debt-v2-option-1-2ef260e93f9a42f9a4aa2ad418831c60))
  - [x] custom text next to the submit button
  - [x] add a product image
  - removal of confusing “£0.01 mention” for monthly donations
  - “Donate £50” instead of “Pay” for one-time donations

Non-code actions:
- [x] add env variables to [Cloudflare dashboard](https://dash.cloudflare.com/078fcdfed9955087315dd86792e71a7e/pages/view/owid/settings/environment-variables) (production and preview environments)
- [x] (when deploying live) remove STRIPE_PUBLIC_KEY from .env (it is not used anymore)
- [x] (when deploying live) update DONATE_API_URL env var (`/donation/donate`)
- [ ] (when deploying live) update API version on stripe dashboard
- [ ] (when deploying live) merge owid/owid-donors#1

Still unclear:
- [x] reporting errors happening in functions. EDIT: see #3068 
- [x] testing on staging server ~~(where to set env vars? Configure preview environment on Cloudflare dashboard?)~~
  - http://staging-site-donate-cloudflare/donate doesn't get past the form. I suspect this is because Stripe requires https when not running on localhost. I might either revive hans or install ngrok on staging-site-donate-cloudflare. EDIT: testing functions is better dealt with Cloudflare previews (see #3055)

Reference:
- see #2947 
- see [notion](https://www.notion.so/owid/2023-12-06-Donor-flow-repay-tech-debt-v2-option-1-2ef260e93f9a42f9a4aa2ad418831c60)
  • Loading branch information
mlbrgl authored Jan 11, 2024
2 parents 6669f7b + 98022de commit 4e57559
Show file tree
Hide file tree
Showing 16 changed files with 591 additions and 116 deletions.
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 donate@ourworldindata.org."
: "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

0 comments on commit 4e57559

Please sign in to comment.