Moved webhook listener outside docker
This commit is contained in:
parent
75ef9c8b6c
commit
568c17622b
|
@ -3,6 +3,7 @@ SITE_PORT="8080"
|
||||||
SITE_DOMAIN="http://localhost:8080"
|
SITE_DOMAIN="http://localhost:8080"
|
||||||
API_PORT="8081"
|
API_PORT="8081"
|
||||||
API_DOMAIN="http://localhost:8081"
|
API_DOMAIN="http://localhost:8081"
|
||||||
|
HOOK_PORT="8082"
|
||||||
ADMIN_EMAIL="hostmaster@example.com"
|
ADMIN_EMAIL="hostmaster@example.com"
|
||||||
SALES_EMAIL="sales@example.com"
|
SALES_EMAIL="sales@example.com"
|
||||||
STRIPE_PUB="pk_test_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
|
STRIPE_PUB="pk_test_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
|
||||||
|
@ -13,6 +14,7 @@ STRIPE_SEC="sk_test_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||||
#SITE_DOMAIN="https://www.example.com"
|
#SITE_DOMAIN="https://www.example.com"
|
||||||
#API_PORT="8081"
|
#API_PORT="8081"
|
||||||
#API_DOMAIN="https://api.example.com"
|
#API_DOMAIN="https://api.example.com"
|
||||||
|
#HOOK_PORT="8082"
|
||||||
#ADMIN_EMAIL="hostmaster@example.com"
|
#ADMIN_EMAIL="hostmaster@example.com"
|
||||||
#SALES_EMAIL="sales@example.com"
|
#SALES_EMAIL="sales@example.com"
|
||||||
#STRIPE_PUB="pk_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
|
#STRIPE_PUB="pk_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
|
||||||
|
|
32
README.md
32
README.md
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
# Shopity
|
# Shopity
|
||||||
|
|
||||||
Ecommerce template using 11ty, knockout, stripe, docker
|
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)
|
- **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)
|
- **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
|
||||||
|
```
|
|
@ -1,8 +1,19 @@
|
||||||
require('dotenv').config()
|
require('dotenv').config()
|
||||||
module.exports = () => { return {
|
let obj = {}
|
||||||
STRIPE_PUB: JSON.parse(process.env.STRIPE_PUB),
|
|
||||||
API_DOMAIN: JSON.parse(process.env.API_DOMAIN),
|
try { obj.STRIPE_PUB = JSON.parse(process.env.STRIPE_PUB) }
|
||||||
SITE_DOMAIN: JSON.parse(process.env.SITE_DOMAIN),
|
catch (err) { obj.STRIPE_PUB = process.env.STRIPE_PUB }
|
||||||
ADMIN_EMAIL: JSON.parse(process.env.ADMIN_EMAIL),
|
|
||||||
SALES_EMAIL: JSON.parse(process.env.SALES_EMAIL),
|
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
100
api.js
|
@ -1,6 +1,5 @@
|
||||||
'use strict'
|
'use strict'
|
||||||
require('dotenv').config()
|
require('dotenv').config()
|
||||||
const exec = require('child_process').exec
|
|
||||||
const fs = require('fs').promises
|
const fs = require('fs').promises
|
||||||
const stripe = require('stripe')(JSON.parse(process.env.STRIPE_SEC))
|
const stripe = require('stripe')(JSON.parse(process.env.STRIPE_SEC))
|
||||||
const express = require('express')
|
const express = require('express')
|
||||||
|
@ -14,13 +13,14 @@ const formatUSD = (v) => v.toLocaleString(undefined, {
|
||||||
const jsonBodyParser = express.json()
|
const jsonBodyParser = express.json()
|
||||||
app.listen(process.env.API_PORT||80)
|
app.listen(process.env.API_PORT||80)
|
||||||
const DATA_DIR = `${__dirname}/_src/_data`
|
const DATA_DIR = `${__dirname}/_src/_data`
|
||||||
const SHOP_DIR = `${__dirname}/_src/shop`
|
|
||||||
const SOLD_DIR = `${__dirname}/sold`
|
|
||||||
const ORDERS_DIR = `${__dirname}/orders`
|
const ORDERS_DIR = `${__dirname}/orders`
|
||||||
// TODO: Use an eleventy hook to save this outside the public _site dir
|
// TODO: Use an eleventy hook to save this outside the public _site dir
|
||||||
const PRODUCTS_FILE = `${__dirname}/_site/assets/data/products.json`
|
const PRODUCTS_FILE = `${__dirname}/_site/assets/data/products.json`
|
||||||
|
|
||||||
|
|
||||||
|
// Run a command
|
||||||
const run = (cmd) => {
|
const run = (cmd) => {
|
||||||
exec(cmd, (err, stdout, stderr) => {
|
require('child_process').exec(cmd, (err, stdout, stderr) => {
|
||||||
if (err) console.error(err.message)
|
if (err) console.error(err.message)
|
||||||
if (stderr) console.log(stderr)
|
if (stderr) console.log(stderr)
|
||||||
console.log(stdout)
|
console.log(stdout)
|
||||||
|
@ -82,7 +82,7 @@ app.options('/order', cors)
|
||||||
const weightPromise = new Promise((resolve, reject) => {
|
const weightPromise = new Promise((resolve, reject) => {
|
||||||
let sum = 0
|
let sum = 0
|
||||||
order.items.forEach((item) => {
|
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))
|
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`)
|
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)
|
else sum += parseFloat(item.weight) * parseFloat(item.qty)
|
||||||
|
@ -93,7 +93,7 @@ app.options('/order', cors)
|
||||||
const volumePromise = new Promise((resolve, reject) => {
|
const volumePromise = new Promise((resolve, reject) => {
|
||||||
let sum = 0
|
let sum = 0
|
||||||
order.items.forEach((item) => {
|
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))
|
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`)
|
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)
|
else sum += parseFloat(item.volume) * parseFloat(item.qty)
|
||||||
|
@ -206,33 +206,33 @@ app.options('/order', cors)
|
||||||
// Save order to fs
|
// Save order to fs
|
||||||
console.log(`[${ip}] Order ${order.id} for ${formatUSD(order.total)} verified.`)
|
console.log(`[${ip}] Order ${order.id} for ${formatUSD(order.total)} verified.`)
|
||||||
const orderFile = `${ORDERS_DIR}/${order.id}.json`
|
const orderFile = `${ORDERS_DIR}/${order.id}.json`
|
||||||
try { fs.writeFile(orderFile,
|
try {
|
||||||
JSON.stringify({
|
await fs.writeFile(orderFile,
|
||||||
id: order.id,
|
JSON.stringify({
|
||||||
items: order.items,
|
id: order.id,
|
||||||
contactName: order.contact.name,
|
orderDate: new Date(),
|
||||||
contactPhone: order.contact.phone,
|
items: order.items,
|
||||||
contactEmail: order.contact.email,
|
contact: order.contact,
|
||||||
shipName: order.shippingAddress.name,
|
shippingAddress: order.shippingAddress,
|
||||||
shipAddr1: order.shippingAddress.addr1,
|
shipping: {
|
||||||
shipAddr2: order.shippingAddress.addr2,
|
date: null,
|
||||||
shipCity: order.shippingAddress.city,
|
address: order.shippingAddress,
|
||||||
shipState: order.shippingAddress.state,
|
carrier: '',
|
||||||
shipZip: order.shippingAddress.zip,
|
tracking: [],
|
||||||
subtotal: parseFloat(order.subtotal),
|
amount: parseFloat(order.shipping),
|
||||||
shipCarrier: '',
|
},
|
||||||
shipTracking: '',
|
subtotal: parseFloat(order.subtotal),
|
||||||
tax: parseFloat(order.tax),
|
tax: parseFloat(order.tax),
|
||||||
taxAmount: parseFloat(order.tax_amount),
|
taxAmount: parseFloat(order.tax_amount),
|
||||||
processing: parseFloat(order.processing),
|
processing: parseFloat(order.processing),
|
||||||
shipping: parseFloat(order.shipping),
|
total: parseFloat(order.total),
|
||||||
total: parseFloat(order.total),
|
paymentMethod: 'USD',
|
||||||
paymentMethod: 'USD',
|
paidDate: null,
|
||||||
orderDate: new Date(),
|
}, null, 2),
|
||||||
paidDate: null,
|
{mode:'666',owner:7000,group:7000}
|
||||||
shipDate: null,
|
)
|
||||||
}, null, 2)
|
fs.chown(orderFile,7000,7000)
|
||||||
) } catch (err) { console.error(`ERROR! Failed to save ${orderFile}:\n${err}`) }
|
} catch (err) { console.error(`ERROR! Failed to save ${orderFile}:\n${err}`) }
|
||||||
|
|
||||||
}
|
}
|
||||||
}).catch((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}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
|
@ -44,9 +44,9 @@ services:
|
||||||
- "./orders:/usr/local/src/orders"
|
- "./orders:/usr/local/src/orders"
|
||||||
- "./sold:/usr/local/src/sold"
|
- "./sold:/usr/local/src/sold"
|
||||||
|
|
||||||
stripe-hook:
|
# stripe-hook:
|
||||||
restart: always
|
# restart: always
|
||||||
image: stripe/stripe-cli:latest
|
# image: stripe/stripe-cli:latest
|
||||||
command: "listen --api-key ${STRIPE_SEC} --forward-to http://api:${API_PORT}/paid --events payment_intent.succeeded"
|
# command: "listen --api-key ${STRIPE_SEC} --forward-to http://api:${API_PORT}/paid --events payment_intent.succeeded"
|
||||||
volumes:
|
# volumes:
|
||||||
- "/etc/timezone:/etc/timezone:ro"
|
# - "/etc/timezone:/etc/timezone:ro"
|
||||||
|
|
|
@ -3,3 +3,4 @@
|
||||||
#
|
#
|
||||||
# USAGE: email-customer orderId contactEmail orderTotal
|
# USAGE: email-customer orderId contactEmail orderTotal
|
||||||
|
|
||||||
|
printf 'Emailing order %s to customer at %s' "${1}" "${2}"
|
||||||
|
|
|
@ -2,4 +2,6 @@
|
||||||
# email-sales
|
# email-sales
|
||||||
#
|
#
|
||||||
# USAGE: email-sales '{order}'
|
# USAGE: email-sales '{order}'
|
||||||
|
source .env
|
||||||
|
|
||||||
|
printf 'Emailing order %s to sales at %s' "$(<<<${1} jq -r '.id')" "${SALES_EMAIL}"
|
||||||
|
|
|
@ -12,4 +12,4 @@ curl --silent \
|
||||||
-d "Order ${1} needs ${2} items shipped." \
|
-d "Order ${1} needs ${2} items shipped." \
|
||||||
"${URL}" >/dev/null \
|
"${URL}" >/dev/null \
|
||||||
&& printf 'Notified %s about order %s\n' "${URL}" "${1}" \
|
&& 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}"
|
|
@ -3,5 +3,7 @@
|
||||||
#
|
#
|
||||||
# USAGE: remove_sid sid shop_dir sold_dir git_dir
|
# 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
|
||||||
|
|
|
@ -10,7 +10,8 @@
|
||||||
"build": "npx @11ty/eleventy --quiet",
|
"build": "npx @11ty/eleventy --quiet",
|
||||||
"serve": "npx @11ty/eleventy --quiet --serve",
|
"serve": "npx @11ty/eleventy --quiet --serve",
|
||||||
"json": "npx @11ty/eleventy --to=json",
|
"json": "npx @11ty/eleventy --to=json",
|
||||||
"api": "node api.js"
|
"api": "node api.js",
|
||||||
|
"stripe-webhook": "node stripe-webhook.js"
|
||||||
},
|
},
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Keith Irwin",
|
"name": "Keith Irwin",
|
||||||
|
|
|
@ -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}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
Loading…
Reference in New Issue