www/api/order-add.js

309 lines
14 KiB
JavaScript

'use strict'
require('dotenv').config()
const fs = require('fs').promises
const stripe = require('stripe')(process.env.STRIPE_SEC)
const axios = require('axios')
const crypto = require('crypto')
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`
const formatUSD = require('../lib/formatUSD')
const MONERO_PRICE_LEEWAY = Number(process.env.MONERO_PRICE_LEEWAY)
// 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)
})()
// Monero price
let XMR_PRICE
const getXmrPrice = async () => {
let res; try {
//res = await axios('https://localmonero.co/web/ticker?currencyCode=USD')
res = await axios('https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=usd')
} catch (err) { return console.error(`Failed to fetch CoinGecko price: ${err}`) }
console.log(`Monero is now worth ${formatUSD(res.data.monero.usd)}`)
if (res.data.monero.usd)
return XMR_PRICE = Number(res.data.monero.usd)
else return
}; getXmrPrice()
setInterval(getXmrPrice,1000*Number(process.env.MONERO_PRICECHECK_SEC))
const usdToXmr = (usd) => Number(usd / XMR_PRICE)
const truncateXmrAddr = (addr) => `${addr.slice(0,4)}..${addr.slice(-4)}`
// Shipping calculations
const zipToZone = (zip) => ZIPS_TO_ZONES[zip.substring(0,3)]
const getUspsRates = (zip, weight, volume) => {
return USPS_SHIPPING_RATES['40'][zipToZone(zip)-1]*Math.floor(weight/40)
+ USPS_SHIPPING_RATES[String(Math.ceil(weight%40))][zipToZone(zip)-1]
}
// Check if order.id exists in fs already
const generateOrderId = async () => {
const id = `r${Math.round(Math.random()*1000000000)}`
if (await require('../lib/fileExists')(`${ORDERS_DIR}/${id}.json`))
return generateOrderId()
else return id
}
module.exports = async (req, res) => {
let order = req.body
const ip = req.ip.slice(7)
if (order.total==null) return res.sendStatus(400)
console.log(`[${ip}] Created 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 product = products[`${cur.pid||''}/${cur.sid||''}`]
if (product==null) {
console.error(`Could not find product: products[${cur.pid||''}/${cur.sid||''}]`)
return res.status(404).send(`One of the products in your order could not be found on our end. Perhaps somebody bought it before you.`)
}
const listPrice = product.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.shipping.address.state) reject(`order.shipping.address.state not provided!`)
let tax = 0
if (order.shipping.address.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.shipping.address.zip) reject('order.shipping.address.zip is unset!')
let shipping = Math.round( getUspsRates(order.shipping.address.zip,
await weightPromise,
await volumePromise
) * 100 )/100
if (shipping===order.shipping.amount) 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]
if (order.paymentMethod==='USD')
order.processingFee = Math.round(((order.preTotal*0.0298661174047)+0.308959835221)*100)/100
else order.processingFee = 0
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)
}
const getProcessedOrder = () => new Promise(async (resolve, reject) => {
order.key = crypto.randomBytes(32).toString('hex')
// Process stripe order
if (order.paymentMethod==='USD') {
console.log(`[${ip}] order ${order.id} was for dollars...`)
// Get stripe payment intent
let paymentIntent; try {
paymentIntent = await stripe.paymentIntents.create({
amount: Math.round(total * 100), // Stripe takes pennies
currency: 'usd',
payment_method_types: ['card'],
description: `San Luis Valley IT order ${order.id}`,
statement_descriptor: `slvit.us ${order.id}`, // 22 chars max
metadata: {
id: order.id,
key: order.key,
},
shipping: {
name: order.shipping.name,
address: {
line1: order.shipping.address.addr1,
line2: order.shipping.address.addr2,
city: order.shipping.address.city,
postal_code: order.shipping.address.zip,
state: order.shipping.address.state,
country: 'US',
},
}
})
} catch (err) {
// https://stripe.com/docs/error-handling?lang=node#error-types
switch (err.type) {
case 'StripeCardError':
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':
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':
res.status(503).send(`There was an error connecting to stripe. Try again later.`)
case 'StripeAPIError':
res.status(503).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':
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':
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 ${process.env.ADMIN_EMAIL||'us'}`)
case 'StripePermissionError':
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':
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':
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:
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'}`)
}
if (err.type==='StripeConnectionError') res.status(504)
.send(`Failed to connect to stripe payment processing (on our end). Try again later and let us know if you continue to get this error`)
reject(`[${ip}] failed to create paymentIntent for order ${order.id} due to\n ${err.type}: ${err.code} ${err.message}`)
}
console.log(`[${ip}] paymentIntent created for ${order.id}`)
order.stripe_secret = paymentIntent.client_secret
resolve(order)
}
// Process monero order
else if (order.paymentMethod==='XMR') {
console.log(`[${ip}] order ${order.id} was for monero...`)
const calculated_xmr = Math.round( 10000 * usdToXmr(total) )/10000
if (
order.totalxmr + MONERO_PRICE_LEEWAY < calculated_xmr
|| order.totalxmr - MONERO_PRICE_LEEWAY > calculated_xmr
) {
res.status(400).send(`The monero conversion didn't add up on our end. Try it again.`)
reject(`[${ip}] XMR conversion didn't add up. Client said ${formatUSD(order.total)} is worth ${order.totalxmr} XMR but we calculated ${calculated_xmr}!`)
}
order.totalxmr = 0.0001 // $0.016 for testing
let xmrRes; try {
xmrRes = await axios.post(`${process.env.MONEROPAY_URL}/receive`, {
amount: Math.round(order.totalxmr*10000) * 100000000,
description: `sales@slvit.us_${order.id}`,
callback_url: `${process.env.API_DOMAIN}/moneropay-cb/${order.id}`,
})
} catch (err) {
res.status(503).send('Unable to reach monero processing backend. Please try again later')
reject(`[${ip}] Failed to hit moneropay backend: ${err}`)
}
if (xmrRes.status!==200) {
res.status(503).send('Failed to connect to monero processing backend. Please try again later.')
reject(`[${ip}] Got an error response from moneropay backend: ${res.status} ${res.statusText}`)
}
console.log(`[${ip}] xmr address created for ${order.id}: ${truncateXmrAddr(xmrRes.data.address)}`)
order.xmr_address = xmrRes.data.address
resolve(order)
}
// Bad payment method
else {
res.status(400).send(`Invalid payment method: ${order.paymentMethod}`)
reject(`Invalid paymentMethod received: ${order.paymentMethod}`)
}
})
let processedOrder; try {
processedOrder = await getProcessedOrder()
} catch (err) {
return console.error(`Failed to process order:\n${err}`)
}
// Send order back to client
res.status(200)
.header('Content-Type','application/json')
.send(JSON.stringify(processedOrder))
console.log(`[${ip}] sent order ${order.id} back to client`)
// Save order to fs
console.log(`[${ip}] Order ${processedOrder.id} for ${formatUSD(processedOrder.total)} verified.`)
const orderFile = `${ORDERS_DIR}/${order.id}.json`
try {
await fs.writeFile(orderFile,
JSON.stringify({
id: processedOrder.id,
key: processedOrder.key,
orderDate: processedOrder.created,
items: processedOrder.items,
contact: processedOrder.contact,
shipping: {
date: null,
address: processedOrder.shipping.address,
carrier: '',
tracking: [],
amount: parseFloat(processedOrder.shipping.amount),
},
subtotal: parseFloat(processedOrder.subtotal),
tax: parseFloat(processedOrder.tax),
taxAmount: parseFloat(processedOrder.taxAmount),
processing: parseFloat(processedOrder.processing),
total: parseFloat(processedOrder.total),
totalxmr: parseFloat(processedOrder.totalxmr),
xmr_address: processedOrder.xmr_address,
stripe_secret: processedOrder.stripe_secret,
paymentMethod: processedOrder.paymentMethod,
paidDate: null,
}, null, 2),
{mode:'640'}
)
fs.chown(orderFile,7000,7000)
} catch (err) { console.error(`ERROR! Failed to save ${orderFile}:\n${err}`) }
// Check for monero payments
if (processedOrder.paymentMethod==='XMR')
require('../listeners/monero')(processedOrder)
}
}).catch((err) => {
// This catch block runs if the subtotal/shipping/taxes don't add up
console.error(err)
return res.sendStatus(500)
})
}