281 lines
12 KiB
JavaScript
281 lines
12 KiB
JavaScript
'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')
|
|
const app = express()
|
|
const cors = require('cors')({
|
|
'origin': JSON.parse(process.env.SITE_DOMAIN),
|
|
'methods': 'GET,POST,OPTIONS',
|
|
})
|
|
const formatUSD = (v) => v.toLocaleString(undefined, {
|
|
style: 'currency', currency: 'USD' })
|
|
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`
|
|
const run = (cmd) => {
|
|
exec(cmd, (err, stdout, stderr) => {
|
|
if (err) console.error(err.message)
|
|
if (stderr) console.log(stderr)
|
|
console.log(stdout)
|
|
})
|
|
}
|
|
|
|
// https://stackoverflow.com/a/57708635/3006854
|
|
const fileExists = async (f) => !!(await fs.stat(f).catch(e=>false))
|
|
|
|
// Read json files
|
|
const readDataFile = async (path) => {
|
|
const RETRY_INTERVAL = 2000 // 2 seconds
|
|
try { return JSON.parse(
|
|
await fs.readFile(`${path}`,'utf8')
|
|
) } catch (err) {
|
|
console.log(`Failed to read data from ${path}\n Retrying in ${RETRY_INTERVAL/1000} seconds.`)
|
|
if (err.code==='ENOENT')
|
|
return setTimeout(async () => {
|
|
return await readDataFile(path) // Recursive, baby
|
|
}, RETRY_INTERVAL)
|
|
else return console.error(err)
|
|
}
|
|
}
|
|
let ZIPS_TO_ZONES, USPS_SHIPPING_RATES, products; (async () => {
|
|
ZIPS_TO_ZONES = await readDataFile(`${DATA_DIR}/zipsToZones.json`)
|
|
USPS_SHIPPING_RATES = await readDataFile(`${DATA_DIR}/uspsShippingRates.json`)
|
|
products = await readDataFile(PRODUCTS_FILE)
|
|
})()
|
|
|
|
// Shipping calculations
|
|
const zipToZone = (zip) => ZIPS_TO_ZONES[zip.substring(0,3)]
|
|
const getUspsRates = (zip, weight, volume) => {
|
|
let rate = 0
|
|
while (weight>40) {
|
|
rate += USPS_SHIPPING_RATES['40'][zipToZone(zip)-1]
|
|
weight -= 40
|
|
}
|
|
if (weight < 1) return rate
|
|
return rate += USPS_SHIPPING_RATES[weight.toFixed(0)][zipToZone(zip)-1]
|
|
}
|
|
// Check if order.id exists in fs already
|
|
const generateOrderId = async () => {
|
|
const id = `r${Math.round(Math.random()*1000000000)}`
|
|
if (await fileExists(`${ORDERS_DIR}/${id}.json`))
|
|
return generateOrderId()
|
|
else return id
|
|
}
|
|
|
|
// Healthcheck the api server, perchance with uptime kuma
|
|
app.get('/', (req, res) => res.sendStatus(200))
|
|
|
|
// Create new order
|
|
app.options('/order', cors)
|
|
.post('/order', jsonBodyParser, cors, async (req, res) => {
|
|
let order = req.body
|
|
const ip = req.ip.slice(7)
|
|
console.log(`[${ip}] Created stripe order for $${order.total.toFixed(2)}`)
|
|
|
|
const weightPromise = new Promise((resolve, reject) => {
|
|
let sum = 0
|
|
order.items.forEach((item) => {
|
|
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)
|
|
})
|
|
resolve(sum)
|
|
})
|
|
|
|
const volumePromise = new Promise((resolve, reject) => {
|
|
let sum = 0
|
|
order.items.forEach((item) => {
|
|
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)
|
|
})
|
|
resolve(sum)
|
|
})
|
|
|
|
const subtotalPromise = new Promise((resolve, reject) => {
|
|
const subtotal = order.items.reduce((acc,cur) => {
|
|
const listPrice = products[`${cur.pid||''}/${cur.sid||''}`].price.usd
|
|
if (parseFloat(cur.price)!==listPrice)
|
|
return reject(`Price of ${cur.pid||''}/${cur.sid||''} listed as $${listPrice} but the client sent $${cur.price}`)
|
|
else return acc + (listPrice * parseFloat(cur.qty))
|
|
}, 0)
|
|
if (subtotal===order.subtotal) resolve(subtotal)
|
|
else reject(`We calculated a subtotal of ${subtotal} but the client gave us ${order.subtotal}`)
|
|
})
|
|
|
|
const taxesPromise = new Promise((resolve, reject) => {
|
|
if (!order.shippingAddress.state) reject(`order.shippingAddress.state not provided!`)
|
|
let tax = 0
|
|
if (order.shippingAddress.state==='CO') tax = 0.049
|
|
else tax = 0
|
|
if (tax===order.tax) resolve(tax)
|
|
else reject(`Taxes calculated as ${tax} didn't match what was sent by the client (${order.tax})`)
|
|
})
|
|
|
|
const shippingPromise = new Promise(async (resolve, reject) => {
|
|
if (!order.items.length) reject('order.items is untrue!')
|
|
if (!order.shippingAddress.zip) reject('order.shippingAddress.zip is unset!')
|
|
let shipping = Math.round( getUspsRates(order.shippingAddress.zip,
|
|
await weightPromise,
|
|
await volumePromise
|
|
) * 100 )/100
|
|
if (shipping===order.shipping) resolve(shipping)
|
|
else reject(`Shipping calculation of ${shipping} didn't match what the client sent (${order.shipping})`)
|
|
})
|
|
|
|
Promise.all([subtotalPromise, taxesPromise, shippingPromise])
|
|
.then(async (values) => {
|
|
order.preTotal = values[0] + Math.round((values[0]*values[1])*100)/100 + values[2]
|
|
order.processingFee = Math.round(((order.preTotal*0.0298661174047)+0.308959835221)*100)/100
|
|
const total = Math.round((order.preTotal + order.processingFee)*100)/100
|
|
if (total !== parseFloat(order.total)){
|
|
console.log(`[${ip}] Totals not equal: we calculated ${values[0]} + ${Math.round((values[0]*values[1])*100)/100} + ${values[2]} + ${order.processingFee} = ${total} but the client sent ${order.total}`)
|
|
return res.sendStatus(400)
|
|
} else {
|
|
try {
|
|
order.id = await generateOrderId()
|
|
} catch (err) {
|
|
console.error(`FAILED to run generateOrderId()! ${err}`)
|
|
return sendStatus(500)
|
|
}
|
|
|
|
// Get payment intent
|
|
let paymentIntent; try {
|
|
paymentIntent = await stripe.paymentIntents.create({
|
|
amount: Math.round(total * 100), // Stripe takes pennies as an integer
|
|
currency: 'usd',
|
|
payment_method_types: ['card'],
|
|
receipt_email: order.contact.email,
|
|
description: `San Luis Valley IT order ${order.id}`,
|
|
statement_descriptor: `slvit.us ${order.id}`, // 22 chars max
|
|
metadata: {
|
|
id: order.id,
|
|
},
|
|
shipping: {
|
|
name: order.shippingAddress.name,
|
|
address: {
|
|
line1: order.shippingAddress.addr1,
|
|
line2: order.shippingAddress.addr2,
|
|
city: order.shippingAddress.city,
|
|
postal_code: order.shippingAddress.zip,
|
|
state: order.shippingAddress.state,
|
|
country: 'US',
|
|
},
|
|
}
|
|
})
|
|
} catch (err) {
|
|
console.error(`[${ip}] failed to create paymentIntent for order ${order.id} due to ${e.type} ${e.code} ${e.message}`)
|
|
// https://stripe.com/docs/error-handling?lang=node#error-types
|
|
switch (e.type) {
|
|
case 'StripeCardError':
|
|
return res.status(403).send(`There was an error with the card. This shouldn't have happened since you haven't provided your card number yet. Something is wrong on our end. Please let us know that you got this error by emailing ${process.env.ADMIN_EMAIL||'us'}`)
|
|
case 'StripeInvalidRequestError':
|
|
return res.status(400).send(`Something about that request was screwy. If were weren't trying any funny business, please let us know that you got this error by emailing ${process.env.ADMIN_EMAIL||'us'}`)
|
|
case 'StripeConnectionError':
|
|
return res.status(500).send(`There was an error connecting to stripe. Try again later.`)
|
|
case 'StripeAPIError':
|
|
return res.status(500).send(`An error occured on the end of our payment processor. These are usually rare. Hopefully this will be fixed soon and you can try again later.`)
|
|
case 'StripeAuthenticationError':
|
|
return res.status(400).send(`We couldn't authenticate with stripe, our payment processor. Something is wrong with our code, so could you let us know you got this error? Just email ${process.env.ADMIN_EMAIL||'us'}`)
|
|
case 'StripeIdempotencyError':
|
|
return res.status(400).send(`There was an "idempotency" error. This shouldn't happen and we don't really even know what this means. If you're reading this, please let us know you got this error by emailing ${proces.env.ADMIN_EMAIL||'us'}`)
|
|
case 'StripePermissionError':
|
|
return res.status(400).send(`We configured the wrong API key for our payment processor, stripe. This is our fault and not yours! Could you let us know we screwed up by emailing ${process.env.ADMIN_EMAIL||'us'}`)
|
|
case 'StripeRateLimitError':
|
|
return res.status(400).send(`We have been getting so many payment requests that our payment processor, stripe, has rate-limited us. Please try to make your payment again later. If you keep getting this message, something might be misconfigured on our end. In that case, please email ${process.env.ADMIN_EMAIL||'us'} to let us know.`)
|
|
case 'StripeSignatureVerificationError':
|
|
return res.status(400).send(`Something is messed up on our end. Please email ${process.env.ADMIN_EMAIL||'us'} and tell us you got a "stripe signature verification error".`)
|
|
default:
|
|
return res.status(500).send(`Something went wrong and we aren't quite sure what... Could you let us know you got this error by emailing ${process.env.ADMIN_EMAIL||'us'}`)
|
|
}
|
|
}
|
|
console.log(`[${ip}] paymentIntent created for ${order.id}`)
|
|
order.stripe_secret = paymentIntent.client_secret
|
|
res.json(JSON.stringify(order))
|
|
console.log(`[${ip}] sent order back to client`)
|
|
|
|
// 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}`) }
|
|
|
|
}
|
|
}).catch((err) => {
|
|
// This catch block runs if the subtotal/shipping/taxes don't add up
|
|
console.error(err)
|
|
return res.sendStatus(500)
|
|
})
|
|
|
|
})
|
|
|
|
// 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.contact.email} ${formatUSD(order.total)}`)
|
|
|
|
// Remove single products from store
|
|
order.items.forEach((item) => {
|
|
if (item.sid) {
|
|
console.log(`Removing product with sid ${item.sid} from store`)
|
|
run(`./hooks/remove_sid ${item.sid} ${SHOP_DIR} ${SOLD_DIR}`)
|
|
}
|
|
})
|
|
|
|
})
|