439 lines
19 KiB
JavaScript
439 lines
19 KiB
JavaScript
'use strict'
|
||
const formatUSD = (v) => v.toLocaleString(undefined, {
|
||
style: 'currency', currency: 'USD' })
|
||
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 can’t be authorized. Attempt the payment again. If you still can’t 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 that’s 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 couldn’t be reached, so the payment couldn’t be authorized. Attempt the payment again. If you still can’t 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 isn’t 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 doesn’t support Online PIN, You need to try again by inserting your card and entering a PIN.`,
|
||
pickup_card: `The customer can’t use this card to make this payment (it’s 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 can’t be processed, try again later.`,
|
||
reenter_transaction: `The payment couldn’t be processed by the issuer for an unknown reason. The payment needs to be attempted again. If it still can’t be processed, You need to contact your card-issuer.`,
|
||
restricted_card: `The customer can’t use this card to make this payment (it’s 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()
|
||
}
|
||
|
||
const cancel = async () => {
|
||
let res; try { res = await fetch(
|
||
`${API_DOMAIN}/order/${qstr.id}?key=${qstr.key}`,
|
||
{ method:'DELETE' }
|
||
) } catch (err) { console.error(err) }
|
||
finally { window.location = '/shop/cart/' }
|
||
}
|
||
|
||
let xmr_price = 160 // For getting fees in USD before the first price loads
|
||
|
||
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 * xmr_price
|
||
self.fee_usd_pretty = `$${self.fee_usd.toFixed(4)}`
|
||
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.orderUrl = ko.pureComputed(() =>
|
||
`${SITE_DOMAIN}/order/?id=${self.orderId()}&key=${self.orderKey().replace(/ /g,'+')}`
|
||
)
|
||
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(new Date())
|
||
self.paidDate_pretty = ko.pureComputed(() =>
|
||
self.paidDate().toDateString()
|
||
)
|
||
self.isPaidUSD = ko.observable(false)
|
||
self.isPaidXMR = ko.pureComputed(() =>
|
||
( (self.paymentMethod()==='XMR') &&
|
||
( self.unlocked() >= self.totalxmr() ) )
|
||
)
|
||
self.isPaid = ko.pureComputed(() =>
|
||
(self.isPaidUSD() || self.isPaidXMR())
|
||
)
|
||
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%20order%20${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.submitStripePayment= ko.observable(()=>{})
|
||
self.shipDate = ko.observable(null)
|
||
self.shipDate_pretty = ko.pureComputed(() =>
|
||
(self.isShipped())?
|
||
self.shipDate().toDateString()
|
||
:''
|
||
)
|
||
self.shipCarrier = ko.observable('')
|
||
self.trackingNumbers = ko.observableArray([])
|
||
self.trackingNumbers_newlined = ko.pureComputed(() =>
|
||
self.trackingNumbers().join('\n')
|
||
)
|
||
self.tracking_url = ko.pureComputed(() => {
|
||
if (self.shipCarrier()==='USPS')
|
||
return `https://tools.usps.com/go/TrackConfirmAction?tLabels=${self.trackingNumbers().join('%2C')}%2C`
|
||
else return ''
|
||
})
|
||
self.isShipped = ko.pureComputed(() =>
|
||
(self.shipDate()!=null)
|
||
)
|
||
|
||
;(async () => {
|
||
const getStripeStatus = async (secret) => {
|
||
// https://stripe.com/docs/error-handling?lang=node#use-stored-information
|
||
const { paymentIntent } = await stripe.retrievePaymentIntent(secret)
|
||
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(`A ${self.total_pretty()} credit card payment for this order was processed ${(self.paidDate())?'on '+self.paidDate_pretty():''}. It will appear on your statement as "slvit.us ${self.orderId()}".`)
|
||
self.isPaidUSD(true)
|
||
if (!self.paidDate()) self.paidDate(new Date())
|
||
if (localStorage.getItem('cartOrder')==self.orderId())
|
||
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(new Date(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)
|
||
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)
|
||
self.shipDate( (order.shipping.date==null)?
|
||
null:new Date(order.shipping.date) )
|
||
self.shipCarrier(order.shipping.carrier)
|
||
self.trackingNumbers(order.shipping.tracking)
|
||
|
||
// USD payment
|
||
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)
|
||
}
|
||
|
||
// Create stripe payment form
|
||
else {
|
||
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)
|
||
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) }
|
||
})
|
||
}
|
||
|
||
}
|
||
|
||
// Monero payment
|
||
else if (order.paymentMethod==='XMR') {
|
||
self.xmr_address(order.xmr_address)
|
||
self.totalxmr(order.totalxmr)
|
||
|
||
// Price checking
|
||
const getXmrPrice = async () => {
|
||
let res; try {
|
||
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)}`)
|
||
xmr_price = parsedRes.monero.usd
|
||
}; getXmrPrice()
|
||
setInterval(getXmrPrice, MONERO_PRICECHECK_SEC*1000)
|
||
|
||
// Transaction polling
|
||
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()
|
||
if (!self.isPaid()) setInterval(() => {
|
||
if (poll_in<=0) {
|
||
self.checkingStatus('Checking for transactions...')
|
||
if (!isChecking) getTransactions()
|
||
} else {
|
||
self.checkingStatus(`Checking for transactions in ${
|
||
poll_in--}...`)
|
||
}
|
||
}, 1000)
|
||
else {
|
||
if (localStorage.getItem('cartOrder')==self.orderId())
|
||
emptyCart()
|
||
if (!self.paidDate()) self.paidDate(new Date())
|
||
}
|
||
|
||
// Websockets (to replace polling)
|
||
// let 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())
|