Add Bitcoin payment method (#121)

* fix settins form submit not working

* prepare routes/config structure to support new blockonomics payment method

* setup blockonomics view, get address and calculate btc

* blockonomics subscribe to web socket to listen for real time order updates on checkout page

* create order with pending status while waiting for blockonomics payment and pass orderid to frontend

* check received amount is enough

* blockonomics btc amount check, backend order confirmation, show order info into backend and frontend

* cleanup and empty cart when order is payed

* decline order if insufficient amount

* handle email and lunr indexing for blockonomis

* blockonomics new confirmation page, auto redirect at confirmation zero, 10 minutes timeout

* Update README with Blockonomics

* add countdown timer to blockonomics payment page

* updated README and checkout page

* update README with blockonomics return url

* build

* reply to blockonomics http callback

* dont check amount on frontend

* restore default settings

* switch from unirest to axios, remove unirest dep

* fix lint problems

* restore gitignore

* restore baseSchema

* build

Co-authored-by: GECKO <4787777-geckojs@users.noreply.gitlab.com>
master
geco 2020-03-14 01:51:26 +01:00 committed by GitHub
parent f3a7231016
commit 7c7af39f4b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 2332 additions and 1874 deletions

3
.gitignore vendored
View File

@ -4,5 +4,4 @@ public/uploads
/config/*-local.json
.vscode
**.DS_Store
env.yaml
ecosystem.config.js
env.yaml

View File

@ -2,7 +2,7 @@
![expressCart](https://raw.githubusercontent.com/mrvautin/expressCart/master/public/images/logo.png)
`expressCart` is a fully functional shopping cart built in Node.js (Express, MongoDB) with Stripe, PayPal, Authorize.net, Adyen and Instore payments.
`expressCart` is a fully functional shopping cart built in Node.js (Express, MongoDB) with Stripe, PayPal, Blockonomics, Authorize.net, Adyen and Instore payments.
[![Github stars](https://img.shields.io/github/stars/mrvautin/expressCart.svg?style=social&label=Star)](https://github.com/mrvautin/expressCart)
[![Build Status](https://travis-ci.org/mrvautin/expressCart.svg?branch=master)](https://travis-ci.org/mrvautin/expressCart)
@ -275,6 +275,23 @@ The Stripe config file is located: `/config/stripe.json`. A example Stripe setti
Note: The `secretKey`, `publicKey` and `stripeWebhookSecret` is obtained from your Stripe account dashboard.
##### Blockonomics (Bitcoin Payments)
You have to configure the `HTTP Callback URL` parameter into Blockonomics -> Merchants -> Settings:
http://CartURL/blockonomics/checkout_return where [**CartURL**](#cart-url) is the address of your server
The Blockonomics config file is located: `/config/blockonomics.json`. A example Blockonomics settings file is provided:
```
{
"apiKey": "this_is_not_real",
"hostUrl": "https://www.blockonomics.co", // You usually don't need to change this
"newAddressApi": "/api/new_address", // You usually don't need to change this
"priceApi": "/api/price?currency=" // You usually don't need to change this
}
```
Note: The `apiKey` is obtained from your Blockonomics account.
##### Authorize.net (Payments)
The Authorize.net config file is located: `/config/authorizenet.json`. A example Authorize.net settings file is provided:

2
app.js
View File

@ -83,6 +83,7 @@ const order = require('./routes/order');
const user = require('./routes/user');
const paypal = require('./routes/payments/paypal');
const stripe = require('./routes/payments/stripe');
const blockonomics = require('./routes/payments/blockonomics');
const authorizenet = require('./routes/payments/authorizenet');
const adyen = require('./routes/payments/adyen');
const instore = require('./routes/payments/instore');
@ -418,6 +419,7 @@ app.use('/', user);
app.use('/', admin);
app.use('/paypal', paypal);
app.use('/stripe', stripe);
app.use('/blockonomics', blockonomics);
app.use('/authorizenet', authorizenet);
app.use('/adyen', adyen);
app.use('/instore', instore);

View File

@ -73,11 +73,12 @@
},
"currencyISO": {
"type": "string",
"enum": ["USD", "EUR", "GBP"],
"default": "USD"
},
"paymentGateway": {
"type": "string",
"enum": ["paypal", "stripe", "authorizenet", "adyen", "instore"]
"enum": ["paypal", "blockonomics", "stripe", "authorizenet", "adyen", "instore"]
},
"databaseConnectionString": {
"type": "string"

6
config/blockonomics.json Normal file
View File

@ -0,0 +1,6 @@
{
"apiKey": "WQh8bEZkDbWZQSDUnKEUr24W02p7NxAEJsvmDhS6ymU",
"hostUrl": "https://www.blockonomics.co",
"newAddressApi": "/api/new_address",
"priceApi": "/api/price?currency="
}

View File

@ -0,0 +1,20 @@
{
"properties": {
"apiKey": {
"type": "string"
},
"hostUrl": {
"type": "string"
},
"newAddressApi": {
"type": "string"
},
"priceApi": {
"type": "string"
}
},
"required": [
"api_key", "hostUrl", "newAddressApi", "priceApi"
],
"additionalProperties": false
}

View File

@ -23,7 +23,10 @@
"theme": "Cloth",
"trackStock": false,
"orderHook": "",
"availableLanguages": ["en", "it"],
"availableLanguages": [
"en",
"it"
],
"defaultLocale": "en",
"maxQuantity": 25,
"twitterHandle": "",
@ -32,6 +35,10 @@
"enabled": {
"shipping": "shipping-basic",
"discount": "discount-voucher"
},
"loaded": {
"shipping": {},
"discount": {}
}
}
}
}

View File

@ -196,6 +196,12 @@
"Users": "Users",
"Create Order": "Create Order",
"User edit": "User edit",
"Blockonomics payment details": "Blockonomics payment details",
"Order Expected BTC": "Order Expected BTC",
"Order Received BTC": "Order Received BTC",
"Order Blockonomics Txid": "Order Blockonomics Txid",
"Currency ISO": "Currency ISO",
"Currency used for Blockonomics conversion": "Currency used for Blockonomics conversion",
"Logout": "Logout",
"Company": "Company"
}

View File

@ -160,5 +160,29 @@
"Shipping": "Spedizione:",
"Empty cart": "Svuota carrello",
"Payment ID": "Payment ID",
"Search shop": "Search shop"
"Search shop": "Search shop",
"Dashboard": "Dashboard",
"Menu": "Menu",
"Discount codes": "Discount codes",
"Shipping options": "Shipping options",
"Return to information": "Return to information",
"Proceed to payment": "Proceed to payment",
"Discount code": "Discount code",
"Apply": "Apply",
"Currency ISO": "Currency ISO",
"Currency used for Blockonomics conversion": "Currency used for Blockonomics conversion",
"Blockonomics payment details": "Blockonomics payment details",
"Payment Message": "Payment Message",
"Order net amount": "Order net amount",
"Order shipping amount": "Order shipping amount",
"Order type": "Order type",
"Order Expected BTC": "Order Expected BTC",
"Order Received BTC": "Order Received BTC",
"Order Blockonomics Txid": "Order Blockonomics Txid",
"Users": "Users",
"New user": "New user",
"Customers can be filtered by: email, name or phone number": "Customers can be filtered by: email, name or phone number",
"Password": "Password",
"Create Order": "Create Order",
"Create order": "Create order"
}

3770
package-lock.json generated

File diff suppressed because it is too large Load Diff

BIN
public/images/spinner.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

File diff suppressed because one or more lines are too long

View File

@ -432,6 +432,52 @@ $(document).ready(function (){
// alert
showNotification(messageVal, messageTypeVal || 'danger', false);
}
// checkout-blockonomics page (blockonomics_payment route) handling START ***
if($('#blockonomics_div').length > 0){
var orderid = $('#blockonomics_div').data('orderid') || '';
var timestamp = $('#blockonomics_div').data('timestamp') || -1;
var address = $('#blockonomics_div').data('address') || '';
var blSocket = new WebSocket('wss://www.blockonomics.co/payment/' + address + '?timestamp=' + timestamp);
blSocket.onopen = function (msg){
};
var timeOutMinutes = 10;
setTimeout(function(){
$('#blockonomics_waiting').html('<b>Payment expired</b><br><br><b><a href=\'/checkout/payment\'>Click here</a></b> to try again.<br><br>If you already paid, your order will be processed automatically.');
showNotification('Payment expired', 'danger');
blSocket.close();
}, 1000 * 60 * timeOutMinutes);
var countdownel = $('#blockonomics_timeout');
var endDatebl = new Date((new Date()).getTime() + 1000 * 60 * timeOutMinutes);
var blcountdown = setInterval(function(){
var now = new Date().getTime();
var distance = endDatebl - now;
if(distance < 0){
clearInterval(blcountdown);
return;
}
var minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
var seconds = Math.floor((distance % (1000 * 60)) / 1000);
countdownel.html(minutes + 'm ' + seconds + 's');
}, 1000);
blSocket.onmessage = function (msg){
var data = JSON.parse(msg.data);
if((data.status === 0) || (data.status === 1) || (data.status === 2)){
// redirect to order confirmation page
var orderMessage = '<br>View <b><a href="/payment/' + orderid + '">Order</a></b>';
$('#blockonomics_waiting').html('Payment detected (<b>' + data.value / 1e8 + ' BTC</b>).' + orderMessage);
showNotification('Payment detected', 'success');
$('#cart-count').html('0');
blSocket.close();
$.ajax({ method: 'POST', url: '/product/emptycart' }).done(function(){
window.location.replace('/payment/' + orderid);
});
}
};
}
// checkout-blockonomics page (blockonomics_payment route) handling *** END
});
function checkMaxQuantity(e, element){

File diff suppressed because one or more lines are too long

View File

@ -58,8 +58,9 @@ router.get('/payment/:orderId', async (req, res, next) => {
if(config.orderHook){
await hooker(order);
};
res.render(`${config.themeViews}payment-complete`, {
let paymentView = `${config.themeViews}payment-complete`;
if(order.orderPaymentGateway === 'Blockonomics') paymentView = `${config.themeViews}payment-complete-blockonomics`;
res.render(paymentView, {
title: 'Payment complete',
config: req.app.config,
session: req.session,
@ -214,6 +215,32 @@ router.get('/checkout/payment', async (req, res) => {
});
});
router.get('/blockonomics_payment', (req, res, next) => {
const config = req.app.config;
let paymentType = '';
if(req.session.cartSubscription){
paymentType = '_subscription';
}
// show bitcoin address and wait for payment, subscribing to wss
res.render(`${config.themeViews}checkout-blockonomics`, {
title: 'Checkout - Payment',
config: req.app.config,
paymentConfig: getPaymentConfig(),
session: req.session,
paymentPage: true,
paymentType,
cartClose: true,
cartReadOnly: true,
page: 'checkout-information',
countryList,
message: clearSessionValue(req.session, 'message'),
messageType: clearSessionValue(req.session, 'messageType'),
helpers: req.handlebars.helpers,
showFooter: 'showFooter'
});
});
router.post('/checkout/adddiscountcode', async (req, res) => {
const config = req.app.config;
const db = req.app.db;

View File

@ -0,0 +1,131 @@
const express = require('express');
const common = require('../../lib/common');
const { indexOrders } = require('../../lib/indexing');
const router = express.Router();
const axios = require('axios').default;
router.get('/checkout_cancel', (req, res, next) => {
// return to checkout for adjustment or repayment
res.redirect('/checkout');
});
router.get('/checkout_return', async (req, res, next) => {
const db = req.app.db;
const config = req.app.config;
const status = req.query.status || -1;
const address = req.query.addr || 'na';
const amount = (req.query.value || 0) / 1e8;
const txid = req.query.txid || 'na';
if(Number(status) === 2){
// we are interested only in final confirmations
const order = await db.orders.findOne({ orderPaymentId: address });
if(order){
if(amount >= order.orderExpectedBtc){
try{
await db.orders.updateOne({
_id: order._id },
{ $set: { orderStatus: 'Paid', orderReceivedBtc: amount, orderBlockonomicsTxid: txid }
}, { multi: false });
// if approved, send email etc
// set payment results for email
const paymentResults = {
message: 'Your payment was successfully completed',
messageType: 'success',
paymentEmailAddr: order.orderEmail,
paymentApproved: true,
paymentDetails: '<p><strong>Order ID: </strong>' + order._id + '</p><p><strong>Transaction ID: </strong>' + order.orderPaymentId + '</p>'
};
// send the email with the response
// TODO: Should fix this to properly handle result
common.sendEmail(req.session.paymentEmailAddr, 'Your payment with ' + config.cartTitle, common.getEmailTemplate(paymentResults));
res.status(200).json({ err: '' });
}catch(ex){
console.info('Error updating status success blockonomics', ex);
res.status(200).json({ err: 'Error updating status' });
}
return;
}
console.info('Amount not sufficient blockonomics', address);
try{
await db.orders.updateOne({
_id: order._id },
{ $set: { orderStatus: 'Declined', orderReceivedBtc: amount }
}, { multi: false });
}catch(ex){
console.info('Error updating status insufficient blockonomics', ex);
}
res.status(200).json({ err: 'Amount not sufficient' });
return;
}
res.status(200).json({ err: 'Order not found' });
console.info('Order not found blockonomics', address);
return;
}
res.status(200).json({ err: 'Payment not final' });
console.info('Payment not final blockonomics', address);
});
router.post('/checkout_action', (req, res, next) => {
const blockonomicsConfig = common.getPaymentConfig();
const config = req.app.config;
const db = req.app.db;
const blockonomicsParams = {};
// get current rate
axios
.get(blockonomicsConfig.hostUrl + blockonomicsConfig.priceApi + config.currencyISO)
.then((response) => {
blockonomicsParams.expectedBtc = Math.round(req.session.totalCartAmount / response.data.price * Math.pow(10, 8)) / Math.pow(10, 8);
// get new address
axios
.post(blockonomicsConfig.hostUrl + blockonomicsConfig.newAddressApi, {}, { headers: { 'Content-Type': 'application/json', 'User-Agent': 'blockonomics', Accept: 'application/json', Authorization: 'Bearer ' + blockonomicsConfig.apiKey } })
.then((response) => {
blockonomicsParams.address = response.data.address;
blockonomicsParams.timestamp = Math.floor(new Date() / 1000);
// create order with status Pending and save ref
const orderDoc = {
orderPaymentId: blockonomicsParams.address,
orderPaymentGateway: 'Blockonomics',
orderExpectedBtc: blockonomicsParams.expectedBtc,
orderTotal: req.session.totalCartAmount,
orderShipping: req.session.totalCartShipping,
orderItemCount: req.session.totalCartItems,
orderProductCount: req.session.totalCartProducts,
orderEmail: req.session.customerEmail,
orderFirstname: req.session.customerFirstname,
orderLastname: req.session.customerLastname,
orderAddr1: req.session.customerAddress1,
orderAddr2: req.session.customerAddress2,
orderCountry: req.session.customerCountry,
orderState: req.session.customerState,
orderPostcode: req.session.customerPostcode,
orderPhoneNumber: req.session.customerPhone,
orderComment: req.session.orderComment,
orderStatus: 'Pending',
orderDate: new Date(),
orderProducts: req.session.cart,
orderType: 'Single'
};
db.orders.insertOne(orderDoc, (err, newDoc) => {
if(err){
console.info(err.stack);
}
// get the new ID
const newId = newDoc.insertedId;
// add to lunr index
indexOrders(req.app)
.then(() => {
// set the order ID in the session, to link to it from blockonomics payment page
blockonomicsParams.pendingOrderId = newId;
req.session.blockonomicsParams = blockonomicsParams;
res.redirect('/blockonomics_payment');
});
});
});
});
});
module.exports = router;

View File

@ -134,9 +134,10 @@ router.post('/checkout_action', (req, res, next) => {
// create payment
paypal.payment.create(payment, (error, payment) => {
if(error){
req.session.message = 'There was an error processing your payment. You have not been changed and can try again.';
req.session.message = 'There was an error processing your payment. You have not been charged and can try again.';
req.session.messageType = 'danger';
res.redirect('/checkout/payment');
console.log(error);
return;
}
if(payment.payer.payment_method === 'paypal'){

View File

@ -28,6 +28,15 @@
<li class="list-group-item"><strong> {{ @root.__ "Order ID" }}: </strong><span class="float-right">{{result._id}}</span></li>
<li class="list-group-item"><strong> {{ @root.__ "Payment Gateway ref" }}: </strong><span class="float-right">{{result.orderPaymentId}}</span></li>
<li class="list-group-item"><strong> {{ @root.__ "Payment Gateway" }}: </strong><span class="float-right">{{result.orderPaymentGateway}}</span></li>
{{#if result.orderExpectedBtc }}
<li class="list-group-item"><strong> {{ @root.__ "Order Expected BTC" }}: </strong><span class="float-right">{{result.orderExpectedBtc}}</span></li>
{{/if}}
{{#if result.orderReceivedBtc }}
<li class="list-group-item"><strong> {{ @root.__ "Order Received BTC" }}: </strong><span class="float-right">{{result.orderReceivedBtc}}</span></li>
{{/if}}
{{#if result.orderBlockonomicsTxid }}
<li class="list-group-item"><strong> {{ @root.__ "Order Blockonomics Txid" }}: </strong><span class="float-right">{{result.orderBlockonomicsTxid}}</span></li>
{{/if}}
{{#if result.orderPaymentMessage}}
<li class="list-group-item"><strong> {{ @root.__ "Payment Message" }}: </strong><span class="float-right">{{result.orderPaymentMessage}}</span></li>
{{/if}}

View File

@ -0,0 +1,3 @@
<div class="col-sm-12 text-center">
<button id="checkout_blockonomics" class="btn btn-outline-success waves-effect waves-light blue darken-3" type="submit">Pay with Bitcoin</button>
</div>

View File

@ -1,10 +1,10 @@
{{> partials/menu}}
<main role="main" class="col-md-9 ml-sm-auto col-lg-10 px-4">
<form id="settingsForm" data-toggle="validator">
<div class="col-sm-12">
<h2>{{ @root.__ "General settings" }} <span class="float-right"><button type="submit" id="btnSettingsUpdate" class="btn btn-outline-success">{{ @root.__ "Update" }}</button></span></h2>
</div>
<div class="col-md-12">
<form id="settingsForm" data-toggle="validator">
<div class="form-group">
<label>{{ @root.__ "Cart name" }} *</label>
<input type="text" class="form-control" name="cartTitle" value="{{config.cartTitle}}" required>
@ -36,6 +36,7 @@
<select class="form-control" name="paymentGateway">
<option {{selectState 'paypal' config.paymentGateway}} value="paypal">Paypal</option>
<option {{selectState 'stripe' config.paymentGateway}} value="stripe">Stripe</option>
<option {{selectState 'blockonomics' config.paymentGateway}} value="blockonomics">Blockonomics</option>
</select>
<p class="help-block">{{ @root.__ "Payment_Gateway_Info" }}</p>
</div>
@ -44,6 +45,15 @@
<input type="text" class="form-control" name="currencySymbol" value="{{currencySymbol config.currencySymbol}}">
<p class="help-block">{{ @root.__ "Set this to your currency symbol. Eg: $, £, €" }}</p>
</div>
<div class="form-group">
<label>{{ @root.__ "Currency ISO" }}</label>
<select class="form-control" name="currencyISO">
<option {{selectState 'USD' config.currencyISO}} value="USD">USD</option>
<option {{selectState 'EUR' config.currencyISO}} value="EUR">EUR</option>
<option {{selectState 'GBP' config.currencyISO}} value="GBP">GBP</option>
</select>
<p class="help-block">{{ @root.__ "Currency used for Blockonomics conversion" }}</p>
</div>
<div class="form-group">
<label>{{ @root.__ "Theme" }}</label>
<select class="form-control" name="theme">
@ -128,6 +138,6 @@
<div class="form-group">
<button id="sendTestEmail" class="btn btn-outline-success">{{ @root.__ "Send test email" }}</button>
</div>
</form>
</div>
</form>
</main>

View File

@ -0,0 +1,83 @@
<div class="col-md-10 offset-md-1 col-sm-12">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">Home</a></li>
<li class="breadcrumb-item" aria-current="page"><a href="/checkout/information">Information</a></li>
<li class="breadcrumb-item" aria-current="page"><a href="/checkout/shipping">Shipping</a></li>
<li class="breadcrumb-item active" aria-current="page"><a href="/checkout/payment">Payment</a></li>
<li class="breadcrumb-item active" aria-current="page">BTC Address</li>
</ol>
</nav>
<div class="row">
{{#if paymentMessage}}
<p class="text-danger text-center">{{paymentMessage}}</p>
{{/if}}
<div class="col-md-5">
<div class="card top-marg-15">
<div class="card-body" id="blockonomics_div" data-address="{{@root.session.blockonomicsParams.address}}" data-amount="{{@root.session.blockonomicsParams.expectedBtc}}" data-timestamp="{{@root.session.blockonomicsParams.timestamp}}" data-orderid="{{@root.session.blockonomicsParams.pendingOrderId}}">
<h5 class="card-title">{{ @root.__ "Blockonomics payment details" }}</h5>
<ul class="list-group bottom-pad-15">
<li class="list-group-item">
{{@root.session.customerFirstname}} {{@root.session.customerLastname}} -
{{@root.session.customerEmail}}
</li>
</ul>
<ul class="list-group bottom-pad-15">
{{#ifCond @root.session.totalCartShipping '>' 0}}
<li class="list-group-item">
<div class="row">
<div class="col-md-6">
{{@root.session.shippingMessage}}
</div>
<div class="col-md-6">
<span><strong>{{currencySymbol @root.config.currencySymbol}}{{formatAmount @root.session.totalCartShipping}}</strong></span>
</div>
</div>
</li>
{{else}}
{{/ifCond}}
</ul>
<ul class="list-group bottom-pad-15">
<li class="list-group-item">
<div class="row">
<div class="col-md-6">
Send BTC amount
</div>
<div class="col-md-6">
<span><strong>{{@root.session.blockonomicsParams.expectedBtc}}</strong></span>
</div>
</div>
</li>
</ul>
<ul class="list-group bottom-pad-15">
<li class="list-group-item">
<div class="row">
<div class="col-md-12" }>
Address:<br>
<span style="font-size:14px;"><strong>{{@root.session.blockonomicsParams.address}}</strong></span>
</div>
</div>
</li>
</ul>
<ul class="list-group bottom-pad-15">
<li class="list-group-item">
<div class="row">
<div class="col-md-12" id="blockonomics_waiting" style="text-align:center;">
Waiting for payment<br>
<strong><span id="blockonomics_timeout">10m 0s</span></strong> left<br>
<img src="/images/spinner.gif">
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
<div id="cart" class="col-md-7">
{{> (getTheme 'cart')}}
</div>
</div>
</div>

View File

@ -55,6 +55,9 @@
{{#ifCond config.paymentGateway '==' 'paypal'}}
{{> partials/payments/paypal}}
{{/ifCond}}
{{#ifCond config.paymentGateway '==' 'blockonomics'}}
{{> partials/payments/blockonomics}}
{{/ifCond}}
{{#ifCond config.paymentGateway '==' 'stripe'}}
{{> partials/payments/stripe}}
{{/ifCond}}

View File

@ -0,0 +1,13 @@
<div class="col-md-10 offset-md-1 col-sm-12 top-pad-50">
<div class="row">
<div class="text-center col-md-10 offset-md-1">
<h2 class="text-success">Thank you. Order have been received.</h2>
<div>
<p><h5>Order will be be processed upon confirmation by the bitcoin network. Please keep below order details for reference.</h5></p>
<p><strong>{{ @root.__ "Order ID" }}:</strong> {{result._id}}</p>
<p><strong>{{ @root.__ "Payment ID" }}:</strong> {{result.orderPaymentId}}</p>
</div>
<a href="/" class="btn btn-outline-warning">Home</a>
</div>
</div>
</div>