This post is going to be slightly more directed towards creating stable applications rather than showing off an experiment. It's slightly "closed-source" rather than "open-source". I've been developing a small eCommerce platform lately. This is a highly economical approach and covers just the basics of the web shop needs. We'll cover:
- How to set a product for sale / pricing
- How to make a shopping cart
- A way to process payment
- A way to send a receipt
There was no requirement for shipping costs in this application so I won't be covering that. I also won't process different currencies and our default is for the norwegian market.
Technology
We have an underlying platform that we'll be using as a base. Umbraco is a free CMS system that is based on the .NET platform. This is a really solid CMS and has a solid and active community behind it. The rest of the stack will rely on SaaS (software as a service) of various kinds. This saves development time in most cases and moves my focus towards developing the cases for the business and not the services itself.
Used technology:
- Umbraco v7.4.1
- Stripe for payment processing
- SendGrid for sending receipts
- Azure for cloud hosting
- Vue.js & ES6 for frontend stuff
Adding a product
We need two extra fields in the product document type in Umbraco. I added "For Sale" and "Price". The "For Sale" field is a checkbox that indicate that a product is listed for sale in our platform. "Price" is the price for the product in the norwegian currency, NOK. We have no requirements for commas in the field, so we parse this value as an integer for now.
In the frontend, I've made a a little post earlier on the usage of vue.js in modules. This makes a lot of the cart-logic and checkout a lot easier. Lets look at the add to cart module:
//productbutton.html
<div class="sale-title">
{{translate.purchasenow}}
</div>
<div class="sales">
<div class="top">
<span class="price"><strong>{{translate.price}}</strong>: {{price}} NOK</span><br />
<strong>{{translate.quantity}}</strong>: <select v-model="quantity">
<option selected>1</option>
<option>2</option>
<option>3</option>
<option>4</option>
<option>5</option>
<option>6</option>
<option>7</option>
<option>8</option>
<option>9</option>
<option>10</option>
</select>
</div>
<a v-bind:href="checkoutlink" target="_blank" type="button" class="btn btn-primary" v-if="added">{{translate.proceedtocheckout}}</a>
<button type="button" class="btn btn-success" v-else v-on:click="addToCart">{{translate.addtocart}}</button>
</div>
//productbutton.js
import Vue from 'vue';
import C from 'lodash/collection';
import O from 'lodash/object';
import Template from '../templates/productbutton.html';
import Emitter from '../common/emitter.js';
import Store from '../common/store.js';
let view = {};
let model = {
added: false,
quantity: 1,
store: new Store(),
emitter: Emitter
};
const initialize = (data) => {
const options = JSON.parse(data.options);
O.assign(model, options);
const cart = model.store.read('cart');
const alreadyincart = C.find(cart, { 'productid': model.productid });
if(alreadyincart){
model.added = true;
}
view = new Vue({
el: data.el,
replace: false,
template: Template,
data: model,
methods: {
addToCart() {
this.added = true;
const productmodel = {
productid: this.productid,
productweight: this.productweight,
price: this.price,
producttitle: this.producttitle,
image: this.image,
url: this.url,
quantity: this.quantity
};
this.store.append('cart', productmodel);
this.emitter.emit('addedtocart', productmodel);
}
}
});
}
export { initialize }
This is the module that takes the umbraco product and translates it for our javascript. We pass along some data as options and we shove them in the module! When we add the product, we send them to localstorage and to a cart counter on the top of the page.
Lets look at the checkout module:
//checkout.html
<div v-if="products.length">
<div class="col-sm-7">
<form v-if="wizardstate === 1" v-on:submit.prevent="setPersonalInformation">
<h2>
{{translate.personalinformation}}
<small>(1/3)</small>
</h2>
<fieldset class="form-group">
<label for="name">{{translate.fullname}}</label>
<input type="text" class="form-control" id="name" v-model="name" v-bind:placeholder="translate.fullname" required>
</fieldset>
<fieldset class="form-group">
<label for="email">{{translate.emailaddress}}</label>
<input type="email" class="form-control" id="email" v-model="email" placeholder="[email protected]" required>
</fieldset>
<fieldset class="form-group">
<label for="phonenumber">{{translate.phonenumber}}</label>
<input type="text" class="form-control" id="phonenumber" v-model="phonenumber" placeholder="+47 000 00 000" required>
</fieldset>
<fieldset class="form-group">
<label for="description">{{translate.description}}</label>
<textarea class="form-control" id="description" v-model="description" v-bind:placeholder="translate.description"></textarea>
</fieldset>
<fieldset class="form-group">
<label for="country">{{translate.country}}</label>
<select class="form-control" id="country" v-model="country">
...some countries
</select>
</fieldset>
<fieldset class="form-group">
<label for="address">{{translate.shippingaddress}}</label>
<input type="text" class="form-control" id="address" v-model="address" v-bind:placeholder="translate.shippingaddress" required>
</fieldset>
<fieldset class="form-group">
<label for="zipcode">{{translate.zipcode}}</label>
<input type="text" class="form-control" id="zipcode" v-model="zipcode" v-bind:placeholder="translate.zipcode" required>
</fieldset>
<fieldset class="form-group">
<label for="city">{{translate.city}}</label>
<input type="text" class="form-control" id="city" v-model="city" v-bind:placeholder="translate.city" required>
</fieldset>
<button class="btn btn-default pull-right">{{translate.next}}</button>
</form>
<form v-if="wizardstate === 2" v-on:submit.prevent="setCreditCard">
<h2>
{{translate.paymentinformation}}
<small>(2/3)</small>
</h2>
<fieldset class="form-group">
<label for="number">{{translate.cardnumber}}</label>
<input type="text" class="form-control" id="number" v-model="number" v-bind:placeholder="translate.cardnumber" pattern="[0-9]*">
</fieldset>
<fieldset class="form-group">
<label for="cvc">CVC</label>
<input type="text" class="form-control" id="cvc" v-model="cvc" placeholder="CVC">
</fieldset>
<fieldset class="form-group">
<label for="month">{{translate.expirationmonth}}</label>
<select class="form-control" id="month" v-model="month">
<option value="01" selected>01</option>
<option value="02">02</option>
<option value="03">03</option>
<option value="04">04</option>
<option value="05">05</option>
<option value="06">06</option>
<option value="07">07</option>
<option value="08">08</option>
<option value="09">09</option>
<option value="10">10</option>
<option value="11">11</option>
<option value="12">12</option>
</select>
</fieldset>
<fieldset class="form-group">
<label for="year">{{translate.expirationyear}}</label>
<select class="form-control" id="year" v-model="year">
<option value="2016" selected>2016</option>
<option value="2017">2017</option>
<option value="2018">2018</option>
<option value="2019">2019</option>
<option value="2020">2020</option>
<option value="2021">2021</option>
<option value="2022">2022</option>
<option value="2023">2023</option>
<option value="2024">2024</option>
<option value="2025">2025</option>
<option value="2026">2026</option>
<option value="2027">2027</option>
<option value="2028">2028</option>
</select>
</fieldset>
<div class="btn btn-default pull-left" v-on:click="goBack">{{translate.goback}}</div>
<button class="btn btn-default pull-right">{{translate.next}}</button>
</form>
<form v-if="wizardstate === 3" v-on:submit.prevent="confirmPurchase">
<h2>
{{translate.confirmpurchase}}
<small>(3/3)</small>
</h2>
<blockquote>
<p>
{{{translate.confirmpurchaseterms}}}
</p>
</blockquote>
<p>
{{translate.confirmpurchasereceipt}}
</p>
<!--
<fieldset class="form-group">
<label for="newsletter">Do you want to subscribe to the Hydema Syd newsletter on email?</label>
<input type="checkbox" id="newsletter" v-model="newsletter">
</fieldset>
-->
<button class="btn btn-success btn-lg btn-block" v-bind:disabled="orderplaced">{{translate.confirmpurchase}}</button>
<br />
<div class="btn btn-default pull-left" v-on:click="goBack">{{translate.goback}}</div>
</form>
<div v-if="wizardstate === 4">
<h2>
{{translate.congratulations}}!
</h2>
<p>
{{translate.congratulationsdescription}}
</p>
</div>
</div>
<div class="col-sm-5">
<h3>{{translate.yourcart}}</h3>
<ul class="cart">
<li v-for="product in products">
<div class="cart-product row">
<div class="col-xs-3">
<div class="circular" v-bind:style="{ backgroundImage: 'url(' + product.image + ')' }">
<img v-bind:src="product.image" />
</div>
</div>
<div class="product-information col-xs-9">
<div>
<a v-bind:href="product.url">{{ product.producttitle }}</a>
</div>
<div>{{translate.price}}: {{ product.price }} NOK</div>
<div>{{translate.quantity}}: {{ product.quantity }}</div>
</div>
</div>
</li>
<li class="calculated-value">
<div class="col-xs-3">
<span class="total-description">Total</span>
</div>
<div class="col-xs-9">
<div>
<span class="total-value">{{calculatedValue}} <small>NOK</small></span> + Shipping
</div>
<div class="shipping">
{{translate.shippingdescription}}
</div>
</div>
</li>
</ul>
</div>
</div>
<div class="row" v-else>
<div class="col-xs-12">
<h2>{{translate.noproductsincart}}!</h2>
<p>{{translate.noproductsincartdescription}}!</p>
</div>
</div>
//checkout.js
import Vue from 'vue';
import Reqwest from 'reqwest';
import _ from 'lodash/object';
import Template from '../templates/checkout.html';
import Emitter from '../common/emitter.js';
import Store from '../common/store.js';
let view = {};
let model = {
name: "",
description: "",
email: "",
phonenumber: "",
address: "",
country: "",
zipcode: "",
city: "",
number: "",
cvc: "",
month: "",
year: "",
newsletter: false,
wizardstate: 1,
token: "",
orderplaced: false,
products: [],
store: new Store(),
emitter: Emitter
};
const initialize = (data) => {
const options = JSON.parse(data.options);
_.assign(model, options);
const cart = model.store.read('cart');
if(cart){
model.products = cart;
}
view = new Vue({
el: data.el,
replace: false,
template: Template,
data: model,
methods: {
setPersonalInformation(){
this.wizardstate++;
},
setCreditCard(){
let self = this;
Stripe.card.createToken({
number: this.number,
cvc: this.cvc,
exp_month: this.month,
exp_year: this.year,
name: this.name,
address_line1: this.address,
address_city: this.city,
address_zip: this.zipcode,
address_country: this.country
}, (status, response) => {
if(status === 200){
self.token = response.id;
self.wizardstate++;
self.emitter.emit('notify', {
color: "#27ae60",
body: `${self.translate.creditcardapproved}!`,
icon: "glyphicon-ok"
});
} else {
self.emitter.emit('notify', {
color: "#c0392b",
body: `${self.translate.creditcardnotapproved}!`,
icon: "glyphicon-exclamation-sign"
});
}
});
},
confirmPurchase() {
let self = this;
let ids = [];
this.orderplaced = true;
this.products.map((prod) => {
ids.push({ Id: prod.productid, Quantity: prod.quantity });
});
const sendmodel = {
Productids: ids,
TokenId: this.token,
FullName: this.name,
PhoneNumber: this.phonenumber,
Email: this.email,
Description: this.description,
CountryCode: this.country,
Address: this.address,
ZipCode: this.zipcode,
City: this.city,
WantsNewsletter: this.newsletter
};
Reqwest({
url: "<secret api path>",
method: "POST",
data: sendmodel
}).then((response) => {
localStorage.clear();
self.wizardstate++;
}).fail((err, msg) =>{
self.emitter.emit('notify', {
color: "#c0392b",
body: `${self.translate.somethingwrongwithtransaction}!`,
icon: "glyphicon-exclamation-sign"
});
self.orderplaced = false;
});
},
goBack(){
this.wizardstate--;
}
},
computed: {
calculatedValue() {
let sum = 0;
this.products.map((product) => {
sum += +product.price * +product.quantity;
});
return sum;
}
}
});
}
export { initialize }
We use localstorage to store the cart and parse this as an array of objects. We make the user complete the checkout by completing steps. In one of the steps we use stripe js-api to authenticate the credit card and generate a token. In the last step the user confirms the purchase. We do not pass any price in the api call. This elliminates the dangers of console injection and we make the calculations on the server. This triggers the action that makes the payment. We calculate the amount in the frontend, but this is sorely to show the customer the cost of the purchase.
Using the Stripe.NET API
We have a dependency in Stripe.NET and umbraco APIs. I've made a simple facade around the payment logic. We could've made an interface here to abstract out the Stripe.NET API. But we'll keep it simple for now. We need to create a charge. A charge really just requires a token and an amount, but we'll send in some other personal data.
//PaymentFacade.cs
public static async Task<string> HandlePayment(PaymentModel payment)
{
return await Task.Run(() =>
{
var source = new StripeSourceOptions
{
TokenId = payment.TokenId
};
var total = GetTotalAmount(payment.Productids);
var metadata = new Dictionary<string, string> {
{ "Order", AggregateOrderString(payment.Productids) },
{ "Description", payment.Description },
{ "Phone Number", payment.PhoneNumber },
{ "Email", payment.Email }
};
var myCharge = new StripeChargeCreateOptions
{
Amount = total * 100,
Currency = "nok",
Description = payment.Description,
Source = source,
Metadata = metadata
};
var chargeService = new StripeChargeService(ConfigurationManager.AppSettings["StripeApiKey"]);
var stripeCharge = chargeService.Create(myCharge);
ReceiptFacade.SendReceipt(payment);
return stripeCharge.Id;
});
}
public static int GetTotalAmount(IEnumerable<MinimalProductModel> products)
{
return GetProductsFromIdList(products).Sum(minimalProductModel => int.Parse(minimalProductModel.Cost)*minimalProductModel.Quantity);
}
public static string AggregateOrderString(IEnumerable<MinimalProductModel> products)
{
var s = new StringBuilder();
foreach (var productModel in GetProductsFromIdList(products))
{
s.AppendLine($"{productModel.Name} ({productModel.Cost} NOK) x {productModel.Quantity}");
}
return s.ToString();
}
public static IEnumerable<ProductModel> GetProductsFromIdList(IEnumerable<MinimalProductModel> products)
{
var nodelist = new List<ProductModel>();
foreach (var minimalProductModel in products)
{
var umbracoNode = new Node(int.Parse(minimalProductModel.Id));
nodelist.Add(new ProductModel
{
Name = umbracoNode.GetProperty("title").Value,
Cost = umbracoNode.GetProperty("price").Value,
Quantity = minimalProductModel.Quantity
});
}
return nodelist;
}
In this example, we do a couple of things! We do an async task that does the payment and sends a receipt. We have a list of id that says something about which products the customer have bought. That list also contains the quantity. This is enough to call the umbraco API and find each respective price and names on the server. I think this is safer than sending the data over a POST webapi call! So this stays single-page and safe.
Generating the email
Generating the receipt is done with Nustaches and SendGrid. This is a very comfortable way to treat your mail templates and objects. When the mail is generated, we send this mail to the customer and to the company. This might not be a good solution if you have a lot transactions and purchases, but for a fairly small load it works well.
`public static string GenerateReceipt(PaymentModel payment)
{
var productlist = PaymentFacade.GetProductsFromIdList(payment.Productids);
var receiptmodel = new ReceiptModel {Payment = payment, Products = productlist, Amount = PaymentFacade.GetTotalAmount(payment.Productids)};
var nustachTemplate = Path.Combine(HostingEnvironment.ApplicationPhysicalPath, "Templates", "EmailReceipt.html");
return Render.FileToString(nustachTemplate, receiptmodel);
}
public static void SendReceipt(PaymentModel payment)
{
var myMessage = new SendGridMessage { From = new MailAddress("[email protected]") };
var recipients = new List<string>
{
System.Configuration.ConfigurationManager.AppSettings["PostMail"],
payment.Email
};
myMessage.AddTo(recipients);
myMessage.Subject = "Receipt for purchase";
myMessage.Html = GenerateReceipt(payment);
var credentials = new NetworkCredential("username", "password");
var transportWeb = new Web(credentials);
transportWeb.DeliverAsync(myMessage);
}
The models in this use case should indeed have some server side validation as well as client-side validation.
There you are! You now have a fully functional, simple eCommerce system for a very low cost. I believe this is a good first iteration. All functions are a subject of change. I have many ideas to further improve this application. I might make a user system around this to add some feedback functionality. It's VERY important to add a SSL certificate when dealing with sensitive data. This is easy to control in azure and IIS. We use DigiCert, but what you choose is up to you.