Adding Adyen payment integration (#97)

Add Adyen payment integration
master
Mark Moffat 2019-11-11 18:17:48 +10:30 committed by GitHub
parent e9e7625789
commit c3c29d04f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 347 additions and 8 deletions

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 and Authorize.net payments.
`expressCart` is a fully functional shopping cart built in Node.js (Express, MongoDB) with Stripe, PayPal, Authorize.net and Adyen 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)
@ -263,6 +263,23 @@ The Authorize.net config file is located: `/config/authorizenet.json`. A example
Note: The credentials are obtained from your Authorize.net account dashboard.
##### Adyen (Payments)
The Adyen config file is located: `/config/adyen.json`. A example Adyen settings file is provided:
```
{
"environment": "TEST",
"apiKey": "this_is_not_real",
"publicKey": "this_is_not_real",
"merchantAccount": "this_is_not_real",
"statementDescriptor": "a_statement_descriptor",
"currency": "AUD"
}
```
Note: The `publicKey`, `apiKey` and `merchantAccount` is obtained from your Adyen account dashboard.
## Email settings
You will need to configure your SMTP details for expressCart to send email receipts to your customers.

9
app.js
View File

@ -54,6 +54,13 @@ switch(config.paymentGateway){
process.exit(2);
}
break;
case'adyen':
if(ajv.validate(require('./config/adyenSchema'), require('./config/adyen.json')) === false){
console.log(colors.red(`adyen config is incorrect: ${ajv.errorsText()}`));
process.exit(2);
}
break;
}
// require the routes
@ -66,6 +73,7 @@ const user = require('./routes/user');
const paypal = require('./routes/payments/paypal');
const stripe = require('./routes/payments/stripe');
const authorizenet = require('./routes/payments/authorizenet');
const adyen = require('./routes/payments/adyen');
const app = express();
@ -324,6 +332,7 @@ app.use('/', admin);
app.use('/paypal', paypal);
app.use('/stripe', stripe);
app.use('/authorizenet', authorizenet);
app.use('/adyen', adyen);
// catch 404 and forward to error handler
app.use((req, res, next) => {

8
config/adyen.json Normal file
View File

@ -0,0 +1,8 @@
{
"environment": "TEST",
"apiKey": "this_is_not_real",
"publicKey": "this_is_not_real",
"merchantAccount": "this_is_not_real",
"statementDescriptor": "a_statement_descriptor",
"currency": "AUD"
}

32
config/adyenSchema.json Normal file
View File

@ -0,0 +1,32 @@
{
"properties": {
"environment": {
"type": "string",
"enum": ["TEST", "LIVE"]
},
"apiKey": {
"type": "string"
},
"publicKey": {
"type": "string"
},
"merchantAccount": {
"type": "string"
},
"statementDescriptor": {
"type": "string"
},
"currency": {
"type": "string"
}
},
"required": [
"environment",
"apiKey",
"publicKey",
"merchantAccount",
"statementDescriptor",
"currency"
],
"additionalProperties": false
}

View File

@ -83,7 +83,7 @@
},
"paymentGateway": {
"type": "string",
"enum": ["paypal", "stripe", "authorizenet"]
"enum": ["paypal", "stripe", "authorizenet", "adyen"]
},
"databaseConnectionString": {
"type": "string"

View File

@ -163,5 +163,6 @@
"List": "List",
"Order type": "Order type",
"New user": "New user",
"Payment ID": "Payment ID"
"Payment ID": "Payment ID",
"Payment Message": "Payment Message"
}

53
package-lock.json generated
View File

@ -4,6 +4,14 @@
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@adyen/api-library": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/@adyen/api-library/-/api-library-2.1.7.tgz",
"integrity": "sha512-EQXOyXJ6A4ceqWYIiAwXdISVWVZfvQEPhf4roYkLiOL01GSTL0waWucUvS8tXOBOtSzrhTw1zUqC0YGBjULaRA==",
"requires": {
"https-proxy-agent": "3.0.1"
}
},
"@ava/babel-plugin-throws-helper": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@ava/babel-plugin-throws-helper/-/babel-plugin-throws-helper-4.0.0.tgz",
@ -508,6 +516,14 @@
"integrity": "sha512-tMUqwBWfLFbJbizRmEcWSLw6HnFzfdJs2sOJEOwwtVPMoH/0Ay+E703oZz78VSXZiiDcZrQ5XKjPIUQixhmgVw==",
"dev": true
},
"agent-base": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz",
"integrity": "sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==",
"requires": {
"es6-promisify": "^5.0.0"
}
},
"ajv": {
"version": "6.10.2",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz",
@ -2865,6 +2881,19 @@
"es6-symbol": "^3.1.1"
}
},
"es6-promise": {
"version": "4.2.8",
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
"integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="
},
"es6-promisify": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz",
"integrity": "sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=",
"requires": {
"es6-promise": "^4.0.3"
}
},
"es6-symbol": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.1.tgz",
@ -5236,6 +5265,30 @@
"toidentifier": "1.0.0"
}
},
"https-proxy-agent": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-3.0.1.tgz",
"integrity": "sha512-+ML2Rbh6DAuee7d07tYGEKOEi2voWPUGan+ExdPbPW6Z3svq+JCqr0v8WmKPOkz1vOVykPCBSuobe7G8GJUtVg==",
"requires": {
"agent-base": "^4.3.0",
"debug": "^3.1.0"
},
"dependencies": {
"debug": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz",
"integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==",
"requires": {
"ms": "^2.1.1"
}
},
"ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
}
}
},
"i18n": {
"version": "0.8.4",
"resolved": "https://registry.npmjs.org/i18n/-/i18n-0.8.4.tgz",

View File

@ -18,13 +18,14 @@
],
"verbose": true,
"environmentVariables": {
"NODE_ENV": "test"
}
"NODE_ENV": "test"
}
},
"engines": {
"node": "10.16.0"
},
"dependencies": {
"@adyen/api-library": "^2.1.7",
"ajv": "^6.10.2",
"async": "^2.6.3",
"axios": "^0.19.0",
@ -87,6 +88,9 @@
"nodejs",
"ecommerce",
"paypal",
"stripe",
"authorise.net",
"adyen",
"lunr",
"cart",
"shopping"

View File

@ -1,4 +1,5 @@
/* eslint-disable prefer-arrow-callback, no-var, no-tabs */
/* eslint-disable prefer-arrow-callback, no-var, no-tabs */
/* globals AdyenCheckout */
$(document).ready(function (){
// setup if material theme
if($('#cartTheme').val() === 'Material'){
@ -310,6 +311,63 @@ $(document).ready(function (){
}
});
if($('#adyen-dropin').length > 0){
$.ajax({
method: 'POST',
url: '/adyen/setup'
})
.done(function(response){
const configuration = {
locale: 'en-AU',
environment: response.environment.toLowerCase(),
originKey: response.publicKey,
paymentMethodsResponse: response.paymentsResponse
};
const checkout = new AdyenCheckout(configuration);
checkout
.create('dropin', {
paymentMethodsConfiguration: {
card: {
hasHolderName: false,
holderNameRequired: false,
enableStoreDetails: false,
groupTypes: ['mc', 'visa'],
name: 'Credit or debit card'
}
},
onSubmit: (state, dropin) => {
if($('#shipping-form').validator('validate').has('.has-error').length === 0){
$.ajax({
type: 'POST',
url: '/adyen/checkout_action',
data: {
shipEmail: $('#shipEmail').val(),
shipFirstname: $('#shipFirstname').val(),
shipLastname: $('#shipLastname').val(),
shipAddr1: $('#shipAddr1').val(),
shipAddr2: $('#shipAddr2').val(),
shipCountry: $('#shipCountry').val(),
shipState: $('#shipState').val(),
shipPostcode: $('#shipPostcode').val(),
shipPhoneNumber: $('#shipPhoneNumber').val(),
payment: JSON.stringify(state.data.paymentMethod)
}
}).done((response) => {
window.location = '/payment/' + response.paymentId;
}).fail((response) => {
console.log('Response', response);
showNotification('Failed to complete transaction', 'danger', true);
});
}
}
})
.mount('#adyen-dropin');
})
.fail(function(msg){
showNotification(msg.responseJSON.message, 'danger');
});
};
// call update settings API
$('#settingsForm').validator().on('submit', function(e){
if(!e.isDefaultPrevented()){

File diff suppressed because one or more lines are too long

138
routes/payments/adyen.js Normal file
View File

@ -0,0 +1,138 @@
const express = require('express');
const common = require('../../lib/common');
const { indexOrders } = require('../../lib/indexing');
const numeral = require('numeral');
const { Client, CheckoutAPI } = require('@adyen/api-library');
const router = express.Router();
router.post('/setup', async (req, res, next) => {
const adyenConfig = common.getPaymentConfig();
const client = new Client({
apiKey: adyenConfig.apiKey,
environment: adyenConfig.environment
});
const checkout = new CheckoutAPI(client);
let paymentsResponse;
try{
paymentsResponse = await checkout.paymentMethods({
amount: {
currency: 'AUD',
value: 0
},
countryCode: 'AU',
channel: 'Web',
merchantAccount: adyenConfig.merchantAccount
});
}catch(ex){
console.log('Exception getting supported payment methods', ex.message);
res.status(400).json({ message: 'Failed to retrieve payment methods.' + ex.message });
}
res.status(200).json({
paymentsResponse,
environment: adyenConfig.environment,
publicKey: adyenConfig.publicKey
});
});
router.post('/checkout_action', async (req, res, next) => {
const db = req.app.db;
const config = req.app.config;
const adyenConfig = common.getPaymentConfig();
const client = new Client({
apiKey: adyenConfig.apiKey,
environment: adyenConfig.environment
});
const checkout = new CheckoutAPI(client);
let response;
try{
response = await checkout.payments({
shopperInteraction: 'Ecommerce',
amount: {
currency: adyenConfig.currency,
value: numeral(req.session.totalCartAmount).format('0.00').replace('.', '')
},
paymentMethod: JSON.parse(req.body.payment),
reference: adyenConfig.statementDescriptor,
merchantAccount: adyenConfig.merchantAccount,
shopperStatement: adyenConfig.statementDescriptor
});
}catch(ex){
console.log('Payment exception', ex.message);
req.session.messageType = 'danger';
req.session.message = 'Card declined. Contact card issuer';
return;
}
// Update response
let paymentStatus = 'Paid';
if(response && response.resultCode !== 'Authorised'){
paymentStatus = 'Declined';
}
// new order doc
const orderDoc = {
orderPaymentId: response.pspReference,
orderPaymentGateway: 'Adyen',
orderPaymentMessage: response.refusalReason,
orderTotal: req.session.totalCartAmount,
orderEmail: req.body.shipEmail,
orderFirstname: req.body.shipFirstname,
orderLastname: req.body.shipLastname,
orderAddr1: req.body.shipAddr1,
orderAddr2: req.body.shipAddr2,
orderCountry: req.body.shipCountry,
orderState: req.body.shipState,
orderPostcode: req.body.shipPostcode,
orderPhoneNumber: req.body.shipPhoneNumber,
orderComment: req.body.orderComment,
orderStatus: paymentStatus,
orderDate: new Date(),
orderProducts: req.session.cart,
orderType: 'Single'
};
// insert order into DB
const newOrder = await db.orders.insertOne(orderDoc);
// get the new ID
const newId = newOrder.insertedId;
// add to lunr index
indexOrders(req.app)
.then(() => {
// Process the result
if(paymentStatus === 'Paid'){
// set the results
req.session.messageType = 'success';
req.session.message = 'Your payment was successfully completed';
req.session.paymentEmailAddr = orderDoc.orderEmail;
req.session.paymentApproved = true;
req.session.paymentDetails = '<p><strong>Order ID: </strong>' + newId + '</p><p><strong>Transaction ID: </strong>' + response.pspReference + '</p>';
// set payment results for email
const paymentResults = {
message: req.session.message,
messageType: req.session.messageType,
paymentEmailAddr: req.session.paymentEmailAddr,
paymentApproved: true,
paymentDetails: req.session.paymentDetails
};
// clear the cart
if(req.session.cart){
req.session.cart = null;
req.session.orderId = null;
req.session.totalCartAmount = 0;
}
// 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({ paymentId: newId });
});
});
module.exports = router;

View File

@ -28,6 +28,9 @@
<li class="list-group-item"><strong> {{ @root.__ "Order ID" }}: </strong><span class="pull-right">{{result._id}}</span></li>
<li class="list-group-item"><strong> {{ @root.__ "Payment Gateway ref" }}: </strong><span class="pull-right">{{result.orderPaymentId}}</span></li>
<li class="list-group-item"><strong> {{ @root.__ "Payment Gateway" }}: </strong><span class="pull-right">{{result.orderPaymentGateway}}</span></li>
{{#if result.orderPaymentMessage}}
<li class="list-group-item"><strong> {{ @root.__ "Payment Message" }}: </strong><span class="pull-right">{{result.orderPaymentMessage}}</span></li>
{{/if}}
<li class="list-group-item"><strong> {{ @root.__ "Order total amount" }}: </strong><span class="pull-right">{{currencySymbol config.currencySymbol}}{{formatAmount result.orderTotal}}</span></li>
<li class="list-group-item"><strong> {{ @root.__ "Email address" }}: </strong><span class="pull-right">{{result.orderEmail}}</span></li>
<li class="list-group-item"><strong> {{ @root.__ "First name" }}: </strong><span class="pull-right">{{result.orderFirstname}}</span></li>

View File

@ -1,5 +1,5 @@
<div class="col-lg-3">
<h2>Menu</h2>
<h2>&nbsp;</h2>
<ul class="list-group">
<li class="list-group-item"><strong>Products</strong></li>
{{#ifCond session.isAdmin '===' true}}

View File

@ -0,0 +1,7 @@
<div class="col-xs-12 col s12 text-center">
<div id="adyen-dropin"></div>
<link rel="stylesheet" href="https://checkoutshopper-live.adyen.com/checkoutshopper/sdk/3.2.0/adyen.css"/>
<script src="https://checkoutshopper-live.adyen.com/checkoutshopper/sdk/3.2.0/adyen.js"></script>
<input type="hidden" id="environment" value="{{@root.paymentConfig.environment}}">
<input type="hidden" id="publicKey" value="{{@root.paymentConfig.publicKey}}">
</div>

View File

@ -60,6 +60,9 @@
{{#ifCond config.paymentGateway '==' 'authorizenet'}}
{{> partials/payments/authorizenet}}
{{/ifCond}}
{{#ifCond config.paymentGateway '==' 'adyen'}}
{{> partials/payments/adyen}}
{{/ifCond}}
{{/if}}
</div>
</div>

View File

@ -54,6 +54,9 @@
{{#ifCond config.paymentGateway '==' 'authorizenet'}}
{{> partials/payments/authorizenet}}
{{/ifCond}}
{{#ifCond config.paymentGateway '==' 'adyen'}}
{{> partials/payments/adyen}}
{{/ifCond}}
{{/if}}
</div>
</div>

View File

@ -54,6 +54,9 @@
{{#ifCond config.paymentGateway '==' 'authorizenet'}}
{{> partials/payments/authorizenet}}
{{/ifCond}}
{{#ifCond config.paymentGateway '==' 'adyen'}}
{{> partials/payments/adyen}}
{{/ifCond}}
{{/if}}
</div>
</div>