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](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) [![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) [![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. 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 ## Email settings
You will need to configure your SMTP details for expressCart to send email receipts to your customers. 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); process.exit(2);
} }
break; 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 // require the routes
@ -66,6 +73,7 @@ const user = require('./routes/user');
const paypal = require('./routes/payments/paypal'); const paypal = require('./routes/payments/paypal');
const stripe = require('./routes/payments/stripe'); const stripe = require('./routes/payments/stripe');
const authorizenet = require('./routes/payments/authorizenet'); const authorizenet = require('./routes/payments/authorizenet');
const adyen = require('./routes/payments/adyen');
const app = express(); const app = express();
@ -324,6 +332,7 @@ app.use('/', admin);
app.use('/paypal', paypal); app.use('/paypal', paypal);
app.use('/stripe', stripe); app.use('/stripe', stripe);
app.use('/authorizenet', authorizenet); app.use('/authorizenet', authorizenet);
app.use('/adyen', adyen);
// catch 404 and forward to error handler // catch 404 and forward to error handler
app.use((req, res, next) => { 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": { "paymentGateway": {
"type": "string", "type": "string",
"enum": ["paypal", "stripe", "authorizenet"] "enum": ["paypal", "stripe", "authorizenet", "adyen"]
}, },
"databaseConnectionString": { "databaseConnectionString": {
"type": "string" "type": "string"

View File

@ -163,5 +163,6 @@
"List": "List", "List": "List",
"Order type": "Order type", "Order type": "Order type",
"New user": "New user", "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, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "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": { "@ava/babel-plugin-throws-helper": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@ava/babel-plugin-throws-helper/-/babel-plugin-throws-helper-4.0.0.tgz", "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==", "integrity": "sha512-tMUqwBWfLFbJbizRmEcWSLw6HnFzfdJs2sOJEOwwtVPMoH/0Ay+E703oZz78VSXZiiDcZrQ5XKjPIUQixhmgVw==",
"dev": true "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": { "ajv": {
"version": "6.10.2", "version": "6.10.2",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz",
@ -2865,6 +2881,19 @@
"es6-symbol": "^3.1.1" "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": { "es6-symbol": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.1.tgz", "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.1.tgz",
@ -5236,6 +5265,30 @@
"toidentifier": "1.0.0" "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": { "i18n": {
"version": "0.8.4", "version": "0.8.4",
"resolved": "https://registry.npmjs.org/i18n/-/i18n-0.8.4.tgz", "resolved": "https://registry.npmjs.org/i18n/-/i18n-0.8.4.tgz",

View File

@ -18,13 +18,14 @@
], ],
"verbose": true, "verbose": true,
"environmentVariables": { "environmentVariables": {
"NODE_ENV": "test" "NODE_ENV": "test"
} }
}, },
"engines": { "engines": {
"node": "10.16.0" "node": "10.16.0"
}, },
"dependencies": { "dependencies": {
"@adyen/api-library": "^2.1.7",
"ajv": "^6.10.2", "ajv": "^6.10.2",
"async": "^2.6.3", "async": "^2.6.3",
"axios": "^0.19.0", "axios": "^0.19.0",
@ -87,6 +88,9 @@
"nodejs", "nodejs",
"ecommerce", "ecommerce",
"paypal", "paypal",
"stripe",
"authorise.net",
"adyen",
"lunr", "lunr",
"cart", "cart",
"shopping" "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 (){ $(document).ready(function (){
// setup if material theme // setup if material theme
if($('#cartTheme').val() === 'Material'){ 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 // call update settings API
$('#settingsForm').validator().on('submit', function(e){ $('#settingsForm').validator().on('submit', function(e){
if(!e.isDefaultPrevented()){ 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.__ "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 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> <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.__ "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.__ "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> <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"> <div class="col-lg-3">
<h2>Menu</h2> <h2>&nbsp;</h2>
<ul class="list-group"> <ul class="list-group">
<li class="list-group-item"><strong>Products</strong></li> <li class="list-group-item"><strong>Products</strong></li>
{{#ifCond session.isAdmin '===' true}} {{#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'}} {{#ifCond config.paymentGateway '==' 'authorizenet'}}
{{> partials/payments/authorizenet}} {{> partials/payments/authorizenet}}
{{/ifCond}} {{/ifCond}}
{{#ifCond config.paymentGateway '==' 'adyen'}}
{{> partials/payments/adyen}}
{{/ifCond}}
{{/if}} {{/if}}
</div> </div>
</div> </div>

View File

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

View File

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