www/api.js

245 lines
11 KiB
JavaScript

'use strict'
require('dotenv').config()
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 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) => {
require('child_process').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 {
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) => {
// This catch block runs if the subtotal/shipping/taxes don't add up
console.error(err)
return res.sendStatus(500)
})
})