wip(backend): Work on payment processing
This commit is contained in:
parent
6cccdaf087
commit
cc538e5df2
8 changed files with 117 additions and 52 deletions
|
@ -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 #
|
||||||
# #
|
# #
|
||||||
|
|
|
@ -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,
|
||||||
|
|
34
sites/backend/src/controllers/payments.mjs
Normal file
34
sites/backend/src/controllers/payments.mjs
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
15
sites/backend/src/routes/payments.mjs
Normal file
15
sites/backend/src/routes/payments.mjs
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue