-
-
Notifications
You must be signed in to change notification settings - Fork 229
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Migrate donate Netlify function to Cloudflare (#proj-donor-page v2 op…
…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
Showing
16 changed files
with
591 additions
and
116 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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= |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -40,3 +40,4 @@ wpmigration | |
dist/ | ||
.wrangler/ | ||
.nx/cache | ||
.dev.vars |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.