Got stripe payment working

This commit is contained in:
Keith Irwin 2023-04-02 18:15:08 -06:00
parent 0b72b39864
commit 2f5d7eac59
Signed by: ki9
GPG Key ID: DF773B3F4A88DA86
17 changed files with 583 additions and 762 deletions

2
.gitignore vendored
View File

@ -16,3 +16,5 @@ _site/*
**/*.bak
# vim swaps
**/*.swp
# VScode
.vscode/

View File

@ -271,30 +271,20 @@ class Cart { constructor() {
}
// Send to server
const res = await fetch(`${API_DOMAIN}/order`, {
method: 'POST', mode: 'cors',
headers: {'Content-Type':'application/json'},
body: ko.toJSON(order),
})
if (res.ok) { // paymentIntent created
console.log(res)
let parsedRes; try {
parsedRes = await res.json()
} catch (err) { console.error(`Failed to parse JSON: ${err}`) }
console.log(typeof parsedRes)
console.log(parsedRes.id)
if (self.payment_method()==='USD')
window.location = `/shop/checkout/stripe/?order=${parsedRes.id}`
else if (self.payment_method()==='XMR')
window.location = `/shop/order/monero/?order=${parsedRes.id}`
else {
alert(`Invalid payment method: ${self.payment_method()}`)
self.isLoading(false)
}
} else { // paymentIntent.create() failed
alert(await res.text())
self.isLoading(false)
}
let parsedRes; try {
const res = await fetch(`${API_DOMAIN}/order`, {
method: 'POST', mode: 'cors',
headers: {'Content-Type':'application/json'},
body: ko.toJSON(order),
})
if (!res.ok) throw await res.text()
else try { parsedRes = await res.json()}
catch (err) { throw `Failed to parse response:\n${err}` }
} catch (err) {
alert('Failed to create order! Please try again later')
console.error(err)
} finally { self.isLoading(false) }
window.location = `/shop/order/?id=${parsedRes.id}&key=${parsedRes.key}`
}

View File

@ -1,93 +0,0 @@
const formatUSD = (v) => v.toLocaleString(undefined, {
style: 'currency', currency: 'USD' })
const qs = new Proxy(new URLSearchParams(window.location.search), {
get: (searchParams, prop) => searchParams.get(prop),
})
const dom = {
itemsTable: document.getElementById('items-table'),
tax: document.getElementById('tax'),
shipping: document.getElementById('shipping'),
processing: document.getElementById('processing'),
subtotal: document.getElementById('subtotal'),
total: document.getElementById('total'),
buttonAmount: document.getElementById('final-amount'),
shippingAddress: document.getElementById('shipping-address'),
contact: document.getElementById('contact'),
submitBtn: document.getElementById('submit'),
}
;(async (orderId) => {
// Fetch order from api
let res; try {
res = await fetch(`${API_DOMAIN}/order/${orderId}`, {
headers: {'Accept':'application/json'},
})
if (!res.ok) throw res.statusText
} catch (err) {
console.error(`Failed to fetch order ${orderId} from ${API_DOMAIN}\n${err}`)
}; let order; try {
order = await res.json()
} catch (err) {
console.error(`Failed to parse JSON:\n${err}`)
}
// Stripe payment form
const elements = stripe.elements({
appearance: {
theme: 'night',
variables: {
colorPrimary: '#3d3d83', // Purple for tab selection
colorBackground: '#212121',
colorText: '#e0e0e0',
colorTextSecondary: '#e0e0e0',
colorLogo: 'dark',
fontFamily: 'dejavu, monospace',
},
},
clientSecret: order.stripe_secret,
})
const paymentElement = elements.create('payment', {
paymentMethodOrder: ['card'],
layout: 'tabs', // or 'accordian'
})
paymentElement.mount('#payment-element')
// Populate page with the data
dom.tax.innerText = formatUSD(order.taxAmount)
dom.shipping.innerText = formatUSD(order.shipping.amount)
dom.processing.innerText = formatUSD(order.processing)
dom.subtotal.innerText = formatUSD(order.subtotal)
dom.total.innerText = formatUSD(order.total)
dom.buttonAmount.innerText = ` for ${formatUSD(order.total)}`
dom.shippingAddress.innerHTML = `${order.shipping.address.name}<br>${order.shipping.address.addr1}<br>${(order.shipping.address.addr2)?order.shipping.address.addr2+'<br>':''}${order.shipping.address.city}, ${order.shipping.address.state} ${order.shipping.address.zip}`
dom.contact.innerHTML = `${order.contact.name}<br>${order.contact.phone}<br>${order.contact.email}`
order.items.forEach((item) => {
dom.itemsTable.innerHTML += `<tr><td>${item.name}</td>
<td style="text-align:right">${formatUSD(parseFloat(item.price))}</td>
<td style="width:2rem;text-align:right">${item.qty}</td>
<td style="text-align:right">${formatUSD(item.subtotal)}</td></tr>`
})
})(qs.order)
// onClick cancel link
const cancel = () => window.location = '/shop/cart/'
// onClick submit button
const submitPayment = async () => {
const oldBtnText = dom.submitBtm.innerText
dom.submitBtn.innerText = 'Placing order...'
dom.submitBtn.setAttribute('disabled','disabled')
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: SUCCESS_URL,
},
})
if (error) {
alert(error.message)
dom.submitBtn.innerText = oldBtnText
dom.submitBtn.removeAttribute('disabled')
}
}

View File

@ -0,0 +1,388 @@
'use strict'
const formatUSD = (v) => v.toLocaleString(undefined, {
style: 'currency', currency: 'USD' })
const cancel = () => window.location = '/shop/cart/'
const qstr = new Proxy(new URLSearchParams(window.location.search), {
get: (searchParams, prop) => searchParams.get(prop),
})
const STRIPE_DECLINE_CODES = {
generic_decline: `The card was declined for an unknown reason. You need to to contact your card-issuer for more information.`,
authentication_required: `The card was declined as the transaction requires authentication. You should try again and authenticate your card when prompted during the transaction. If the transaction <i>was</i> authenticated, contact your card-issuer for more information.`,
approve_with_id: `The payment cant be authorized. Attempt the payment again. If you still cant process it, contact your card-issuer.`,
call_issuer: `The card was declined for an unknown reason. Contact your card-issuer for more information.`,
card_not_supported: `The card does not support this type of purchase. You can contact your card-issuer to make sure your card can be used to make this type of purchase.`,
card_velocity_exceeded: `You have exceeded the balance or credit limit available on your card. You can to contact your card-issuer for more information.`,
currency_not_supported: `The card does not support the specified currency. You need to check with the issuer whether the card can be used for the type of currency specified.`,
do_not_honor: `The card was declined for an unknown reason. You need to contact your card-issuer for more information.`,
do_not_try_again: `The card was declined for an unknown reason. You need to contact your card-issuer for more information.`,
duplicate_transaction: `A transaction with identical amount and credit card information was submitted very recently. Check to see if a recent payment already exists.`,
expired_card: `The card has expired. You need to use another card.`,
incorrect_number: `The card number is incorrect. You need to try again using the correct card number.`,
incorrect_cvc: `The CVC number is incorrect. You need to try again using the correct CVC.`,
incorrect_pin: `The PIN entered is incorrect. This decline code only applies to payments made with a card reader. You need to try again using the correct PIN.`,
incorrect_zip: `The postal code is incorrect. You need to try again using the correct billing postal code.`,
insufficient_funds: `The card has insufficient funds to complete the purchase. You need to use an alternative payment method.`,
invalid_account: `The card, or account the card is connected to, is invalid. You need to contact your card-issuer to check that the card is working correctly.`,
invalid_amount: `The payment amount is invalid, or exceeds the amount thats allowed. If the amount appears to be correct, You need to check with your card-issuer that they can make purchases of that amount.`,
invalid_cvc: `The CVC number is incorrect. You need to try again using the correct CVC.`,
invalid_expiry_month: `The expiration month is invalid. You need to try again using the correct expiration date.`,
invalid_expiry_year: `The expiration year is invalid. You need try again using the correct expiration date.`,
invalid_number: `The card number is incorrect. You need try again using the correct card number.`,
invalid_pin: `The PIN entered is incorrect. This decline code only applies to payments made with a card reader. You need to try again using the correct PIN.`,
issuer_not_available: `The card-issuer couldnt be reached, so the payment couldnt be authorized. Attempt the payment again. If you still cant process it, You need to contact your card-issuer.`,
new_account_information_available: `The card, or account the card is connected to, is invalid. You need to contact your card-issuer for more information.`,
no_action_taken: `The card was declined for an unknown reason. You need to contact your card-issuer for more information.`,
not_permitted: `The payment isnt permitted. You need to contact your card-issuer for more information.`,
offline_pin_required: `The card was declined because it requires a PIN. You need to try again by inserting your card and entering a PIN.`,
online_or_offline_pin_required: `The card was declined as it requires a PIN. If the card reader supports Online PIN, prompt the customer for a PIN without creating a new transaction. If the card reader doesnt support Online PIN, You need to try again by inserting your card and entering a PIN.`,
pickup_card: `The customer cant use this card to make this payment (its possible it was reported lost or stolen). They need to contact your card-issuer for more information.`,
pin_try_exceeded: `The allowable number of PIN tries was exceeded. The customer must use another card or method of payment.`,
processing_error: `An error occurred while processing the card. The payment needs to be attempted again. If it still cant be processed, try again later.`,
reenter_transaction: `The payment couldnt be processed by the issuer for an unknown reason. The payment needs to be attempted again. If it still cant be processed, You need to contact your card-issuer.`,
restricted_card: `The customer cant use this card to make this payment (its possible it was reported lost or stolen). You need to contact your card-issuer for more information.`,
revocation_of_all_authorizations: `The card was declined for an unknown reason. You need to contact your card-issuer for more information.`,
revocation_of_authorization: `The card was declined for an unknown reason. You need to contact your card-issuer for more information.`,
security_violation: `The card was declined for an unknown reason. You need to contact your card-issuer for more information.`,
service_not_allowed: `The card was declined for an unknown reason. You need to contact your card-issuer for more information.`,
stop_payment_order: `The card was declined for an unknown reason. You need to contact your card-issuer for more information.`,
testmode_decline: `A Stripe test card number was used. A genuine card must be used to make a payment.`,
transaction_not_allowed: `The card was declined for an unknown reason. You need to contact your card-issuer for more information.`,
try_again_later: `The card was declined for an unknown reason. Ask the customer to attempt the payment again. If subsequent payments are declined, You need to contact your card-issuer for more information.`,
withdrawal_count_limit_exceeded: `The customer has exceeded the balance or credit limit available on your card. You need to use an alternative payment method.`,
}
const emptyCart = () => {
Object.keys(localStorage).forEach(i => {
if (i.substring(0,5)==='cart_')
localStorage.removeItem(i)
}); recountCart()
}
//let socket
class MoneroTransaction { constructor(data) {
let self = this
self.amount = data.amount/1000000000000
self.confirmations = ko.observable(0)
self.double_spend_seen = ko.observable(false)
self.fee = data.fee/1000000000000
self.fee_usd = self.fee * self.xmr_price()
self.fee_usd_pretty = `$${self.fee_usd}`
self.height = data.height
self.datetime = new Date(data.timestamp)
self.date = self.datetime.toLocaleDateString()
self.time = self.datetime.toLocaleTimeString()
self.tx_hash = data.tx_hash
self.unlock_time = ko.observable(0)
self.locked = ko.observable(true)
} }
class Checkout { constructor(data) {
let self = this
self.orderId = ko.observable(qstr.id||qstr.order)
self.orderKey = ko.observable(qstr.key)
self.items = ko.observableArray([])
self.shipname = ko.observable('')
self.contactname = ko.observable('')
self.phone = ko.observable('')
self.email = ko.observable('')
self.addr1 = ko.observable('')
self.addr2 = ko.observable('')
self.city = ko.observable('')
self.zip = ko.observable('')
self.state = ko.observable('')
self.tax = ko.observable(0)
self.shipping = ko.observable(0)
self.processing = ko.observable(0)
self.processing_pretty = ko.computed(() =>
formatUSD(self.processing())
)
self.subtotal = ko.observable(0)
self.total = ko.observable(0)
self.total_pretty = ko.computed(() =>
formatUSD(self.total())
)
self.paymentMethod = ko.observable('')
self.totalxmr = ko.observable(0)
self.totalxmr_pretty = ko.pureComputed(() =>
`${self.totalxmr()} XMR`
)
self.xmr_address = ko.observable('')
self.submitted = ko.observable(0)
self.submitted_pretty = ko.pureComputed( () =>
`${self.submitted()} XMR`
)
self.unsubmitted = ko.pureComputed( () =>
self.totalxmr() - self.submitted()
)
self.unlocked = ko.observable(0)
self.unlocked_pretty = ko.pureComputed( () =>
`${self.unlocked()} XMR`
)
self.isSubmitted = ko.pureComputed( () =>
( self.submitted() >= self.totalxmr() )
)
self.paidDate = ko.observable('')
self.paidDate_pretty = ko.pureComputed(() =>
self.paidDate()
)
self.isPaidUSD = ko.observable(false)
self.isPaid = ko.pureComputed(() =>
(self.isPaidUSD() || // USD paid
( self.unlocked() >= self.totalxmr() )) // XMR paid
)
self.isOverpaid = ko.pureComputed( () =>
( self.submitted() > self.totalxmr() )
)
self.overpaidAmount = ko.pureComputed( () =>
(self.isOverpaid)? self.submitted() - self.totalxmr() : 0
)
self.overpaidAmount_pretty = ko.pureComputed( () =>
`${self.overpaidAmount()} XMR`
)
self.transactions = ko.observableArray([])
self.xmr_uri = ko.pureComputed(() =>
`monero:${self.xmr_address()}?tx_amount=${self.unsubmitted()}&tx_description=sales@slvit.us_${self.orderId()}`
)
let xmr_qr = new QRCode(
document.getElementById('xmr_qr'),
self.xmr_uri()
)
self.xmr_uri.subscribe(() => {
xmr_qr.clear()
xmr_qr.makeCode(self.xmr_uri())
})
self.isPlacingStripeOrder = ko.observable(false)
self.stripeSubmitButtonText = ko.computed(() =>
(self.isPlacingStripeOrder())?'Placing order...':
`Place order for ${self.total_pretty()}`
)
self.checkingStatus = ko.observable('')
self.titleStatus = ko.observable('')
self.stripeText = ko.observable('')
self.xmr_price = ko.observable(0)
self.submitStripePayment= ko.observable(()=>{})
;(async () => {
const getStripeStatus = async (secret, empty_cart=false) => {
// https://stripe.com/docs/error-handling?lang=node#use-stored-information
const { paymentIntent } = await stripe.retrievePaymentIntent(secret)
console.log(paymentIntent)
if (paymentIntent.last_payment_error !== null) {
if (paymentIntent.last_payment_error.type==='StripeCardError') {
self.titleStatus(' (declined)')
self.stripeText(`Your payment was declined. Our payment processor, Stripe, sent back this error: <code>${paymentIntent.last_payment_error.code||'generic_decline'}</code>. Here's what they think that means: </p>
<blockquote>${DECLINE_CODES[paymentIntent.last_payment_error.code||'generic_decline']||DECLINE_CODES['generic_decline']}</blockquote>
<p>What you do now is up to you. Maybe you want to <a href="/shop/checkout/stripe/">go back to the checkout page</a> and try again.`)
} else {
self.titleStatus(' (failed)')
self.stripeText(`The payment failed due to an ${paymentIntent.last_payment_error.type} error.`)
}
self.isPaidUSD(false)
} else {
if (paymentIntent.status==='succeeded') {
self.titleStatus(' (paid)')
self.stripeText(`Your order has been placed and your payment was processed. You may bookmark and/or print this page for your records. Your order number is: ${order.id}. You will receive a copy of this order by email. Tracking information will be sent when your order ships.`)
self.isPaidUSD(true)
if (empty_cart) emptyCart()
} else if (paymentIntent.status==='processing') {
self.titleStatus(' (processing)')
self.stripeText(`Your payment wasn't processed <i>yet</i>. Wait a few minutes and then try <a href="javascript:window.location.reload(true)">refreshing this page</a>. `)
self.isPaidUSD(false)
} else {
self.titleStatus(' (failed)')
self.stripeText('The payment failed due to an unknown error.')
self.isPaidUSD(false)
}
}
}
// Check stripe status first if possible
if (qstr.payment_intent_client_secret!=null)
getStripeStatus(qstr.payment_intent_client_secret)
// Fetch order from API
let res; try {
res = await fetch(`${API_DOMAIN}/order/${qstr.id}?key=${qstr.key}`, {
headers: {'Accept':'application/json'},
})
if (!res.ok) throw res.statusText
} catch (err) {
console.error(`Failed to fetch order from API!\n${err}`)
}; let order; try {
order = await res.json()
} catch (err) {
console.error(`Failed to parse JSON:\n${err}`)
}
// Populate view model with order data
self.paidDate(order.paidDate)
self.subtotal(formatUSD(order.subtotal))
self.shipping(formatUSD(order.shipping.amount))
self.processing(order.processing)
self.processing_pretty = ko.pureComputed(() =>
self.processing()
)
self.tax(formatUSD(order.taxAmount))
self.total(formatUSD(order.total))
self.paymentMethod(order.paymentMethod)
self.totalxmr(order.totalxmr)
order.items.forEach((item) =>
self.items.push({
name: item.name,
price: formatUSD(parseFloat(item.price)),
qty: item.qty,
subtotal: formatUSD(item.subtotal),
})
)
self.shipname(order.shipping.address.name)
self.addr1(order.shipping.address.addr1)
self.addr2(order.shipping.address.addr2)
self.city(order.shipping.address.city)
self.zip(order.shipping.address.zip)
self.state(order.shipping.address.state)
self.contactname(order.contact.name)
self.phone(order.contact.phone)
self.email(order.contact.email)
if (order.paymentMethod==='USD') {
self.isPaidUSD((qstr.redirect_status=='succeeded'||order.paidDate!=null))
// Try to get payment status if we didn't already
if (self.isPaidUSD()) {
if (qstr.payment_intent_client_secret==null)
getStripeStatus(order.stripe_secret)
} else {
// Stripe payment form
const elements = stripe.elements({
clientSecret: order.stripe_secret,
appearance: {
theme: 'night',
variables: {
colorPrimary: '#3d3d83',
colorBackground: '#212121',
colorText: '#e0e0e0',
colorTextSecondary: '#e0e0e0',
colorLogo: 'dark',
fontFamily: 'dejavu, monospace',
},
},
})
elements.create('payment', {
paymentMethodOrder: ['card'],
}).mount('#stripe-payment')
self.submitStripePayment(async () => {
self.isPlacingStripeOrder(true)
// const { error } = await stripe.confirmPayment({
// elements,
// confirmParams: { return_url: SUCCESS_URL },
// })
let payment_intent; try {
payment_intent = await stripe.confirmPayment({
elements,
confirmParams: { return_url: `${SUCCESS_URL}?id=${order.id}&key=${order.key}` },
})
if (payment_intent.error) throw payment_intent.error
} catch (err) { alert(err.message) }
finally { self.isPlacingStripeOrder(false) }
})
}
}
else if (order.paymentMethod==='XMR') {
// Price checking
const getXmrPrice = async () => {
let res; try {
res = await fetch('https://localmonero.co/web/ticker?currencyCode=USD')
} catch (err) {
return console.error(`Failed to fetch CoinGecko price: ${err}`)
}; let parsedRes; try {
parsedRes = await res.json()
} catch (err) {
return console.error(`Error parsing CoinGecko response: ${err}`)
}
console.log(`Monero is now worth ${formatUSD(parsedRes.monero.usd)}`)
self.xmr_price(parsedRes.monero.usd)
}; getXmrPrice()
setInterval(getXmrPrice, MONERO_PRICECHECK_SEC*1000)
self.xmr_address(order.xmr_address)
self.totalxmr(order.totalxmr)
// Poll for new xmr transactions
let poll_in = MONERO_CHECKOUT_POLL_SECS
let isChecking = false
const getTransactions = async () => {
isChecking = true
let res; try {
res = await fetch(`${API_DOMAIN}/xmr-receive/${order.xmr_address}`)
} catch (err) {
return console.error(`Failed to get update about xmr address ${order.xmr_address}\n${err}`)
} if (!res.ok)
return console.error(`Got a bad response when requesting update on this xmr address: ${res.status}\n${res}`)
let resData; try {
resData = await res.json()
} catch (err) {
return console.error(`Failed to parse response JSON: ${err}`)
} finally {
isChecking = false
poll_in = MONERO_CHECKOUT_POLL_SECS
}
if (resData.transactions) {
resData.transactions.forEach( (tx) => {
const existingTransactions = self.transactions()
.filter((i) => (i.tx_hash===tx.tx_hash))
if (existingTransactions.length) {
existingTransactions[0].confirmations(tx.confirmations)
existingTransactions[0].double_spend_seen(tx.double_spend_seen)
existingTransactions[0].unlock_time(tx.unlock_time)
existingTransactions[0].locked(tx.locked)
} else {
self.transactions.push(new MoneroTransaction(tx))
self.transactions.sort( (a,b) =>
(a.height===b.height)?0
:(a.height<b.height)?-1:1
)
}
})
self.submitted(resData.amount.covered.total/1000000000000)
self.unlocked(resData.amount.covered.unlocked/1000000000000)
}
}; getTransactions(); setInterval(() => {
if (poll_in<=0) {
self.checkingStatus('Checking for transactions...')
if (!isChecking) getTransactions()
} else {
self.checkingStatus(`Checking for transactions in ${
poll_in--}...`)
}
}, 1000)
// Websockets
// socket = io(API_DOMAIN,{query:{orderid:data.id}})
// socket.on('mp-cb', (body) => {
// console.log(body)
// body.transactions.forEach( (tx) => {
// const existingTransactions = self.transactions()
// .filter((i) => (i.tx_hash===tx.tx_hash))
// if (existingTransactions.length>0) {
// existingTransactions[0].confirmations(tx.confirmations)
// existingTransactions[0].double_spend_seen(tx.double_spend_seen)
// existingTransactions[0].unlock_time(tx.unlock_time)
// existingTransactions[0].locked(tx.locked)
// } else self.transactions.push(tx)
// })
// self.submitted(body.amount.covered.total)
// self.unlocked(body.amount.covered.unlocked)
// })
}
else alert(
`Invalid payment method in order: ${order.paymentMethod}`
)
} )()
} }; ko.applyBindings(new Checkout())

View File

@ -1,232 +0,0 @@
'use strict'
const formatUSD = (v) => v.toLocaleString(undefined, {
style: 'currency', currency: 'USD' })
const cancel = () => window.location = '/shop/cart/'
// Parse querystring (https://stackoverflow.com/a/901144/)
const qs = new Proxy(new URLSearchParams(window.location.search), {
get: (searchParams, prop) => searchParams.get(prop),
})
//let socket
class moneroCheckoutItem { constructor(data) {
let self = this
self.name = data.name
self.price = data.price
self.qty = data.qty
self.subtotal = data.subtotal
} }
class moneroCheckoutTransaction { constructor(data) {
let self = this
self.amount = data.amount/1000000000000
self.confirmations = ko.observable(0)
self.double_spend_seen = ko.observable(false)
self.fee = data.fee/1000000000000
self.fee_usd = self.fee * self.xmr_price()
self.fee_usd_pretty = `$${self.fee_usd}`
self.height = data.height
self.datetime = new Date(data.timestamp)
self.date = self.datetime.toLocaleDateString()
self.time = self.datetime.toLocaleTimeString()
self.tx_hash = data.tx_hash
self.unlock_time = ko.observable(0)
self.locked = ko.observable(true)
} }
class moneroCheckout { constructor(data) {
let self = this
self.items = ko.observableArray([])
self.shipname = ko.observable('')
self.contactname = ko.observable('')
self.phone = ko.observable('')
self.email = ko.observable('')
self.addr1 = ko.observable('')
self.addr2 = ko.observable('')
self.city = ko.observable('')
self.zip = ko.observable('')
self.state = ko.observable('')
self.tax = ko.observable(0)
self.shipping = ko.observable(0)
self.subtotal = ko.observable(0)
self.total = ko.observable(0)
self.totalxmr = ko.observable(0)
self.totalxmr_pretty = ko.pureComputed(() =>
`${self.totalxmr()} XMR`
)
self.orderid = ko.observable('')
self.xmr_address = ko.observable('')
self.submitted = ko.observable(0)
self.submitted_pretty = ko.pureComputed( () =>
`${self.submitted()} XMR`
)
self.unsubmitted = ko.pureComputed( () =>
self.totalxmr() - self.submitted()
)
self.unlocked = ko.observable(0)
self.unlocked_pretty = ko.pureComputed( () =>
`${self.unlocked()} XMR`
)
self.isSubmitted = ko.pureComputed( () =>
( self.submitted() >= self.totalxmr() )
)
self.isPaid = ko.pureComputed( () =>
( self.unlocked() >= self.totalxmr() )
)
self.isOverpaid = ko.pureComputed( () =>
( self.submitted() > self.totalxmr() )
)
self.overpaidAmount = ko.pureComputed( () =>
(self.isOverpaid)? self.submitted() - self.totalxmr() : 0
)
self.overpaidAmount_pretty = ko.pureComputed( () =>
`${self.overpaidAmount()} XMR`
)
self.transactions = ko.observableArray([])
self.xmr_uri = ko.pureComputed(() =>
`monero:${self.xmr_address()}?tx_amount=${self.unsubmitted()}&tx_description=sales@slvit.us_${self.orderid()}`
)
let xmr_qr = new QRCode(
document.getElementById('xmr_qr'),
self.xmr_uri()
)
self.xmr_uri.subscribe(() => {
xmr_qr.clear()
xmr_qr.makeCode(self.xmr_uri())
})
self.checkingStatus = ko.observable('')
// Monero pricing
self.xmr_price = ko.observable(0)
const getXmrPrice = async () => {
let res; try {
res = await fetch('https://localmonero.co/web/ticker?currencyCode=USD')
//res = await fetch('https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=usd')
} catch (err) {
return console.error(`Failed to fetch CoinGecko price: ${err}`)
}; let parsedRes; try {
parsedRes = await res.json()
} catch (err) {
return console.error(`Error parsing CoinGecko response: ${err}`)
}
console.log(`Monero is now worth ${formatUSD(parsedRes.monero.usd)}`)
self.xmr_price(parsedRes.monero.usd)
//self.xmr_price(parsedRes.USD.avg_24h)
}; getXmrPrice()
setInterval(getXmrPrice, MONERO_PRICECHECK_SEC*1000)
// Get order info from querystring
self.orderid(qs.order)
;(async () => {
// Fetch order from API
let res; try {
res = await fetch(`${API_DOMAIN}/order/${qs.order}`, {
headers: {'Accept':'application/json'},
})
if (!res.ok) throw res.statusText
} catch (err) {
console.error(`Failed to fetch order from API!\n${err}`)
}; let order; try {
order = await res.json()
} catch (err) {
console.error(`Failed to parse JSON:\n${err}`)
}
// Populate DOM with order
self.xmr_address(order.xmr_address)
self.subtotal(formatUSD(order.subtotal))
self.shipping(formatUSD(order.shipping.amount))
self.tax(formatUSD(order.taxAmount))
self.total(formatUSD(order.total))
self.orderid(order.id)
self.totalxmr(order.totalxmr)
order.items.forEach((item) =>
self.items.push({
name: item.name,
price: formatUSD(parseFloat(item.price)),
qty: item.qty,
subtotal: formatUSD(item.subtotal),
})
)
self.shipname(order.shipping.address.name)
self.addr1(order.shipping.address.addr1)
self.addr2(order.shipping.address.addr2)
self.city(order.shipping.address.city)
self.zip(order.shipping.address.zip)
self.state(order.shipping.address.state)
self.contactname(order.contact.name)
self.phone(order.contact.phone)
self.email(order.contact.email)
// Poll for new transactions
let poll_in = MONERO_CHECKOUT_POLL_SECS
let isChecking = false
const getTransactions = async () => {
isChecking = true
let res; try {
res = await fetch(`${API_DOMAIN}/xmr-receive/${order.xmr_address}`)
} catch (err) {
return console.error(`Failed to get update about xmr address ${order.xmr_address}\n${err}`)
} if (!res.ok)
return console.error(`Got a bad response when requesting update on this xmr address: ${res.status}\n${res}`)
let resData; try {
resData = await res.json()
} catch (err) {
return console.error(`Failed to parse response JSON: ${err}`)
} finally {
isChecking = false
poll_in = MONERO_CHECKOUT_POLL_SECS
}
if (resData.transactions) {
resData.transactions.forEach( (tx) => {
const existingTransactions = self.transactions()
.filter((i) => (i.tx_hash===tx.tx_hash))
if (existingTransactions.length) {
existingTransactions[0].confirmations(tx.confirmations)
existingTransactions[0].double_spend_seen(tx.double_spend_seen)
existingTransactions[0].unlock_time(tx.unlock_time)
existingTransactions[0].locked(tx.locked)
} else {
self.transactions.push(new moneroCheckoutTransaction(tx))
self.transactions.sort( (a,b) =>
(a.height===b.height)?0
:(a.height<b.height)?-1:1
)
}
})
self.submitted(resData.amount.covered.total/1000000000000)
self.unlocked(resData.amount.covered.unlocked/1000000000000)
}
}; getTransactions(); setInterval(() => {
if (poll_in<=0) {
self.checkingStatus('Checking for transactions...')
if (!isChecking) getTransactions()
} else {
self.checkingStatus(`Checking for transactions in ${
poll_in--}...`)
}
}, 1000)
// Websockets
// socket = io(API_DOMAIN,{query:{orderid:data.id}})
// socket.on('mp-cb', (body) => {
// console.log(body)
// body.transactions.forEach( (tx) => {
// const existingTransactions = self.transactions()
// .filter((i) => (i.tx_hash===tx.tx_hash))
// if (existingTransactions.length>0) {
// existingTransactions[0].confirmations(tx.confirmations)
// existingTransactions[0].double_spend_seen(tx.double_spend_seen)
// existingTransactions[0].unlock_time(tx.unlock_time)
// existingTransactions[0].locked(tx.locked)
// } else self.transactions.push(tx)
// })
// self.submitted(body.amount.covered.total)
// self.unlocked(body.amount.covered.unlocked)
// })
} )()
} }; ko.applyBindings(new moneroCheckout())

View File

@ -1,156 +0,0 @@
const formatUSD = (v) => v.toLocaleString(undefined, {
style: 'currency', currency: 'USD' })
// Parse querystring (https://stackoverflow.com/a/901144/)
const qs = new Proxy(new URLSearchParams(window.location.search), {
get: (searchParams, prop) => searchParams.get(prop),
})
// See https://stripe.com/docs/declines/codes#generic_decline
const DECLINE_CODES = {
generic_decline: `The card was declined for an unknown reason. You need to to contact your card-issuer for more information.`,
authentication_required: `The card was declined as the transaction requires authentication. You should try again and authenticate your card when prompted during the transaction. If the transaction <i>was</i> authenticated, contact your card-issuer for more information.`,
approve_with_id: `The payment cant be authorized. Attempt the payment again. If you still cant process it, contact your card-issuer.`,
call_issuer: `The card was declined for an unknown reason. Contact your card-issuer for more information.`,
card_not_supported: `The card does not support this type of purchase. You can contact your card-issuer to make sure your card can be used to make this type of purchase.`,
card_velocity_exceeded: `You have exceeded the balance or credit limit available on your card. You can to contact your card-issuer for more information.`,
currency_not_supported: `The card does not support the specified currency. You need to check with the issuer whether the card can be used for the type of currency specified.`,
do_not_honor: `The card was declined for an unknown reason. You need to contact your card-issuer for more information.`,
do_not_try_again: `The card was declined for an unknown reason. You need to contact your card-issuer for more information.`,
duplicate_transaction: `A transaction with identical amount and credit card information was submitted very recently. Check to see if a recent payment already exists.`,
expired_card: `The card has expired. You need to use another card.`,
incorrect_number: `The card number is incorrect. You need to try again using the correct card number.`,
incorrect_cvc: `The CVC number is incorrect. You need to try again using the correct CVC.`,
incorrect_pin: `The PIN entered is incorrect. This decline code only applies to payments made with a card reader. You need to try again using the correct PIN.`,
incorrect_zip: `The postal code is incorrect. You need to try again using the correct billing postal code.`,
insufficient_funds: `The card has insufficient funds to complete the purchase. You need to use an alternative payment method.`,
invalid_account: `The card, or account the card is connected to, is invalid. You need to contact your card-issuer to check that the card is working correctly.`,
invalid_amount: `The payment amount is invalid, or exceeds the amount thats allowed. If the amount appears to be correct, You need to check with your card-issuer that they can make purchases of that amount.`,
invalid_cvc: `The CVC number is incorrect. You need to try again using the correct CVC.`,
invalid_expiry_month: `The expiration month is invalid. You need to try again using the correct expiration date.`,
invalid_expiry_year: `The expiration year is invalid. You need try again using the correct expiration date.`,
invalid_number: `The card number is incorrect. You need try again using the correct card number.`,
invalid_pin: `The PIN entered is incorrect. This decline code only applies to payments made with a card reader. You need to try again using the correct PIN.`,
issuer_not_available: `The card-issuer couldnt be reached, so the payment couldnt be authorized. Attempt the payment again. If you still cant process it, You need to contact your card-issuer.`,
new_account_information_available: `The card, or account the card is connected to, is invalid. You need to contact your card-issuer for more information.`,
no_action_taken: `The card was declined for an unknown reason. You need to contact your card-issuer for more information.`,
not_permitted: `The payment isnt permitted. You need to contact your card-issuer for more information.`,
offline_pin_required: `The card was declined because it requires a PIN. You need to try again by inserting your card and entering a PIN.`,
online_or_offline_pin_required: `The card was declined as it requires a PIN. If the card reader supports Online PIN, prompt the customer for a PIN without creating a new transaction. If the card reader doesnt support Online PIN, You need to try again by inserting your card and entering a PIN.`,
pickup_card: `The customer cant use this card to make this payment (its possible it was reported lost or stolen). They need to contact your card-issuer for more information.`,
pin_try_exceeded: `The allowable number of PIN tries was exceeded. The customer must use another card or method of payment.`,
processing_error: `An error occurred while processing the card. The payment needs to be attempted again. If it still cant be processed, try again later.`,
reenter_transaction: `The payment couldnt be processed by the issuer for an unknown reason. The payment needs to be attempted again. If it still cant be processed, You need to contact your card-issuer.`,
restricted_card: `The customer cant use this card to make this payment (its possible it was reported lost or stolen). You need to contact your card-issuer for more information.`,
revocation_of_all_authorizations: `The card was declined for an unknown reason. You need to contact your card-issuer for more information.`,
revocation_of_authorization: `The card was declined for an unknown reason. You need to contact your card-issuer for more information.`,
security_violation: `The card was declined for an unknown reason. You need to contact your card-issuer for more information.`,
service_not_allowed: `The card was declined for an unknown reason. You need to contact your card-issuer for more information.`,
stop_payment_order: `The card was declined for an unknown reason. You need to contact your card-issuer for more information.`,
testmode_decline: `A Stripe test card number was used. A genuine card must be used to make a payment.`,
transaction_not_allowed: `The card was declined for an unknown reason. You need to contact your card-issuer for more information.`,
try_again_later: `The card was declined for an unknown reason. Ask the customer to attempt the payment again. If subsequent payments are declined, You need to contact your card-issuer for more information.`,
withdrawal_count_limit_exceeded: `The customer has exceeded the balance or credit limit available on your card. You need to use an alternative payment method.`,
}
// Page elements
const dom = {
title: document.getElementById('title'),
msg: document.getElementById('msg'),
itemsTable: document.getElementById('items-table'),
tax: document.getElementById('tax'),
shipping: document.getElementById('shipping'),
processing: document.getElementById('processing'),
subtotal: document.getElementById('subtotal'),
total: document.getElementById('total'),
shippingAddress: document.getElementById('shipping-address'),
contact: document.getElementById('contact'),
}
const emptyCart = () => {
Object.keys(localStorage).forEach(i => {
if (i.substring(0,5)==='cart_')
localStorage.removeItem(i)
}); recountCart()
}
const getStripeStatus = async (secret) => {
const succeed = async () => {
dom.title.innerText = `Order placed`
dom.msg.innerText = `Your order has been placed and your payment was processed. You may print this page for your records. Your order number is: ${order.id}. You will receive a copy of this order by email. Tracking information will be sent when your order ships.`
emptyCart()
}
const decline = (err_code) => {
dom.title.innerText = 'Card declined'
dom.msg.innerText = `Your payment was declined. Our payment processor, Stripe, sent back this error: <code>${err_code}</code>. Here's what they think that means: </p>
<blockquote>${DECLINE_CODES[err_code]||DECLINE_CODES['generic_decline']}</blockquote>
<p>What you do now is up to you. Maybe you want to <a href="/shop/checkout/stripe/">go back to the checkout page</a> and try again.`
}
const fail = (err) => {
dom.title.innerText = 'Payment failed'
dom.msg.innerText = `The payment failed due to an ${err} error.`
}
const pend = () => {
dom.title.innerText = 'Processing!'
dom.msg.innerText = `Your payment wasn't processed <i>yet</i>. Wait a few minutes and then try <a href="javascript:window.location.reload(true)">refreshing this page</a>. `
}
// https://stripe.com/docs/error-handling?lang=node#use-stored-information
const { paymentIntent } = await stripe.retrievePaymentIntent(secret)
//payment_intent.last_payment_error
if (paymentIntent.last_payment_error !== null) {
if (paymentIntent.last_payment_error.type==='StripeCardError')
decline(paymentIntent.last_payment_error.code||'generic_decline')
else
fail(paymentIntent.last_payment_error.type)
} else switch (paymentIntent.status) {
case 'succeeded':
succeed(); break
case 'processing':
pend(); break
default:
fail('unknown'); break
}
}
;(async () => {
// Check stripe status with qs if possible
if (qs.payment_intent_client_secret!=null)
getStripeStatus(qs.payment_intent_client_secret)
// Meanwhile, get the order data from api
let res; try {
res = await fetch(`${API_DOMAIN}/order/${qs.order}`, {
headers: {'Accept':'application/json'},
})
if (!res.ok) throw res.statusText
} catch (err) {
console.error(`Failed to fetch order from API!\n${err}`)
}; let order; try {
order = await res.json()
} catch (err) {
console.error(`Failed to parse JSON:\n${err}`)
}
// Get stripe status if we couldn't use the qs
if (order.stripe_secret!==qs.payment_intent_client_secret)
getStripeStatus(stripe_secret)
// Populate the page with the order data
dom.tax.innerText = formatUSD(order.taxAmount)
dom.shipping.innerText = formatUSD(order.shippingAmount)
dom.processing.innerText = formatUSD(order.processing)
dom.subtotal.innerText = formatUSD(order.subtotal)
dom.total.innerText = formatUSD(order.total)
dom.shippingAddress.innerHTML = `${shipping_data.name}<br>${shipping_data.addr1}<br>${(shipping_data.addr2)?shipping_data.addr2+'<br>':''}${shipping_data.city}, ${shipping_data.state} ${shipping_data.zip}`
dom.contact.innerHTML = `${contact_data.name}<br>${contact_data.phone}<br>${contact_data.email}`
order.items.forEach((item) =>
dom.itemsTable.innerHTML += `<tr><td>${item.name}</td>
<td style="text-align:right">${formatUSD(parseFloat(item.price))}</td>
<td style="width:2rem;text-align:right">${item.qty}</td>
<td style="text-align:right">${formatUSD(item.subtotal)}</td></tr>`
)
})()

View File

@ -153,11 +153,6 @@ table#cart-items {
}
}
#checkout-button, #submit {
margin: 2em 0 1em;
height: 4em;
}
#totals-container {
border-collapse: collapse;
}

View File

@ -41,7 +41,7 @@ title: Cart
<p>Select payment method to calculate processing fees: </p><label><input type="radio" value="XMR" data-bind="checked:payment_method"/> <img src="/assets/img/monero.svg" style="width:1em;height:1em"> XMR Monero</label>
<!--<br><label><input type="radio" value="BTC" data-bind="checked:payment_method"/> 🪙 BTC ₿itcoin (lightning)</label>-->
<br><label><input type="radio" value="USD" data-bind="checked:payment_method" checked/> 💳 USD Debit/credit</label>
<p data-bind="visible:payment_method()==='USD'">(Stripe's <a href="https://stripe.com/pricing">fees</a> and <a href="/policies/">terms</a> apply.)</p>
<p data-bind="visible:payment_method()==='USD'">Stripe's <a href="https://stripe.com/pricing">processing fee</a> (2.9% + 30¢) and <a href="/policies/">terms</a> apply.</p>
<p data-bind="visible:payment_method()==='XMR'">(Payment processing through our secure monero daemon.)</p>
<p data-bind="visible:payment_method()==='BTC'">(Payment processing through our secure bitcoin daemon.)</p>
</div>

View File

@ -1,64 +0,0 @@
---
layout: layouts/base.njk
title: Checkout
---
<link rel="stylesheet" href="/assets/shop.css">
<script src="https://js.stripe.com/v3/"></script>
<h1>📃 Stripe Checkout</h1>
<table class="tal" style="width:100%"><thead>
<th>Item</th>
<th class="tar">Price</th>
<th class="tar">Qty</th>
<th class="tar">Sub</th>
</thead><tbody id="items-table"></tbody></table>
<hr>
<div class="flex" style="align-items:flex-start">
<p id="shipping-container">
<b>📦 <span class="ul">Shipping</span></b><br>
<span id="shipping-address"></span>
</p>
<p id="contact-container">
<b>📇 <span class="ul">Contact</ul></b><br>
<span id="contact"></span>
</p>
<div id="totals-container"><table><tbody>
<tr>
<td><b>Subtotal:</b></td>
<td class="tar"><b id="subtotal"></b></td>
</tr><tr>
<td>Tax:</td>
<td id="tax" class="tar"></td>
</tr><tr>
<td>Shipping:</td>
<td id="shipping" class="tar"></td>
</tr><tr>
<td>Processing:</td>
<td id="processing" class="tar"></td>
</tr><tr style="border-top:.1vh solid">
<td><b>Total:</b></td>
<td class="tar"><b id="total"></b>
</td>
</tr>
</tbody></table></div>
</div>
<hr>
<p>To change order details, <a href="#" onclick="cancel()">cancel it and return to your cart</a>. Otherwise, enter your payment information below. </p>
<div id="payment-form">
<div id="payment-element">Loading payment form...</div>
<button id="submit" onclick="submitPayment()">
<span id="button-text">💳 Place order<span id="final-amount"></span></span>
</button>
<div id="payment-message"></div>
</div>
<script>
const SUCCESS_URL = '{{env.SITE_DOMAIN}}/shop/order/stripe/'
const API_DOMAIN = '{{env.API_DOMAIN}}'
const stripe = Stripe('{{env.STRIPE_PUB}}')
</script>
<script src="/assets/scripts/checkout/stripe.js" integrity="{{'/assets/scripts/checkout/stripe.js'|srintegrity}}" defer></script>

149
_src/shop/order.njk Normal file
View File

@ -0,0 +1,149 @@
---
layout: layouts/base.njk
title: Order
---
<style>
#qr_wrapper {
max-width: 80vh;
width: 80vw;
height: 80vh;
max-height: 80vw;
padding: calc(1.5vh + 1.5vw);
background-color: #FFFFFF;
margin: auto;
}
#qr_wrapper div {
width: 100%;
height: 100%;
}
</style>
<link rel="stylesheet" href="/assets/shop.css">
<script src="https://js.stripe.com/v3/"></script>
<h1>📋 Order <span data-bind="text:orderId"></span> <span data-bind="text:titleStatus"></span></h1>
<table class="tal" style="width:100%"><thead><b>
<th>Item</th>
<th class="tar">Price</th>
<th class="tar">Qty</th>
<th class="tar">Sub</th>
</b></thead><tbody data-bind="foreach:items"><tr>
<td data-bind="text:name"></td>
<td class="tar" data-bind="text:price"></td>
<td class="tar" data-bind="text:qty" style="width:2rem"></td>
<td class="tar" data-bind="text:subtotal"></td>
</tr></tbody></table>
<hr>
<div class="flex" style="align-items:flex-start">
<p id="shipping-container">
<b>📦 <span class="ul">Shipping</span></b><br>
<span data-bind="text:shipname"></span>
<br><span data-bind="text:addr1"></span>
<br><span data-bind="text:addr2"></span>
<br><span data-bind="text:city"></span>,
<span data-bind="text:state"></span>
<span data-bind="text:zip"></span>
</p>
<p id="contact-container">
<b>📇 <span class="ul">Contact</ul></b><br>
<span data-bind="text:contactname"></span>
<br><span data-bind="text:phone"></span>
<br><span data-bind="text:email"></span>
</p>
<div id="totals-container">
<table><tbody>
<tr>
<td>Subtotal:</td>
<td class="tar" data-bind="text:subtotal"></td>
</tr><tr>
<td>Tax:</td>
<td class="tar" data-bind="text:tax"></td>
</tr><tr>
<td>Shipping:</td>
<td class="tar" data-bind="text:shipping"></td>
</tr><tr data-bind="visible:paymentMethod()=='USD'">
<td>Processing:</td>
<td class="tar" data-bind="text:processing_pretty"></td>
</tr><tr style="border-top:.1vh solid">
<td><b>Total:</b></td>
<td class="tar"><b data-bind="text:total_pretty"></b></td>
</tr><tr data-bind="visible:paymentMethod()=='XMR'">
<td><b>Monero:</b></td>
<td class="tar"><b data-bind="text:totalxmr_pretty"></b>
</td></tr>
</tbody></table>
<p data-bind="visible:isPaid">
Paid: <span data-bind="text:paidDate_pretty"></span>
</p>
</div>
</div>
<p data-bind="hidden:isPaid">To change order details, <a href="#" onclick="cancel()">cancel it and return to your cart</a>.</p>
<hr>
<h2><span data-bind="visible:paymentMethod()==='USD'">💳 </span><span data-bind="visible:paymentMethod()==='XMR'"><img src="/assets/img/monero.svg" style="width:1em;height:1em"> </span>Payment</h2>
{# XMR Payment form #}
<div data-bind="hidden:(paymentMethod()!='XMR'||isPaid())">
<p>Please send <code data-bind="text:unsubmitted"></code> XMR to this address: </p>
<pre><code data-bind="text:xmr_address"></code></pre>
<p>You can also click, tap, or scan this qr code:</p>
<a data-bind="attr:{href:xmr_uri}"><div id="qr_wrapper"><div id="xmr_qr"></div></div></a>
</div>
{# Stripe payment form #}
<div data-bind="hidden:(paymentMethod()!='USD'||isPaid())">
<div id="stripe-payment">Loading payment form...</div>
<button data-bind="click:submitStripePayment(), text:stripeSubmitButtonText(),disable:isPlacingStripeOrder()" style="margin:2em 0 1em;height:4em"></button>
</div>
<hr data-bind="hidden:(isPaid()||paymentMethod()==='USD')">
{# Stripe payment status #}
<p data-bind="text:stripeText"></p>
{# Monero payment status #}
<p data-bind="visible:paymentMethod()=='XMR'">Your order is for <code data-bind="text:totalxmr_pretty"></code>.
<br><code data-bind="text:submitted_pretty"></code> have been submitted to the blockchain.
<br><code data-bind="text:unlocked_pretty"></code> have been unlocked.
</p>
<p data-bind="hidden:(paymentMethod()!='XMR'||isPaid)">Transactions you send will appear on this page when they are accepted into a block. <b><i>THIS COULD TAKE TEN MINUTES OR MORE</i></b> Transactions "unlock" after receiving 10 confirmations on the blockchain, each taking a few minutes each. Once the full <span data-bind="text:totalxmr_pretty"></span> are unlocked, we'll ship your order.
<p data-bind="hidden:(paymentMethod()!='XMR'||isPaid)">You don't have to wait for the payment to unlock. After sumbitting it in your wallet app, you can close this window. We sent a link to this page to the email specified, if you ever want to come back and check on the order.</p>
<p data-bind="visible:(paymentMethod()=='XMR'&&isPaid)">Your payment has been confirmed! We will ship your order as soon as possible and send an email with the tracking info.</p>
<p data-bind="visible:(paymentMethod()=='XMR'&&isOverpaid)">You <i>overpaid</I> us by <code data-bind="text:overpaidAmount_pretty"></code>! We would be happy to return the change to you at no extra cost. This process is done by hand so please email us{% if env.SALES_EMAIL %} at {{env.SALES_EMAIL}}{% endif %} to let us know that you overpaid. Be sure to include your order number, <code data-bind="text:orderId"></code>, as well as a monero address where you can receive the refund. </p>
<h3 data-bind="visible:(paymentMethod()=='XMR'&&transactions().length>0)">Transactions</h3>
<table data-bind="visible:(paymentMethod()=='XMR'&&transactions().length>0)">
<thead>
<th class="tal">Date</th>
<th class="tal">Time</th>
<th class="tal">Amt</th>
<th class="tal">Fee</th>
<th class="tal">Confs</th>
<th class="tal">Block</th>
<th class="tal">Stat</th>
</thead>
<tbody data-bind="foreach:transactions"><tr>
<td class="tal" data-bind="text:date"></td>
<td class="tal" data-bind="text:time"></td>
<td class="tal" data-bind="text:amount"></td>
<td class="tal" data-bind="text:fee_usd_pretty"></td>
<td class="tal" data-bind="text:confirmations"></td>
<td class="tal" data-bind="text:height"></td>
<td class="tal" style="cursor:default">
<span data-bind="visible:locked" title="LOCKED: Wait for 10 confirmations for this transaction to unlock">⏲️</span>
<span data-bind="visible:double_spend_seen" title="DOUBLE SPENT! Double-spend has been detected! This transaction is invalid. Contact {{metadata.sales.email}} to resolve.">⛔</span>
<span data-bind="hidden:(locked||double_spend_seen)" title="CONFIRMED! This transaction is valid and has been confirmed by the blockchain.">✅</span>
</td>
</tr></tbody>
</table>
<p><i data-bind="text:checkingStatus"></i></p>
<script>
const stripe = Stripe('{{env.STRIPE_PUB}}')
const SUCCESS_URL = '{{env.SITE_DOMAIN}}/shop/order/'
const API_DOMAIN = '{{env.API_DOMAIN}}'
const MONERO_PRICECHECK_SEC = {{env.MONERO_PRICECHECK_SEC}}
const MONERO_CHECKOUT_POLL_SECS = {{env.MONERO_CHECKOUT_POLL_SECS}}
</script>
<script src="/assets/scripts/lib/qrcode.min.js" integrity="sha256-CxytvzTC2wdC92a5wqFc1DTkbS+uuQ8N2NgxGcuqoDc="></script>
<script src="/assets/scripts/lib/knockout-3.5.1.min.js" integrity="sha256-6JV7sYKlBHsHvqCkn9IrEWFLGrmsW4KG/LIln0hljnM="></script>
{#<script src="/assets/scripts/lib/socket-io_4-5-4.min.js" integrity="sha256-GKNqkn2sVGULGLkD+Ph3ghngLhOUblgdmz4eSZX3Q1s="></script>#}
<script src="/assets/scripts/order.js" integrity="{{'/assets/scripts/order.js'|srintegrity}}" defer></script>

View File

@ -1,124 +0,0 @@
---
layout: layouts/base.njk
title: Monero order
---
<style>
#qr_wrapper {
max-width: 80vh;
width: 80vw;
height: 80vh;
max-height: 80vw;
padding: calc(1.5vh + 1.5vw);
background-color: #FFFFFF;
margin: auto;
}
#qr_wrapper div {
width: 100%;
height: 100%;
}
</style>
<link rel="stylesheet" href="/assets/shop.css">
<h1><img src="/assets/img/monero.svg" style="width:1em;height:1em"> Monero <span data-bind="hidden:isPaid">Checkout</span><span data-bind="visible:isPaid">Order</span></h1>
<table class="tal" style="width:100%"><thead>
<th>Item</th>
<th class="tar">Price</th>
<th class="tar">Qty</th>
<th class="tar">Sub</th>
</thead><tbody data-bind="foreach:items"><tr>
<td data-bind="text:name"></td>
<td class="tar" data-bind="text:price"></td>
<td class="tar" data-bind="text:qty" style="width:2rem"></td>
<td class="tar" data-bind="text:subtotal"></td>
</tr></tbody></table>
<hr>
<div class="flex" style="align-items:flex-start">
<p id="shipping-container">
<b>📦 <span class="ul">Shipping</span></b><br>
<span data-bind="text:shipname"></span>
<br><span data-bind="text:addr1"></span>
<br><span data-bind="text:addr2"></span>
<br><span data-bind="text:city"></span>,
<span data-bind="text:state"></span>
<span data-bind="text:zip"></span>
</p>
<p id="contact-container">
<b>📇 <span class="ul">Contact</ul></b><br>
<span data-bind="text:contactname"></span>
<br><span data-bind="text:phone"></span>
<br><span data-bind="text:email"></span>
</p>
<div id="totals-container"><table><tbody>
<tr>
<td><b>Subtotal:</b></td>
<td class="tar"><b data-bind="text:subtotal"></b></td>
</tr><tr>
<td>Tax:</td>
<td class="tar" data-bind="text:tax"></td>
</tr><tr>
<td>Shipping:</td>
<td class="tar" data-bind="text:shipping"></td>
</tr><tr style="border-top:.1vh solid">
<td>Total:</td>
<td class="tar" data-bind="text:total"></td>
</tr><tr>
<td><b>Monero:</b></td>
<td class="tar"><b data-bind="text:totalxmr_pretty"></b>
</td></tr>
</tbody></table></div>
</div>
<p data-bind="hidden:isPaid">To change order details, <a href="#" onclick="cancel()">cancel it and return to your cart</a>.</p>
<hr>
<h2>Payment status</h2>
<p>Your order is for <code data-bind="text:totalxmr_pretty"></code>.
<br><code data-bind="text:submitted_pretty"></code> have been submitted to the blockchain.
<br><code data-bind="text:unlocked_pretty"></code> have been unlocked.
</p>
<p data-bind="hidden:isPaid">Transactions you send will appear on this page when they are accepted into a block. <b><i>THIS COULD TAKE TEN MINUTES OR MORE</i></b> Transactions "unlock" after receiving 10 confirmations on the blockchain, each taking a few minutes each. Once the full <span data-bind="text:totalxmr_pretty"></span> are unlocked, we'll ship your order.
<p data-bind="hidden:isPaid">You don't have to wait for the payment to unlock. After sumbitting it in your wallet app, you can close this window. We sent a link to this page to the email specified, if you ever want to come back and check on the order.</p>
<p data-bind="visible:isPaid">Your payment has been confirmed! We will ship your order as soon as possible and send an email with the tracking info.</p>
<p data-bind="visible:isOverpaid">You <i>overpaid</I> us by <code data-bind="text:overpaidAmount_pretty"></code>! We would be happy to return the change to you at no extra cost. This process is done by hand so please email us{% if env.SALES_EMAIL %} at {{env.SALES_EMAIL}}{% endif %} to let us know that you overpaid. Be sure to include your order number, <code data-bind="text:orderid"></code>, as well as a monero address where you can receive the refund. </p>
<h3 data-bind="visible:transactions().length>0">Transactions</h3>
<table data-bind="visible:transactions().length>0">
<thead>
<th class="tal">Date</th>
<th class="tal">Time</th>
<th class="tal">Amt</th>
<th class="tal">Fee</th>
<th class="tal">Confs</th>
<th class="tal">Block</th>
<th class="tal">Stat</th>
</thead>
<tbody data-bind="foreach:transactions"><tr>
<td class="tal" data-bind="text:date"></td>
<td class="tal" data-bind="text:time"></td>
<td class="tal" data-bind="text:amount"></td>
<td class="tal" data-bind="text:fee_usd_pretty"></td>
<td class="tal" data-bind="text:confirmations"></td>
<td class="tal" data-bind="text:height"></td>
<td class="tal" style="cursor:default">
<span data-bind="visible:locked" title="LOCKED: Wait for 10 confirmations for this transaction to unlock">⏲️</span>
<span data-bind="visible:double_spend_seen" title="DOUBLE SPENT! Double-spend has been detected! This transaction is invalid. Contact {{metadata.sales.email}} to resolve.">⛔</span>
<span data-bind="hidden:(locked||double_spend_seen)" title="CONFIRMED! This transaction is valid and has been confirmed by the blockchain.">✅</span>
</td>
</tr></tbody>
</table>
<p><i data-bind="text:checkingStatus"></i></p>
<hr data-bind="hidden:isPaid">
<div data-bind="hidden:isPaid">
<h2>Monero payment</h2>
<p>Please send <code data-bind="text:unsubmitted"></code> XMR to this address: </p>
<pre><code data-bind="text:xmr_address"></code></pre>
<p>You can also click, tap, or scan this qr code:</p>
<a data-bind="attr:{href:xmr_uri}"><div id="qr_wrapper"><div id="xmr_qr"></div></div></a>
</div>
<script>
const API_DOMAIN = '{{env.API_DOMAIN}}'
const MONERO_PRICECHECK_SEC = {{env.MONERO_PRICECHECK_SEC}}
const MONERO_CHECKOUT_POLL_SECS = '{{env.MONERO_CHECKOUT_POLL_SECS}}'
</script>
<script src="/assets/scripts/lib/qrcode.min.js" integrity="sha256-CxytvzTC2wdC92a5wqFc1DTkbS+uuQ8N2NgxGcuqoDc="></script>
<script src="/assets/scripts/lib/knockout-3.5.1.min.js" integrity="sha256-6JV7sYKlBHsHvqCkn9IrEWFLGrmsW4KG/LIln0hljnM="></script>
<script src="/assets/scripts/lib/socket-io_4-5-4.min.js" integrity="sha256-GKNqkn2sVGULGLkD+Ph3ghngLhOUblgdmz4eSZX3Q1s="></script>
<script src="/assets/scripts/order/monero.js" defer></script>

View File

@ -1,50 +0,0 @@
---
layout: layouts/base.njk
---
<link rel="stylesheet" href="/assets/shop.css">
<h1>🧾 <span id="title">Loading...</span></h1>
<script src="https://js.stripe.com/v3/"></script>
<p id="msg"></p>
<hr>
<table class="tal" style="width:100%"><thead>
<th>Item</th>
<th class="tar">Price</th>
<th class="tar">Qty</th>
<th class="tar">Sub</th>
</thead><tbody id="items-table"></tbody></table>
<hr>
<div class="flex" style="align-items:flex-start">
<p id="shipping-container">
<b>📦 <span class="ul">Shipping</span></b><br>
<span id="shipping-address"></span>
</p>
<p id="contact-container">
<b>📇 <span class="ul">Contact</ul></b><br>
<span id="contact"></span>
</p>
<div id="totals-container"><table><tbody>
<tr>
<td><b>Subtotal:</b></td>
<td class="tar"><b id="subtotal"></b></td>
</tr><tr>
<td>Tax:</td>
<td id="tax" class="tar"></td>
</tr><tr>
<td>Shipping:</td>
<td id="shipping" class="tar"></td>
</tr><tr>
<td>Processing:</td>
<td id="processing" class="tar"></td>
</tr><tr style="border-top:.1vh solid">
<td><b>Total:</b></td>
<td class="tar"><b id="total"></b>
</td>
</tr>
</tbody></table></div>
</div>
<hr>
<p><a href="/shop/">&lt; Return to shop</a></p>
<script>const stripe=Stripe('{{env.STRIPE_PUB}}')</script>
<script src="/assets/scripts/order/stripe.js" integrity="{{'/assets/scripts/order/stripe.js'|srintegrity}}" defer></script>

View File

@ -3,6 +3,7 @@ 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`
@ -15,7 +16,7 @@ const MONERO_PRICE_LEEWAY = Number(process.env.MONERO_PRICE_LEEWAY)
const readDataFile = async (path) => {
const RETRY_INTERVAL = 2000 // 2 seconds
try { return JSON.parse(
await fs.readFile(`${path}`,'utf8')
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')
@ -145,6 +146,7 @@ module.exports = async (req, res) => {
}
const getProcessedOrder = () => new Promise(async (resolve, reject) => {
order.key = crypto.randomBytes(64).toString('base64')
// Process stripe order
if (order.paymentMethod==='USD') {
@ -152,13 +154,14 @@ module.exports = async (req, res) => {
// Get stripe payment intent
let paymentIntent; try {
paymentIntent = await stripe.paymentIntents.create({
amount: Math.round(total * 100), // Stripe takes pennies as an integer
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.shippingAddress.name,
@ -172,6 +175,7 @@ module.exports = async (req, res) => {
},
}
})
} catch (err) {
// https://stripe.com/docs/error-handling?lang=node#error-types
switch (err.type) {
@ -196,7 +200,9 @@ module.exports = async (req, res) => {
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'}`)
}
reject(`[${ip}] failed to create paymentIntent for order ${order.id} due to ${err.type} ${err.code} ${err.message}`)
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
@ -252,7 +258,7 @@ module.exports = async (req, res) => {
res.status(200)
.header('Content-Type','application/json')
.send(JSON.stringify(processedOrder))
console.log(`[${ip}] sent order back to client`)
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.`)
@ -261,6 +267,7 @@ module.exports = async (req, res) => {
await fs.writeFile(orderFile,
JSON.stringify({
id: processedOrder.id,
key: processedOrder.key,
orderDate: processedOrder.created,
items: processedOrder.items,
contact: processedOrder.contact,

View File

@ -7,12 +7,19 @@ module.exports = async (req, res) => {
const ip = req.ip.slice(7)
const orderFile = `${ORDERS_DIR}/${req.params.orderid}.json`
let order; try {
order = await fs.readFile(orderFile)
order = await JSON.parse(await fs.readFile(orderFile))
} catch (err) {
console.error(`[${ip}] requested ${orderFile} but it couldn't be read.\n${err}`)
console.error(`[${ip}] requested ${orderFile} but it couldn't be read:\n${err}`)
return res.status(500).send('Failed to read order from disk')
}
console.log(`[${ip}] Got order ${req.params.orderid}`)
return res.header('Content-Type','application/json')
.status(200).send(order)
if (req.query.key.replace(/ /g,'+')===order.key) {
console.log(`[${ip}] Got order ${order.id}`)
return res.header('Content-Type','application/json')
.status(200).send(order)
}
else {
console.log(`[${ip}] sent wrong key:\n ${req.query.key.replace(/ /g,'+')}`)
console.log(`[${ip}] correct key:\n ${order.key}`)
return res.status(403).send('Incorrect key!')
}
}

View File

@ -14,7 +14,7 @@ module.exports = async (order) => {
to: order.contact.email,
replyTo: process.env.SALES_EMAIL,
subject: `Order ${order.id} for ${amt} has been processed`,
text: `Dear ${order.contact.name},\n\nThank you for your payment. We will ship your order as soon as possible and send the tracking info to this email address. For now, keep this copy of your order details as proof of your payment. \n\n---\n\nITEMS: \n${customer_mail_items_string}\nTOTALS: \nSubtotal: ${formatUSD(order.subtotal)}\nTax: ${formatUSD(order.taxAmount)}\nShipping: ${formatUSD(order.shipping.amount)}\nProcessing: ${formatUSD(order.processing)}\nTOTAL: ${formatUSD(order.total)}${(order.paymentMethod==='XMR')?'\nMONERO TOTAL: '+amt:''}\n\nCONTACT: \n${order.contact.name}\n${order.contact.phone}\n${order.contact.email}\n\nSHIPPING ADDRESS:\n${order.shipping.address.name}\n${order.shipping.address.addr1}${(order.shipping.address.addr2)?'\n'+order.shipping.address.addr2:''}\n${order.shipping.address.city}, ${order.shipping.address.state} ${order.shipping.address.zip}\n\n---\n\nTo view your order info anytime, visit this page: ${process.env.SITE_URL}/shop/order/stripe/?order=${order.id}\n\nFor questions, comments, or to cancel this order, reply to this email or call 719-936-7778 x1. \n\nThank you!\n\n`,
text: `Dear ${order.contact.name},\n\nThank you for your payment. We will ship your order as soon as possible and send the tracking info to this email address. For now, keep this copy of your order details as proof of your payment. \n\n---\n\nITEMS: \n${customer_mail_items_string}\nTOTALS: \nSubtotal: ${formatUSD(order.subtotal)}\nTax: ${formatUSD(order.taxAmount)}\nShipping: ${formatUSD(order.shipping.amount)}\nProcessing: ${formatUSD(order.processing)}\nTOTAL: ${formatUSD(order.total)}${(order.paymentMethod==='XMR')?'\nMONERO TOTAL: '+amt:''}\n\nCONTACT: \n${order.contact.name}\n${order.contact.phone}\n${order.contact.email}\n\nSHIPPING ADDRESS:\n${order.shipping.address.name}\n${order.shipping.address.addr1}${(order.shipping.address.addr2)?'\n'+order.shipping.address.addr2:''}\n${order.shipping.address.city}, ${order.shipping.address.state} ${order.shipping.address.zip}\n\n---\n\nTo view your order info anytime, visit this page: ${process.env.SITE_URL}/shop/order/stripe/?order=${order.id}&key=${order.key}\n\nFor questions, comments, or to cancel this order, reply to this email or call 719-936-7778 x1. \n\nThank you!\n\n`,
})
console.log(`Sent email ${customer_mail_res.messageId} to customer for order ${order.id}`)
} catch (err) { console.error(err) }

View File

@ -1,5 +1,7 @@
'use strict'
require('dotenv').config()
const formatUSD = (v) => v.toLocaleString(undefined, {
style: 'currency', currency: 'USD' })
const fs = require('fs').promises
const axios = require('axios')
@ -30,7 +32,7 @@ const xmrPaidCheck = async (order) => {
}
// Throw hook if paid
if (res.amount.expected<=res.amount.covered.unlocked && order.paidDate==null) {
if (res.amount!=null) if (res.amount.expected<=res.amount.covered.unlocked && order.paidDate==null) {
console.log(`Order ${order.id} has been paid with ${res.amount.expected/1000000000000} XMR`)
// Save payment data to order
@ -67,5 +69,5 @@ let interval
module.exports = (order) => {
if (order.paymentMethod!=='XMR')
return console.log(`Invalid paymentMethod: ${order.paymentMethod}`)
interval = setInterval(()=>xmrPaidCheck(order), process.env.MONERO_PRICECHECK_SEC)
interval = setInterval(()=>xmrPaidCheck(order), 1000*process.env.MONERO_PRICECHECK_SEC)
}

View File

@ -6,7 +6,7 @@ const app = express()
const ORDERS_DIR = `${__dirname}/../orders`
// Start webhook forwarder so we don't need a public endpoint
require('../lib/run.js')(`stripe listen --api-key '${process.env.STRIPE_RES}' --forward-to 'http://localhost:${process.env.HOOK_PORT}/' --format 'JSON'`)
require('../lib/run.js')(`stripe listen --api-key '${process.env.STRIPE_RES}' --forward-to 'http://localhost:${process.env.STRIPE_LISTENER_PORT}/' --format 'JSON'`)
// Receive that webhook
app.listen(process.env.STRIPE_LISTENER_PORT)