diff --git a/.eslintignore b/.eslintignore index 80a17a7..dc783fd 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1,3 @@ **/*.min.js -pm2.config.js \ No newline at end of file +pm2.config.js +jsconfig.json \ No newline at end of file diff --git a/config/payment/config/zip.json b/config/payment/config/zip.json new file mode 100644 index 0000000..469870b --- /dev/null +++ b/config/payment/config/zip.json @@ -0,0 +1,12 @@ +{ + "description": "Pay with Zip", + "apiKey": "this_is_a_test_api_key", + "publicKey": "sk_test_this_is_not_real", + "privateKey": "KqtURWtVQXAA9ksD1CPpufYXgtxFe0hL9O2BF7hLXWQ=", + "mode": "test", + "currency": "AUD", + "supportedCountries": [ + "Australia", + "New Zealand" + ] +} \ No newline at end of file diff --git a/config/payment/schema/zip.json b/config/payment/schema/zip.json new file mode 100644 index 0000000..e153e1c --- /dev/null +++ b/config/payment/schema/zip.json @@ -0,0 +1,40 @@ +{ + "properties": { + "description": { + "type": "string" + }, + "apiKey": { + "type": "string" + }, + "publicKey": { + "type": "string", + "default": "sandbox" + }, + "privateKey": { + "type": "string" + }, + "mode": { + "type": "string", + "default": "test", + "enum": ["test", "live"] + }, + "currency": { + "type": "string", + "default": "AUD", + "enum": ["AUD", "NZD"] + }, + "supportedCountries":{ + "type": "array" + } + }, + "required": [ + "description", + "apiKey", + "publicKey", + "privateKey", + "mode", + "currency", + "supportedCountries" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/config/settingsSchema.json b/config/settingsSchema.json index e73d949..0c01ae8 100644 --- a/config/settingsSchema.json +++ b/config/settingsSchema.json @@ -99,6 +99,10 @@ { "type": "string", "enum": [ "instore"] + }, + { + "type": "string", + "enum": [ "zip"] } ] } diff --git a/lib/common.js b/lib/common.js index b31e6ba..4b2cffb 100755 --- a/lib/common.js +++ b/lib/common.js @@ -16,8 +16,13 @@ const { // Parse country list once const countryArray = []; +const countryCodes = {}; Object.keys(countryList.countries).forEach((country) => { - countryArray.push(countryList.countries[country].name); + const countryName = countryList.countries[country].name; + countryArray.push(countryName); + countryCodes[countryName] = { + code: country + }; }); // Allowed mime types for product images @@ -252,6 +257,10 @@ const getCountryList = () => { return countryArray; }; +const getCountryNameToCode = (name) => { + return countryCodes[name]; +}; + const cleanAmount = (amount) => { if(amount){ return parseInt(amount.toString().replace('.', '')); @@ -278,5 +287,6 @@ module.exports = { newId, hooker, getCountryList, + getCountryNameToCode, cleanAmount }; diff --git a/lib/payments/zip.js b/lib/payments/zip.js new file mode 100644 index 0000000..364db87 --- /dev/null +++ b/lib/payments/zip.js @@ -0,0 +1,275 @@ +const express = require('express'); +const { indexOrders } = require('../indexing'); +const { getId, sendEmail, getEmailTemplate, getCountryNameToCode } = require('../common'); +const { getPaymentConfig } = require('../config'); +const { emptyCart } = require('../cart'); +const axios = require('axios'); +const ObjectId = require('mongodb').ObjectID; +const paymentConfig = getPaymentConfig('zip'); +const router = express.Router(); + +// Setup the API +const payloadConfig = { + headers: { + Authorization: `Bearer ${paymentConfig.privateKey}`, + 'zip-Version': '2017-03-01', + 'Content-Type': 'application/json' + } +}; +let apiUrl = 'https://api.zipmoney.com.au/merchant/v1'; +if(paymentConfig.mode === 'test'){ + apiUrl = 'https://api.sandbox.zipmoney.com.au/merchant/v1'; +} + +// The homepage of the site +router.post('/setup', async (req, res, next) => { + const db = req.app.db; + const config = req.app.config; + const paymentConfig = getPaymentConfig('zip'); + + // Check country is supported + if(!paymentConfig.supportedCountries.includes(req.session.customerCountry)){ + res.status(400).json({ + error: 'Unfortunately Zip is not supported in your country.' + }); + return; + } + + if(!req.session.cart){ + res.status(400).json({ + error: 'Cart is empty. Please add something to your cart before checking out.' + }); + return; + } + + // Get country code from country name + const countryCode = getCountryNameToCode(req.session.customerCountry).code; + + // Create the payload + const payload = { + shopper: { + first_name: req.session.customerFirstname, + last_name: req.session.customerLastname, + phone: req.session.customerPhone, + email: req.session.customerEmail, + billing_address: { + line1: req.session.customerAddress1, + city: req.session.customerAddress2, + state: req.session.customerState, + postal_code: req.session.customerPostcode, + country: countryCode + } + }, + order: { + amount: req.session.totalCartAmount, + currency: paymentConfig.currency, + shipping: { + pickup: false, + address: { + line1: req.session.customerAddress1, + city: req.session.customerAddress2, + state: req.session.customerState, + postal_code: req.session.customerPostcode, + country: countryCode + } + }, + items: [ + { + name: 'Shipping', + amount: req.session.totalCartShipping, + quantity: 1, + type: 'shipping', + reference: 'Shipping amount' + } + ] + }, + config: { + redirect_uri: 'http://localhost:1111/zip/return' + } + }; + + // Loop items in the cart + Object.keys(req.session.cart).forEach((cartItemId) => { + const cartItem = req.session.cart[cartItemId]; + const item = { + name: cartItem.title, + amount: cartItem.totalItemPrice, + quantity: cartItem.quantity, + type: 'sku', + reference: cartItem.productId.toString() + }; + + if(cartItem.productImage && cartItem.productImage !== ''){ + item.image_uri = `${config.baseUrl}${cartItem.productImage}`; + } + payload.order.items.push(item); + }); + + try{ + const response = await axios.post(`${apiUrl}/checkouts`, + payload, + payloadConfig + ); + + // Setup order + const orderDoc = { + orderPaymentId: response.data.id, + orderPaymentGateway: 'Zip', + orderTotal: req.session.totalCartAmount, + orderShipping: req.session.totalCartShipping, + orderItemCount: req.session.totalCartItems, + orderProductCount: req.session.totalCartProducts, + orderCustomer: getId(req.session.customerId), + orderEmail: req.session.customerEmail, + orderCompany: req.session.customerCompany, + 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' + }; + + // insert order into DB + const order = await db.orders.insertOne(orderDoc); + + // Set the order ID for the response + req.session.orderId = order.insertedId; + + // add to lunr index + indexOrders(req.app) + .then(() => { + // Return response + res.json({ + orderId: order.insertedId, + redirectUri: response.data.uri + }); + }); + }catch(ex){ + console.log('ex', ex); + res.status(400).json({ + error: 'Failed to process payment' + }); + } +}); + +router.get('/return', async (req, res, next) => { + const db = req.app.db; + const result = req.query.result; + + // If cancelled + if(result === 'cancelled'){ + // Update the order + await db.orders.deleteOne({ + _id: ObjectId(req.session.orderId) + }); + } + + res.redirect('/checkout/payment'); +}); + +router.post('/charge', async (req, res, next) => { + const db = req.app.db; + const config = req.app.config; + const paymentConfig = getPaymentConfig('zip'); + const checkoutId = req.body.checkoutId; + + // grab order + const order = await db.orders.findOne({ _id: ObjectId(req.session.orderId) }); + + // Cross check the checkoutId + if(checkoutId !== order.orderPaymentId){ + console.log('order check failed'); + res.status(400).json({ err: 'Order not found. Please try again.' }); + return; + } + + // Create charge payload + const payload = { + authority: { + type: 'checkout_id', + value: checkoutId + }, + reference: order._id, + amount: order.orderTotal, + currency: paymentConfig.currency, + capture: true + }; + + try{ + // Create charge + const response = await axios.post(`${apiUrl}/charges`, + payload, + payloadConfig + ); + + // Set the order fields + const updateDoc = { + orderStatus: 'Paid', + orderPaymentMessage: 'Payment completed successfully', + orderPaymentId: checkoutId + }; + + // Update result + if(response.data.state === 'captured'){ + updateDoc.orderStatus = 'Paid'; + updateDoc.orderPaymentMessage = 'Payment completed successfully'; + }else{ + updateDoc.orderPaymentMessage = `Check payment: ${response.data.state}`; + } + + // Update the order + await db.orders.updateOne({ + _id: ObjectId(req.session.orderId) + }, + { $set: updateDoc }, + { multi: false, returnOriginal: false }); + + // Return decline + if(updateDoc.orderStatus !== 'Paid'){ + res.status(400).json({ err: 'Your payment has declined. Please try again' }); + return; + } + + // 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){ + emptyCart(req, res, 'function'); + } + + // send the email with the response + // TODO: Should fix this to properly handle result + sendEmail(req.session.paymentEmailAddr, `Your payment with ${config.cartTitle}`, getEmailTemplate(paymentResults)); + + // add to lunr index + indexOrders(req.app) + .then(() => { + res.json({ + message: 'Payment completed successfully', + paymentId: order._id + }); + }); + }catch(ex){ + console.log('ex', ex); + res.status(400).json({ + error: 'Failed to process payment' + }); + } +}); + +module.exports = router; diff --git a/public/images/zip.svg b/public/images/zip.svg new file mode 100644 index 0000000..6a93ea8 --- /dev/null +++ b/public/images/zip.svg @@ -0,0 +1,24 @@ + + \ No newline at end of file diff --git a/public/javascripts/common.js b/public/javascripts/common.js index 304477b..2261f8f 100644 --- a/public/javascripts/common.js +++ b/public/javascripts/common.js @@ -1,6 +1,6 @@ /* eslint-disable no-unused-vars, prefer-template */ /* eslint-disable prefer-arrow-callback, no-var, no-tabs */ -/* globals Stripe, AdyenCheckout */ +/* globals Stripe, AdyenCheckout, Zip */ $(document).ready(function (){ // validate form and show stripe payment if($('#stripe-form').length > 0){ @@ -119,6 +119,32 @@ $(document).ready(function (){ showNotification(msg.responseJSON.message, 'danger'); }); }; + + if($('#zip-checkout').length > 0){ + Zip.Checkout.attachButton('#zip-checkout', { + checkoutUri: '/zip/setup', + onComplete: function(args){ + if(args.state !== 'approved'){ + window.location = '/zip/return?result=' + args.state; + return; + } + $.ajax({ + type: 'POST', + url: '/zip/charge', + data: { + checkoutId: args.checkoutId + } + }).done((response) => { + window.location = '/payment/' + response.paymentId; + }).fail((response) => { + showNotification('Failed to complete transaction', 'danger', true); + }); + }, + onError: function(args){ + window.location = '/zip/return?result=cancelled'; + } + }); + }; }); // show notification popup diff --git a/public/javascripts/common.min.js b/public/javascripts/common.min.js index dcdf364..aa7fd78 100644 --- a/public/javascripts/common.min.js +++ b/public/javascripts/common.min.js @@ -1 +1 @@ -function showNotification(e,a,o,n){o=o||!1,n=n||null,e||(e="Unknown error has occured. Check inputs."),$("#notify_message").removeClass(),$("#notify_message").addClass("alert-"+a),$("#notify_message").html(e),$("#notify_message").slideDown(600).delay(2500).slideUp(600,function(){n&&(window.location=n),!0===o&&location.reload()})}function slugify(e){return $.trim(e).replace(/[^a-z0-9-æøå]/gi,"-").replace(/-+/g,"-").replace(/^-|-$/g,"").replace(/æ/gi,"ae").replace(/ø/gi,"oe").replace(/å/gi,"a").toLowerCase()}$(document).ready(function(){if($("#stripe-form").length>0){var e=Stripe($("#stripePublicKey").val()),a=e.elements().create("card",{style:{hidePostalCode:!0,base:{color:"#32325d",fontFamily:'"Helvetica Neue", Helvetica, sans-serif',fontSmoothing:"antialiased",fontSize:"16px","::placeholder":{color:"#aab7c4"}},invalid:{color:"#fa755a",iconColor:"#fa755a"}}});a.mount("#card-element"),$(document).on("submit","#stripe-payment-form",function(o){o.preventDefault(),e.createToken(a).then(function(e){e.error?showNotification("Failed to complete transaction","danger",!0):$.ajax({type:"POST",url:"/stripe/checkout_action",data:{token:e.token.id}}).done(e=>{window.location="/payment/"+e.paymentId}).fail(e=>{window.location="/payment/"+e.paymentId})})})}$("#checkoutInstore").validator().on("click",function(e){e.preventDefault(),$.ajax({type:"POST",url:"/instore/checkout_action"}).done(e=>{window.location="/payment/"+e.paymentId}).fail(e=>{window.location="/payment/"+e.paymentId})}),$("#adyen-dropin").length>0&&$.ajax({method:"POST",url:"/adyen/setup"}).done(function(e){const a={locale:"en-AU",environment:e.environment.toLowerCase(),originKey:e.originKey,paymentMethodsResponse:e.paymentsResponse};new AdyenCheckout(a).create("dropin",{paymentMethodsConfiguration:{card:{hasHolderName:!1,holderNameRequired:!1,enableStoreDetails:!1,groupTypes:["mc","visa"],name:"Credit or debit card"}},onSubmit:(e,a)=>{0===$("#shipping-form").validator("validate").has(".has-error").length&&$.ajax({type:"POST",url:"/adyen/checkout_action",data:{shipEmail:$("#shipEmail").val(),shipCompany:$("#shipCompany").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(e.data.paymentMethod)}}).done(e=>{window.location="/payment/"+e.paymentId}).fail(e=>{showNotification("Failed to complete transaction","danger",!0)})}}).mount("#adyen-dropin")}).fail(function(e){showNotification(e.responseJSON.message,"danger")})}); \ No newline at end of file +function showNotification(e,t,a,o){a=a||!1,o=o||null,e||(e="Unknown error has occured. Check inputs."),$("#notify_message").removeClass(),$("#notify_message").addClass("alert-"+t),$("#notify_message").html(e),$("#notify_message").slideDown(600).delay(2500).slideUp(600,function(){o&&(window.location=o),!0===a&&location.reload()})}function slugify(e){return $.trim(e).replace(/[^a-z0-9-æøå]/gi,"-").replace(/-+/g,"-").replace(/^-|-$/g,"").replace(/æ/gi,"ae").replace(/ø/gi,"oe").replace(/å/gi,"a").toLowerCase()}$(document).ready(function(){if($("#stripe-form").length>0){var e=Stripe($("#stripePublicKey").val()),t=e.elements().create("card",{style:{hidePostalCode:!0,base:{color:"#32325d",fontFamily:'"Helvetica Neue", Helvetica, sans-serif',fontSmoothing:"antialiased",fontSize:"16px","::placeholder":{color:"#aab7c4"}},invalid:{color:"#fa755a",iconColor:"#fa755a"}}});t.mount("#card-element"),$(document).on("submit","#stripe-payment-form",function(a){a.preventDefault(),e.createToken(t).then(function(e){e.error?showNotification("Failed to complete transaction","danger",!0):$.ajax({type:"POST",url:"/stripe/checkout_action",data:{token:e.token.id}}).done(e=>{window.location="/payment/"+e.paymentId}).fail(e=>{window.location="/payment/"+e.paymentId})})})}$("#checkoutInstore").validator().on("click",function(e){e.preventDefault(),$.ajax({type:"POST",url:"/instore/checkout_action"}).done(e=>{window.location="/payment/"+e.paymentId}).fail(e=>{window.location="/payment/"+e.paymentId})}),$("#adyen-dropin").length>0&&$.ajax({method:"POST",url:"/adyen/setup"}).done(function(e){const t={locale:"en-AU",environment:e.environment.toLowerCase(),originKey:e.originKey,paymentMethodsResponse:e.paymentsResponse};new AdyenCheckout(t).create("dropin",{paymentMethodsConfiguration:{card:{hasHolderName:!1,holderNameRequired:!1,enableStoreDetails:!1,groupTypes:["mc","visa"],name:"Credit or debit card"}},onSubmit:(e,t)=>{0===$("#shipping-form").validator("validate").has(".has-error").length&&$.ajax({type:"POST",url:"/adyen/checkout_action",data:{shipEmail:$("#shipEmail").val(),shipCompany:$("#shipCompany").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(e.data.paymentMethod)}}).done(e=>{window.location="/payment/"+e.paymentId}).fail(e=>{showNotification("Failed to complete transaction","danger",!0)})}}).mount("#adyen-dropin")}).fail(function(e){showNotification(e.responseJSON.message,"danger")}),$("#zip-checkout").length>0&&Zip.Checkout.attachButton("#zip-checkout",{checkoutUri:"/zip/setup",onComplete:function(e){"approved"===e.state?$.ajax({type:"POST",url:"/zip/charge",data:{checkoutId:e.checkoutId}}).done(e=>{window.location="/payment/"+e.paymentId}).fail(e=>{showNotification("Failed to complete transaction","danger",!0)}):window.location="/zip/return?result="+e.state},onError:function(e){window.location="/zip/return?result=cancelled"}})}); \ No newline at end of file diff --git a/views/partials/payments/zip.hbs b/views/partials/payments/zip.hbs new file mode 100644 index 0000000..1bf1450 --- /dev/null +++ b/views/partials/payments/zip.hbs @@ -0,0 +1,6 @@ + +{{!-- --}} +