1
0
Fork 0

wip(backend): Work on payment processing

This commit is contained in:
joostdecock 2023-07-31 09:03:55 +02:00
parent 6cccdaf087
commit cc538e5df2
8 changed files with 117 additions and 52 deletions

View file

@ -131,6 +131,18 @@ BACKEND_ENABLE_SANITY=no
# Sanity API version # Sanity API version
#SANITY_VERSION=v2022-10-31 #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 # # Github integration #
# # # #

View file

@ -44,6 +44,7 @@ const baseConfig = {
}, },
sanity: envToBool(process.env.BACKEND_ENABLE_SANITY), sanity: envToBool(process.env.BACKEND_ENABLE_SANITY),
ses: envToBool(process.env.BACKEND_ENABLE_AWS_SES), ses: envToBool(process.env.BACKEND_ENABLE_AWS_SES),
stripe: envToBool(process.env.BACKEND_ENABLE_PAYMENTS),
tests: { tests: {
base: envToBool(process.env.BACKEND_ENABLE_TESTS), base: envToBool(process.env.BACKEND_ENABLE_TESTS),
email: envToBool(process.env.BACKEND_ENABLE_TESTS_EMAIL), 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) // AWS SES config (for sending out emails)
if (baseConfig.use.ses) if (baseConfig.use.ses)
baseConfig.aws = { baseConfig.aws = {
@ -209,6 +218,7 @@ const vars = {
BACKEND_ENABLE_GITHUB: 'optional', BACKEND_ENABLE_GITHUB: 'optional',
BACKEND_ENABLE_OAUTH_GITHUB: 'optional', BACKEND_ENABLE_OAUTH_GITHUB: 'optional',
BACKEND_ENABLE_OAUTH_GOOGLE: 'optional', BACKEND_ENABLE_OAUTH_GOOGLE: 'optional',
BACKEND_ENABLE_PAYMENTS: 'optional',
BACKEND_ENABLE_TESTS: 'optional', BACKEND_ENABLE_TESTS: 'optional',
BACKEND_ALLOW_TESTS_IN_PRODUCTION: 'optional', BACKEND_ALLOW_TESTS_IN_PRODUCTION: 'optional',
BACKEND_ENABLE_DUMP_CONFIG_AT_STARTUP: '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_ID = 'required'
vars.BACKEND_OAUTH_GOOGLE_CLIENT_SECRET = 'requiredSecret' 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 // Vars for (unit) tests
if (envToBool(process.env.BACKEND_ENABLE_TESTS)) { if (envToBool(process.env.BACKEND_ENABLE_TESTS)) {
vars.BACKEND_TEST_DOMAIN = 'optional' 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), 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) if (config.sanity)
dump.sanity = { dump.sanity = {
...config.sanity, ...config.sanity,

View file

@ -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,
})
}

View file

@ -6,6 +6,7 @@ import { confirmationsRoutes } from './confirmations.mjs'
import { curatedSetsRoutes } from './curated-sets.mjs' import { curatedSetsRoutes } from './curated-sets.mjs'
import { issuesRoutes } from './issues.mjs' import { issuesRoutes } from './issues.mjs'
import { flowsRoutes } from './flows.mjs' import { flowsRoutes } from './flows.mjs'
import { paymentsRoutes } from './payments.mjs'
export const routes = { export const routes = {
apikeysRoutes, apikeysRoutes,
@ -16,4 +17,5 @@ export const routes = {
curatedSetsRoutes, curatedSetsRoutes,
issuesRoutes, issuesRoutes,
flowsRoutes, flowsRoutes,
paymentsRoutes,
} }

View file

@ -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))
}
}

View file

@ -1,78 +1,53 @@
import { stripeConfig } from 'shared/config/stripe.mjs' import { stripeConfig } from 'shared/config/stripe.mjs'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useBackend } from 'shared/hooks/use-backend.mjs'
import { useTheme } from 'shared/hooks/use-theme.mjs' import { useTheme } from 'shared/hooks/use-theme.mjs'
import { Elements, PaymentElement } from '@stripe/react-stripe-js' import { Elements, PaymentElement } from '@stripe/react-stripe-js'
import { loadStripe } from '@stripe/stripe-js' import { loadStripe } from '@stripe/stripe-js'
import { Loading } from 'shared/components/spinner.mjs'
/*
* 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,
})
}
export const Payment = ({ amount = 2500, currency = 'eur' }) => { export const Payment = ({ amount = 2500, currency = 'eur' }) => {
const appearance = useTheme().stripe const appearance = useTheme().stripe
const backend = useBackend()
const [stripe, setStripe] = useState(false) const [stripe, setStripe] = useState(false)
const [intent, setIntent] = useState(false) const [clientSecret, setClientSecret] = useState(false)
const options = intent
? { const options = {
mode: 'payment', mode: 'payment',
layout: { layout: {
type: 'accordion', type: 'accordion',
radios: true, radios: true,
spacedAccordianItems: true, spacedAccordianItems: true,
}, },
business: { business: {
name: 'FreeSewing', name: 'FreeSewing',
}, },
amount, amount,
currency, currency,
appearance, appearance,
} }
: null
useEffect(() => { useEffect(() => {
const getPaymentIntent = async () => { const getPaymentIntent = async () => {
const stripeClient = await loadStripe(stripeConfig.apiKey) const stripeClient = await loadStripe(stripeConfig.apiKey)
const result = await createPaymentIntent({ amount, currency }) const result = await backend.createPaymentIntent({ amount, currency })
const json = await result.json() if (result.success && result.data.clientSecret) setClientSecret(result.data.clientSecret)
setStripe(stripeClient) setStripe(stripeClient)
setIntent(json)
} }
getPaymentIntent() getPaymentIntent()
}, [amount, currency]) }, [amount, currency])
return intent ? ( return clientSecret ? (
<Elements stripe={stripe} options={options} layout="accordion"> <Elements stripe={stripe} options={options} layout="accordion">
<form> <form>
<PaymentElement options={options} /> <PaymentElement options={options} />
<pre>{JSON.stringify(intent, null, 2)}</pre> <pre>{JSON.stringify(clientSecret, null, 2)}</pre>
</form> </form>
</Elements> </Elements>
) : ( ) : (
<> <div className="my-12">
<p>One moment please...</p> <Loading />
<pre>{JSON.stringify(options, null, 2)}</pre> </div>
</>
) )
} }

View file

@ -1,5 +1,5 @@
export const stripeConfig = { 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], amounts: [5, 10, 25, 50, 100, 250, 500],
periods: ['w', 'm', '3m', '6m', 'y', 'x'], periods: ['w', 'm', '3m', '6m', 'y', 'x'],
currencies: { currencies: {

View file

@ -301,6 +301,13 @@ Backend.prototype.sendLanguageSuggestion = async function (data) {
return responseHandler(await api.post(`/flows/language-suggestion/jwt`, data, this.auth)) 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) { export function useBackend(token = false) {
/* /*
* This backend object is what we'll end up returning * This backend object is what we'll end up returning