const config = require('./config')
const _ = require('lodash')
const axios = require('axios')
const SocketIO = require('socket.io-client')
const QRCode = require('qrcode')
const template = require('../statics/template.html')
const loading = require('../statics/loading.svg')
const tick = require('../statics/tick.svg')
const cross = require('../statics/cross.svg')
/**
* <p>A Cash Payment Server Invoice</p>
* <p>Invoices can be created client-side or server-side. However, for security
* reasons, it is recommended that invoices are created server-side and passed
* back to the client-side.</p>
* <p><small><strong>Note:</strong> Any field that is prefixed with an underscore
* is private and should never be accessed directly.</small></p>
* <p><small><strong>Note:</strong> Any field that is not prefixed by an
* underscore is considered public
* and stable (hence there are no "getter" functions in this class).
* Only use setters to modify the parameters of an invoice - do not set
* values directly.</small></p>
*
* @property {String} id - ID of the invoice
*
* @param {object} opts Options for invoice instance (use setters instead)
* @param {object} invoice Invoice properties (use setters instead)
* @example
* //
* // Server-side
* //
* let invoice = new CashPay.Invoice()
* .setAPIKey('your.site|SECURE_KEY_123')
* .addAddress('bitcoincash:qpfsrgdeq49fsjrg5qk4xqhswjl7g248950nzsrwvn', '1USD')
* .setWebhook('https://webhook.site/1aa1cc3b-8ee8-4f70-a4cd-abc0c9b8d1f2'. ['confirmed'])
* await invoice.create()
*
* // Send Payload JSON to browser
* return invoice.payload()
*
* //
* // Client-side
* //
* let invoice = new CashPay.Invoice()
* .intoContainer(document.getElementById('invoice-container'))
* .on(['broadcasting', 'broadcasted'], (event) {
* console.log(event)
* })
* await invoice.createFrom('https://your-endpoint-above', {
* items: ['ITEM-001', 'ITEM_002']
* })
*/
class Invoice {
constructor (opts = {}, invoice = {}) {
this._instance = {}
Object.assign(this, _.cloneDeep(config.invoice), invoice)
Object.assign(this._instance, _.cloneDeep(config.options), opts)
}
/**
* <p>Add an event handler.</p>
* <p>Most of these events will be sent by the WebSocket connection.</p>
* <p>Supported events are:</p>
* <ul>
* <li>created</li>
* <li>broadcasting</li>
* <li>broadcasted</li>
* <li>expired</li>
* <li>failed</li>
* </ul>
* @param {(string|array)} events Event to handle (or array of events)
* @param callback Callback function
* @example
* // Add listener for failed event
* let invoice = new CashPay.Invoice()
* .addAddress('bitcoincash:qpfsrgdeq49fsjrg5qk4xqhswjl7g248950nzsrwvn', '1AAAA')
* .on('failed', err => {
* alert(err.message)
* }
*
* // Add event listener for broadcasting and broadcasted event
* let invoice = new CashPay.Invoice()
* .addAddress('bitcoincash:qpfsrgdeq49fsjrg5qk4xqhswjl7g248950nzsrwvn', '1AAAA')
* .on(['broadcasting', 'broadcasted'], e => {
* console.log(e)
* }
*/
on (events, callback) {
if (typeof events === 'string') {
events = [events]
}
events.forEach(event => this._instance.on[event].push(callback))
return this
}
/**
* Add an address output to Invoice.
* @param {String} address Bitcoin Cash address
* @param {String|Number} amount Amount in satoshis or string with currency code suffix
* @example
* let invoice = new Invoice();
* invoice.addAddress("bitcoincash:qzeup9lysjazfvqnv07ns9c846aaul7dtuqqncf6jg", 100000);
*
* // Or specify a currency code for on-the-fly conversion
* invoice.addAddress("bitcoincash:qzeup9lysjazfvqnv07ns9c846aaul7dtuqqncf6jg", "2.50USD");
*/
addAddress (address, amount) {
this.outputs.push({
address: address,
amount: amount || 0
})
return this
}
/**
* <p>Add a script output to the Invoice.</p>
* <p>Note that this is not supported by JSONPaymentProtocol.</p>
* @param {string} script Raw output script (in ASM)
* @param {number} [amount=0] Amount in satoshis
* @example
* // Set OP_RETURN data to "TEST_OP_RETURN"
* invoice.addOutput('OP_RETURN 544553545f4f505f52455455524e')
* invoice.addOutput(`OP_RETURN ${Buffer.from('TEST_OP_RETURN').toString('hex')}`)
*/
addOutput (script, amount = 0) {
this.outputs.push({
script: script,
amount: amount
})
return this
}
/**
* Set network
* @param {string} network Network to use
* @example
* // Use testnet
* invoice.setNetwork('test')
*/
setNetwork (network) {
this.network = network
return this
}
/**
* Set expiration time
* @param {number} seconds Seconds from time of creation that Payment Request expires
* @example
* // 15 minutes
* invoice.setExpires(15 * 60)
*/
setExpires (seconds) {
this.expires = seconds
return this
}
/**
* Sets a BIP70/JPP memo
* @param {string} memo Memo text
* @example
* // Memos are not supported by all wallets
* invoice.setMemo("Payment to YOUR_SERVICE_NAME")
*/
setMemo (memo) {
this.memo = memo
return this
}
/**
* Sets a BIP70/JPP memo to show AFTER payment
* @param {string} memoPaid Memo text
* @example
* // Memos are not supported by all wallets
* invoice.setMemoPaid('Thank you for your payment')
*/
setMemoPaid (memoPaid) {
this.memoPaid = memoPaid
return this
}
/**
* <p>Sets Merchant Data associated with invoice</p>
* <p><strong>This must be Base64 encoded</strong></p>
* @param {string} data Base64 encoded string
* @example
* // Node
* invoice.setMerchantData(Buffer.from('INVOICE_001', 'base64'))
*
* // Browser
* invoice.setMerchantData(btoa('INVOICE_001'))
*/
setMerchantData (base64) {
this.merchantData = base64
return this
}
/**
* <p>Sets the API Key</p>
* <p>An arbitrary API Key that can be used to later retrieve invoice information.</p>
* <p>This field will not be included in WebSocket events and omitted in the payload() function.</p>
* <p><small>This should never be used if the invoice is created client-side (in the browser).</small></p>
* @example
* invoice.setAPIKey('https://t.me/yourname|SECURE_STRING')
*/
setAPIKey (key) {
this.apiKey = key
return this
}
/**
* <p>Sets (Public) Data against the invoice.</p>
* @param {(string|object)} data If an object is passed, this will be converted to a string.
* @example
* // Using a string
* invoice.setData("https://your-site.com/some-url-to-redirect-to");
*
* // Using an object
* invoice.setData({
* redirectURL: 'https://your-site.com/some-url-to-redirect-to'
* })
*/
setData (data) {
if (typeof data === 'object') {
data = JSON.stringify(data)
}
this.data = data
return this
}
/**
* <p>Sets Private Data against the invoice.</p>
* <p>This field will not be included in WebSocket events and omitted in the payload() function.</p>
* @param {(string|object)} data If an object is passed, this will be converted to a string.
* @example
* // Using a string
* invoice.setPrivateData("ORDERID: 12345");
*
* // Using an object
* invoice.setPrivateData({
* orderId: '12345'
* })
*/
setPrivateData (data) {
if (typeof data === 'object') {
data = JSON.stringify(data)
}
this.privateData = data
return this
}
/**
* The unit of fiat (e.g. USD) that will be displayed to the user
* @param {string} currency The currency code
* @example
* // Show invoice total in Australian Dollars
* invoice.setUserCurrency('AUD')
*/
setUserCurrency (currency) {
this.userCurrency = currency
return this
}
/**
* <p>Set Webhook</p>
* <p>Used to notify server endpoint of transaction event.</p>
* <p>Note that a JSON response can be returned by the server to modify the fields:
* "data" and "privateData".</p>
* @param {String} endpoint The endpoint that should be hit
* @param {(Array|String)} events The type of Webhook (broadcasting, broadcasted, confirmed)
* @example
* // Set Webhook (defaults to broadcasting, broadcasted and confirmed)
* let invoice = new CashPay.Invoice();
* .setWebhook('https://webhook.site/1aa1cc3b-8ee8-4f70-a4cd-abc0c9b8d1f2');
*
* // Only trigger on "broadcasting" and "broadcasted"
* let invoice = new CashPay.Invoice();
* .setWebhook('https://webhook.site/1aa1cc3b-8ee8-4f70-a4cd-abc0c9b8d1f2', ['broadcasting', 'broadcasted'])
*/
setWebhook (endpoint, events = ['broadcasting', 'broadcasted', 'confirmed']) {
if (typeof events === 'string') {
events = [events]
}
events.forEach(event => {
this.webhook[event] = endpoint
})
return this
}
/**
* <p>Create the invoice.</p>
* <p>Browser Environment: Websocket and Expiry timers WILL be instantiated.</p>
* <p>NodeJS Enviroment: Websocket and Expiry timers WILL NOT be instantiated.</p>
* @example
* let invoice = new CashPay.Invoice()
* .intoContainer(document.getElementById('invoice-container'))
* .addAddress('bitcoincash:qpfsrgdeq49fsjrg5qk4xqhswjl7g248950nzsrwvn', '1USD')
* .on('broadcasted', e => {
* console.log(e)
* })
* await invoice.create()
*/
async create () {
try {
if (!this._id) {
const invoiceRes = await axios.post(this._instance.endpoint + '/invoice/create', _.omit(this, '_instance'))
Object.assign(this, invoiceRes.data)
}
if (this._instance.listen) {
// Setup expiration timer
this._setupExpirationTimer()
// Setup event listener for expired and broadcasted to stop Websocket listener
this.on(['expired', 'broadcasted'], (secondsRemaining) => {
this.destroy()
})
await this._listen()
}
this._instance.on.created.forEach(cb => cb())
return this
} catch (err) {
this._instance.on.failed.forEach(cb => cb(err))
throw err
}
}
/**
* <p>Load a created invoice from a server-side endpoint.</p>
* @param {String} endpoint The endpoint to use
* @param {Object} [params] POST parameters to send to endpoint
* @param {Object} [option] Options to pass to axios.post
* @example
* // Using default container
* const invoice = new CashPay.Invoice()
* .intoContainer(document.getElementById('invoice-container'))
* .on('broadcasted', e => {
* console.log(e)
* })
* await invoice.createFrom('https://api.your-site.com/request-invoice', {
* items: ['ITEM_001', 'ITEM_002']
* })
*/
async createFrom (endpoint, params = {}, options = {}) {
try {
const res = await axios.post(endpoint, params, options)
Object.assign(this, res.data)
await this.create()
} catch (err) {
this._instance.on.failed.forEach(cb => cb(err))
throw err
}
}
/**
* <p>Instantiate the invoice from an existing invoice.</p>
* <p>Similar to createFrom, but where you handle the AJAX request.</p>
* @param {Object} [params] POST parameters to send to endpoint
* @example
* const res = axios.post('https://api.your-site.com/request-invoice', {
* items: ['ITEM_001', 'ITEM_002']
* })
*
* let invoice = new CashPay.Invoice()
* .intoContainer(document.getElementById('invoice-container'))
* .on('broadcasted', e => {
* console.log(e)
* })
* await invoice.createFromExisting(res.data.invoice)
*/
async createFromExisting (invoice) {
try {
Object.assign(this, invoice)
await this.create()
} catch (err) {
this._instance.on.failed.forEach(cb => cb(err))
throw err
}
}
/**
* <p>Get the payload of the invoice.</p>
* <p>This function can be used to pass the invoice back to the browser from the server-side.</p>
* <p><small>The fields [apiKey, privateData, webhook, events] will be omitted from the payload</small></p>
* @example
* // Get JSON payload
* let payload = invoice.payload()
*/
payload () {
return _.omit(this, '_instance', 'apiKey', 'privateData', 'webhook', 'events')
}
/**
* <p>Destroy the invoice instance.</p>
* <p>This function should be used to clear all listeners from the invoice.</p>
* <p>You will need to call this manually if you are not using the OOTB intoContainer function.</p>
* <p><small>Note this does not destroy the container itself, but the timers/websocket listener.</small></p>
* @example
* // Destroy the invoice to prevent memory-leaks/dangling references
* invoice.destroy()
*/
async destroy () {
this._instance.socket.disconnect()
clearInterval(this._instance.expiryTimer)
}
/**
* @private
* Setup WebSocket listener.
* This should not need to be called manually
*/
async _listen () {
this._instance.socket = SocketIO(this.service.webSocketURI)
this._instance.socket.on('connect', () => {
this._instance.socket.emit('subscribe', {
invoiceId: this.id
})
})
this._instance.socket.on('subscribed', (msg) => {
this._instance.on.subscribed.forEach(cb => cb(msg))
})
this._instance.socket.on('requested', (msg) => {
Object.assign(this, _.omit(msg.invoice, 'id'))
this._instance.on.requested.forEach(cb => cb(msg))
})
this._instance.socket.on('broadcasted', (msg) => {
Object.assign(this, _.omit(msg.invoice, 'id'))
this._instance.on.broadcasted.forEach(cb => cb(msg))
})
this._instance.socket.on('failed', (msg) => {
this._instance.on.failed.forEach(cb => cb(msg))
})
return this
}
/**
* @private
*/
_setupExpirationTimer () {
const timerFunc = () => {
const expires = new Date(this.expires * 1000).getTime()
const now = new Date().getTime()
const secondsRemaining = Math.round((expires - now) / 1000)
if (secondsRemaining) {
this._instance.on.timer.forEach(cb => cb(secondsRemaining))
} else {
this._instance.on.expired.forEach(cb => cb())
}
}
this._instance.expiryTimer = setInterval(timerFunc, 1000)
timerFunc()
}
/**
* <p>Load the invoice into a DOM container.</p>
* <p>If the DOM element is empty, default template will be used.</p>
* <p>Note: If container is removed from DOM, invoice listeners will be destroyed by default. See destroyOnRemoved param in options.</p>
* @param {DOMElement} container Container to load into
* @param {String} options.lang.expiresIn Text to use for Expires In
* @param {String} options.lang.invoiceHasExpired Text to use when Invoice has expired
* @param {Boolean} [options.destroyOnRemoved=true] Destroy invoice listeners when DOM element removed
* @example
* // No options
* const invoice = new CashPay.Invoice()
* .intoContainer(document.getElementById('invoice-container')
*
* // Change Invoice Expired Text for Captcha
* const invoice = new CashPay.Invoice()
* .intoContainer(document.getElementById('invoice-container', {
* lang: {
* invoiceHasExpired: 'Captcha has expired'
* }
* })
*/
intoContainer (container, options) {
options = Object.assign({
template: template,
lang: {
expiresIn: 'Expires in ',
invoiceHasExpired: 'Invoice has expired'
},
destroyOnRemoved: true
}, options)
// Inject template (otherwise, assume it's already there)
if (options.template && container.innerHTML.trim() === '') {
container.innerHTML = options.template
}
// Find container elements
const subContainerEl = container.querySelector('.cashpay-container')
const LinkEl = container.querySelector('.cashpay-link')
const svgContainerEl = container.querySelector('.cashpay-svg-container')
const totalNativeEl = container.querySelector('.cashpay-total-native')
const totalFiatEl = container.querySelector('.cashpay-total-fiat')
const expiresEl = container.querySelector('.cashpay-expires')
const errorEl = container.querySelector('.cashpay-error')
// Set loading SVG
if (subContainerEl) subContainerEl.classList.add('loading')
if (svgContainerEl) svgContainerEl.innerHTML = loading
// Trigger on invoice creation...
this.on('created', async () => {
// Remove loading class
if (subContainerEl) subContainerEl.classList.remove('loading')
// Render QR Code
if (svgContainerEl) {
svgContainerEl.classList.add('cashpay-animation-zoom-in')
svgContainerEl.innerHTML = await QRCode.toString(this.service.walletURI, {
type: 'svg',
margin: 0
})
}
// Set link on QR Code
if (LinkEl) LinkEl.href = this.service.walletURI
// Set totals for BCH and Fiat
if (totalFiatEl) totalFiatEl.innerText = `${this.totals.userCurrencyTotal}${this.userCurrency}`
// Show value in BCH
if (totalNativeEl) totalNativeEl.innerText = `${this.totals.nativeTotal / 100000000}${this.currency}`
// Show the subcontainer
if (subContainerEl) subContainerEl.style.display = 'block'
})
// Trigger on invoice broadcasted...
this.on('broadcasted', () => {
if (subContainerEl) subContainerEl.classList.add('broadcasted')
if (svgContainerEl) svgContainerEl.innerHTML = tick
if (svgContainerEl) svgContainerEl.classList.remove('cashpay-animation-zoom-in')
if (svgContainerEl) svgContainerEl.classList.add('cashpay-animation-pulse')
if (LinkEl) LinkEl.removeAttribute('href')
if (expiresEl) expiresEl.innerText = ''
})
// Trigger on invoice expiry
this.on('expired', () => {
if (subContainerEl) subContainerEl.classList.add('expired')
if (svgContainerEl) svgContainerEl.innerHTML = cross
if (svgContainerEl) svgContainerEl.classList.remove('cashpay-animation-zoom-in')
if (svgContainerEl) svgContainerEl.classList.add('cashpay-animation-pulse')
if (LinkEl) LinkEl.removeAttribute('href')
if (expiresEl) expiresEl.innerText = options.lang.invoiceHasExpired
})
// Trigger each time expiration timer updates
this.on('timer', (secondsRemaining) => {
const minutes = Math.floor(secondsRemaining / 60)
const seconds = secondsRemaining % 60
if (expiresEl) expiresEl.innerText = `Expires in ${minutes}:${seconds.toString().padStart(2, '0')}`
})
// Trigger on failed
this.on('failed', (err) => {
if (errorEl) errorEl.innerText = err.message
})
// If DOM element is removed, call destroy
if (options.destroyOnRemoved) {
const observer = new MutationObserver((mutations) => {
if (!document.body.contains(container)) {
observer.disconnect()
this.destroy()
}
})
observer.observe(document.body, { childList: true, subtree: true })
}
return this
}
}
module.exports = Invoice