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 ? (
One moment please...
-{JSON.stringify(options, null, 2)}- > +