Moved webhook listener outside docker

This commit is contained in:
Keith Irwin 2023-03-07 14:18:31 -07:00
parent 75ef9c8b6c
commit 568c17622b
Signed by: ki9
GPG Key ID: DF773B3F4A88DA86
11 changed files with 175 additions and 85 deletions

View File

@ -3,6 +3,7 @@ SITE_PORT="8080"
SITE_DOMAIN="http://localhost:8080"
API_PORT="8081"
API_DOMAIN="http://localhost:8081"
HOOK_PORT="8082"
ADMIN_EMAIL="hostmaster@example.com"
SALES_EMAIL="sales@example.com"
STRIPE_PUB="pk_test_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
@ -13,6 +14,7 @@ STRIPE_SEC="sk_test_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
#SITE_DOMAIN="https://www.example.com"
#API_PORT="8081"
#API_DOMAIN="https://api.example.com"
#HOOK_PORT="8082"
#ADMIN_EMAIL="hostmaster@example.com"
#SALES_EMAIL="sales@example.com"
#STRIPE_PUB="pk_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"

View File

@ -1,4 +1,3 @@
# Shopity
Ecommerce template using 11ty, knockout, stripe, docker
@ -7,3 +6,34 @@ Ecommerce template using 11ty, knockout, stripe, docker
- **zips-to-zones:** [USPS Zone chart by zip code](https://postcalc.usps.com/DomesticZoneChart)
- **usps-shipping-rates:** [USPS Rates by zone/weight](https://pe.usps.com/text/dmm300/Notice123.htm#_c078)
## Website and API
```
docker-compose up
```
## Stripe hook
Runs on `HOOK_PORT`.
```
npm install
npm run stripe-webhook
```
`/etc/systemd/system/www-stripe-hook.service`
```
[Unit]
Description=Stripe webhook
Documentation=
After=network-online.target
[Service]
WorkingDirectory=/usr/local/src/mysite
ExecStart=npm run stripe-webhook.js
[Install]
WantedBy=default.target
```

View File

@ -1,8 +1,19 @@
require('dotenv').config()
module.exports = () => { return {
STRIPE_PUB: JSON.parse(process.env.STRIPE_PUB),
API_DOMAIN: JSON.parse(process.env.API_DOMAIN),
SITE_DOMAIN: JSON.parse(process.env.SITE_DOMAIN),
ADMIN_EMAIL: JSON.parse(process.env.ADMIN_EMAIL),
SALES_EMAIL: JSON.parse(process.env.SALES_EMAIL),
} }
let obj = {}
try { obj.STRIPE_PUB = JSON.parse(process.env.STRIPE_PUB) }
catch (err) { obj.STRIPE_PUB = process.env.STRIPE_PUB }
try { obj.API_DOMAIN = JSON.parse(process.env.API_DOMAIN) }
catch (err) { obj.API_DOMAIN = process.env.API_DOMAIN }
try { obj.SITE_DOMAIN = JSON.parse(process.env.SITE_DOMAIN) }
catch (err) { obj.SITE_DOMAIN = process.env.SITE_DOMAIN }
try { obj.ADMIN_EMAIL = JSON.parse(process.env.ADMIN_EMAIL) }
catch (err) { obj.ADMIN_EMAIL = process.env.ADMIN_EMAIL }
try { obj.SALES_EMAIL = JSON.parse(process.env.SALES_EMAIL) }
catch (err) { obj.SALES_EMAIL = process.env.SALES_EMAIL }
module.exports = () => obj

100
api.js
View File

@ -1,6 +1,5 @@
'use strict'
require('dotenv').config()
const exec = require('child_process').exec
const fs = require('fs').promises
const stripe = require('stripe')(JSON.parse(process.env.STRIPE_SEC))
const express = require('express')
@ -14,13 +13,14 @@ const formatUSD = (v) => v.toLocaleString(undefined, {
const jsonBodyParser = express.json()
app.listen(process.env.API_PORT||80)
const DATA_DIR = `${__dirname}/_src/_data`
const SHOP_DIR = `${__dirname}/_src/shop`
const SOLD_DIR = `${__dirname}/sold`
const ORDERS_DIR = `${__dirname}/orders`
// TODO: Use an eleventy hook to save this outside the public _site dir
const PRODUCTS_FILE = `${__dirname}/_site/assets/data/products.json`
// Run a command
const run = (cmd) => {
exec(cmd, (err, stdout, stderr) => {
require('child_process').exec(cmd, (err, stdout, stderr) => {
if (err) console.error(err.message)
if (stderr) console.log(stderr)
console.log(stdout)
@ -82,7 +82,7 @@ app.options('/order', cors)
const weightPromise = new Promise((resolve, reject) => {
let sum = 0
order.items.forEach((item) => {
console.log(`Checking weight of products['${item.pid||''}/${item.sid||''}']`)
//console.log(`Checking weight of products['${item.pid||''}/${item.sid||''}']`)
if (products[`${item.pid}/${item.sid}`].weight.lbs !== parseFloat(item.weight))
reject(`${item.pid}/${item.sid} weight of ${products[`${item.pid}/${item.sid}`].weight.lbs} lbs did not match the request, which gave it ${item.weight} lbs`)
else sum += parseFloat(item.weight) * parseFloat(item.qty)
@ -93,7 +93,7 @@ app.options('/order', cors)
const volumePromise = new Promise((resolve, reject) => {
let sum = 0
order.items.forEach((item) => {
console.log(`Checking volume of products['${item.pid||''}/${item.sid||''}']`)
//console.log(`Checking volume of products['${item.pid||''}/${item.sid||''}']`)
if (products[`${item.pid||''}/${item.sid||''}`].volume.in3 !== parseFloat(item.volume))
reject(`${item.pid}/${item.sid} volume of ${products[`${item.pid||''}/${item.sid||''}`].volume.in3} in^3 did not match the request, which gave it ${item.volume} lbs`)
else sum += parseFloat(item.volume) * parseFloat(item.qty)
@ -206,33 +206,33 @@ app.options('/order', cors)
// Save order to fs
console.log(`[${ip}] Order ${order.id} for ${formatUSD(order.total)} verified.`)
const orderFile = `${ORDERS_DIR}/${order.id}.json`
try { fs.writeFile(orderFile,
JSON.stringify({
id: order.id,
items: order.items,
contactName: order.contact.name,
contactPhone: order.contact.phone,
contactEmail: order.contact.email,
shipName: order.shippingAddress.name,
shipAddr1: order.shippingAddress.addr1,
shipAddr2: order.shippingAddress.addr2,
shipCity: order.shippingAddress.city,
shipState: order.shippingAddress.state,
shipZip: order.shippingAddress.zip,
subtotal: parseFloat(order.subtotal),
shipCarrier: '',
shipTracking: '',
tax: parseFloat(order.tax),
taxAmount: parseFloat(order.tax_amount),
processing: parseFloat(order.processing),
shipping: parseFloat(order.shipping),
total: parseFloat(order.total),
paymentMethod: 'USD',
orderDate: new Date(),
paidDate: null,
shipDate: null,
}, null, 2)
) } catch (err) { console.error(`ERROR! Failed to save ${orderFile}:\n${err}`) }
try {
await fs.writeFile(orderFile,
JSON.stringify({
id: order.id,
orderDate: new Date(),
items: order.items,
contact: order.contact,
shippingAddress: order.shippingAddress,
shipping: {
date: null,
address: order.shippingAddress,
carrier: '',
tracking: [],
amount: parseFloat(order.shipping),
},
subtotal: parseFloat(order.subtotal),
tax: parseFloat(order.tax),
taxAmount: parseFloat(order.tax_amount),
processing: parseFloat(order.processing),
total: parseFloat(order.total),
paymentMethod: 'USD',
paidDate: null,
}, null, 2),
{mode:'666',owner:7000,group:7000}
)
fs.chown(orderFile,7000,7000)
} catch (err) { console.error(`ERROR! Failed to save ${orderFile}:\n${err}`) }
}
}).catch((err) => {
@ -242,37 +242,3 @@ app.options('/order', cors)
})
})
// Receive paymentIntent.success from stripe
// Should only be available to stripe-hook container
// No need to be world-visible!
app.options('/paid', cors)
.post('/paid', jsonBodyParser, cors, async (req, res) => {
// Send an ok back to stripe immediately because we got it
res.sendStatus(200)
const orderId = req.body.data.object.metadata.id
console.log(`Received stripe hook for order ${orderId}`)
const orderFile = `${ORDERS_DIR}/${orderId}.json`
// If we fail to save that fact, let it error out
// and fix it by hand in the data later
// Save paidDate to filesystem
// TODO: Catch errors for each awaited
let order = await JSON.parse(await fs.readFile(orderFile))
order.paidDate = new Date()
await fs.writeFile(orderFile, JSON.stringify(order,null,2))
// Notify sales team
run(`./hooks/ntfy ${order.id} ${order.items.length} ${formatUSD(order.total)}`)
run(`./hooks/email-sales '${JSON.stringify(order)}'`)
// Email customer
run(`./hooks/email-customer ${order.id} ${order.contactEmail} ${formatUSD(order.total)}`)
// Remove single products from store
order.items.forEach((item) => {
if (item.sid)
run(`./hooks/remove_sid ${item.sid} ${SHOP_DIR} ${SOLD_DIR} ${__dirname}`)
})
})

View File

@ -44,9 +44,9 @@ services:
- "./orders:/usr/local/src/orders"
- "./sold:/usr/local/src/sold"
stripe-hook:
restart: always
image: stripe/stripe-cli:latest
command: "listen --api-key ${STRIPE_SEC} --forward-to http://api:${API_PORT}/paid --events payment_intent.succeeded"
volumes:
- "/etc/timezone:/etc/timezone:ro"
# stripe-hook:
# restart: always
# image: stripe/stripe-cli:latest
# command: "listen --api-key ${STRIPE_SEC} --forward-to http://api:${API_PORT}/paid --events payment_intent.succeeded"
# volumes:
# - "/etc/timezone:/etc/timezone:ro"

View File

@ -3,3 +3,4 @@
#
# USAGE: email-customer orderId contactEmail orderTotal
printf 'Emailing order %s to customer at %s' "${1}" "${2}"

View File

@ -2,4 +2,6 @@
# email-sales
#
# USAGE: email-sales '{order}'
source .env
printf 'Emailing order %s to sales at %s' "$(<<<${1} jq -r '.id')" "${SALES_EMAIL}"

View File

@ -12,4 +12,4 @@ curl --silent \
-d "Order ${1} needs ${2} items shipped." \
"${URL}" >/dev/null \
&& printf 'Notified %s about order %s\n' "${URL}" "${1}" \
| || printf 'FAILED to notify %s about order %s\n' "${URL}" "${1}"jq '.message'
|| printf 'FAILED to notify %s about order %s\n' "${URL}" "${1}"

View File

@ -3,5 +3,7 @@
#
# USAGE: remove_sid sid shop_dir sold_dir git_dir
printf 'Removing sid %s from website\n' "${1}" "${2}" "${3}" "${4}"
find "${2}" -type d -name "${1}" -exec mv {} "${3}/${1}" \; 2>/dev/null && npm run build
find "${2}" -type d -name "${1}" -exec mv {} "${3}/${1}" \; 2>/dev/null
printf 'Removed sid %s from website' "${1}"
source .env && npx @11ty/eleventy --quiet

View File

@ -10,7 +10,8 @@
"build": "npx @11ty/eleventy --quiet",
"serve": "npx @11ty/eleventy --quiet --serve",
"json": "npx @11ty/eleventy --to=json",
"api": "node api.js"
"api": "node api.js",
"stripe-webhook": "node stripe-webhook.js"
},
"author": {
"name": "Keith Irwin",

75
stripe-webhook.js Normal file
View File

@ -0,0 +1,75 @@
'use strict'
require('dotenv').config()
const fs = require('fs').promises
const express = require('express')
const app = express()
const ORDERS_DIR = `${__dirname}/orders`
const SHOP_DIR = `${__dirname}/_src/shop`
const SOLD_DIR = `${__dirname}/sold`
// Run a command
const run = (cmd) => {
require('child_process').exec(cmd, (err, stdout, stderr) => {
if (err) console.error(err.message)
if (stderr) console.log(stderr)
console.log(stdout)
})
}
// Format float to USD string
const formatUSD = (v) => v.toLocaleString(undefined, {
style: 'currency', currency: 'USD' })
// Start webhook forwarder so we don't need a public endpoint
run(`stripe listen --api-key '${process.env.STRIPE_SEC}' --forward-to 'http://localhost:${process.env.HOOK_PORT}/' --format 'JSON'`)
// Receive that webhook
app.listen(process.env.HOOK_PORT)
app.post('/', express.json(), async (req) => {
if (req.body.data.object.object==='charge') {
// Check if paid
if (!req.body.data.object.paid)
return console.log(`[${req.body.id}] Charge unpaid!`)
// Get order ID
const orderId = req.body.data.object.metadata.id
if (orderId == null)
return console.error(`[${req.body.id}] Charge has no metadata.id!`)
else
console.log(`[${req.body.id}] Charge paid for order ${orderId}`)
// Get order file
const orderFile = `${ORDERS_DIR}/${orderId}.json`
let order; try {
order = await JSON.parse(await fs.readFile(orderFile))
} catch (err) {
return console.error(`[${req.body.id}] Failed to retrieve order from ${orderFile}:\n${err}`)
}
// Save paidDate to order
try { order.paidDate = new Date()
fs.writeFile(orderFile, JSON.stringify(order,null,2))
} catch (err) {
console.error(`[${req.body.id}] Failed to write paidDate to ${orderFile}:\n${err}`)
}
// Email customer
run(`./hooks/email-customer ${order.id} ${order.contact.email} ${formatUSD(order.total)}`)
// Notify sales team
run(`./hooks/ntfy ${order.id} ${order.items.length} ${formatUSD(order.total)}`)
run(`./hooks/email-sales '${JSON.stringify(order)}'`)
// Remove single products from store
order.items.forEach((item) => {
if (item.sid)
run(`./hooks/remove_sid ${item.sid} ${SHOP_DIR} ${SOLD_DIR} ${__dirname}`)
})
}
})