309 lines
14 KiB
JavaScript
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.089
|
|
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}!`)
|
|
}
|
|
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: {
|
|
name: processedOrder.shipping.name,
|
|
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)
|
|
})
|
|
|
|
}
|