From 568c17622b905a59ff4bef578c19b74b0273ac44 Mon Sep 17 00:00:00 2001 From: Keith Irwin Date: Tue, 7 Mar 2023 14:18:31 -0700 Subject: [PATCH] Moved webhook listener outside docker --- .env.sample | 2 + README.md | 32 +++++++++++- _src/_data/env.js | 25 +++++++--- api.js | 100 +++++++++++++------------------------- docker-compose.yml.sample | 12 ++--- hooks/email-customer | 1 + hooks/email-sales | 2 + hooks/ntfy | 2 +- hooks/remove_sid | 6 ++- package.json | 3 +- stripe-webhook.js | 75 ++++++++++++++++++++++++++++ 11 files changed, 175 insertions(+), 85 deletions(-) create mode 100644 stripe-webhook.js diff --git a/.env.sample b/.env.sample index 38927a4..aa78d10 100644 --- a/.env.sample +++ b/.env.sample @@ -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" diff --git a/README.md b/README.md index 99a901b..1843f93 100644 --- a/README.md +++ b/README.md @@ -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 +``` \ No newline at end of file diff --git a/_src/_data/env.js b/_src/_data/env.js index 9a30424..4e4cab7 100644 --- a/_src/_data/env.js +++ b/_src/_data/env.js @@ -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 diff --git a/api.js b/api.js index 98a06ce..74cabf3 100644 --- a/api.js +++ b/api.js @@ -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}`) - }) - -}) diff --git a/docker-compose.yml.sample b/docker-compose.yml.sample index 9f10690..6246e6c 100644 --- a/docker-compose.yml.sample +++ b/docker-compose.yml.sample @@ -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" diff --git a/hooks/email-customer b/hooks/email-customer index 88f7a2e..450062c 100755 --- a/hooks/email-customer +++ b/hooks/email-customer @@ -3,3 +3,4 @@ # # USAGE: email-customer orderId contactEmail orderTotal +printf 'Emailing order %s to customer at %s' "${1}" "${2}" diff --git a/hooks/email-sales b/hooks/email-sales index 6ead4c0..32bcc6b 100755 --- a/hooks/email-sales +++ b/hooks/email-sales @@ -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}" diff --git a/hooks/ntfy b/hooks/ntfy index dbc3fae..b2793ef 100755 --- a/hooks/ntfy +++ b/hooks/ntfy @@ -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' \ No newline at end of file + || printf 'FAILED to notify %s about order %s\n' "${URL}" "${1}" \ No newline at end of file diff --git a/hooks/remove_sid b/hooks/remove_sid index f6261ab..da31c7b 100755 --- a/hooks/remove_sid +++ b/hooks/remove_sid @@ -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 diff --git a/package.json b/package.json index a9ea1d7..e613c61 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/stripe-webhook.js b/stripe-webhook.js new file mode 100644 index 0000000..b3d3a8b --- /dev/null +++ b/stripe-webhook.js @@ -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}`) + }) + + } + +})