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 update
master
Mark Moffat 2019-11-06 21:10:27 +10:30 committed by GitHub
parent c92194dd56
commit a87d2fbf0a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 343 additions and 29 deletions

View File

@ -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.
## 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
`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:

View File

@ -69,6 +69,17 @@
"productTags" : "shirt",
"productOptions" : "{\"Size\":{\"optName\":\"Size\",\"optLabel\":\"Select size\",\"optType\":\"select\",\"optOptions\":[\"S\",\"M\",\"L\"]}}",
"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": [

View File

@ -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) => {
try{
fs.statSync(directory);
@ -551,6 +567,7 @@ module.exports = {
addSitemapProducts,
clearSessionValue,
updateTotalCartAmount,
updateSubscriptionCheck,
checkDirectorySync,
getThemes,
getImages,

View File

@ -160,5 +160,6 @@
"Cart contents": "Cart contents",
"Shipping": "Shipping:",
"Empty cart": "Empty cart",
"List": "List"
"List": "List",
"Order type": "Order type"
}

View File

@ -289,6 +289,9 @@ $(document).ready(function (){
image: $('#stripeButton').data('image'),
locale: 'auto',
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').submit();
}
@ -301,7 +304,8 @@ $(document).ready(function (){
description: $('#stripeButton').data('description'),
zipCode: $('#stripeButton').data('zipCode'),
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

View File

@ -13,6 +13,7 @@ const {
getPaymentConfig,
getImages,
updateTotalCartAmount,
updateSubscriptionCheck,
getData,
addSitemapProducts
} = 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) => {
const config = req.app.config;
@ -105,6 +110,11 @@ router.get('/pay', async (req, res, next) => {
return;
}
let paymentType = '';
if(req.session.cartSubscription){
paymentType = '_subscription';
}
// render the payment page
res.render(`${config.themeViews}pay`, {
title: 'Pay',
@ -113,6 +123,7 @@ router.get('/pay', async (req, res, next) => {
pageCloseBtn: showCartCloseBtn('pay'),
session: req.session,
paymentPage: true,
paymentType,
page: 'pay',
message: clearSessionValue(req.session, 'message'),
messageType: clearSessionValue(req.session, 'messageType'),
@ -220,6 +231,9 @@ router.post('/product/updatecart', (req, res, next) => {
// update total cart amount
updateTotalCartAmount(req, res);
// Update checking cart for subscription
updateSubscriptionCheck(req, res);
// Update cart to the DB
await db.cart.updateOne({ sessionId: req.session.id }, {
$set: { cart: req.session.cart }
@ -239,20 +253,20 @@ router.post('/product/updatecart', (req, res, next) => {
});
// 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;
let itemRemoved = false;
// remove item from cart
async.each(req.session.cart, (item, callback) => {
req.session.cart.forEach((item) => {
if(item){
if(item.productId === req.body.cartId){
itemRemoved = true;
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 }
@ -260,15 +274,21 @@ router.post('/product/removefromcart', (req, res, next) => {
// 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
router.post('/product/emptycart', async (req, res, next) => {
emptyCart(req, res, 'json');
});
const emptyCart = async (req, res, type) => {
const db = req.app.db;
// Remove from session
@ -280,8 +300,20 @@ router.post('/product/emptycart', async (req, res, next) => {
// update total cart amount
updateTotalCartAmount(req, res);
// 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
router.post('/product/addtocart', async (req, res, next) => {
@ -300,13 +332,25 @@ router.post('/product/addtocart', async (req, res, next) => {
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) });
// No product found
if(!product){
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(config.trackStock && product.productStock){
const stockHeld = await db.cart.aggregate(
@ -346,8 +390,14 @@ 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.
let options = {};
if(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 = {
productId: req.body.productId,
options: options
@ -376,6 +426,7 @@ router.post('/product/addtocart', async (req, res, next) => {
productObj.options = options;
productObj.productImage = product.productImage;
productObj.productComment = productComment;
productObj.productSubscription = product.productSubscription;
if(product.productPermalink){
productObj.link = product.productPermalink;
}else{
@ -394,6 +445,13 @@ router.post('/product/addtocart', async (req, res, next) => {
// update total cart amount
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
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 });

View File

@ -52,7 +52,8 @@ router.post('/checkout_action', (req, res, next) => {
orderComment: req.body.orderComment,
orderStatus: paymentStatus,
orderDate: new Date(),
orderProducts: req.session.cart
orderProducts: req.session.cart,
orderType: 'Single'
};
// 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;

View File

@ -141,6 +141,41 @@ test.serial('[Success] Customer login with correct email', async t => {
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 => {
const res = await request
.post('/product/addtocart')
@ -157,6 +192,18 @@ test.serial('[Success] Add product to cart', async t => {
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 => {
const res = await request
.post('/product/addtocart')

View File

@ -16,6 +16,7 @@
<div class="pull-right col-md-2">
<select class="form-control input-sm" id="orderStatus">
<option>{{ @root.__ "Completed" }}</option>
<option>{{ @root.__ "Paid" }}</option>
<option>{{ @root.__ "Pending" }}</option>
<option>{{ @root.__ "Cancelled" }}</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.__ "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.__ "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">&nbsp;</li>
@ -57,7 +59,7 @@
{{/each}}
)
{{/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}}
<h4><span class="text-danger">Comment:</span> {{this.productComment}}</h4>
{{/if}}

View File

@ -12,6 +12,9 @@
data-description="{{@root.config.cartTitle}} Payment"
data-image="{{@root.paymentConfig.stripeLogoURL}}"
data-email="{{@root.session.customer.email}}"
{{#if @root.session.cartSubscription}}
data-subscription="{{@root.session.cartSubscription}}"
{{/if}}
data-locale="auto"
data-zip-code="false"
data-currency="{{@root.paymentConfig.stripeCurrency}}">

View File

@ -107,6 +107,15 @@
<p class="help-block">{{ @root.__ "Here you can set options for your product. Eg: Size, color, style" }}</p>
</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">
<label for="productComment" class="col-sm-2 control-label">{{ @root.__ "Allow comment" }}</label>
<div class="col-sm-10">

View File

@ -106,6 +106,15 @@
<p class="help-block">{{ @root.__ "Here you can set options for your product. Eg: Size, color, style" }}</p>
</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">
<label for="productComment" class="col-sm-2 control-label">{{ @root.__ "Allow comment" }}</label>
<div class="col-sm-10">

View File

@ -36,7 +36,7 @@
<button id="customerLogout" class="btn btn-sm btn-success pull-right">{{ @root.__ "Change customer" }}</button>
</div>
{{/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}}
{{#if session.customer}}
{{#ifCond config.paymentGateway '==' 'paypal'}}

View File

@ -28,7 +28,7 @@
<button id="customerLogout" class="btn waves-effect waves-light blue darken-3 pull-right">{{ @root.__ "Change customer" }}</button>
</div>
{{/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}}
{{#if session.customer}}
{{#ifCond config.paymentGateway '==' 'paypal'}}

View File

@ -28,7 +28,7 @@
<button id="customerLogout" class="btn waves-effect waves-light black pull-right">{{ @root.__ "Change customer" }}</button>
</div>
{{/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}}
{{#if session.customer}}
{{#ifCond config.paymentGateway '==' 'paypal'}}