From cc538e5df229d714ecde87d6a001f4de4458f78f Mon Sep 17 00:00:00 2001 From: joostdecock Date: Mon, 31 Jul 2023 09:03:55 +0200 Subject: [PATCH] wip(backend): Work on payment processing --- sites/backend/example.env | 12 ++++ sites/backend/src/config.mjs | 20 ++++++ sites/backend/src/controllers/payments.mjs | 34 +++++++++ sites/backend/src/routes/index.mjs | 2 + sites/backend/src/routes/payments.mjs | 15 ++++ sites/shared/components/patrons/payment.mjs | 77 +++++++-------------- sites/shared/config/stripe.mjs | 2 +- sites/shared/hooks/use-backend.mjs | 7 ++ 8 files changed, 117 insertions(+), 52 deletions(-) create mode 100644 sites/backend/src/controllers/payments.mjs create mode 100644 sites/backend/src/routes/payments.mjs diff --git a/sites/backend/example.env b/sites/backend/example.env index 85ae0364791..33981a434da 100644 --- a/sites/backend/example.env +++ b/sites/backend/example.env @@ -131,6 +131,18 @@ BACKEND_ENABLE_SANITY=no # Sanity API version #SANITY_VERSION=v2022-10-31 +##################################################################### +# Payments via Stripe # +# # +# We use Stripe as payments processor for patron contributions # +##################################################################### + +# Set this to no to disable Stripe payments altogther +BACKEND_ENABLE_PAYMENTS=no + +# Stripe API key with permissions to create a payment intent +#BACKEND_STRIPE_CREATE_INTENT_KEY=yourKeyHere + ##################################################################### # Github integration # # # diff --git a/sites/backend/src/config.mjs b/sites/backend/src/config.mjs index ac9a9bb3a28..210d02b4bc5 100644 --- a/sites/backend/src/config.mjs +++ b/sites/backend/src/config.mjs @@ -44,6 +44,7 @@ const baseConfig = { }, sanity: envToBool(process.env.BACKEND_ENABLE_SANITY), ses: envToBool(process.env.BACKEND_ENABLE_AWS_SES), + stripe: envToBool(process.env.BACKEND_ENABLE_PAYMENTS), tests: { base: envToBool(process.env.BACKEND_ENABLE_TESTS), email: envToBool(process.env.BACKEND_ENABLE_TESTS_EMAIL), @@ -152,6 +153,14 @@ if (baseConfig.use.sanity) }`, } +// Stripe config +if (baseConfig.use.stripe) + baseConfig.stripe = { + keys: { + createIntent: process.env.BACKEND_STRIPE_CREATE_INTENT_KEY || false, + }, + } + // AWS SES config (for sending out emails) if (baseConfig.use.ses) baseConfig.aws = { @@ -209,6 +218,7 @@ const vars = { BACKEND_ENABLE_GITHUB: 'optional', BACKEND_ENABLE_OAUTH_GITHUB: 'optional', BACKEND_ENABLE_OAUTH_GOOGLE: 'optional', + BACKEND_ENABLE_PAYMENTS: 'optional', BACKEND_ENABLE_TESTS: 'optional', BACKEND_ALLOW_TESTS_IN_PRODUCTION: 'optional', BACKEND_ENABLE_DUMP_CONFIG_AT_STARTUP: 'optional', @@ -250,6 +260,11 @@ if (envToBool(process.env.BACKEND_ENABLE_OAUTH_GOOGLE)) { vars.BACKEND_OAUTH_GOOGLE_CLIENT_ID = 'required' vars.BACKEND_OAUTH_GOOGLE_CLIENT_SECRET = 'requiredSecret' } +// Vars for Stripe integration +if (envToBool(process.env.BACKEND_ENABLE_PAYMENTS)) { + vars.BACKEND_STRIPE_CREATE_INTENT_KEY = 'requiredSecret' +} + // Vars for (unit) tests if (envToBool(process.env.BACKEND_ENABLE_TESTS)) { vars.BACKEND_TEST_DOMAIN = 'optional' @@ -324,6 +339,11 @@ export function verifyConfig(silent = false) { config.jwt.secretOrKey.slice(0, 4) + '**redacted**' + config.jwt.secretOrKey.slice(-4), }, } + if (config.stripe) + dump.stripe = { + ...config.stripe.keys, + //token: config.sanity.token.slice(0, 4) + '**redacted**' + config.sanity.token.slice(-4), + } if (config.sanity) dump.sanity = { ...config.sanity, diff --git a/sites/backend/src/controllers/payments.mjs b/sites/backend/src/controllers/payments.mjs new file mode 100644 index 00000000000..08e5543de9f --- /dev/null +++ b/sites/backend/src/controllers/payments.mjs @@ -0,0 +1,34 @@ +export function PaymentsController() {} + +/* + * Create payment intent + * + * This is the endpoint that handles creation of a Stripe payment intent + * See: https://freesewing.dev/reference/backend/api/apikey + */ +PaymentsController.prototype.createIntent = async (req, res, tools) => { + if (!req.body.amount) return this.setResponse(400, 'amountMissing') + if (!req.body.currency) return this.setResponse(400, 'currencyMissing') + + const body = new URLSearchParams() + body.append('amount', req.body.amount) + body.append('currency', req.body.currency) + + // Call Stripe API with fetch to create the payment inent + const result = await fetch('https://api.stripe.com/v1/payment_intents', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + authorization: `Bearer ${tools.config.stripe.keys.createIntent}`, + }, + body, + }) + // Convert response to JSON + const json = await result.json() + + // Return status code 201 (created) and clientSecret + return res.status(201).send({ + result: 'success', + clientSecret: json.client_secret, + }) +} diff --git a/sites/backend/src/routes/index.mjs b/sites/backend/src/routes/index.mjs index 3155af45471..d15916f33cb 100644 --- a/sites/backend/src/routes/index.mjs +++ b/sites/backend/src/routes/index.mjs @@ -6,6 +6,7 @@ import { confirmationsRoutes } from './confirmations.mjs' import { curatedSetsRoutes } from './curated-sets.mjs' import { issuesRoutes } from './issues.mjs' import { flowsRoutes } from './flows.mjs' +import { paymentsRoutes } from './payments.mjs' export const routes = { apikeysRoutes, @@ -16,4 +17,5 @@ export const routes = { curatedSetsRoutes, issuesRoutes, flowsRoutes, + paymentsRoutes, } diff --git a/sites/backend/src/routes/payments.mjs b/sites/backend/src/routes/payments.mjs new file mode 100644 index 00000000000..9be9e76d466 --- /dev/null +++ b/sites/backend/src/routes/payments.mjs @@ -0,0 +1,15 @@ +import { PaymentsController } from '../controllers/payments.mjs' + +const Payments = new PaymentsController() + +export function paymentsRoutes(tools) { + const { app } = tools + + /* + * Only add these routes if payments are enabled in the config + */ + if (tools.config.use.stripe) { + // Create payment intent + app.post('/payments/intent', (req, res) => Payments.createIntent(req, res, tools)) + } +} diff --git a/sites/shared/components/patrons/payment.mjs b/sites/shared/components/patrons/payment.mjs index ba327ae76ef..1b76bc63332 100644 --- a/sites/shared/components/patrons/payment.mjs +++ b/sites/shared/components/patrons/payment.mjs @@ -1,78 +1,53 @@ import { stripeConfig } from 'shared/config/stripe.mjs' import { useEffect, useState } from 'react' +import { useBackend } from 'shared/hooks/use-backend.mjs' import { useTheme } from 'shared/hooks/use-theme.mjs' import { Elements, PaymentElement } from '@stripe/react-stripe-js' import { loadStripe } from '@stripe/stripe-js' - -/* - * The stripe API docs will emphasize to always handle this server-side because - * doing this client-side allows nefarious users to modify the payment amount. - * Which is great advice, but FreeSewing uses a pay-whatever-you-want model so - * if people want to muck about with JS to change the amount rather than change - * it in the input field for the amount, let them. - * - * This is also why we need to use fetch and talk to the API directly, because - * the stripe client-side SDK does not include this functionality. - */ -const createPaymentIntent = async ({ amount, currency }) => { - const body = new URLSearchParams() - body.append('amount', amount) - body.append('currency', currency) - - return await fetch('https://api.stripe.com/v1/payment_intents', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', - authorization: `Bearer ${stripeConfig.apiKey}`, - }, - body, - }) -} +import { Loading } from 'shared/components/spinner.mjs' export const Payment = ({ amount = 2500, currency = 'eur' }) => { const appearance = useTheme().stripe + const backend = useBackend() const [stripe, setStripe] = useState(false) - const [intent, setIntent] = useState(false) - const options = intent - ? { - mode: 'payment', - layout: { - type: 'accordion', - radios: true, - spacedAccordianItems: true, - }, - business: { - name: 'FreeSewing', - }, - amount, - currency, - appearance, - } - : null + const [clientSecret, setClientSecret] = useState(false) + + const options = { + mode: 'payment', + layout: { + type: 'accordion', + radios: true, + spacedAccordianItems: true, + }, + business: { + name: 'FreeSewing', + }, + amount, + currency, + appearance, + } useEffect(() => { const getPaymentIntent = async () => { const stripeClient = await loadStripe(stripeConfig.apiKey) - const result = await createPaymentIntent({ amount, currency }) - const json = await result.json() + const result = await backend.createPaymentIntent({ amount, currency }) + if (result.success && result.data.clientSecret) setClientSecret(result.data.clientSecret) setStripe(stripeClient) - setIntent(json) } getPaymentIntent() }, [amount, currency]) - return intent ? ( + return clientSecret ? (
-
{JSON.stringify(intent, null, 2)}
+
{JSON.stringify(clientSecret, null, 2)}
) : ( - <> -

One moment please...

-
{JSON.stringify(options, null, 2)}
- +
+ +
) } diff --git a/sites/shared/config/stripe.mjs b/sites/shared/config/stripe.mjs index 0c0b04fa47f..dc75b06d42b 100644 --- a/sites/shared/config/stripe.mjs +++ b/sites/shared/config/stripe.mjs @@ -1,5 +1,5 @@ export const stripeConfig = { - apiKey: process.env.FS_STRIPE_API_KEY, + apiKey: process.env.NEXT_PUBLIC_STRIPE_API_KEY, amounts: [5, 10, 25, 50, 100, 250, 500], periods: ['w', 'm', '3m', '6m', 'y', 'x'], currencies: { diff --git a/sites/shared/hooks/use-backend.mjs b/sites/shared/hooks/use-backend.mjs index 9466c0a2fc6..63b8a67d970 100644 --- a/sites/shared/hooks/use-backend.mjs +++ b/sites/shared/hooks/use-backend.mjs @@ -301,6 +301,13 @@ Backend.prototype.sendLanguageSuggestion = async function (data) { return responseHandler(await api.post(`/flows/language-suggestion/jwt`, data, this.auth)) } +/* + * Create payment intent + */ +Backend.prototype.createPaymentIntent = async function (data) { + return responseHandler(await api.post(`/payments/intent`, data), 201) +} + export function useBackend(token = false) { /* * This backend object is what we'll end up returning