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. 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:

View File

@ -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": [

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) => { 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,

View File

@ -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"
}

View File

@ -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

View File

@ -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 });

View File

@ -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;

View File

@ -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')

View File

@ -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">&nbsp;</li> <li class="list-group-item">&nbsp;</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}}

View File

@ -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}}">

View File

@ -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">

View File

@ -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">

View File

@ -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'}}
@ -68,4 +68,4 @@
{{> (getTheme 'cart')}} {{> (getTheme 'cart')}}
</div> </div>
</div> </div>
</div> </div>

View File

@ -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'}}

View File

@ -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'}}