Adding subscriptions (#95)
* Adding subscriptions * Adding in ability to use webhook for subscriptions * Add docs and ability to create subscriptions * Fixed populating value * Adding subscription tests * Language updatemaster
							parent
							
								
									c92194dd56
								
							
						
					
					
						commit
						a87d2fbf0a
					
				|  | @ -118,6 +118,13 @@ Note: An `Options` value is not required when `Type` is set to `Checkbox`. | ||||||
| 
 | 
 | ||||||
| Tags are used when indexing the products for search. It's advised to set tags (keywords) so that customers can easily find the products they are searching for. | Tags are used when indexing the products for search. It's advised to set tags (keywords) so that customers can easily find the products they are searching for. | ||||||
| 
 | 
 | ||||||
|  | ## Subscriptions (Stripe only) | ||||||
|  | 
 | ||||||
|  | You are able to setup product subscriptions through Stripe. First setup the `Plan` in the [Stripe dashboard](https://dashboard.stripe.com/) then enter the Plan ID (Formatted: plan_XXXXXXXXXXXXXX) when creating or editing a product. When purchasing, a customer can only add a single subscription to their cart at one time. Subscriptions cannot be combined with other products in their cart. On Checkout/Payment the customer and subscription is created in Stripe and the billing cycle commences based on the plan setup. | ||||||
|  | 
 | ||||||
|  | ##### Subscription Webhooks (Stripe only) | ||||||
|  | You are able to configure a Webhook in Stripe to receive subscription updates on successful/failed payments [here](https://dashboard.stripe.com/webhooks). The `expressCart` Webhook endpoint should be set to: `https://<example.com>/stripe/subscription_update`. You will need to set the `Events to send` value to both: `invoice.payment_failed` and `invoice.payment_succeeded`. | ||||||
|  | 
 | ||||||
| ## Database | ## Database | ||||||
| 
 | 
 | ||||||
| `expressCart` uses a MongoDB for storing all the data. Setting of the database connection string is done through the `/config/settings.json` file. There are two properties relating to the database connection: | `expressCart` uses a MongoDB for storing all the data. Setting of the database connection string is done through the `/config/settings.json` file. There are two properties relating to the database connection: | ||||||
|  |  | ||||||
|  | @ -69,6 +69,17 @@ | ||||||
|             "productTags" : "shirt", |             "productTags" : "shirt", | ||||||
|             "productOptions" : "{\"Size\":{\"optName\":\"Size\",\"optLabel\":\"Select size\",\"optType\":\"select\",\"optOptions\":[\"S\",\"M\",\"L\"]}}", |             "productOptions" : "{\"Size\":{\"optName\":\"Size\",\"optLabel\":\"Select size\",\"optType\":\"select\",\"optOptions\":[\"S\",\"M\",\"L\"]}}", | ||||||
|             "productStock": 10 |             "productStock": 10 | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             "productPermalink" : "gym-membership", | ||||||
|  |             "productTitle" : "Gym membership", | ||||||
|  |             "productPrice" : "15", | ||||||
|  |             "productDescription" : "This a monthly recurring Gym membership subscription.", | ||||||
|  |             "productPublished": true, | ||||||
|  |             "productTags" : "subscription", | ||||||
|  |             "productOptions" : "", | ||||||
|  |             "productStock": null, | ||||||
|  |             "productSubscription": "plan_XXXXXXXXXXXXXX" | ||||||
|         } |         } | ||||||
|     ], |     ], | ||||||
|     "customers": [ |     "customers": [ | ||||||
|  |  | ||||||
|  | @ -131,6 +131,22 @@ const updateTotalCartAmount = (req, res) => { | ||||||
|     } |     } | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | const updateSubscriptionCheck = (req, res) => { | ||||||
|  |     // If cart is empty
 | ||||||
|  |     if(!req.session.cart || req.session.cart.length === 0){ | ||||||
|  |         req.session.cartSubscription = null; | ||||||
|  |         return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     req.session.cart.forEach((item) => { | ||||||
|  |         if(item.productSubscription){ | ||||||
|  |             req.session.cartSubscription = item.productSubscription; | ||||||
|  |         }else{ | ||||||
|  |             req.session.cartSubscription = null; | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
| const checkDirectorySync = (directory) => { | const checkDirectorySync = (directory) => { | ||||||
|     try{ |     try{ | ||||||
|         fs.statSync(directory); |         fs.statSync(directory); | ||||||
|  | @ -551,6 +567,7 @@ module.exports = { | ||||||
|     addSitemapProducts, |     addSitemapProducts, | ||||||
|     clearSessionValue, |     clearSessionValue, | ||||||
|     updateTotalCartAmount, |     updateTotalCartAmount, | ||||||
|  |     updateSubscriptionCheck, | ||||||
|     checkDirectorySync, |     checkDirectorySync, | ||||||
|     getThemes, |     getThemes, | ||||||
|     getImages, |     getImages, | ||||||
|  |  | ||||||
|  | @ -160,5 +160,6 @@ | ||||||
| 	"Cart contents": "Cart contents", | 	"Cart contents": "Cart contents", | ||||||
| 	"Shipping": "Shipping:", | 	"Shipping": "Shipping:", | ||||||
| 	"Empty cart": "Empty cart", | 	"Empty cart": "Empty cart", | ||||||
| 	"List": "List" | 	"List": "List", | ||||||
|  | 	"Order type": "Order type" | ||||||
| } | } | ||||||
|  | @ -289,6 +289,9 @@ $(document).ready(function (){ | ||||||
|                 image: $('#stripeButton').data('image'), |                 image: $('#stripeButton').data('image'), | ||||||
|                 locale: 'auto', |                 locale: 'auto', | ||||||
|                 token: function(token){ |                 token: function(token){ | ||||||
|  |                     if($('#stripeButton').data('subscription')){ | ||||||
|  |                         $('#shipping-form').append('<input type="hidden" name="stripePlan" value="' + $('#stripeButton').data('subscription') + '" />'); | ||||||
|  |                     } | ||||||
|                     $('#shipping-form').append('<input type="hidden" name="stripeToken" value="' + token.id + '" />'); |                     $('#shipping-form').append('<input type="hidden" name="stripeToken" value="' + token.id + '" />'); | ||||||
|                     $('#shipping-form').submit(); |                     $('#shipping-form').submit(); | ||||||
|                 } |                 } | ||||||
|  | @ -301,7 +304,8 @@ $(document).ready(function (){ | ||||||
|                 description: $('#stripeButton').data('description'), |                 description: $('#stripeButton').data('description'), | ||||||
|                 zipCode: $('#stripeButton').data('zipCode'), |                 zipCode: $('#stripeButton').data('zipCode'), | ||||||
|                 amount: $('#stripeButton').data('amount'), |                 amount: $('#stripeButton').data('amount'), | ||||||
|                 currency: $('#stripeButton').data('currency') |                 currency: $('#stripeButton').data('currency'), | ||||||
|  |                 subscription: $('#stripeButton').data('subscription') | ||||||
|             }); |             }); | ||||||
|         } |         } | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							|  | @ -13,6 +13,7 @@ const { | ||||||
|     getPaymentConfig, |     getPaymentConfig, | ||||||
|     getImages, |     getImages, | ||||||
|     updateTotalCartAmount, |     updateTotalCartAmount, | ||||||
|  |     updateSubscriptionCheck, | ||||||
|     getData, |     getData, | ||||||
|     addSitemapProducts |     addSitemapProducts | ||||||
| } = require('../lib/common'); | } = require('../lib/common'); | ||||||
|  | @ -68,6 +69,10 @@ router.get('/payment/:orderId', async (req, res, next) => { | ||||||
|     }); |     }); | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
|  | router.get('/emptycart', async (req, res, next) => { | ||||||
|  |     emptyCart(req, res, ''); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
| router.get('/checkout', async (req, res, next) => { | router.get('/checkout', async (req, res, next) => { | ||||||
|     const config = req.app.config; |     const config = req.app.config; | ||||||
| 
 | 
 | ||||||
|  | @ -105,6 +110,11 @@ router.get('/pay', async (req, res, next) => { | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     let paymentType = ''; | ||||||
|  |     if(req.session.cartSubscription){ | ||||||
|  |         paymentType = '_subscription'; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     // render the payment page
 |     // render the payment page
 | ||||||
|     res.render(`${config.themeViews}pay`, { |     res.render(`${config.themeViews}pay`, { | ||||||
|         title: 'Pay', |         title: 'Pay', | ||||||
|  | @ -113,6 +123,7 @@ router.get('/pay', async (req, res, next) => { | ||||||
|         pageCloseBtn: showCartCloseBtn('pay'), |         pageCloseBtn: showCartCloseBtn('pay'), | ||||||
|         session: req.session, |         session: req.session, | ||||||
|         paymentPage: true, |         paymentPage: true, | ||||||
|  |         paymentType, | ||||||
|         page: 'pay', |         page: 'pay', | ||||||
|         message: clearSessionValue(req.session, 'message'), |         message: clearSessionValue(req.session, 'message'), | ||||||
|         messageType: clearSessionValue(req.session, 'messageType'), |         messageType: clearSessionValue(req.session, 'messageType'), | ||||||
|  | @ -220,6 +231,9 @@ router.post('/product/updatecart', (req, res, next) => { | ||||||
|         // update total cart amount
 |         // update total cart amount
 | ||||||
|         updateTotalCartAmount(req, res); |         updateTotalCartAmount(req, res); | ||||||
| 
 | 
 | ||||||
|  |         // Update checking cart for subscription
 | ||||||
|  |         updateSubscriptionCheck(req, res); | ||||||
|  | 
 | ||||||
|         // Update cart to the DB
 |         // Update cart to the DB
 | ||||||
|         await db.cart.updateOne({ sessionId: req.session.id }, { |         await db.cart.updateOne({ sessionId: req.session.id }, { | ||||||
|             $set: { cart: req.session.cart } |             $set: { cart: req.session.cart } | ||||||
|  | @ -239,36 +253,42 @@ router.post('/product/updatecart', (req, res, next) => { | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| // Remove single product from cart
 | // Remove single product from cart
 | ||||||
| router.post('/product/removefromcart', (req, res, next) => { | router.post('/product/removefromcart', async (req, res, next) => { | ||||||
|     const db = req.app.db; |     const db = req.app.db; | ||||||
|     let itemRemoved = false; |     let itemRemoved = false; | ||||||
| 
 | 
 | ||||||
|     // remove item from cart
 |     // remove item from cart
 | ||||||
|     async.each(req.session.cart, (item, callback) => { |     req.session.cart.forEach((item) => { | ||||||
|         if(item){ |         if(item){ | ||||||
|             if(item.productId === req.body.cartId){ |             if(item.productId === req.body.cartId){ | ||||||
|                 itemRemoved = true; |                 itemRemoved = true; | ||||||
|                 req.session.cart = _.pull(req.session.cart, item); |                 req.session.cart = _.pull(req.session.cart, item); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|         callback(); |  | ||||||
|     }, async () => { |  | ||||||
|         // Update cart in DB
 |  | ||||||
|         await db.cart.updateOne({ sessionId: req.session.id }, { |  | ||||||
|             $set: { cart: req.session.cart } |  | ||||||
|         }); |  | ||||||
|         // update total cart amount
 |  | ||||||
|         updateTotalCartAmount(req, res); |  | ||||||
| 
 |  | ||||||
|         if(itemRemoved === false){ |  | ||||||
|             return res.status(400).json({ message: 'Product not found in cart' }); |  | ||||||
|         } |  | ||||||
|         return res.status(200).json({ message: 'Product successfully removed', totalCartItems: Object.keys(req.session.cart).length }); |  | ||||||
|     }); |     }); | ||||||
|  | 
 | ||||||
|  |     // Update cart in DB
 | ||||||
|  |     await db.cart.updateOne({ sessionId: req.session.id }, { | ||||||
|  |         $set: { cart: req.session.cart } | ||||||
|  |     }); | ||||||
|  |     // update total cart amount
 | ||||||
|  |     updateTotalCartAmount(req, res); | ||||||
|  | 
 | ||||||
|  |     // Update checking cart for subscription
 | ||||||
|  |     updateSubscriptionCheck(req, res); | ||||||
|  | 
 | ||||||
|  |     if(itemRemoved === false){ | ||||||
|  |         return res.status(400).json({ message: 'Product not found in cart' }); | ||||||
|  |     } | ||||||
|  |     return res.status(200).json({ message: 'Product successfully removed', totalCartItems: Object.keys(req.session.cart).length }); | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| // Totally empty the cart
 | // Totally empty the cart
 | ||||||
| router.post('/product/emptycart', async (req, res, next) => { | router.post('/product/emptycart', async (req, res, next) => { | ||||||
|  |     emptyCart(req, res, 'json'); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | const emptyCart = async (req, res, type) => { | ||||||
|     const db = req.app.db; |     const db = req.app.db; | ||||||
| 
 | 
 | ||||||
|     // Remove from session
 |     // Remove from session
 | ||||||
|  | @ -280,8 +300,20 @@ router.post('/product/emptycart', async (req, res, next) => { | ||||||
| 
 | 
 | ||||||
|     // update total cart amount
 |     // update total cart amount
 | ||||||
|     updateTotalCartAmount(req, res); |     updateTotalCartAmount(req, res); | ||||||
|     res.status(200).json({ message: 'Cart successfully emptied', totalCartItems: 0 }); | 
 | ||||||
| }); |     // Update checking cart for subscription
 | ||||||
|  |     updateSubscriptionCheck(req, res); | ||||||
|  | 
 | ||||||
|  |     // If POST, return JSON else redirect nome
 | ||||||
|  |     if(type === 'json'){ | ||||||
|  |         res.status(200).json({ message: 'Cart successfully emptied', totalCartItems: 0 }); | ||||||
|  |         return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     req.session.message = 'Cart successfully emptied.'; | ||||||
|  |     req.session.messageType = 'success'; | ||||||
|  |     res.redirect('/'); | ||||||
|  | }; | ||||||
| 
 | 
 | ||||||
| // Add item to cart
 | // Add item to cart
 | ||||||
| router.post('/product/addtocart', async (req, res, next) => { | router.post('/product/addtocart', async (req, res, next) => { | ||||||
|  | @ -300,13 +332,25 @@ router.post('/product/addtocart', async (req, res, next) => { | ||||||
|         req.session.cart = []; |         req.session.cart = []; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // Get the item from the DB
 |     // Get the product from the DB
 | ||||||
|     const product = await db.products.findOne({ _id: getId(req.body.productId) }); |     const product = await db.products.findOne({ _id: getId(req.body.productId) }); | ||||||
|     // No product found
 |     // No product found
 | ||||||
|     if(!product){ |     if(!product){ | ||||||
|         return res.status(400).json({ message: 'Error updating cart. Please try again.' }); |         return res.status(400).json({ message: 'Error updating cart. Please try again.' }); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     // If cart already has a subscription you cannot add anything else
 | ||||||
|  |     if(req.session.cartSubscription){ | ||||||
|  |         return res.status(400).json({ message: 'Subscription already existing in cart. You cannot add more.' }); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // If existing cart isn't empty check if product is a subscription
 | ||||||
|  |     if(req.session.cart.length !== 0){ | ||||||
|  |         if(product.productSubscription){ | ||||||
|  |             return res.status(400).json({ message: 'You cannot combine scubscription products with existing in your cart. Empty your cart and try again.' }); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     // If stock management on check there is sufficient stock for this product
 |     // If stock management on check there is sufficient stock for this product
 | ||||||
|     if(config.trackStock && product.productStock){ |     if(config.trackStock && product.productStock){ | ||||||
|         const stockHeld = await db.cart.aggregate( |         const stockHeld = await db.cart.aggregate( | ||||||
|  | @ -346,7 +390,13 @@ router.post('/product/addtocart', async (req, res, next) => { | ||||||
|     // Doc used to test if existing in the cart with the options. If not found, we add new.
 |     // Doc used to test if existing in the cart with the options. If not found, we add new.
 | ||||||
|     let options = {}; |     let options = {}; | ||||||
|     if(req.body.productOptions){ |     if(req.body.productOptions){ | ||||||
|         options = JSON.parse(req.body.productOptions); |         try{ | ||||||
|  |             if(typeof req.body.productOptions === 'object'){ | ||||||
|  |                 options = req.body.productOptions; | ||||||
|  |             }else{ | ||||||
|  |                 options = JSON.parse(req.body.productOptions); | ||||||
|  |             } | ||||||
|  |         }catch(ex){} | ||||||
|     } |     } | ||||||
|     const findDoc = { |     const findDoc = { | ||||||
|         productId: req.body.productId, |         productId: req.body.productId, | ||||||
|  | @ -376,6 +426,7 @@ router.post('/product/addtocart', async (req, res, next) => { | ||||||
|         productObj.options = options; |         productObj.options = options; | ||||||
|         productObj.productImage = product.productImage; |         productObj.productImage = product.productImage; | ||||||
|         productObj.productComment = productComment; |         productObj.productComment = productComment; | ||||||
|  |         productObj.productSubscription = product.productSubscription; | ||||||
|         if(product.productPermalink){ |         if(product.productPermalink){ | ||||||
|             productObj.link = product.productPermalink; |             productObj.link = product.productPermalink; | ||||||
|         }else{ |         }else{ | ||||||
|  | @ -394,6 +445,13 @@ router.post('/product/addtocart', async (req, res, next) => { | ||||||
|     // update total cart amount
 |     // update total cart amount
 | ||||||
|     updateTotalCartAmount(req, res); |     updateTotalCartAmount(req, res); | ||||||
| 
 | 
 | ||||||
|  |     // Update checking cart for subscription
 | ||||||
|  |     updateSubscriptionCheck(req, res); | ||||||
|  | 
 | ||||||
|  |     if(product.productSubscription){ | ||||||
|  |         req.session.cartSubscription = product.productSubscription; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     // update how many products in the shopping cart
 |     // update how many products in the shopping cart
 | ||||||
|     req.session.cartTotalItems = req.session.cart.reduce((a, b) => +a + +b.quantity, 0); |     req.session.cartTotalItems = req.session.cart.reduce((a, b) => +a + +b.quantity, 0); | ||||||
|     return res.status(200).json({ message: 'Cart successfully updated', totalCartItems: req.session.cartTotalItems }); |     return res.status(200).json({ message: 'Cart successfully updated', totalCartItems: req.session.cartTotalItems }); | ||||||
|  |  | ||||||
|  | @ -52,7 +52,8 @@ router.post('/checkout_action', (req, res, next) => { | ||||||
|             orderComment: req.body.orderComment, |             orderComment: req.body.orderComment, | ||||||
|             orderStatus: paymentStatus, |             orderStatus: paymentStatus, | ||||||
|             orderDate: new Date(), |             orderDate: new Date(), | ||||||
|             orderProducts: req.session.cart |             orderProducts: req.session.cart, | ||||||
|  |             orderType: 'Single' | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         // insert order into DB
 |         // insert order into DB
 | ||||||
|  | @ -111,4 +112,149 @@ router.post('/checkout_action', (req, res, next) => { | ||||||
|     }); |     }); | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
|  | // Subscription hook from Stripe
 | ||||||
|  | router.all('/subscription_update', async (req, res, next) => { | ||||||
|  |     const db = req.app.db; | ||||||
|  | 
 | ||||||
|  |     if(!req.body.data.object.customer){ | ||||||
|  |         return res.status(400).json({ message: 'Customer not found' }); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const order = await db.orders.findOne({ | ||||||
|  |         orderCustomer: req.body.data.object.customer, | ||||||
|  |         orderType: 'Subscription' | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     if(!order){ | ||||||
|  |         return res.status(400).json({ message: 'Order not found' }); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     let orderStatus = 'Paid'; | ||||||
|  |     if(req.body.type === 'invoice.payment_failed'){ | ||||||
|  |         orderStatus = 'Declined'; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // Update order status
 | ||||||
|  |     await db.orders.updateOne({ | ||||||
|  |         _id: common.getId(order._id), | ||||||
|  |         orderType: 'Subscription' | ||||||
|  |     }, { | ||||||
|  |         $set: { | ||||||
|  |             orderStatus: orderStatus | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     return res.status(200).json({ message: 'Status successfully updated' }); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | router.post('/checkout_action_subscription', async (req, res, next) => { | ||||||
|  |     const db = req.app.db; | ||||||
|  |     const config = req.app.config; | ||||||
|  | 
 | ||||||
|  |     try{ | ||||||
|  |         const plan = await stripe.plans.retrieve(req.body.stripePlan); | ||||||
|  |         if(!plan){ | ||||||
|  |             req.session.messageType = 'danger'; | ||||||
|  |             req.session.message = 'The plan connected to this product doesn\'t exist'; | ||||||
|  |             res.redirect('/pay/'); | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |     }catch(ex){ | ||||||
|  |         req.session.messageType = 'danger'; | ||||||
|  |         req.session.message = 'The plan connected to this product doesn\'t exist'; | ||||||
|  |         res.redirect('/pay/'); | ||||||
|  |         return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // Create customer
 | ||||||
|  |     const customer = await stripe.customers.create({ | ||||||
|  |         source: req.body.stripeToken, | ||||||
|  |         plan: req.body.stripePlan, | ||||||
|  |         email: req.body.shipEmail, | ||||||
|  |         name: `${req.body.shipFirstname} ${req.body.shipLastname}`, | ||||||
|  |         phone: req.body.shipPhoneNumber | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     if(!customer){ | ||||||
|  |         req.session.messageType = 'danger'; | ||||||
|  |         req.session.message = 'Your subscripton has declined. Please try again'; | ||||||
|  |         req.session.paymentApproved = false; | ||||||
|  |         req.session.paymentDetails = ''; | ||||||
|  |         res.redirect('/pay'); | ||||||
|  |         return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // Check for a subscription
 | ||||||
|  |     if(customer.subscriptions.data && customer.subscriptions.data.length === 0){ | ||||||
|  |         req.session.messageType = 'danger'; | ||||||
|  |         req.session.message = 'Your subscripton has declined. Please try again'; | ||||||
|  |         req.session.paymentApproved = false; | ||||||
|  |         req.session.paymentDetails = ''; | ||||||
|  |         res.redirect('/pay'); | ||||||
|  |         return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const subscription = customer.subscriptions.data[0]; | ||||||
|  | 
 | ||||||
|  |     // Create the new order document
 | ||||||
|  |     const orderDoc = { | ||||||
|  |         orderPaymentId: subscription.id, | ||||||
|  |         orderPaymentGateway: 'Stripe', | ||||||
|  |         orderPaymentMessage: subscription.collection_method, | ||||||
|  |         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: 'Pending', | ||||||
|  |         orderDate: new Date(), | ||||||
|  |         orderProducts: req.session.cart, | ||||||
|  |         orderType: 'Subscription', | ||||||
|  |         orderCustomer: customer.id | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     // insert order into DB
 | ||||||
|  |     const order = await db.orders.insertOne(orderDoc); | ||||||
|  |     const orderId = order.insertedId; | ||||||
|  | 
 | ||||||
|  |     indexOrders(req.app) | ||||||
|  |     .then(() => { | ||||||
|  |         // set the results
 | ||||||
|  |         req.session.messageType = 'success'; | ||||||
|  |         req.session.message = 'Your subscription was successfully created'; | ||||||
|  |         req.session.paymentEmailAddr = req.body.shipEmail; | ||||||
|  |         req.session.paymentApproved = true; | ||||||
|  |         req.session.paymentDetails = '<p><strong>Order ID: </strong>' + orderId + '</p><p><strong>Subscription ID: </strong>' + subscription.id + '</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.cartSubscription = null; | ||||||
|  |             req.session.cart = null; | ||||||
|  |             req.session.orderId = null; | ||||||
|  |             req.session.totalCartAmount = 0; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // send the email with the response
 | ||||||
|  |         common.sendEmail(req.session.paymentEmailAddr, 'Your payment with ' + config.cartTitle, common.getEmailTemplate(paymentResults)); | ||||||
|  | 
 | ||||||
|  |         // redirect to outcome
 | ||||||
|  |         res.redirect('/payment/' + orderId); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
| module.exports = router; | module.exports = router; | ||||||
|  |  | ||||||
							
								
								
									
										47
									
								
								test/test.js
								
								
								
								
							
							
						
						
									
										47
									
								
								test/test.js
								
								
								
								
							|  | @ -141,6 +141,41 @@ test.serial('[Success] Customer login with correct email', async t => { | ||||||
|     t.deepEqual(res.body.message, 'Successfully logged in'); |     t.deepEqual(res.body.message, 'Successfully logged in'); | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
|  | test.serial('[Success] Add subscripton product to cart', async t => { | ||||||
|  |     const res = await request | ||||||
|  |         .post('/product/addtocart') | ||||||
|  |         .send({ | ||||||
|  |             productId: products[7]._id, | ||||||
|  |             productQuantity: 1, | ||||||
|  |             productOptions: {} | ||||||
|  |         }) | ||||||
|  |         .expect(200); | ||||||
|  |     const sessions = await db.cart.find({}).toArray(); | ||||||
|  |     if(!sessions || sessions.length === 0){ | ||||||
|  |         t.fail(); | ||||||
|  |     } | ||||||
|  |     t.deepEqual(res.body.message, 'Cart successfully updated'); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test.serial('[Fail] Add product to cart when subscription already added', async t => { | ||||||
|  |     const res = await request | ||||||
|  |         .post('/product/addtocart') | ||||||
|  |         .send({ | ||||||
|  |             productId: products[1]._id, | ||||||
|  |             productQuantity: 100, | ||||||
|  |             productOptions: JSON.stringify(products[1].productOptions) | ||||||
|  |         }) | ||||||
|  |         .expect(400); | ||||||
|  |     t.deepEqual(res.body.message, 'Subscription already existing in cart. You cannot add more.'); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test.serial('[Success] Empty cart', async t => { | ||||||
|  |     const res = await request | ||||||
|  |         .post('/product/emptycart') | ||||||
|  |         .expect(200); | ||||||
|  |     t.deepEqual(res.body.message, 'Cart successfully emptied'); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
| test.serial('[Success] Add product to cart', async t => { | test.serial('[Success] Add product to cart', async t => { | ||||||
|     const res = await request |     const res = await request | ||||||
|         .post('/product/addtocart') |         .post('/product/addtocart') | ||||||
|  | @ -157,6 +192,18 @@ test.serial('[Success] Add product to cart', async t => { | ||||||
|     t.deepEqual(res.body.message, 'Cart successfully updated'); |     t.deepEqual(res.body.message, 'Cart successfully updated'); | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
|  | test.serial('[Fail] Cannot add subscripton when other product in cart', async t => { | ||||||
|  |     const res = await request | ||||||
|  |         .post('/product/addtocart') | ||||||
|  |         .send({ | ||||||
|  |             productId: products[7]._id, | ||||||
|  |             productQuantity: 1, | ||||||
|  |             productOptions: {} | ||||||
|  |         }) | ||||||
|  |         .expect(400); | ||||||
|  |     t.deepEqual(res.body.message, 'You cannot combine scubscription products with existing in your cart. Empty your cart and try again.'); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
| test.serial('[Fail] Add product to cart with not enough stock', async t => { | test.serial('[Fail] Add product to cart with not enough stock', async t => { | ||||||
|     const res = await request |     const res = await request | ||||||
|         .post('/product/addtocart') |         .post('/product/addtocart') | ||||||
|  |  | ||||||
|  | @ -16,6 +16,7 @@ | ||||||
|                 <div class="pull-right col-md-2"> |                 <div class="pull-right col-md-2"> | ||||||
|                     <select class="form-control input-sm" id="orderStatus"> |                     <select class="form-control input-sm" id="orderStatus"> | ||||||
|                         <option>{{ @root.__ "Completed" }}</option> |                         <option>{{ @root.__ "Completed" }}</option> | ||||||
|  |                         <option>{{ @root.__ "Paid" }}</option> | ||||||
|                         <option>{{ @root.__ "Pending" }}</option> |                         <option>{{ @root.__ "Pending" }}</option> | ||||||
|                         <option>{{ @root.__ "Cancelled" }}</option> |                         <option>{{ @root.__ "Cancelled" }}</option> | ||||||
|                         <option>{{ @root.__ "Declined" }}</option> |                         <option>{{ @root.__ "Declined" }}</option> | ||||||
|  | @ -37,6 +38,7 @@ | ||||||
|             <li class="list-group-item"><strong> {{ @root.__ "State" }}: </strong><span class="pull-right">{{result.orderState}}</span></li> |             <li class="list-group-item"><strong> {{ @root.__ "State" }}: </strong><span class="pull-right">{{result.orderState}}</span></li> | ||||||
|             <li class="list-group-item"><strong> {{ @root.__ "Postcode" }}: </strong><span class="pull-right">{{result.orderPostcode}}</span></li> |             <li class="list-group-item"><strong> {{ @root.__ "Postcode" }}: </strong><span class="pull-right">{{result.orderPostcode}}</span></li> | ||||||
|             <li class="list-group-item"><strong> {{ @root.__ "Phone number" }}: </strong><span class="pull-right">{{result.orderPhoneNumber}}</span></li> |             <li class="list-group-item"><strong> {{ @root.__ "Phone number" }}: </strong><span class="pull-right">{{result.orderPhoneNumber}}</span></li> | ||||||
|  |             <li class="list-group-item"><strong> {{ @root.__ "Order type" }}: </strong><span class="pull-right">{{result.orderType}}</span></li> | ||||||
|             <li class="list-group-item"><strong> {{ @root.__ "Order comment" }}: </strong><span class="pull-right">{{result.orderComment}}</span></li> |             <li class="list-group-item"><strong> {{ @root.__ "Order comment" }}: </strong><span class="pull-right">{{result.orderComment}}</span></li> | ||||||
| 
 | 
 | ||||||
|             <li class="list-group-item"> </li> |             <li class="list-group-item"> </li> | ||||||
|  | @ -57,7 +59,7 @@ | ||||||
|                         {{/each}} |                         {{/each}} | ||||||
|                         ) |                         ) | ||||||
|                     {{/if}} |                     {{/if}} | ||||||
|                     <div class="pull-right">{{currencySymbol config.currencySymbol}}{{formatAmount this.totalItemPrice}}</div> |                     <div class="pull-right">{{currencySymbol @root.config.currencySymbol}}{{formatAmount this.totalItemPrice}}</div> | ||||||
|                     {{#if productComment}} |                     {{#if productComment}} | ||||||
|                     <h4><span class="text-danger">Comment:</span> {{this.productComment}}</h4> |                     <h4><span class="text-danger">Comment:</span> {{this.productComment}}</h4> | ||||||
|                     {{/if}} |                     {{/if}} | ||||||
|  |  | ||||||
|  | @ -12,6 +12,9 @@ | ||||||
|             data-description="{{@root.config.cartTitle}} Payment" |             data-description="{{@root.config.cartTitle}} Payment" | ||||||
|             data-image="{{@root.paymentConfig.stripeLogoURL}}" |             data-image="{{@root.paymentConfig.stripeLogoURL}}" | ||||||
|             data-email="{{@root.session.customer.email}}" |             data-email="{{@root.session.customer.email}}" | ||||||
|  |             {{#if @root.session.cartSubscription}} | ||||||
|  |             data-subscription="{{@root.session.cartSubscription}}" | ||||||
|  |             {{/if}} | ||||||
|             data-locale="auto" |             data-locale="auto" | ||||||
|             data-zip-code="false" |             data-zip-code="false" | ||||||
|             data-currency="{{@root.paymentConfig.stripeCurrency}}"> |             data-currency="{{@root.paymentConfig.stripeCurrency}}"> | ||||||
|  |  | ||||||
|  | @ -107,6 +107,15 @@ | ||||||
|                     <p class="help-block">{{ @root.__ "Here you can set options for your product. Eg: Size, color, style" }}</p> |                     <p class="help-block">{{ @root.__ "Here you can set options for your product. Eg: Size, color, style" }}</p> | ||||||
|                 </div> |                 </div> | ||||||
|             </div> |             </div> | ||||||
|  |             {{#ifCond config.paymentGateway '==' 'stripe'}} | ||||||
|  |             <div class="form-group"> | ||||||
|  |                 <label class="col-sm-2 control-label">Subscription plan</label> | ||||||
|  |                 <div class="col-sm-10"> | ||||||
|  |                     <input type="text" class="form-control" name="productSubscription" id="productSubscription" placeholder="plan_XXXXXXXXXXXXXX" value={{@root.result.productSubscription}}> | ||||||
|  |                     <p class="help-block">First setup the plan in <strong>Stripe</strong> dashboard and enter the Plan ID. Format: plan_XXXXXXXXXXXXXX</p> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |             {{/ifCond}} | ||||||
|             <div class="form-group"> |             <div class="form-group"> | ||||||
|                 <label for="productComment" class="col-sm-2 control-label">{{ @root.__ "Allow comment" }}</label> |                 <label for="productComment" class="col-sm-2 control-label">{{ @root.__ "Allow comment" }}</label> | ||||||
|                 <div class="col-sm-10"> |                 <div class="col-sm-10"> | ||||||
|  |  | ||||||
|  | @ -106,6 +106,15 @@ | ||||||
|                     <p class="help-block">{{ @root.__ "Here you can set options for your product. Eg: Size, color, style" }}</p> |                     <p class="help-block">{{ @root.__ "Here you can set options for your product. Eg: Size, color, style" }}</p> | ||||||
|                 </div> |                 </div> | ||||||
|             </div> |             </div> | ||||||
|  |             {{#ifCond config.paymentGateway '==' 'stripe'}} | ||||||
|  |             <div class="form-group"> | ||||||
|  |                 <label class="col-sm-2 control-label">Subscription plan</label> | ||||||
|  |                 <div class="col-sm-10"> | ||||||
|  |                     <input type="text" class="form-control" name="productSubscription" id="productSubscription" placeholder="plan_XXXXXXXXXXXXXX"> | ||||||
|  |                     <p class="help-block">First setup the plan in <strong>Stripe</strong> dashboard and enter the Plan ID. Format: plan_XXXXXXXXXXXXXX</p> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |             {{/ifCond}} | ||||||
|             <div class="form-group"> |             <div class="form-group"> | ||||||
|                 <label for="productComment" class="col-sm-2 control-label">{{ @root.__ "Allow comment" }}</label> |                 <label for="productComment" class="col-sm-2 control-label">{{ @root.__ "Allow comment" }}</label> | ||||||
|                 <div class="col-sm-10"> |                 <div class="col-sm-10"> | ||||||
|  |  | ||||||
|  | @ -36,7 +36,7 @@ | ||||||
|                         <button id="customerLogout" class="btn btn-sm btn-success pull-right">{{ @root.__ "Change customer" }}</button> |                         <button id="customerLogout" class="btn btn-sm btn-success pull-right">{{ @root.__ "Change customer" }}</button> | ||||||
|                     </div> |                     </div> | ||||||
|                     {{/if}} |                     {{/if}} | ||||||
|                     <form id="shipping-form" class="shipping-form" action="/{{config.paymentGateway}}/checkout_action" method="post" role="form" data-toggle="validator" novalidate="false"> |                     <form id="shipping-form" class="shipping-form" action="/{{config.paymentGateway}}/checkout_action{{@root.paymentType}}" method="post" role="form" data-toggle="validator" novalidate="false"> | ||||||
|                         {{> partials/payments/shipping-form}} |                         {{> partials/payments/shipping-form}} | ||||||
|                         {{#if session.customer}} |                         {{#if session.customer}} | ||||||
|                         {{#ifCond config.paymentGateway '==' 'paypal'}} |                         {{#ifCond config.paymentGateway '==' 'paypal'}} | ||||||
|  |  | ||||||
|  | @ -28,7 +28,7 @@ | ||||||
|                         <button id="customerLogout" class="btn waves-effect waves-light blue darken-3 pull-right">{{ @root.__ "Change customer" }}</button> |                         <button id="customerLogout" class="btn waves-effect waves-light blue darken-3 pull-right">{{ @root.__ "Change customer" }}</button> | ||||||
|                     </div> |                     </div> | ||||||
|                     {{/if}} |                     {{/if}} | ||||||
|                     <form id="shipping-form" class="shipping-form" action="/{{config.paymentGateway}}/checkout_action" method="post" role="form" data-toggle="validator" novalidate="false"> |                     <form id="shipping-form" class="shipping-form" action="/{{config.paymentGateway}}/checkout_action{{@root.paymentType}}" method="post" role="form" data-toggle="validator" novalidate="false"> | ||||||
|                         {{> themes/Material/shipping-form}} |                         {{> themes/Material/shipping-form}} | ||||||
|                         {{#if session.customer}} |                         {{#if session.customer}} | ||||||
|                         {{#ifCond config.paymentGateway '==' 'paypal'}} |                         {{#ifCond config.paymentGateway '==' 'paypal'}} | ||||||
|  |  | ||||||
|  | @ -28,7 +28,7 @@ | ||||||
|                         <button id="customerLogout" class="btn waves-effect waves-light black pull-right">{{ @root.__ "Change customer" }}</button> |                         <button id="customerLogout" class="btn waves-effect waves-light black pull-right">{{ @root.__ "Change customer" }}</button> | ||||||
|                     </div> |                     </div> | ||||||
|                     {{/if}} |                     {{/if}} | ||||||
|                     <form id="shipping-form" class="shipping-form" action="/{{config.paymentGateway}}/checkout_action" method="post" role="form" data-toggle="validator" novalidate="false"> |                     <form id="shipping-form" class="shipping-form" action="/{{config.paymentGateway}}/checkout_action{{@root.paymentType}}" method="post" role="form" data-toggle="validator" novalidate="false"> | ||||||
|                         {{> themes/Mono/shipping-form}} |                         {{> themes/Mono/shipping-form}} | ||||||
|                         {{#if session.customer}} |                         {{#if session.customer}} | ||||||
|                         {{#ifCond config.paymentGateway '==' 'paypal'}} |                         {{#ifCond config.paymentGateway '==' 'paypal'}} | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue