From 76d0695b8c4e67b77e1bc06750c7192766f40d40 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 29 Nov 2023 13:50:10 +0000 Subject: [PATCH 01/31] =?UTF-8?q?=F0=9F=9A=A7=20(donate)=20re-structure=20?= =?UTF-8?q?code=20as=20cloudflare=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- functions/donate/donate.ts | 61 +++++++++++++++++++++ functions/donate/stripe.ts | 106 +++++++++++++++++++++++++++++++++++++ functions/donate/types.ts | 38 +++++++++++++ functions/package.json | 1 + settings/clientSettings.ts | 2 +- yarn.lock | 36 +++++++++++++ 6 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 functions/donate/donate.ts create mode 100644 functions/donate/stripe.ts create mode 100644 functions/donate/types.ts diff --git a/functions/donate/donate.ts b/functions/donate/donate.ts new file mode 100644 index 00000000000..e82d285bb8d --- /dev/null +++ b/functions/donate/donate.ts @@ -0,0 +1,61 @@ +import { DonationRequest } from "./types.js" +import { URLSearchParams } from "url" +import fetch from "node-fetch" +import { createSession } from "./stripe.js" + +const { RECAPTCHA_SECRET_KEY } = process.env + +const DEFAULT_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS, PUT, DELETE", + "Access-Control-Allow-Headers": + "Content-Type, Access-Control-Allow-Headers, X-Requested-With", +} + +export const onRequestPost: PagesFunction = async (context) => { + // Parse the body of the request as JSON + const data: DonationRequest = await context.request.json() + + try { + if (!(await validCaptcha(data.captchaToken))) { + throw { + status: 400, + message: + "The CAPTCHA challenge failed, please try submitting the form again.", + } + } + const session = await createSession(data) + return new Response(JSON.stringify({ id: session.id }), { + headers: DEFAULT_HEADERS, + status: 200, + }) + } catch (error) { + console.error(error) + return new Response( + JSON.stringify({ + message: + "An unexpected error occurred. " + (error && error.message), + }), + { + headers: DEFAULT_HEADERS, + status: +error.status || 500, + } + ) + } +} + +async function validCaptcha(token: string): Promise { + const body = new URLSearchParams({ + secret: RECAPTCHA_SECRET_KEY, + response: token, + }) + const response = await fetch( + "https://www.google.com/recaptcha/api/siteverify", + { + method: "post", + body: body, + } + ) + const json = (await response.json()) as { success: boolean } + return json.success +} diff --git a/functions/donate/stripe.ts b/functions/donate/stripe.ts new file mode 100644 index 00000000000..c3e8e8d10a5 --- /dev/null +++ b/functions/donate/stripe.ts @@ -0,0 +1,106 @@ +import Stripe from "stripe" +import { + DonationRequest, + Interval, + CurrencyCode, + StripeMetadata, + plansByCurrencyCode, +} from "./types.js" + +const { STRIPE_SECRET_KEY } = process.env + +if (!STRIPE_SECRET_KEY) { + throw new Error("Please set the STRIPE_SECRET_KEY environment variable") +} + +export const stripe = new Stripe(STRIPE_SECRET_KEY, { + apiVersion: "2020-08-27", + maxNetworkRetries: 2, +}) + +function getPaymentMethodTypes( + donation: DonationRequest +): Stripe.Checkout.SessionCreateParams.PaymentMethodType[] { + if ( + donation.interval === Interval.ONCE && + donation.currency === CurrencyCode.EUR + ) { + return [ + "card", + "sepa_debit", + "giropay", + "ideal", + "bancontact", + "eps", + "sofort", + ] + } + return ["card"] +} + +export async function createSession(donation: DonationRequest) { + if (donation.amount == null) + throw { status: 400, message: "Please specify an amount" } + if (!Object.values(Interval).includes(donation.interval)) + throw { status: 400, message: "Please specify an interval" } + // Temporarily disable while the new form is being deployed. + // if (!Object.values(CurrencyCode).includes(donation.currency)) throw { status: 400, message: "Please specify a currency" } + if (donation.successUrl == null || donation.cancelUrl == null) + throw { + status: 400, + message: "Please specify a successUrl and cancelUrl", + } + + const { name, showOnList, interval, successUrl, cancelUrl } = donation + const amount = Math.floor(donation.amount) + + // It used to be only possible to donate in USD, so USD was hardcoded here. + // We want to temporarily handle the old payload while the new form is deployed. + const currency = donation.currency || CurrencyCode.USD + + if (amount < 100 || amount > 10_000 * 100) { + throw { + status: 400, + message: + "You can only donate between $1 and $10,000 USD. For higher amounts, please contact donate@ourworldindata.org", + } + } + + const metadata: StripeMetadata = { name, showOnList } + + const options: Stripe.Checkout.SessionCreateParams = { + success_url: successUrl, + cancel_url: cancelUrl, + payment_method_types: getPaymentMethodTypes(donation), + } + + if (interval === Interval.MONTHLY) { + options.subscription_data = { + items: [ + { + plan: plansByCurrencyCode[currency], + quantity: amount, + }, + ], + metadata: metadata as any, + } + } else if (interval === Interval.ONCE) { + options.line_items = [ + { + amount: amount, + currency: currency, + name: "One-time donation", + quantity: 1, + }, + ] + options.payment_intent_data = { + metadata: metadata as any, + } + } + + try { + return await stripe.checkout.sessions.create(options) + } catch (error) { + throw { message: `Error from our payments processor: ${error.message}` } + } +} diff --git a/functions/donate/types.ts b/functions/donate/types.ts new file mode 100644 index 00000000000..76835df579f --- /dev/null +++ b/functions/donate/types.ts @@ -0,0 +1,38 @@ +export enum Interval { + ONCE = "once", + MONTHLY = "monthly", +} + +export enum CurrencyCode { + USD = "USD", + GBP = "GBP", + EUR = "EUR", +} + +export interface DonationRequest { + name: string + showOnList: boolean + currency?: CurrencyCode + amount: number + interval: Interval + successUrl: string + cancelUrl: string + captchaToken: string +} + +export interface StripeMetadata { + name: string + showOnList: boolean +} + +const { + STRIPE_MONTHLY_USD_PLAN_ID, + STRIPE_MONTHLY_GBP_PLAN_ID, + STRIPE_MONTHLY_EUR_PLAN_ID, +} = process.env + +export const plansByCurrencyCode: Record = { + [CurrencyCode.USD]: STRIPE_MONTHLY_USD_PLAN_ID!, + [CurrencyCode.GBP]: STRIPE_MONTHLY_GBP_PLAN_ID!, + [CurrencyCode.EUR]: STRIPE_MONTHLY_EUR_PLAN_ID!, +} diff --git a/functions/package.json b/functions/package.json index abf71d42c8b..f6369122e56 100644 --- a/functions/package.json +++ b/functions/package.json @@ -4,6 +4,7 @@ "@ourworldindata/grapher": "workspace:^", "@ourworldindata/utils": "workspace:^", "itty-router": "^4.0.23", + "stripe": "^14.5.0", "svg2png-wasm": "^1.4.1" } } diff --git a/settings/clientSettings.ts b/settings/clientSettings.ts index 4ae84884a4c..cb91b12496f 100644 --- a/settings/clientSettings.ts +++ b/settings/clientSettings.ts @@ -48,7 +48,7 @@ export const ALGOLIA_SEARCH_KEY: string = process.env.ALGOLIA_SEARCH_KEY ?? "" export const STRIPE_PUBLIC_KEY: string = process.env.STRIPE_PUBLIC_KEY ?? "pk_test_nIHvmH37zsoltpw3xMssPIYq" export const DONATE_API_URL: string = - process.env.DONATE_API_URL ?? "http://localhost:9000/donate" + process.env.DONATE_API_URL ?? "http://localhost:8788/donate/donate" export const RECAPTCHA_SITE_KEY: string = process.env.RECAPTCHA_SITE_KEY ?? "6LcJl5YUAAAAAATQ6F4vl9dAWRZeKPBm15MAZj4Q" diff --git a/yarn.lock b/yarn.lock index 137a927dcad..23a84e3a7e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4739,6 +4739,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:>=8.1.0": + version: 20.10.0 + resolution: "@types/node@npm:20.10.0" + dependencies: + undici-types: "npm:~5.26.4" + checksum: c7d5ddbdbf3491e2363135c9611eb6bfae90eda2957279237fa232bcb29cd0df1cc3ee149d6de9915b754262a531ee2d57d33c9ecd58d763e8ad4856113822f3 + languageName: node + linkType: hard + "@types/node@npm:^14.14.31": version: 14.17.2 resolution: "@types/node@npm:14.17.2" @@ -15439,6 +15448,7 @@ __metadata: "@ourworldindata/grapher": "workspace:^" "@ourworldindata/utils": "workspace:^" itty-router: "npm:^4.0.23" + stripe: "npm:^14.5.0" svg2png-wasm: "npm:^1.4.1" languageName: unknown linkType: soft @@ -16251,6 +16261,15 @@ __metadata: languageName: node linkType: hard +"qs@npm:^6.11.0": + version: 6.11.2 + resolution: "qs@npm:6.11.2" + dependencies: + side-channel: "npm:^1.0.4" + checksum: f2321d0796664d0f94e92447ccd3bdfd6b6f3a50b6b762aa79d7f5b1ea3a7a9f94063ba896b82bc2a877ed6a7426d4081e4f16568fdb04f0ee188cca9d8505b4 + languageName: node + linkType: hard + "qs@npm:^6.5.1 < 6.10": version: 6.9.7 resolution: "qs@npm:6.9.7" @@ -18942,6 +18961,16 @@ __metadata: languageName: node linkType: hard +"stripe@npm:^14.5.0": + version: 14.5.0 + resolution: "stripe@npm:14.5.0" + dependencies: + "@types/node": "npm:>=8.1.0" + qs: "npm:^6.11.0" + checksum: a2b9615ef7b1f53a2214ce21d533fe66df3575e89f22b74fba885111c66ac17064951f68065b336fb16ce1d93f7bf2c334604c1c6419917073cf6778a251f516 + languageName: node + linkType: hard + "striptags@npm:^3.2.0": version: 3.2.0 resolution: "striptags@npm:3.2.0" @@ -19873,6 +19902,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~5.26.4": + version: 5.26.5 + resolution: "undici-types@npm:5.26.5" + checksum: 0097779d94bc0fd26f0418b3a05472410408877279141ded2bd449167be1aed7ea5b76f756562cb3586a07f251b90799bab22d9019ceba49c037c76445f7cddd + languageName: node + linkType: hard + "undici@npm:^5.22.1": version: 5.28.2 resolution: "undici@npm:5.28.2" From f431bd0d1ccf5181c0b4f39c4997da9c6743cf47 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 30 Nov 2023 18:08:38 +0000 Subject: [PATCH 02/31] =?UTF-8?q?=F0=9F=9A=A7=20(donate)=20upgrade=20strip?= =?UTF-8?q?e=20API=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- functions/donate/stripe.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/functions/donate/stripe.ts b/functions/donate/stripe.ts index c3e8e8d10a5..6a094d2ae8d 100644 --- a/functions/donate/stripe.ts +++ b/functions/donate/stripe.ts @@ -38,7 +38,12 @@ function getPaymentMethodTypes( return ["card"] } -export async function createSession(donation: DonationRequest) { +export async function createSession(donation: DonationRequest, key: string) { + const stripe = new Stripe(key, { + apiVersion: "2023-10-16", + maxNetworkRetries: 2, + }) + if (donation.amount == null) throw { status: 400, message: "Please specify an amount" } if (!Object.values(Interval).includes(donation.interval)) From 97e22bcfe453d3ae986c19cd435365c3939750d7 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 30 Nov 2023 18:20:03 +0000 Subject: [PATCH 03/31] =?UTF-8?q?=F0=9F=9A=A7=20(donate)=20handle=20API=20?= =?UTF-8?q?secrets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dev.vars.example | 10 ++++++++++ .gitignore | 1 + functions/donate/donate.ts | 34 ++++++++++++++++++++++++---------- functions/donate/stripe.ts | 12 ------------ functions/donate/types.ts | 14 ++++---------- 5 files changed, 39 insertions(+), 32 deletions(-) create mode 100644 .dev.vars.example diff --git a/.dev.vars.example b/.dev.vars.example new file mode 100644 index 00000000000..6144dc82c32 --- /dev/null +++ b/.dev.vars.example @@ -0,0 +1,10 @@ +# env vars for local development of /functions/donate/donate.ts cloudflare pages function +# rename to .dev.vars and fill in the values from the Stripe and Recaptcha dashboards + +# https://dashboard.stripe.com/test/apikeys +# required +STRIPE_SECRET_KEY= + +# https://www.google.com/recaptcha/admin/site/345413385 +#required +RECAPTCHA_SECRET_KEY= diff --git a/.gitignore b/.gitignore index 2d717aa2e13..a8651ff3d84 100755 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ wpmigration dist/ .wrangler/ .nx/cache +.dev.vars diff --git a/functions/donate/donate.ts b/functions/donate/donate.ts index e82d285bb8d..62d7c7838cd 100644 --- a/functions/donate/donate.ts +++ b/functions/donate/donate.ts @@ -1,10 +1,7 @@ -import { DonationRequest } from "./types.js" -import { URLSearchParams } from "url" +import { DonationRequest, EnvVars } from "./types.js" import fetch from "node-fetch" import { createSession } from "./stripe.js" -const { RECAPTCHA_SECRET_KEY } = process.env - const DEFAULT_HEADERS = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, OPTIONS, PUT, DELETE", @@ -12,19 +9,36 @@ const DEFAULT_HEADERS = { "Content-Type, Access-Control-Allow-Headers, X-Requested-With", } -export const onRequestPost: PagesFunction = async (context) => { +const isEnvVars = (env: any): env is EnvVars => { + return !!env.ASSETS && !!env.STRIPE_SECRET_KEY && !!env.RECAPTCHA_SECRET_KEY +} + +export const onRequestPost: PagesFunction = async ({ + request, + env, +}: { + request: Request + env +}) => { + if (!isEnvVars(env)) + 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 data: DonationRequest = await context.request.json() + const data: DonationRequest = await request.json() try { - if (!(await validCaptcha(data.captchaToken))) { + if ( + !(await validCaptcha(data.captchaToken, env.RECAPTCHA_SECRET_KEY)) + ) { throw { status: 400, message: "The CAPTCHA challenge failed, please try submitting the form again.", } } - const session = await createSession(data) + const session = await createSession(data, env.STRIPE_SECRET_KEY) return new Response(JSON.stringify({ id: session.id }), { headers: DEFAULT_HEADERS, status: 200, @@ -44,9 +58,9 @@ export const onRequestPost: PagesFunction = async (context) => { } } -async function validCaptcha(token: string): Promise { +async function validCaptcha(token: string, key: string): Promise { const body = new URLSearchParams({ - secret: RECAPTCHA_SECRET_KEY, + secret: key, response: token, }) const response = await fetch( diff --git a/functions/donate/stripe.ts b/functions/donate/stripe.ts index 6a094d2ae8d..b364752635b 100644 --- a/functions/donate/stripe.ts +++ b/functions/donate/stripe.ts @@ -4,20 +4,8 @@ import { Interval, CurrencyCode, StripeMetadata, - plansByCurrencyCode, } from "./types.js" -const { STRIPE_SECRET_KEY } = process.env - -if (!STRIPE_SECRET_KEY) { - throw new Error("Please set the STRIPE_SECRET_KEY environment variable") -} - -export const stripe = new Stripe(STRIPE_SECRET_KEY, { - apiVersion: "2020-08-27", - maxNetworkRetries: 2, -}) - function getPaymentMethodTypes( donation: DonationRequest ): Stripe.Checkout.SessionCreateParams.PaymentMethodType[] { diff --git a/functions/donate/types.ts b/functions/donate/types.ts index 76835df579f..589ed3c8498 100644 --- a/functions/donate/types.ts +++ b/functions/donate/types.ts @@ -25,14 +25,8 @@ export interface StripeMetadata { showOnList: boolean } -const { - STRIPE_MONTHLY_USD_PLAN_ID, - STRIPE_MONTHLY_GBP_PLAN_ID, - STRIPE_MONTHLY_EUR_PLAN_ID, -} = process.env - -export const plansByCurrencyCode: Record = { - [CurrencyCode.USD]: STRIPE_MONTHLY_USD_PLAN_ID!, - [CurrencyCode.GBP]: STRIPE_MONTHLY_GBP_PLAN_ID!, - [CurrencyCode.EUR]: STRIPE_MONTHLY_EUR_PLAN_ID!, +export interface EnvVars { + ASSETS: Fetcher + STRIPE_SECRET_KEY: string + RECAPTCHA_SECRET_KEY: string } From 0fe72ae179b7967c68b93b27627088a22a1212ff Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 30 Nov 2023 18:21:53 +0000 Subject: [PATCH 04/31] =?UTF-8?q?=F0=9F=9A=A7=20(donate)=20handle=20stripe?= =?UTF-8?q?=20subscriptions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- functions/donate/stripe.ts | 55 +++++++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/functions/donate/stripe.ts b/functions/donate/stripe.ts index b364752635b..934a5530a91 100644 --- a/functions/donate/stripe.ts +++ b/functions/donate/stripe.ts @@ -68,27 +68,50 @@ export async function createSession(donation: DonationRequest, key: string) { } if (interval === Interval.MONTHLY) { - options.subscription_data = { - items: [ - { - plan: plansByCurrencyCode[currency], - quantity: amount, - }, - ], - metadata: metadata as any, - } - } else if (interval === Interval.ONCE) { + options.mode = "subscription" options.line_items = [ { - amount: amount, - currency: currency, - name: "One-time donation", + price_data: { + currency: currency, + product_data: { + name: "Monthly donation", + description: metadata.showOnList + ? `This is a public monthly donation: you will be listed as "${metadata.name}"` + : "This is an anonymous monthly donation", + metadata: { + ...metadata, + // Stripe metadata are key-value pairs of strings. + // 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. + showOnList: showOnList.toString(), + }, + }, + recurring: { + interval: "month", + interval_count: 1, + }, + unit_amount: amount, + }, quantity: 1, }, ] - options.payment_intent_data = { - metadata: metadata as any, - } + } else if (interval === Interval.ONCE) { + // options.line_items = [ + // { + // amount: amount, + // currency: currency, + // name: "One-time donation", + // quantity: 1, + // }, + // ] + // options.payment_intent_data = { + // metadata: metadata as any, + // } } try { From 3151c07be7fcd7a486eacf1caf67060164f60e57 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 4 Dec 2023 08:58:41 +0000 Subject: [PATCH 05/31] =?UTF-8?q?=F0=9F=90=9B=20(donate)=20include=20subsc?= =?UTF-8?q?ription=5Fdata=20metadata=20in=20the=20right=20place=20for=20ex?= =?UTF-8?q?port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- functions/donate/stripe.ts | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/functions/donate/stripe.ts b/functions/donate/stripe.ts index 934a5530a91..8dac8b19a6d 100644 --- a/functions/donate/stripe.ts +++ b/functions/donate/stripe.ts @@ -69,6 +69,20 @@ export async function createSession(donation: DonationRequest, key: string) { if (interval === Interval.MONTHLY) { options.mode = "subscription" + options.subscription_data = { + metadata: { + ...metadata, + // Stripe metadata are key-value pairs of strings. + // 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. + showOnList: showOnList.toString(), + }, + } options.line_items = [ { price_data: { @@ -78,18 +92,6 @@ export async function createSession(donation: DonationRequest, key: string) { description: metadata.showOnList ? `This is a public monthly donation: you will be listed as "${metadata.name}"` : "This is an anonymous monthly donation", - metadata: { - ...metadata, - // Stripe metadata are key-value pairs of strings. - // 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. - showOnList: showOnList.toString(), - }, }, recurring: { interval: "month", From 3a28a26989f20654711edf1835ccbdae1b0ec7e9 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 4 Dec 2023 09:24:34 +0000 Subject: [PATCH 06/31] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(donate)=20better=20?= =?UTF-8?q?types=20and=20abstraction=20for=20Stripe=20metadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- functions/donate/stripe.ts | 33 +++++++++++++-------------------- functions/donate/types.ts | 5 ----- 2 files changed, 13 insertions(+), 25 deletions(-) diff --git a/functions/donate/stripe.ts b/functions/donate/stripe.ts index 8dac8b19a6d..95d17d50839 100644 --- a/functions/donate/stripe.ts +++ b/functions/donate/stripe.ts @@ -1,10 +1,5 @@ import Stripe from "stripe" -import { - DonationRequest, - Interval, - CurrencyCode, - StripeMetadata, -} from "./types.js" +import { DonationRequest, Interval, CurrencyCode } from "./types.js" function getPaymentMethodTypes( donation: DonationRequest @@ -59,7 +54,16 @@ export async function createSession(donation: DonationRequest, key: string) { } } - const metadata: StripeMetadata = { name, showOnList } + 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, @@ -70,18 +74,7 @@ export async function createSession(donation: DonationRequest, key: string) { if (interval === Interval.MONTHLY) { options.mode = "subscription" options.subscription_data = { - metadata: { - ...metadata, - // Stripe metadata are key-value pairs of strings. - // 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. - showOnList: showOnList.toString(), - }, + metadata, } options.line_items = [ { @@ -89,7 +82,7 @@ export async function createSession(donation: DonationRequest, key: string) { currency: currency, product_data: { name: "Monthly donation", - description: metadata.showOnList + description: showOnList ? `This is a public monthly donation: you will be listed as "${metadata.name}"` : "This is an anonymous monthly donation", }, diff --git a/functions/donate/types.ts b/functions/donate/types.ts index 589ed3c8498..772839caf27 100644 --- a/functions/donate/types.ts +++ b/functions/donate/types.ts @@ -20,11 +20,6 @@ export interface DonationRequest { captchaToken: string } -export interface StripeMetadata { - name: string - showOnList: boolean -} - export interface EnvVars { ASSETS: Fetcher STRIPE_SECRET_KEY: string From 6f4af7c6ebc6ff1b0a4356b79df0f588b8d57bb2 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 5 Dec 2023 09:39:35 +0000 Subject: [PATCH 07/31] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(donate):=20deprecat?= =?UTF-8?q?ed=20stripe=20redirectToCheckout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- functions/donate/donate.ts | 2 +- settings/clientSettings.ts | 2 -- site/stripe/DonateForm.tsx | 15 +++------------ site/stripe/stripe.ts | 8 -------- 4 files changed, 4 insertions(+), 23 deletions(-) delete mode 100644 site/stripe/stripe.ts diff --git a/functions/donate/donate.ts b/functions/donate/donate.ts index 62d7c7838cd..1903ab6b3fc 100644 --- a/functions/donate/donate.ts +++ b/functions/donate/donate.ts @@ -39,7 +39,7 @@ export const onRequestPost: PagesFunction = async ({ } } const session = await createSession(data, env.STRIPE_SECRET_KEY) - return new Response(JSON.stringify({ id: session.id }), { + return new Response(JSON.stringify({ url: session.url }), { headers: DEFAULT_HEADERS, status: 200, }) diff --git a/settings/clientSettings.ts b/settings/clientSettings.ts index cb91b12496f..37c1798224b 100644 --- a/settings/clientSettings.ts +++ b/settings/clientSettings.ts @@ -45,8 +45,6 @@ export const WORDPRESS_URL: string = process.env.WORDPRESS_URL ?? "" export const ALGOLIA_ID: string = process.env.ALGOLIA_ID ?? "" export const ALGOLIA_SEARCH_KEY: string = process.env.ALGOLIA_SEARCH_KEY ?? "" -export const STRIPE_PUBLIC_KEY: string = - process.env.STRIPE_PUBLIC_KEY ?? "pk_test_nIHvmH37zsoltpw3xMssPIYq" export const DONATE_API_URL: string = process.env.DONATE_API_URL ?? "http://localhost:8788/donate/donate" diff --git a/site/stripe/DonateForm.tsx b/site/stripe/DonateForm.tsx index 6c2ba5a4bbd..15c7714eb77 100644 --- a/site/stripe/DonateForm.tsx +++ b/site/stripe/DonateForm.tsx @@ -10,7 +10,6 @@ import { BAKED_BASE_URL, RECAPTCHA_SITE_KEY, } from "../../settings/clientSettings.js" -import stripe from "./stripe.js" import { Tippy, stringifyUnknownError, titleCase } from "@ourworldindata/utils" import { Checkbox } from "@ourworldindata/components" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome/index.js" @@ -155,21 +154,13 @@ export class DonateForm extends React.Component { }), }) const session = await response.json() - if (!response.ok) throw session - - if (!stripe) + if (!response.ok) { throw new Error( "Could not connect to Stripe, our payment provider." ) - - const result = await stripe?.redirectToCheckout({ - sessionId: session.id, - }) - if (result.error) { - // If `redirectToCheckout` fails due to a browser or network - // error, display the localized error message to your customer. - throw result.error } + + window.location.href = session.url } @bind async getCaptchaToken() { diff --git a/site/stripe/stripe.ts b/site/stripe/stripe.ts deleted file mode 100644 index db9cbd93f88..00000000000 --- a/site/stripe/stripe.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { StripeConstructor } from "@stripe/stripe-js" - -const Stripe = window.Stripe as StripeConstructor -import { STRIPE_PUBLIC_KEY } from "../../settings/clientSettings.js" - -const stripe = Stripe ? Stripe(STRIPE_PUBLIC_KEY) : undefined - -export default stripe From a9517039268cf0ac60ab0f63a757f87c72c2f926 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 5 Dec 2023 11:02:03 +0000 Subject: [PATCH 08/31] =?UTF-8?q?=F0=9F=92=AC=20(donate)=20update=20donati?= =?UTF-8?q?on=20custom=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- functions/donate/stripe.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/functions/donate/stripe.ts b/functions/donate/stripe.ts index 95d17d50839..6f509fa63a9 100644 --- a/functions/donate/stripe.ts +++ b/functions/donate/stripe.ts @@ -71,6 +71,14 @@ export async function createSession(donation: DonationRequest, key: string) { payment_method_types: getPaymentMethodTypes(donation), } + const messageInterval = + interval === Interval.MONTHLY + ? "You will be charged monthly and can cancel any time by writing us at donate@ourworldindata.org." + : "You will only be charged once." + const message = showOnList + ? `You chose for your donation to be publicly attributed to "${metadata.name}. It 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 donors. ${messageInterval}` + if (interval === Interval.MONTHLY) { options.mode = "subscription" options.subscription_data = { @@ -82,9 +90,6 @@ export async function createSession(donation: DonationRequest, key: string) { currency: currency, product_data: { name: "Monthly donation", - description: showOnList - ? `This is a public monthly donation: you will be listed as "${metadata.name}"` - : "This is an anonymous monthly donation", }, recurring: { interval: "month", @@ -95,6 +100,11 @@ export async function createSession(donation: DonationRequest, key: string) { quantity: 1, }, ] + options.custom_text = { + submit: { + message, + }, + } } else if (interval === Interval.ONCE) { // options.line_items = [ // { From 7f22f1b7cca997c1a322bc1e53c313b4e728b8bf Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 5 Dec 2023 11:02:50 +0000 Subject: [PATCH 09/31] =?UTF-8?q?=E2=9C=A8=20(donate)=20handle=20one-time?= =?UTF-8?q?=20donations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- functions/donate/stripe.ts | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/functions/donate/stripe.ts b/functions/donate/stripe.ts index 6f509fa63a9..1c8c09b66e0 100644 --- a/functions/donate/stripe.ts +++ b/functions/donate/stripe.ts @@ -106,17 +106,32 @@ export async function createSession(donation: DonationRequest, key: string) { }, } } else if (interval === Interval.ONCE) { - // options.line_items = [ - // { - // amount: amount, - // currency: currency, - // name: "One-time donation", - // quantity: 1, - // }, - // ] - // options.payment_intent_data = { - // metadata: metadata as any, - // } + 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: currency, + product_data: { + name: "One-time donation", + }, + unit_amount: amount, + }, + quantity: 1, + }, + ] } try { From b1436cfc63f08751ff393e63e04e994848f2673d Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 5 Dec 2023 17:08:22 +0000 Subject: [PATCH 10/31] fix (donate) CORS headers --- functions/donate/donate.ts | 18 ++++++++++++------ site/stripe/DonateForm.tsx | 4 ++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/functions/donate/donate.ts b/functions/donate/donate.ts index 1903ab6b3fc..327ff3ca2ce 100644 --- a/functions/donate/donate.ts +++ b/functions/donate/donate.ts @@ -2,11 +2,17 @@ import { DonationRequest, EnvVars } from "./types.js" import fetch from "node-fetch" import { createSession } from "./stripe.js" -const DEFAULT_HEADERS = { +// 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": "GET, POST, OPTIONS, PUT, DELETE", - "Access-Control-Allow-Headers": - "Content-Type, Access-Control-Allow-Headers, X-Requested-With", + "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 isEnvVars = (env: any): env is EnvVars => { @@ -40,7 +46,7 @@ export const onRequestPost: PagesFunction = async ({ } const session = await createSession(data, env.STRIPE_SECRET_KEY) return new Response(JSON.stringify({ url: session.url }), { - headers: DEFAULT_HEADERS, + headers: { ...CORS_HEADERS, "Content-Type": "application/json" }, status: 200, }) } catch (error) { @@ -51,7 +57,7 @@ export const onRequestPost: PagesFunction = async ({ "An unexpected error occurred. " + (error && error.message), }), { - headers: DEFAULT_HEADERS, + headers: CORS_HEADERS, status: +error.status || 500, } ) diff --git a/site/stripe/DonateForm.tsx b/site/stripe/DonateForm.tsx index 15c7714eb77..4da2434635e 100644 --- a/site/stripe/DonateForm.tsx +++ b/site/stripe/DonateForm.tsx @@ -138,9 +138,9 @@ export class DonateForm extends React.Component { const captchaToken = await this.getCaptchaToken() const response = await fetch(DONATE_API_URL, { method: "POST", - credentials: "same-origin", headers: { - Accept: "application/json", + Accept: "application/json", // expect JSON in response + "Content-Type": "application/json", // send JSON in request }, body: JSON.stringify({ name: this.name, From b798f4cc0a6f2219450e1b47182a1a9ff4172f6c Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 5 Dec 2023 17:09:29 +0000 Subject: [PATCH 11/31] wip (donate) add preflight request handler --- functions/donate/donate.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/functions/donate/donate.ts b/functions/donate/donate.ts index 327ff3ca2ce..9f7c040a87c 100644 --- a/functions/donate/donate.ts +++ b/functions/donate/donate.ts @@ -19,6 +19,14 @@ const isEnvVars = (env: any): env is EnvVars => { return !!env.ASSETS && !!env.STRIPE_SECRET_KEY && !!env.RECAPTCHA_SECRET_KEY } +// 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, From 7636c9eddf364082516fc0f58a4d3cc394373d80 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 6 Dec 2023 13:05:45 +0000 Subject: [PATCH 12/31] =?UTF-8?q?=F0=9F=92=AC=20(donate)=20show=20"Donate"?= =?UTF-8?q?=20on=20stripe=20payment=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- functions/donate/stripe.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/functions/donate/stripe.ts b/functions/donate/stripe.ts index 1c8c09b66e0..2c426479515 100644 --- a/functions/donate/stripe.ts +++ b/functions/donate/stripe.ts @@ -106,6 +106,7 @@ export async function createSession(donation: DonationRequest, key: string) { }, } } else if (interval === 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 From 4bb704c2de6192447e4d8eb74cffb4dac733231f Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 6 Dec 2023 16:29:00 +0000 Subject: [PATCH 13/31] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(donate)=20share=20d?= =?UTF-8?q?onation=20types=20between=20client=20and=20cf=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- functions/donate/donate.ts | 8 ++- functions/donate/stripe.ts | 27 ++------ functions/donate/types.ts | 27 -------- packages/@ourworldindata/utils/src/index.ts | 3 + .../@ourworldindata/utils/src/owidTypes.ts | 15 +++++ site/stripe/DonateForm.tsx | 63 +++++++++---------- 6 files changed, 61 insertions(+), 82 deletions(-) delete mode 100644 functions/donate/types.ts diff --git a/functions/donate/donate.ts b/functions/donate/donate.ts index 9f7c040a87c..599ce22390d 100644 --- a/functions/donate/donate.ts +++ b/functions/donate/donate.ts @@ -1,6 +1,12 @@ -import { DonationRequest, EnvVars } from "./types.js" import fetch from "node-fetch" import { createSession } from "./stripe.js" +import { DonationRequest } from "@ourworldindata/utils" + +interface EnvVars { + ASSETS: Fetcher + STRIPE_SECRET_KEY: string + RECAPTCHA_SECRET_KEY: string +} // CORS headers need to be sent in responses to both preflight ("OPTIONS") and // actual requests. diff --git a/functions/donate/stripe.ts b/functions/donate/stripe.ts index 2c426479515..206a28abacc 100644 --- a/functions/donate/stripe.ts +++ b/functions/donate/stripe.ts @@ -1,13 +1,10 @@ import Stripe from "stripe" -import { DonationRequest, Interval, CurrencyCode } from "./types.js" +import { DonationRequest } from "@ourworldindata/utils" function getPaymentMethodTypes( donation: DonationRequest ): Stripe.Checkout.SessionCreateParams.PaymentMethodType[] { - if ( - donation.interval === Interval.ONCE && - donation.currency === CurrencyCode.EUR - ) { + if (donation.interval === "once" && donation.currency === "EUR") { return [ "card", "sepa_debit", @@ -27,24 +24,12 @@ export async function createSession(donation: DonationRequest, key: string) { maxNetworkRetries: 2, }) - if (donation.amount == null) - throw { status: 400, message: "Please specify an amount" } - if (!Object.values(Interval).includes(donation.interval)) - throw { status: 400, message: "Please specify an interval" } - // Temporarily disable while the new form is being deployed. - // if (!Object.values(CurrencyCode).includes(donation.currency)) throw { status: 400, message: "Please specify a currency" } - if (donation.successUrl == null || donation.cancelUrl == null) - throw { - status: 400, - message: "Please specify a successUrl and cancelUrl", - } - const { name, showOnList, interval, successUrl, cancelUrl } = donation const amount = Math.floor(donation.amount) // It used to be only possible to donate in USD, so USD was hardcoded here. // We want to temporarily handle the old payload while the new form is deployed. - const currency = donation.currency || CurrencyCode.USD + const currency = donation.currency || "USD" if (amount < 100 || amount > 10_000 * 100) { throw { @@ -72,14 +57,14 @@ export async function createSession(donation: DonationRequest, key: string) { } const messageInterval = - interval === Interval.MONTHLY + interval === "monthly" ? "You will be charged monthly and can cancel any time by writing us at donate@ourworldindata.org." : "You will only be charged once." const message = showOnList ? `You chose for your donation to be publicly attributed to "${metadata.name}. It 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 donors. ${messageInterval}` - if (interval === Interval.MONTHLY) { + if (interval === "monthly") { options.mode = "subscription" options.subscription_data = { metadata, @@ -105,7 +90,7 @@ export async function createSession(donation: DonationRequest, key: string) { message, }, } - } else if (interval === Interval.ONCE) { + } else if (interval === "once") { options.submit_type = "donate" options.mode = "payment" // Create a customer for one-time payments. Without this, payments are diff --git a/functions/donate/types.ts b/functions/donate/types.ts deleted file mode 100644 index 772839caf27..00000000000 --- a/functions/donate/types.ts +++ /dev/null @@ -1,27 +0,0 @@ -export enum Interval { - ONCE = "once", - MONTHLY = "monthly", -} - -export enum CurrencyCode { - USD = "USD", - GBP = "GBP", - EUR = "EUR", -} - -export interface DonationRequest { - name: string - showOnList: boolean - currency?: CurrencyCode - amount: number - interval: Interval - successUrl: string - cancelUrl: string - captchaToken: string -} - -export interface EnvVars { - ASSETS: Fetcher - STRIPE_SECRET_KEY: string - RECAPTCHA_SECRET_KEY: string -} diff --git a/packages/@ourworldindata/utils/src/index.ts b/packages/@ourworldindata/utils/src/index.ts index b9e83a3ba20..b7bc4073fbd 100644 --- a/packages/@ourworldindata/utils/src/index.ts +++ b/packages/@ourworldindata/utils/src/index.ts @@ -29,6 +29,9 @@ export { type Deploy, type DeployChange, DeployStatus, + type DonationCurrencyCode, + type DonationInterval, + type DonationRequest, type EnrichedDetail, type EnrichedFaq, type FaqEntryData, diff --git a/packages/@ourworldindata/utils/src/owidTypes.ts b/packages/@ourworldindata/utils/src/owidTypes.ts index 47fbcf299d6..6d1e3309331 100644 --- a/packages/@ourworldindata/utils/src/owidTypes.ts +++ b/packages/@ourworldindata/utils/src/owidTypes.ts @@ -1767,3 +1767,18 @@ export interface DisplaySource { retrievedFrom?: string citation?: string } + +export type DonationInterval = "once" | "monthly" + +export type DonationCurrencyCode = "USD" | "GBP" | "EUR" + +export interface DonationRequest { + name: string + showOnList: boolean + currency: DonationCurrencyCode + amount: number + interval: DonationInterval + successUrl: string + cancelUrl: string + captchaToken: string +} diff --git a/site/stripe/DonateForm.tsx b/site/stripe/DonateForm.tsx index 4da2434635e..1f076b63a9d 100644 --- a/site/stripe/DonateForm.tsx +++ b/site/stripe/DonateForm.tsx @@ -10,24 +10,23 @@ import { BAKED_BASE_URL, RECAPTCHA_SITE_KEY, } from "../../settings/clientSettings.js" -import { Tippy, stringifyUnknownError, titleCase } from "@ourworldindata/utils" +import { + Tippy, + stringifyUnknownError, + titleCase, + DonationCurrencyCode, + DonationInterval, + DonationRequest, +} from "@ourworldindata/utils" import { Checkbox } from "@ourworldindata/components" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome/index.js" import { faArrowRight, faInfoCircle } from "@fortawesome/free-solid-svg-icons" import Bugsnag from "@bugsnag/js" -type Interval = "once" | "monthly" - -enum CurrencyCode { - GBP = "GBP", - EUR = "EUR", - USD = "USD", -} - -const currencySymbolByCode: Record = { - [CurrencyCode.GBP]: "£", - [CurrencyCode.EUR]: "€", - [CurrencyCode.USD]: "$", +const currencySymbolByCode: Record = { + GBP: "£", + EUR: "€", + USD: "$", } const ONETIME_DONATION_AMOUNTS = [10, 50, 100, 500, 1000] @@ -39,15 +38,11 @@ const MONTHLY_DEFAULT_INDEX = 1 const MIN_DONATION = 1 const MAX_DONATION: number = 10_000 -const SUPPORTED_CURRENCY_CODES = [ - CurrencyCode.GBP, - CurrencyCode.EUR, - CurrencyCode.USD, -] +const SUPPORTED_CURRENCY_CODES: DonationCurrencyCode[] = ["GBP", "EUR", "USD"] @observer export class DonateForm extends React.Component { - @observable interval: Interval = "once" + @observable interval: DonationInterval = "once" @observable presetAmount?: number = ONETIME_DONATION_AMOUNTS[ONETIME_DEFAULT_INDEX] @observable customAmount: string = "" @@ -56,7 +51,7 @@ export class DonateForm extends React.Component { @observable errorMessage?: string @observable isSubmitting: boolean = false @observable isLoading: boolean = true - @observable currencyCode: CurrencyCode = CurrencyCode.GBP + @observable currencyCode: DonationCurrencyCode = "GBP" captchaInstance?: Recaptcha | null @observable.ref captchaPromiseHandlers?: { @@ -64,7 +59,7 @@ export class DonateForm extends React.Component { reject: (value: any) => void } - @action.bound setInterval(interval: Interval) { + @action.bound setInterval(interval: DonationInterval) { this.interval = interval this.presetAmount = this.intervalAmounts[ @@ -98,7 +93,7 @@ export class DonateForm extends React.Component { this.errorMessage = message } - @action.bound setCurrency(currency: CurrencyCode) { + @action.bound setCurrency(currency: DonationCurrencyCode) { this.currencyCode = currency } @@ -136,22 +131,24 @@ export class DonateForm extends React.Component { } const captchaToken = await this.getCaptchaToken() + const requestBody: DonationRequest = { + name: this.name, + showOnList: this.showOnList, + currency: this.currencyCode, + amount: Math.floor(this.amount * 100), + interval: this.interval, + successUrl: `${BAKED_BASE_URL}/thank-you`, + cancelUrl: `${BAKED_BASE_URL}/donate`, + captchaToken: captchaToken, + } + const response = await fetch(DONATE_API_URL, { method: "POST", headers: { Accept: "application/json", // expect JSON in response "Content-Type": "application/json", // send JSON in request }, - body: JSON.stringify({ - name: this.name, - showOnList: this.showOnList, - currency: this.currencyCode, - amount: Math.floor(this.amount * 100), - interval: this.interval, - successUrl: `${BAKED_BASE_URL}/thank-you`, - cancelUrl: `${BAKED_BASE_URL}/donate`, - captchaToken: captchaToken, - }), + body: JSON.stringify(requestBody), }) const session = await response.json() if (!response.ok) { @@ -163,7 +160,7 @@ export class DonateForm extends React.Component { window.location.href = session.url } - @bind async getCaptchaToken() { + @bind async getCaptchaToken(): Promise { return new Promise((resolve, reject) => { if (!this.captchaInstance) return reject( From b58c06a60e53e6028d1f9f7df36731aa67698ef3 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 6 Dec 2023 16:32:38 +0000 Subject: [PATCH 14/31] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(donate)=20remove=20?= =?UTF-8?q?obsolete=20currency=20handling=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- functions/donate/stripe.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/functions/donate/stripe.ts b/functions/donate/stripe.ts index 206a28abacc..666f440c278 100644 --- a/functions/donate/stripe.ts +++ b/functions/donate/stripe.ts @@ -24,13 +24,10 @@ export async function createSession(donation: DonationRequest, key: string) { maxNetworkRetries: 2, }) - const { name, showOnList, interval, successUrl, cancelUrl } = donation + const { name, currency, showOnList, interval, successUrl, cancelUrl } = + donation const amount = Math.floor(donation.amount) - // It used to be only possible to donate in USD, so USD was hardcoded here. - // We want to temporarily handle the old payload while the new form is deployed. - const currency = donation.currency || "USD" - if (amount < 100 || amount > 10_000 * 100) { throw { status: 400, @@ -72,7 +69,7 @@ export async function createSession(donation: DonationRequest, key: string) { options.line_items = [ { price_data: { - currency: currency, + currency, product_data: { name: "Monthly donation", }, @@ -109,7 +106,7 @@ export async function createSession(donation: DonationRequest, key: string) { options.line_items = [ { price_data: { - currency: currency, + currency, product_data: { name: "One-time donation", }, From 1012edc929692705d9d681049e8ce7297c265ece Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 8 Dec 2023 10:50:34 +0000 Subject: [PATCH 15/31] =?UTF-8?q?=F0=9F=A5=85=20(donate):=20more=20isomorp?= =?UTF-8?q?hic=20error=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- functions/donate/donate.ts | 43 +++--- functions/donate/stripe.ts | 60 ++++++-- .../@ourworldindata/utils/src/DonateUtils.ts | 49 +++++++ packages/@ourworldindata/utils/src/index.ts | 11 ++ .../@ourworldindata/utils/src/owidTypes.ts | 38 ++++-- site/stripe/DonateForm.tsx | 128 +++++++++--------- 6 files changed, 218 insertions(+), 111 deletions(-) create mode 100644 packages/@ourworldindata/utils/src/DonateUtils.ts diff --git a/functions/donate/donate.ts b/functions/donate/donate.ts index 599ce22390d..6f8b8d6e55b 100644 --- a/functions/donate/donate.ts +++ b/functions/donate/donate.ts @@ -1,6 +1,12 @@ import fetch from "node-fetch" -import { createSession } from "./stripe.js" -import { DonationRequest } from "@ourworldindata/utils" +import { createCheckoutSession } from "./stripe.js" +import { + DonateSessionResponse, + DonationRequest, + JsonError, + PLEASE_TRY_AGAIN, + stringifyUnknownError, +} from "@ourworldindata/utils" interface EnvVars { ASSETS: Fetcher @@ -21,6 +27,8 @@ const CORS_HEADERS = { "Access-Control-Allow-Headers": "Content-Type", } +const DEFAULT_HEADERS = { ...CORS_HEADERS, "Content-Type": "application/json" } + const isEnvVars = (env: any): env is EnvVars => { return !!env.ASSETS && !!env.STRIPE_SECRET_KEY && !!env.RECAPTCHA_SECRET_KEY } @@ -41,6 +49,7 @@ export const onRequestPost: PagesFunction = async ({ env }) => { if (!isEnvVars(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." ) @@ -50,26 +59,22 @@ export const onRequestPost: PagesFunction = async ({ try { if ( - !(await validCaptcha(data.captchaToken, env.RECAPTCHA_SECRET_KEY)) - ) { - throw { - status: 400, - message: - "The CAPTCHA challenge failed, please try submitting the form again.", - } - } - const session = await createSession(data, env.STRIPE_SECRET_KEY) - return new Response(JSON.stringify({ url: session.url }), { - headers: { ...CORS_HEADERS, "Content-Type": "application/json" }, + !(await isCaptchaValid(data.captchaToken, env.RECAPTCHA_SECRET_KEY)) + ) + throw new JsonError( + `The CAPTCHA challenge failed. ${PLEASE_TRY_AGAIN}` + ) + + const session = await createCheckoutSession(data, env.STRIPE_SECRET_KEY) + const sessionResponse: DonateSessionResponse = { url: session.url } + + return new Response(JSON.stringify(sessionResponse), { + headers: DEFAULT_HEADERS, status: 200, }) } catch (error) { - console.error(error) return new Response( - JSON.stringify({ - message: - "An unexpected error occurred. " + (error && error.message), - }), + JSON.stringify({ error: stringifyUnknownError(error) }), { headers: CORS_HEADERS, status: +error.status || 500, @@ -78,7 +83,7 @@ export const onRequestPost: PagesFunction = async ({ } } -async function validCaptcha(token: string, key: string): Promise { +async function isCaptchaValid(token: string, key: string): Promise { const body = new URLSearchParams({ secret: key, response: token, diff --git a/functions/donate/stripe.ts b/functions/donate/stripe.ts index 666f440c278..486aee55bbc 100644 --- a/functions/donate/stripe.ts +++ b/functions/donate/stripe.ts @@ -1,5 +1,11 @@ import Stripe from "stripe" -import { DonationRequest } from "@ourworldindata/utils" +import { + DonationRequest, + DonationRequestTypeObject, + getErrorMessageDonation, + JsonError, +} from "@ourworldindata/utils" +import { Value } from "@sinclair/typebox/value" function getPaymentMethodTypes( donation: DonationRequest @@ -18,24 +24,45 @@ function getPaymentMethodTypes( return ["card"] } -export async function createSession(donation: DonationRequest, key: string) { +export async function createCheckoutSession(donation: unknown, key: string) { const stripe = new Stripe(key, { apiVersion: "2023-10-16", maxNetworkRetries: 2, }) - const { name, currency, showOnList, interval, successUrl, cancelUrl } = - donation - const amount = Math.floor(donation.amount) - - if (amount < 100 || amount > 10_000 * 100) { - throw { - status: 400, - message: - "You can only donate between $1 and $10,000 USD. For higher amounts, please contact donate@ourworldindata.org", - } + // 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})`) } + // When the donation object is valid, we check that the donation parameters + // are within the allowed range. If not, we send a helpful error message. + // Once again, 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 @@ -77,7 +104,7 @@ export async function createSession(donation: DonationRequest, key: string) { interval: "month", interval_count: 1, }, - unit_amount: amount, + unit_amount: amountRoundedCents, }, quantity: 1, }, @@ -110,7 +137,7 @@ export async function createSession(donation: DonationRequest, key: string) { product_data: { name: "One-time donation", }, - unit_amount: amount, + unit_amount: amountRoundedCents, }, quantity: 1, }, @@ -120,6 +147,9 @@ export async function createSession(donation: DonationRequest, key: string) { try { return await stripe.checkout.sessions.create(options) } catch (error) { - throw { message: `Error from our payments processor: ${error.message}` } + throw new JsonError( + `Error from our payments processor: ${error.message}`, + 500 + ) } } diff --git a/packages/@ourworldindata/utils/src/DonateUtils.ts b/packages/@ourworldindata/utils/src/DonateUtils.ts new file mode 100644 index 00000000000..dc11cd54ab4 --- /dev/null +++ b/packages/@ourworldindata/utils/src/DonateUtils.ts @@ -0,0 +1,49 @@ +/** + * This file cointains code shared by the DonateForm component on the client and + * the donate Cloudflare function on the server. + */ + +import { DonationCurrencyCode, DonationRequest } from "./owidTypes" + +const CURRENCY_SYMBOLS: Record = { + GBP: "£", + EUR: "€", + USD: "$", +} +export const getCurrencySymbol = (currency: DonationCurrencyCode): string => { + return CURRENCY_SYMBOLS[currency] +} + +export const SUPPORTED_CURRENCY_CODES: DonationCurrencyCode[] = [ + "GBP", + "EUR", + "USD", +] + +export const MIN_DONATION_AMOUNT = 1 +export const MAX_DONATION_AMOUNT = 10_000 + +export const PLEASE_TRY_AGAIN = + "Please try again. If the problem persists, please get in touch with us at donate@ourworldindata.org." + +export const getErrorMessageDonation = ( + donation: DonationRequest +): string | undefined => { + const symbol = getCurrencySymbol(donation.currency) + + if (!donation.amount) + return "Please enter an amount or select a preset amount." + + if ( + donation.amount < MIN_DONATION_AMOUNT || + donation.amount > MAX_DONATION_AMOUNT + ) { + return `You can only donate between ${symbol}${MIN_DONATION_AMOUNT} and ${symbol}${MAX_DONATION_AMOUNT}. For other amounts, please get in touch with us at donate@ourwourldindata.org.` + } + + if (donation.showOnList && !donation.name) { + return "Please enter your full name if you would like to be included on our public list of donors." + } + + return +} diff --git a/packages/@ourworldindata/utils/src/index.ts b/packages/@ourworldindata/utils/src/index.ts index b7bc4073fbd..c0882d0f981 100644 --- a/packages/@ourworldindata/utils/src/index.ts +++ b/packages/@ourworldindata/utils/src/index.ts @@ -29,9 +29,11 @@ export { type Deploy, type DeployChange, DeployStatus, + type DonateSessionResponse, type DonationCurrencyCode, type DonationInterval, type DonationRequest, + DonationRequestTypeObject, type EnrichedDetail, type EnrichedFaq, type FaqEntryData, @@ -677,3 +679,12 @@ export { parseFormattingOptions, parseKeyValueArgs, } from "./wordpressUtils.js" + +export { + getErrorMessageDonation, + getCurrencySymbol, + SUPPORTED_CURRENCY_CODES, + MIN_DONATION_AMOUNT, + MAX_DONATION_AMOUNT, + PLEASE_TRY_AGAIN, +} from "./DonateUtils.js" diff --git a/packages/@ourworldindata/utils/src/owidTypes.ts b/packages/@ourworldindata/utils/src/owidTypes.ts index 6d1e3309331..4f225d47bb7 100644 --- a/packages/@ourworldindata/utils/src/owidTypes.ts +++ b/packages/@ourworldindata/utils/src/owidTypes.ts @@ -1772,13 +1772,31 @@ export type DonationInterval = "once" | "monthly" export type DonationCurrencyCode = "USD" | "GBP" | "EUR" -export interface DonationRequest { - name: string - showOnList: boolean - currency: DonationCurrencyCode - amount: number - interval: DonationInterval - successUrl: string - cancelUrl: string - captchaToken: string -} +export interface DonateSessionResponse { + url?: string + error?: string +} + +// This is used to validate the type of the request body in the donate session +// when received by the server (see functions/donate/stripe). +export const DonationRequestTypeObject = Type.Object({ + name: Type.Optional(Type.String()), + showOnList: Type.Boolean(), + currency: Type.Union([ + Type.Literal("GBP"), + Type.Literal("EUR"), + Type.Literal("USD"), + ]), + amount: Type.Number({ + // We don't want to enforce a minimum or maximum donation amount at the + // type level so that we can return friendlier error messages than + // Typebox's default ones. These friendlier error messages are returned + // by getErrorMessageDonation(). + }), + interval: Type.Union([Type.Literal("once"), Type.Literal("monthly")]), + successUrl: Type.String(), + cancelUrl: Type.String(), + captchaToken: Type.String(), +}) + +export type DonationRequest = Static diff --git a/site/stripe/DonateForm.tsx b/site/stripe/DonateForm.tsx index 1f076b63a9d..860bfaab408 100644 --- a/site/stripe/DonateForm.tsx +++ b/site/stripe/DonateForm.tsx @@ -1,7 +1,7 @@ import React from "react" import ReactDOM from "react-dom" import cx from "classnames" -import { observable, action, computed, runInAction } from "mobx" +import { observable, action, computed } from "mobx" import { observer } from "mobx-react" import { bind } from "decko" import Recaptcha from "react-recaptcha" @@ -17,29 +17,23 @@ import { DonationCurrencyCode, DonationInterval, DonationRequest, + getErrorMessageDonation, + SUPPORTED_CURRENCY_CODES, + getCurrencySymbol, + DonateSessionResponse, + PLEASE_TRY_AGAIN, } from "@ourworldindata/utils" import { Checkbox } from "@ourworldindata/components" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome/index.js" import { faArrowRight, faInfoCircle } from "@fortawesome/free-solid-svg-icons" import Bugsnag from "@bugsnag/js" -const currencySymbolByCode: Record = { - GBP: "£", - EUR: "€", - USD: "$", -} - const ONETIME_DONATION_AMOUNTS = [10, 50, 100, 500, 1000] const MONTHLY_DONATION_AMOUNTS = [5, 10, 20, 50, 100] const ONETIME_DEFAULT_INDEX = 1 const MONTHLY_DEFAULT_INDEX = 1 -const MIN_DONATION = 1 -const MAX_DONATION: number = 10_000 - -const SUPPORTED_CURRENCY_CODES: DonationCurrencyCode[] = ["GBP", "EUR", "USD"] - @observer export class DonateForm extends React.Component { @observable interval: DonationInterval = "once" @@ -93,6 +87,10 @@ export class DonateForm extends React.Component { this.errorMessage = message } + @action.bound setIsSubmitting(isSubmitting: boolean) { + this.isSubmitting = isSubmitting + } + @action.bound setCurrency(currency: DonationCurrencyCode) { this.currencyCode = currency } @@ -110,38 +108,37 @@ export class DonateForm extends React.Component { } @computed get currencySymbol(): string { - return currencySymbolByCode[this.currencyCode] + return getCurrencySymbol(this.currencyCode) } async submitDonation(): Promise { - if ( - !this.amount || - this.amount > MAX_DONATION || - this.amount < MIN_DONATION - ) { - throw new Error( - `You can only donate between ${this.currencySymbol}1 and ${this.currencySymbol}10,000. For other amounts, please get in touch with us at donate@ourworldindata.org.` - ) - } - - if (this.showOnList && !this.name) { - throw new Error( - "Please enter your full name if you would like to be included on our public list of donors." - ) - } - - const captchaToken = await this.getCaptchaToken() - const requestBody: DonationRequest = { + const requestBodyForClientSideValidation: DonationRequest = { name: this.name, showOnList: this.showOnList, currency: this.currencyCode, - amount: Math.floor(this.amount * 100), + amount: this.amount || 0, interval: this.interval, successUrl: `${BAKED_BASE_URL}/thank-you`, cancelUrl: `${BAKED_BASE_URL}/donate`, - captchaToken: captchaToken, + captchaToken: "", + } + + // Validate the request body before requesting the CAPTCHA token for + // faster feedback in case of form errors (e.g. invalid amount). + const errorMessage = getErrorMessageDonation( + requestBodyForClientSideValidation + ) + if (errorMessage) throw new Error(errorMessage) + + // Get the CAPTCHA token once the request body is validated. + const captchaToken = await this.getCaptchaToken() + + const requestBody: DonationRequest = { + ...requestBodyForClientSideValidation, + captchaToken, } + // Send the request to the server, along with the CAPTCHA token. const response = await fetch(DONATE_API_URL, { method: "POST", headers: { @@ -150,10 +147,12 @@ export class DonateForm extends React.Component { }, body: JSON.stringify(requestBody), }) - const session = await response.json() - if (!response.ok) { + + const session: DonateSessionResponse = await response.json() + + if (!response.ok || !session.url) { throw new Error( - "Could not connect to Stripe, our payment provider." + session.error || `Something went wrong. ${PLEASE_TRY_AGAIN}` ) } @@ -164,9 +163,7 @@ export class DonateForm extends React.Component { return new Promise((resolve, reject) => { if (!this.captchaInstance) return reject( - new Error( - "Could not load reCAPTCHA. Please try again. If the problem persists, please get in touch with us at donate@ourworldindata.org" - ) + new Error(`Could not load reCAPTCHA. ${PLEASE_TRY_AGAIN}`) ) this.captchaPromiseHandlers = { resolve, reject } this.captchaInstance.reset() @@ -185,39 +182,36 @@ export class DonateForm extends React.Component { @bind async onSubmit(event: React.FormEvent) { event.preventDefault() - this.isSubmitting = true - this.errorMessage = undefined + this.setIsSubmitting(true) + this.setErrorMessage(undefined) + try { await this.submitDonation() } catch (error) { - this.isSubmitting = false - - runInAction(() => { - const prefixedErrorMessage = stringifyUnknownError(error) - // Send all errors to Bugsnag. This will help surface issues - // with our aging reCAPTCHA setup, and pull the trigger on a - // (hook-based?) rewrite if it starts failing. This reporting - // also includes form validation errors, which are useful to - // identify possible UX improvements or validate UX experiments - // (such as the combination of the name field and the "include - // my name on the list" checkbox). - Bugsnag.notify( - error instanceof Error - ? error - : new Error(prefixedErrorMessage) - ) + this.setIsSubmitting(false) + + const prefixedErrorMessage = stringifyUnknownError(error) + // Send all errors to Bugsnag. This will help surface issues + // with our aging reCAPTCHA setup, and pull the trigger on a + // (hook-based?) rewrite if it starts failing. This reporting + // also includes form validation errors, which are useful to + // identify possible UX improvements or validate UX experiments + // (such as the combination of the name field and the "include + // my name on the list" checkbox). + Bugsnag.notify( + error instanceof Error ? error : new Error(prefixedErrorMessage) + ) - if (!prefixedErrorMessage) { - this.errorMessage = - "Something went wrong. Please get in touch with us at donate@ourworldindata.org" - return - } + if (!prefixedErrorMessage) { + this.setErrorMessage( + `Something went wrong. ${PLEASE_TRY_AGAIN}` + ) + return + } - const rawErrorMessage = - prefixedErrorMessage.match(/^Error: (.*)$/) + const rawErrorMessage = prefixedErrorMessage.match(/^Error: (.*)$/) - this.errorMessage = rawErrorMessage?.[1] || prefixedErrorMessage - }) + this.setErrorMessage(rawErrorMessage?.[1] || prefixedErrorMessage) } } @@ -252,7 +246,7 @@ export class DonateForm extends React.Component { {SUPPORTED_CURRENCY_CODES.map((code) => ( this.setCurrency(code)} className={cx("donation-options__button", { active: this.currencyCode === code, From 1117d5f1cc85d9caa2cf8dde9f78f0e799f4b26c Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 8 Dec 2023 11:28:16 +0000 Subject: [PATCH 16/31] =?UTF-8?q?=F0=9F=90=9B=20(donate):=20fix=20reCAPTCH?= =?UTF-8?q?A=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- functions/donate/donate.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/functions/donate/donate.ts b/functions/donate/donate.ts index 6f8b8d6e55b..2b6d5ed39b5 100644 --- a/functions/donate/donate.ts +++ b/functions/donate/donate.ts @@ -84,15 +84,10 @@ export const onRequestPost: PagesFunction = async ({ } async function isCaptchaValid(token: string, key: string): Promise { - const body = new URLSearchParams({ - secret: key, - response: token, - }) const response = await fetch( - "https://www.google.com/recaptcha/api/siteverify", + `https://www.google.com/recaptcha/api/siteverify?secret=${key}&response=${token}`, { - method: "post", - body: body, + method: "POST", } ) const json = (await response.json()) as { success: boolean } From f3014608717fbac0ba2aa669c05ef232c691d56a Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 8 Dec 2023 12:46:27 +0000 Subject: [PATCH 17/31] =?UTF-8?q?=F0=9F=A6=BA=20(donate)=20reset=20form=20?= =?UTF-8?q?errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/stripe/DonateForm.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/site/stripe/DonateForm.tsx b/site/stripe/DonateForm.tsx index 860bfaab408..a05ca8d02ba 100644 --- a/site/stripe/DonateForm.tsx +++ b/site/stripe/DonateForm.tsx @@ -66,21 +66,25 @@ export class DonateForm extends React.Component { @action.bound setPresetAmount(amount?: number) { this.presetAmount = amount this.customAmount = "" + this.errorMessage = undefined } @action.bound setCustomAmount(amount: string) { this.customAmount = amount this.presetAmount = undefined + this.errorMessage = undefined } @action.bound setName(name: string) { // capitalize first letter of each word. Words can be separated by // spaces or hyphens. this.name = titleCase(name) + this.errorMessage = undefined } @action.bound toggleShowOnList() { this.showOnList = !this.showOnList + this.errorMessage = undefined } @action.bound setErrorMessage(message?: string) { From 0b41a78336682613bdf73841bc57b9aab47df216 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 8 Dec 2023 13:08:38 +0000 Subject: [PATCH 18/31] =?UTF-8?q?=F0=9F=93=9D=20(donate)=20add=20docs=20fo?= =?UTF-8?q?r=20donate=20cf=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- functions/README.md | 73 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/functions/README.md b/functions/README.md index 913bc86b13b..31376b641d8 100644 --- a/functions/README.md +++ b/functions/README.md @@ -12,6 +12,79 @@ In addition, there's a [`_routes.json`](../_routes.json) file that specifies whi # Our dynamic routes +## `/donate/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 + +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/donate/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 + ## `/grapher/:slug` Our grapher pages are (slightly) dynamic! From 72da26accc437c1b8c07c89c2d2e47a0025530e9 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 12 Dec 2023 08:03:01 +0000 Subject: [PATCH 19/31] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(donate)=20check=20f?= =?UTF-8?q?or=20donation=20type=20earlier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- functions/donate/donate.ts | 28 ++++++++++++++++++++++++---- functions/donate/stripe.ts | 29 ++++++++--------------------- 2 files changed, 32 insertions(+), 25 deletions(-) diff --git a/functions/donate/donate.ts b/functions/donate/donate.ts index 2b6d5ed39b5..f52dae783bb 100644 --- a/functions/donate/donate.ts +++ b/functions/donate/donate.ts @@ -2,11 +2,12 @@ import fetch from "node-fetch" import { createCheckoutSession } from "./stripe.js" import { DonateSessionResponse, - DonationRequest, + DonationRequestTypeObject, JsonError, PLEASE_TRY_AGAIN, stringifyUnknownError, } from "@ourworldindata/utils" +import { Value } from "@sinclair/typebox/value" interface EnvVars { ASSETS: Fetcher @@ -55,17 +56,36 @@ export const onRequestPost: PagesFunction = async ({ ) // Parse the body of the request as JSON - const data: DonationRequest = await request.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(data.captchaToken, env.RECAPTCHA_SECRET_KEY)) + !(await isCaptchaValid( + donation.captchaToken, + env.RECAPTCHA_SECRET_KEY + )) ) throw new JsonError( `The CAPTCHA challenge failed. ${PLEASE_TRY_AGAIN}` ) - const session = await createCheckoutSession(data, env.STRIPE_SECRET_KEY) + const session = await createCheckoutSession( + donation, + env.STRIPE_SECRET_KEY + ) const sessionResponse: DonateSessionResponse = { url: session.url } return new Response(JSON.stringify(sessionResponse), { diff --git a/functions/donate/stripe.ts b/functions/donate/stripe.ts index 486aee55bbc..7f8dcf085b1 100644 --- a/functions/donate/stripe.ts +++ b/functions/donate/stripe.ts @@ -1,11 +1,9 @@ import Stripe from "stripe" import { DonationRequest, - DonationRequestTypeObject, getErrorMessageDonation, JsonError, } from "@ourworldindata/utils" -import { Value } from "@sinclair/typebox/value" function getPaymentMethodTypes( donation: DonationRequest @@ -24,30 +22,19 @@ function getPaymentMethodTypes( return ["card"] } -export async function createCheckoutSession(donation: unknown, key: string) { +export async function createCheckoutSession( + donation: DonationRequest, + key: string +) { const stripe = new Stripe(key, { apiVersion: "2023-10-16", maxNetworkRetries: 2, }) - // 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})`) - } - - // When the donation object is valid, we check that the donation parameters - // are within the allowed range. If not, we send a helpful error message. - // Once again, 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. + // 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) From 27fe39160629a9bd295e843fe1fed6e14a3c0764 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 12 Dec 2023 08:04:12 +0000 Subject: [PATCH 20/31] =?UTF-8?q?=F0=9F=93=A6=20(donate)=20add=20missing?= =?UTF-8?q?=20stripe=20package?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + yarn.lock | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/package.json b/package.json index 58d3943e264..39de0e545de 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index 23a84e3a7e3..aa28076acb2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10928,6 +10928,7 @@ __metadata: simple-statistics: "npm:^7.3.2" sql-fixtures: "npm:^1.0.4" string-pixel-width: "npm:^1.10.0" + stripe: "npm:^14.8.0" striptags: "npm:^3.2.0" svgo: "npm:^3.0.2" timezone-mock: "npm:^1.0.18" @@ -18971,6 +18972,16 @@ __metadata: languageName: node linkType: hard +"stripe@npm:^14.8.0": + version: 14.8.0 + resolution: "stripe@npm:14.8.0" + dependencies: + "@types/node": "npm:>=8.1.0" + qs: "npm:^6.11.0" + checksum: 6cf0046b210e43eeacd8a630c43b2478d5d18213a65f06234c5572cbf01b454356843a15cff8f84c5e72e9d65c21fec5cff0728b6dca1d2f26217dc27d9d41e6 + languageName: node + linkType: hard + "striptags@npm:^3.2.0": version: 3.2.0 resolution: "striptags@npm:3.2.0" From 6180e869a68bd3f8f7005d11f36b51f8f982697b Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 12 Dec 2023 17:10:42 +0000 Subject: [PATCH 21/31] =?UTF-8?q?=F0=9F=92=AC=20(donate)=20update=20custom?= =?UTF-8?q?=20text=20on=20stripe=20checkout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- functions/donate/stripe.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/functions/donate/stripe.ts b/functions/donate/stripe.ts index 7f8dcf085b1..79d615491b6 100644 --- a/functions/donate/stripe.ts +++ b/functions/donate/stripe.ts @@ -69,11 +69,11 @@ export async function createCheckoutSession( const messageInterval = interval === "monthly" - ? "You will be charged monthly and can cancel any time by writing us at donate@ourworldindata.org." + ? "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 attributed to "${metadata.name}. It 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 donors. ${messageInterval}` + ? `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" From bd01b65e0c5e17749eba0d225dc0a68357a65bd5 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 12 Dec 2023 17:35:18 +0000 Subject: [PATCH 22/31] =?UTF-8?q?=E2=9C=A8=20(donate)=20add=20image=20to?= =?UTF-8?q?=20stripe=20checkout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- functions/donate/stripe.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/functions/donate/stripe.ts b/functions/donate/stripe.ts index 79d615491b6..b2dccbe08c4 100644 --- a/functions/donate/stripe.ts +++ b/functions/donate/stripe.ts @@ -86,6 +86,9 @@ export async function createCheckoutSession( currency, product_data: { name: "Monthly donation", + images: [ + "https://ourworldindata.org/default-thumbnail.jpg", + ], }, recurring: { interval: "month", @@ -123,6 +126,9 @@ export async function createCheckoutSession( currency, product_data: { name: "One-time donation", + images: [ + "https://ourworldindata.org/default-thumbnail.jpg", + ], }, unit_amount: amountRoundedCents, }, From e4714afc5c034d80e410e25b9bf7f2d78337b67d Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 13 Dec 2023 08:05:40 +0000 Subject: [PATCH 23/31] =?UTF-8?q?=F0=9F=9A=9A=20(donate)=20rename=20stripe?= =?UTF-8?q?=20->=20checkout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- functions/donate/{stripe.ts => checkout.ts} | 0 functions/donate/donate.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename functions/donate/{stripe.ts => checkout.ts} (100%) diff --git a/functions/donate/stripe.ts b/functions/donate/checkout.ts similarity index 100% rename from functions/donate/stripe.ts rename to functions/donate/checkout.ts diff --git a/functions/donate/donate.ts b/functions/donate/donate.ts index f52dae783bb..56da0fde092 100644 --- a/functions/donate/donate.ts +++ b/functions/donate/donate.ts @@ -1,5 +1,5 @@ import fetch from "node-fetch" -import { createCheckoutSession } from "./stripe.js" +import { createCheckoutSession } from "./checkout.js" import { DonateSessionResponse, DonationRequestTypeObject, From 72d28daa4ce83934d305eb358c5bf48342b8bbaa Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 13 Dec 2023 10:02:37 +0000 Subject: [PATCH 24/31] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(donate)=20rename=20?= =?UTF-8?q?env=20var=20fn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- functions/donate/donate.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/functions/donate/donate.ts b/functions/donate/donate.ts index 56da0fde092..a909caece28 100644 --- a/functions/donate/donate.ts +++ b/functions/donate/donate.ts @@ -9,12 +9,16 @@ import { } from "@ourworldindata/utils" import { Value } from "@sinclair/typebox/value" -interface EnvVars { +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 = { @@ -30,10 +34,6 @@ const CORS_HEADERS = { const DEFAULT_HEADERS = { ...CORS_HEADERS, "Content-Type": "application/json" } -const isEnvVars = (env: any): env is EnvVars => { - return !!env.ASSETS && !!env.STRIPE_SECRET_KEY && !!env.RECAPTCHA_SECRET_KEY -} - // This function is called when the request is a preflight request ("OPTIONS"). export const onRequestOptions: PagesFunction = async () => { return new Response(null, { @@ -49,7 +49,7 @@ export const onRequestPost: PagesFunction = async ({ request: Request env }) => { - if (!isEnvVars(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." From a88527a4d5c5cacd8c01d74e821791c24f885c75 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 13 Dec 2023 15:42:44 +0000 Subject: [PATCH 25/31] =?UTF-8?q?=F0=9F=A9=B9=20(donate)=20correct=20heade?= =?UTF-8?q?r=20response=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- functions/donate/donate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/donate/donate.ts b/functions/donate/donate.ts index a909caece28..fec8eb8832e 100644 --- a/functions/donate/donate.ts +++ b/functions/donate/donate.ts @@ -96,7 +96,7 @@ export const onRequestPost: PagesFunction = async ({ return new Response( JSON.stringify({ error: stringifyUnknownError(error) }), { - headers: CORS_HEADERS, + headers: DEFAULT_HEADERS, status: +error.status || 500, } ) From f6a1b571f97f3b8d5c1b8b0180dd490d6306a902 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 14 Dec 2023 07:43:50 +0000 Subject: [PATCH 26/31] =?UTF-8?q?=F0=9F=9A=9A=20(donate)=20move=20donate?= =?UTF-8?q?=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/{stripe => }/DonateForm.tsx | 2 +- site/owid.entry.ts | 2 +- site/stripe/readme.md | 3 --- 3 files changed, 2 insertions(+), 5 deletions(-) rename site/{stripe => }/DonateForm.tsx (99%) delete mode 100644 site/stripe/readme.md diff --git a/site/stripe/DonateForm.tsx b/site/DonateForm.tsx similarity index 99% rename from site/stripe/DonateForm.tsx rename to site/DonateForm.tsx index a05ca8d02ba..489d77459ea 100644 --- a/site/stripe/DonateForm.tsx +++ b/site/DonateForm.tsx @@ -9,7 +9,7 @@ import { DONATE_API_URL, BAKED_BASE_URL, RECAPTCHA_SITE_KEY, -} from "../../settings/clientSettings.js" +} from "../settings/clientSettings.js" import { Tippy, stringifyUnknownError, diff --git a/site/owid.entry.ts b/site/owid.entry.ts index fbaa25175cf..c2dd9211ca2 100644 --- a/site/owid.entry.ts +++ b/site/owid.entry.ts @@ -9,7 +9,7 @@ import { runChartsIndexPage } from "./runChartsIndexPage.js" import { runSearchPage } from "./search/SearchPanel.js" import { runNotFoundPage } from "./NotFoundPageMain.js" import { runFeedbackPage } from "./Feedback.js" -import { runDonateForm } from "./stripe/DonateForm.js" +import { runDonateForm } from "./DonateForm.js" import { runCountryProfilePage } from "./runCountryProfilePage.js" import { runTableOfContents } from "./TableOfContents.js" import { runRelatedCharts } from "./blocks/RelatedCharts.js" diff --git a/site/stripe/readme.md b/site/stripe/readme.md deleted file mode 100644 index 03956e366dd..00000000000 --- a/site/stripe/readme.md +++ /dev/null @@ -1,3 +0,0 @@ -# Stripe - -This folder contains the donation form and code for interacting with the Stripe API. From 8b44c325fb9a98aab498a500d3753c95d5e8253d Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 14 Dec 2023 08:07:17 +0000 Subject: [PATCH 27/31] =?UTF-8?q?=F0=9F=9A=9A=20(donate)=20change=20base?= =?UTF-8?q?=20folder=20to=20"donation"=20Prevent=20conflict=20with=20/dona?= =?UTF-8?q?te/thank-you=20redirect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dev.vars.example | 2 +- functions/README.md | 4 ++-- functions/{donate => donation}/checkout.ts | 0 functions/{donate => donation}/donate.ts | 0 packages/@ourworldindata/utils/src/owidTypes.ts | 2 +- settings/clientSettings.ts | 2 +- 6 files changed, 5 insertions(+), 5 deletions(-) rename functions/{donate => donation}/checkout.ts (100%) rename functions/{donate => donation}/donate.ts (100%) diff --git a/.dev.vars.example b/.dev.vars.example index 6144dc82c32..1e9c27b9ea8 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -1,4 +1,4 @@ -# env vars for local development of /functions/donate/donate.ts cloudflare pages function +# 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 # https://dashboard.stripe.com/test/apikeys diff --git a/functions/README.md b/functions/README.md index 31376b641d8..5f420d6be1f 100644 --- a/functions/README.md +++ b/functions/README.md @@ -12,7 +12,7 @@ In addition, there's a [`_routes.json`](../_routes.json) file that specifies whi # Our dynamic routes -## `/donate/donate` +## `/donation/donate` This route is used to create a Stripe Checkout session for a donation. @@ -78,7 +78,7 @@ 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/donate/donate`. +The route is available at `http://localhost:8788/donation/donate`. Note: compatibility dates between local development and production environments should be kept in sync: diff --git a/functions/donate/checkout.ts b/functions/donation/checkout.ts similarity index 100% rename from functions/donate/checkout.ts rename to functions/donation/checkout.ts diff --git a/functions/donate/donate.ts b/functions/donation/donate.ts similarity index 100% rename from functions/donate/donate.ts rename to functions/donation/donate.ts diff --git a/packages/@ourworldindata/utils/src/owidTypes.ts b/packages/@ourworldindata/utils/src/owidTypes.ts index 4f225d47bb7..2ef349fc8e2 100644 --- a/packages/@ourworldindata/utils/src/owidTypes.ts +++ b/packages/@ourworldindata/utils/src/owidTypes.ts @@ -1778,7 +1778,7 @@ export interface DonateSessionResponse { } // This is used to validate the type of the request body in the donate session -// when received by the server (see functions/donate/stripe). +// when received by the server (see functions/donation/checkout). export const DonationRequestTypeObject = Type.Object({ name: Type.Optional(Type.String()), showOnList: Type.Boolean(), diff --git a/settings/clientSettings.ts b/settings/clientSettings.ts index 37c1798224b..190886d14a9 100644 --- a/settings/clientSettings.ts +++ b/settings/clientSettings.ts @@ -46,7 +46,7 @@ export const ALGOLIA_ID: string = process.env.ALGOLIA_ID ?? "" export const ALGOLIA_SEARCH_KEY: string = process.env.ALGOLIA_SEARCH_KEY ?? "" export const DONATE_API_URL: string = - process.env.DONATE_API_URL ?? "http://localhost:8788/donate/donate" + process.env.DONATE_API_URL ?? "http://localhost:8788/donation/donate" export const RECAPTCHA_SITE_KEY: string = process.env.RECAPTCHA_SITE_KEY ?? "6LcJl5YUAAAAAATQ6F4vl9dAWRZeKPBm15MAZj4Q" From 5227079c434337f9641af2ae4439e35553b1e49c Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 15 Dec 2023 17:08:36 +0000 Subject: [PATCH 28/31] =?UTF-8?q?=F0=9F=93=9D=20(donate)=20add=20testing?= =?UTF-8?q?=20instructions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dev.vars.example | 1 + functions/README.md | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.dev.vars.example b/.dev.vars.example index 1e9c27b9ea8..ef24c595047 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -1,5 +1,6 @@ # 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 diff --git a/functions/README.md b/functions/README.md index 5f420d6be1f..32919be7d55 100644 --- a/functions/README.md +++ b/functions/README.md @@ -73,7 +73,9 @@ sequenceDiagram ### Development -Start the Cloudflare function development server with either: +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 @@ -85,6 +87,8 @@ Note: compatibility dates between local development and production environments - 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! From 35001ce24cb38e96705508e3ab66c9d156642e9a Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 3 Jan 2024 13:18:40 +0000 Subject: [PATCH 29/31] =?UTF-8?q?=E2=9C=8F=EF=B8=8F=20(donate)=20add=20mis?= =?UTF-8?q?sing=20quote?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- functions/donation/checkout.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/donation/checkout.ts b/functions/donation/checkout.ts index b2dccbe08c4..da09065647f 100644 --- a/functions/donation/checkout.ts +++ b/functions/donation/checkout.ts @@ -72,7 +72,7 @@ export async function createCheckoutSession( ? "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 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") { From ad1dfcca7430df3b4cc18e9d171a991ea6fd9af8 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 3 Jan 2024 14:16:14 +0000 Subject: [PATCH 30/31] =?UTF-8?q?=F0=9F=A9=B9=20(donate)=20correct=20error?= =?UTF-8?q?=20message=20for=20"0"=20donations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/@ourworldindata/utils/src/DonateUtils.ts | 4 ++-- packages/@ourworldindata/utils/src/owidTypes.ts | 14 ++++++++------ site/DonateForm.tsx | 2 +- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/@ourworldindata/utils/src/DonateUtils.ts b/packages/@ourworldindata/utils/src/DonateUtils.ts index dc11cd54ab4..72a38d0d996 100644 --- a/packages/@ourworldindata/utils/src/DonateUtils.ts +++ b/packages/@ourworldindata/utils/src/DonateUtils.ts @@ -31,8 +31,8 @@ export const getErrorMessageDonation = ( ): string | undefined => { const symbol = getCurrencySymbol(donation.currency) - if (!donation.amount) - return "Please enter an amount or select a preset amount." + if (donation.amount === undefined || Number.isNaN(donation.amount)) + return "Please enter a valid amount or select a preset amount." if ( donation.amount < MIN_DONATION_AMOUNT || diff --git a/packages/@ourworldindata/utils/src/owidTypes.ts b/packages/@ourworldindata/utils/src/owidTypes.ts index 2ef349fc8e2..8fba2bf6374 100644 --- a/packages/@ourworldindata/utils/src/owidTypes.ts +++ b/packages/@ourworldindata/utils/src/owidTypes.ts @@ -1787,12 +1787,14 @@ export const DonationRequestTypeObject = Type.Object({ Type.Literal("EUR"), Type.Literal("USD"), ]), - amount: Type.Number({ - // We don't want to enforce a minimum or maximum donation amount at the - // type level so that we can return friendlier error messages than - // Typebox's default ones. These friendlier error messages are returned - // by getErrorMessageDonation(). - }), + amount: Type.Optional( + Type.Number({ + // We don't want to enforce a minimum or maximum donation amount at the + // type level so that we can return friendlier error messages than + // Typebox's default ones. These friendlier error messages are returned + // by getErrorMessageDonation(). + }) + ), interval: Type.Union([Type.Literal("once"), Type.Literal("monthly")]), successUrl: Type.String(), cancelUrl: Type.String(), diff --git a/site/DonateForm.tsx b/site/DonateForm.tsx index 489d77459ea..936f03bc21f 100644 --- a/site/DonateForm.tsx +++ b/site/DonateForm.tsx @@ -120,7 +120,7 @@ export class DonateForm extends React.Component { name: this.name, showOnList: this.showOnList, currency: this.currencyCode, - amount: this.amount || 0, + amount: this.amount, interval: this.interval, successUrl: `${BAKED_BASE_URL}/thank-you`, cancelUrl: `${BAKED_BASE_URL}/donate`, From 98022dec225558080fe1cd355e0258559c28cd54 Mon Sep 17 00:00:00 2001 From: owidbot Date: Thu, 11 Jan 2024 15:52:35 +0000 Subject: [PATCH 31/31] =?UTF-8?q?=F0=9F=90=9B=20(donate)=20do=20not=20send?= =?UTF-8?q?=20name=20if=20not=20on=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/DonateForm.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/site/DonateForm.tsx b/site/DonateForm.tsx index 936f03bc21f..3501c61eecc 100644 --- a/site/DonateForm.tsx +++ b/site/DonateForm.tsx @@ -117,7 +117,10 @@ export class DonateForm extends React.Component { async submitDonation(): Promise { const requestBodyForClientSideValidation: DonationRequest = { - name: this.name, + // Don't send the name if the reader doesn't want to appear on the + // list of supporters, but keep it in the form in case they change + // their mind. + name: this.showOnList ? this.name : "", showOnList: this.showOnList, currency: this.currencyCode, amount: this.amount,