1. Introduction
In this tutorial, I'm going to show you how to build a Software-as-a-Service (SaaS) minimum viable product (MVP). To keep things simple, the software is going to allow our customers to save a list of notes.
I am going to offer three subscription plans: the Basic plan will have a limit of 100 notes per user, the Professional plan will allow customers to save up to 10,000 notes, and the Business plan will allow a million notes. The plans are going to cost $10, $20 and $30 per month respectively. In order to receive payment from our customers, I'm going to use Stripe as a payment gateway, and the website is going to be deployed to Azure.
2. Setup
2.1 Stripe
In a very short time Stripe has become a very well known Payment Gateway, mainly because of their developer-friendly approach, with simple and well-documented APIs. Their pricing is also very clear: 2.9% per transaction + 30 cents. No setup fees or hidden charges.
Credit card data is also very sensitive data, and in order to be allowed to receive and store that data in my server, I need to be PCI compliant. Because that's not an easy or quick task for most small companies, the approach that many payment gateways take is: You display the order details, and when the customer agrees to purchase, you redirect the customer to a page hosted by the payment gateway (bank, PayPal, etc), and then they redirect the customer back.
Stripe has a nicer approach to this problem. They offer a JavaScript API, so we can send the credit card number directly from the front-end to Stripe's servers. They return a one-time use token that we can save to our database. Now, we only need an SSL certificate for our website that we can quickly purchase from about $5 per year.
Now, sign up for a Stripe account, as you'll need it to charge your customers.
2.2 Azure
As a developer I don't want to be dealing with dev-ops tasks and managing servers if I don't have to. Azure websites is my choice for hosting, because it's a fully managed Platform-as-a-Service. It allows me to deploy from Visual Studio or Git, I can scale it easily if my service is successful, and I can focus on improving my application. They offer $200 to spend on all Azure services in the first month to new customers. That's enough to pay for the services that I am using for this MVP. Sign up for Azure.
2.3 Mandrill and Mailchimp: Transactional Email
Sending emails from our application might not seem like a very complex task, but I would like to monitor how many emails are delivered successfully, and also design responsive templates easily. This is what Mandrill offers, and they also let us send up to 12,000 emails per month for free. Mandrill is built by MailChimp, so they know about the business of sending emails. Also, we can create our templates from MailChimp, export them to Mandrill, and send emails from our app using our templates. Sign up for Mandrill, and sign up for MailChimp.
2.4 Visual Studio 2013 Community Edition
Last but not least, we need Visual Studio to write our application. This edition, which was launched only a few months ago, is completely free and is pretty much equivalent to Visual Studio Professional. You can download it here, and this is all we need, so now we can focus on the development.
3. Creating the Website
The first thing that we need to do is open Visual Studio 2013. Create a new ASP.NET Web Application:
- Go to File > New Project and choose ASP.NET Web Application.
- On the ASP.NET template dialog, choose the MVC template and select Individual User Accounts.
This project creates an application where a user can login by registering an account with the website. The website is styled using Bootstrap, and I'll continue building the rest of the app with Bootstrap. If you hit F5 in Visual Studio to run the application, this is what you will see:
This is the default landing page, and this page is one of the most important steps to convert our visitors into customers. We need to explain the product, show the price for each plan, and offer them the chance to sign up for a free trial. For this application I am creating three different subscription plans:
- Basic: $10 per month
- Professional: $20 per month
- Business: $30 per month
3.1 Landing Page
For some help creating a landing page, you can visit ThemeForest and purchase a template. For this sample, I am using a free template, and you can see the final result in the photo below.
3.2 Registration Page
In the website that we created in the previous step, we also get a Registration form template. From the landing page, when you navigate to Prices, and click on Free Trial, you navigate to the registration page. This is the default design:
We only need one extra field here to identify the subscription plan that the user is joining. If you can see in the navigation bar of the photo, I am passing that as a GET parameter. In order to do that, I generate the markup for the links in the landing page using this line of code:
<a href="@Url.Action("Register", "Account", new { plan = "business" })"> Free Trial </a>
To bind the Subscription Plan to the back-end, I need to modify the class RegisterViewModel
and add the new property.
public class RegisterViewModel { [Required] [EmailAddress] [Display(Name = "Email")] public string Email { get; set; } [Required] [StringLength(100, ErrorMessage = "The {0} must be at least {2} characters long.", MinimumLength = 6)] [DataType(DataType.Password)] [Display(Name = "Password")] public string Password { get; set; } [DataType(DataType.Password)] [Display(Name = "Confirm password")] [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")] public string ConfirmPassword { get; set; } public string SubscriptionPlan { get; set; } }
I also have to edit AccountController.cs, and modify the Action Register to receive the plan:
[AllowAnonymous] public ActionResult Register(string plan) { return View(new RegisterViewModel { SubscriptionPlan = plan }); }
Now, I have to render the Plan Identifier in a hidden field in the Register form:
@Html.HiddenFor(m => m.SubscriptionPlan)
The last step will be to subscribe the user to the plan, but we'll get to that a bit later. I also update the design of the registration form.
3.3 Login Page
In the template we also get a login page and action controllers implemented. The only thing I need to do is to make it look prettier.
3.4 Forgot Password
Take a second look at the previous screenshot, and you'll notice that I added a "Forgot your Password?" link. This is already implemented in the template, but it's commented out by default. I don't like the default behaviour, where the user needs to have the email address confirmed to be able to reset the password. Let's remove that restriction. In the file AccountController.cs edit the action ForgotPassword
:
[HttpPost] [AllowAnonymous] [ValidateAntiForgeryToken] public async Task<ActionResult> ForgotPassword(ForgotPasswordViewModel model) { if (ModelState.IsValid) { var user = await UserManager.FindByNameAsync(model.Email); if (user == null) { // Don't reveal that the user does not exist or is not confirmed return View("ForgotPasswordConfirmation"); } // For more information on how to enable account confirmation and password reset please visit http://go.microsoft.com/fwlink/?LinkID=320771 // Send an email with this link // string code = await UserManager.GeneratePasswordResetTokenAsync(user.Id); // var callbackUrl = Url.Action("ResetPassword", "Account", new { userId = user.Id, code = code }, protocol: Request.Url.Scheme); // await UserManager.SendEmailAsync(user.Id, "Reset Password", "Please reset your password by clicking <a href=\"" + callbackUrl + "\">here</a>"); // return RedirectToAction("ForgotPasswordConfirmation", "Account"); } // If we got this far, something failed, redisplay form return View(model); }
The code to send the email with the link to reset the password is commented out. I'll show how to implement that part a bit later. The only thing left for now is to update the design of the pages:
- ForgotPassword.cshtml: Form that is displayed to the user to enter his or her email.
- ForgotPasswordConfirmation.cshtml: Confirmation message after the reset link has been emailed to the user.
- ResetPassword.cshtml: Form to reset the password after navigating to the reset link from the email.
- ResetPasswordConfirmation.cshtml: Confirmation message after the password has been reset.
4. ASP.NET Identity 2.0
ASP.NET Identity is a fairly new library that has been built based on the assumption that users will no longer log in by using only a username and password. OAuth integration to allow users to log in through social channels such as Facebook, Twitter, and others is very easy now. Also, this library can be used with Web API, and SignalR.
On the other hand, the persistence layer can be replaced, and it's easy to plug in different storage mechanisms such as NoSQL databases. For the purposes of this application, I will use Entity Framework and SQL Server.
The project that we just created contains the following three NuGet packages for ASP.NET Identity:
- Microsoft.AspNet.Identity.Core: This package contains the core interfaces for ASP.NET Identity.
- Microsoft.AspNet.Identity.EntityFramework: This package has the Entity Framework implementation of the previous library. It will persist the data to SQL Server.
- Microsoft.AspNet.Identity.Owin: This package plugs the middle-ware OWIN authentication with ASP.NET Identity.
The main configuration for Identity is in App_Start/IdentityConfig.cs. This is the code that initializes Identity.
public static ApplicationUserManager Create(IdentityFactoryOptions<ApplicationUserManager> options, IOwinContext context) { var manager = new ApplicationUserManager(new UserStore<ApplicationUser>(context.Get<ApplicationDbContext>())); // Configure validation logic for usernames manager.UserValidator = new UserValidator<ApplicationUser>(manager) { AllowOnlyAlphanumericUserNames = false, RequireUniqueEmail = true }; // Configure validation logic for passwords manager.PasswordValidator = new PasswordValidator { RequiredLength = 6, RequireNonLetterOrDigit = true, RequireDigit = true, RequireLowercase = true, RequireUppercase = true, }; // Configure user lockout defaults manager.UserLockoutEnabledByDefault = true; manager.DefaultAccountLockoutTimeSpan = TimeSpan.FromMinutes(5); manager.MaxFailedAccessAttemptsBeforeLockout = 5; // Register two factor authentication providers. This application uses Phone and Emails as a step of receiving a code for verifying the user // You can write your own provider and plug it in here. manager.RegisterTwoFactorProvider("Phone Code", new PhoneNumberTokenProvider<ApplicationUser> { MessageFormat = "Your security code is {0}" }); manager.RegisterTwoFactorProvider("Email Code", new EmailTokenProvider<ApplicationUser> { Subject = "Security Code", BodyFormat = "Your security code is {0}" }); manager.EmailService = new EmailService(); manager.SmsService = new SmsService(); var dataProtectionProvider = options.DataProtectionProvider; if (dataProtectionProvider != null) { manager.UserTokenProvider = new DataProtectorTokenProvider<ApplicationUser>(dataProtectionProvider.Create("ASP.NET Identity")); } return manager; }
As you can see in the code, it's pretty easy to configure users' validators and password validators, and two factor authentication can also be enabled. For this application, I use cookie-based authentication. The cookie is generated by the framework and is encrypted. This way, we can scale horizontally, adding more servers if our application needs it.
5. Sending Emails With Mandrill
You can use MailChimp to design email templates, and Mandrill to send emails from your application. In the first place you need to link your Mandrill account to your MailChimp account:
- Log in to MailChimp, click your username in the right-hand panel, and select Account from the drop-down.
- Click on Integrations and find the Mandrill option in the list of integrations.
- Click on it to see the integration details, and click the Authorize Connection button. You will be redirected to Mandrill. Allow the connection, and the integration will be completed.
5.1 Creating the "Welcome to My Notes" Email Template
Navigate to Templates in MailChimp, and click on Create Template.
Now, select one of the templates offered by MailChimp. I selected the first one:
In the template editor, we modify the content as we like. One thing to note, as you can see below, is that we can use variables. The format is *|VARIABLE_NAME|*
. From the code, we'll set those for each customer. When you are ready, click on Save and Exit at the bottom right.
In the Templates list, click on Edit, on the right side, and select Send To Mandrill. After a few seconds you will get a confirmation message.
To confirm that the template has been exported, navigate to Mandrill and log in. Select Outbound from the left menu, and then Templates from the top menu. In the image below you can see that the template has been exported.
If you click on the name of the template, you'll see more information about the template. The field "Template Slug" is the text identifier that we will use in our application to let Mandrill API know which template we want to use for the email that we are sending.
I leave it as an exercise for you to create a "Reset Password" template.
5.2 Sending Emails From My Notes
In the first place, install Mandrill from NuGet. After that, add your Mandrill API Key to Web.config App Settings. Now, open App_Start/IdentityConfig.cs and you'll see the class EmailService
skeleton pending implementation:
public class EmailService : IIdentityMessageService { public Task SendAsync(IdentityMessage message) { // Plug in your email service here to send an email. return Task.FromResult(0); } }
Although this class has only the method SendAsync
, because we have two different templates (Welcome Email Template and Reset Password Template), we will implement new methods. The final implementation will look like this.
public class EmailService : IIdentityMessageService { private readonly MandrillApi _mandrill; private const string EmailFromAddress = "[email protected]"; private const string EmailFromName = "My Notes"; public EmailService() { _mandrill = new MandrillApi(ConfigurationManager.AppSettings["MandrillApiKey"]); } public Task SendAsync(IdentityMessage message) { var task = _mandrill.SendMessageAsync(new EmailMessage { from_email = EmailFromAddress, from_name = EmailFromName, subject = message.Subject, to = new List<Mandrill.EmailAddress> { new EmailAddress(message.Destination) }, html = message.Body }); return task; } public Task SendWelcomeEmail(string firstName, string email) { const string subject = "Welcome to My Notes"; var emailMessage = new EmailMessage { from_email = EmailFromAddress, from_name = EmailFromName, subject = subject, to = new List<Mandrill.EmailAddress> { new EmailAddress(email) }, merge = true, }; emailMessage.AddGlobalVariable("subject", subject); emailMessage.AddGlobalVariable("first_name", firstName); var task = _mandrill.SendMessageAsync(emailMessage, "welcome-my-notes-saas", null); task.Wait(); return task; } public Task SendResetPasswordEmail(string firstName, string email, string resetLink) { const string subject = "Reset My Notes Password Request"; var emailMessage = new EmailMessage { from_email = EmailFromAddress, from_name = EmailFromName, subject = subject, to = new List<Mandrill.EmailAddress> { new EmailAddress(email) } }; emailMessage.AddGlobalVariable("subject", subject); emailMessage.AddGlobalVariable("FIRST_NAME", firstName); emailMessage.AddGlobalVariable("RESET_PASSWORD_LINK", resetLink); var task = _mandrill.SendMessageAsync(emailMessage, "reset-password-my-notes-saas", null); return task; } }
To send an email through Mandrill API:
- Create email message.
- Set message variables' values.
- Send email specifying the template slug.
In AccountController -> Register action, this is the code snippet to send the welcome email:
await _userManager.EmailService.SendWelcomeEmail(user.UserName, user.Email);
In AccountController -> ForgotPassword action, this is the code to send the email:
// Send an email to reset password string code = await UserManager.GeneratePasswordResetTokenAsync(user.Id); var callbackUrl = Url.Action("ResetPassword", "Account", new { userId = user.Id, code = code }, protocol: Request.Url.Scheme); await UserManager.EmailService.SendResetPasswordEmail(user.UserName, user.Email, callbackUrl);
6. Integrating SAAS Ecom for Billing
One important thing in SAAS applications is billing. We need to have a way to charge our customers periodically, monthly in this example. Because this part is something that requires a lot of work, but doesn't add anything valuable to the product that we are selling, we are going to use the open source library SAAS Ecom that was created for this purpose.
6.1 Data Model: Entity Framework Code First
SAAS Ecom has a dependency on Entity Framework Code First. For those of you that are not familiar with it, Entity Framework Code First allows you to focus on creating C# POCO classes, letting Entity Framework map the classes to database tables. It follows the idea of convention over configuration, but you can still specify mappings, foreign keys and so on, if needed.
To add SAAS Ecom to our project, just install the dependency using NuGet. The library is split in two packages: SaasEcom.Core that contains the business logic, and SaasEcom.FrontEnd that contains some view helpers to use in an MVC application. Go ahead and install SaasEcom.FrontEnd.
You can see that some files have been added to your solution:
- Content/card-icons: Credit card icons to display in the billing area
- Controllers/BillingController: Main controller
- Controllers/StripeWebhooksController: Stripe Webhooks
- Scripts/saasecom.card.form.js: Script to add credit card to Stripe
- Views/Billing: Views and view partials
There are still a few steps left to integrate SAAS Ecom, so get your Stripe API Keys and add them to Web.config.
<appSettings> <add key="StripeApiSecretKey" value="your_key_here" /> <add key="StripeApiPublishableKey" value="your_key_here" /> </appSettings>
If you try to compile, you'll see errors:
Open the file Models/IdentityModels.cs, and then make the class ApplicationUser inherit from SaasEcomUser.
ApplicationUser : SaasEcomUser { /* your class methods*/ }
Open the file Models/IdentityModels.cs, and then your class ApplicationDbContext should inherit from SaasEcomDbContext<ApplicationUser>.
ApplicationDbContext : SaasEcomDbContext<ApplicationUser> { /* Your Db context properties */ }
Because ApplicationUser
is inheriting from SaasEcomUser
, the default behaviour for Entity Framework would be to create two tables in the database. Because we don't need that in this case, we need to add this method to the class ApplicationDbContext
to specify that it should use only one table:
protected override void OnModelCreating(DbModelBuilder modelBuilder) { modelBuilder.Entity<ApplicationUser>().Map(m => m.MapInheritedProperties()); base.OnModelCreating(modelBuilder); }
As we just updated the DbContext
, to make it inherit from SaasEcomDbContext
, the database has to be updated too. In order to do that, enable code migrations and update the database opening NuGet Package Manager from the menu Tools > NuGet Package Manager > Package Manager Console:
PM > enable-migrations PM > add-migration Initial PM > update-database
If you get an error when you run update-database
, the database (SQL Compact) is inside your AppData folder, so open the database, delete all the tables in it, and then run update-database
again.
6.2 Creating the Subscription Plans in Stripe and Database
The next step in the project is to integrate Stripe to charge our customers monthly, and for that we need to create the subscription plans and pricing in Stripe. So sign in to your Stripe dashboard, and create your subscription plans as you can see in the pictures.
Once we have created the Subscription Plans in Stripe, let's add them to the database. We do this so that we don't have to query Stripe API each time that we need any information related to subscription plans.
Also, we can store specific properties related to each plan. In this example, I'm saving as a property of each plan the number of notes that a user can save: 100 notes for the basic plan, 10,000 for the professional, and 1 million for the business plan. We add that information to the Seed method that is executed each time that the database is updated when we run update-database
from NuGet Package Manager console.
Open the file Migrations/Configuration.cs and add this method:
protected override void Seed(MyNotes.Models.ApplicationDbContext context) { // This method will be called after migrating to the latest version. var basicMonthly = new SubscriptionPlan { Id = "basic_monthly", Name = "Basic", Interval = SubscriptionPlan.SubscriptionInterval.Monthly, TrialPeriodInDays = 30, Price = 10.00, Currency = "USD" }; basicMonthly.Properties.Add(new SubscriptionPlanProperty { Key = "MaxNotes", Value = "100" }); var professionalMonthly = new SubscriptionPlan { Id = "professional_monthly", Name = "Professional", Interval = SubscriptionPlan.SubscriptionInterval.Monthly, TrialPeriodInDays = 30, Price = 20.00, Currency = "USD" }; professionalMonthly.Properties.Add(new SubscriptionPlanProperty { Key = "MaxNotes", Value = "10000" }); var businessMonthly = new SubscriptionPlan { Id = "business_monthly", Name = "Business", Interval = SubscriptionPlan.SubscriptionInterval.Monthly, TrialPeriodInDays = 30, Price = 30.00, Currency = "USD" }; businessMonthly.Properties.Add(new SubscriptionPlanProperty { Key = "MaxNotes", Value = "1000000" }); context.SubscriptionPlans.AddOrUpdate( sp => sp.Id, basicMonthly, professionalMonthly, businessMonthly); }
6.3 Subscribe a Customer to a Plan on Sign-Up
The next thing that we need to do is to ensure that each time a user registers for our app, we also create the user in Stripe using their API. To do that we use SAAS Ecom API, and we just need to edit the action Register on AccountController and add these lines after creating the user in the database:
// Create Stripe user await SubscriptionsFacade.SubscribeUserAsync(user, model.SubscriptionPlan); await UserManager.UpdateAsync(user);
The method SubscribeUserAsync
subscribes the user to the plan in Stripe, and if the user doesn't exist already in Stripe it is created too. This is useful if you have a freemium SAAS and you only create users in Stripe once they are on a paid plan. Another small change in the Register
action from AccountController
is to save the RegistrationDate
and LastLoginTime
when you create the user:
var user = new ApplicationUser { UserName = model.Email, Email = model.Email, RegistrationDate = DateTime.UtcNow, LastLoginTime = DateTime.UtcNow }; var result = await UserManager.CreateAsync(user, model.Password);
As we need the dependency SubscriptionsFacade from SAAS Ecom, add it as a property to Account Controller:
private SubscriptionsFacade _subscriptionsFacade; private SubscriptionsFacade SubscriptionsFacade { get { return _subscriptionsFacade ?? (_subscriptionsFacade = new SubscriptionsFacade( new SubscriptionDataService<ApplicationDbContext, ApplicationUser> (HttpContext.GetOwinContext().Get<ApplicationDbContext>()), new SubscriptionProvider(ConfigurationManager.AppSettings["StripeApiSecretKey"]), new CardProvider(ConfigurationManager.AppSettings["StripeApiSecretKey"], new CardDataService<ApplicationDbContext, ApplicationUser>(Request.GetOwinContext().Get<ApplicationDbContext>())), new CardDataService<ApplicationDbContext, ApplicationUser>(Request.GetOwinContext().Get<ApplicationDbContext>()), new CustomerProvider(ConfigurationManager.AppSettings["StripeApiSecretKey"]), new SubscriptionPlanDataService<ApplicationDbContext, ApplicationUser>(Request.GetOwinContext().Get<ApplicationDbContext>()), new ChargeProvider(ConfigurationManager.AppSettings["StripeApiSecretKey"]))); } }
You can simplify the way that this is instantiated using dependency injection, but this is something that can be covered in another article.
6.4 Integrate Billing Views
When we added SAAS Ecom to the project, some view partials were added too. They use the main _Layout.cshtml, but that layout is the one being used by the landing page. We need to add a different layout for the web application area or customer dashboard.
I have created a very similar version to the _Layout.cshtml that is created when you add a new MVC project in Visual Studio—you can see the _DashboardLayout.cshtml in GitHub.
The main differences are that I have added font-awesome and an area to display Bootstrap notifications if they're present:
<div id="bootstrap_alerts"> @if (TempData.ContainsKey("flash")) { @Html.Partial("_Alert", TempData["flash"]); } </div>
For the views in the folder Views/Billing, set the layout to _DashboardLayout, otherwise it would use the default one that is _Layout.cshtml. Do the same thing for views on the folder Views/Manage:
Layout = "~/Views/Shared/_DashboardLayout.cshtml";
I have slightly modified "DashboardLayout" to use some styles from the main website, and it looks like this after signing up and navigating to the Billing section:
In the billing area a customer can Cancel or Upgrade / Downgrade a subscription. Add payment details, using Stripe JavaScript API, so we don't need to be PCI compliant and only need SSL in the server to take payments from our customers.
To properly test your new application, you can use several credit card numbers provided by Stripe.
The last thing that you might want to do is set up Stripe Webhooks. This is used to let Stripe notify you about events that happen in your billing, like payment successful, payment overdue, trial about to expire, and so on—you can get a full list from the Stripe documentation. The Stripe event is sent as JSON to a public facing URL. To test this locally you probably want to use Ngrok.
When SAAS Ecom was installed, a new controller was added to handle the webhooks from Stripe: StripeWebhooksController.cs. You can see there how the invoice created event is handled:
case "invoice.payment_succeeded": // Occurs whenever an invoice attempts to be paid, and the payment succeeds. StripeInvoice stripeInvoice = Stripe.Mapper<StripeInvoice>.MapFromJson(stripeEvent.Data.Object.ToString()); Invoice invoice = SaasEcom.Core.Infrastructure.Mappers.Map(stripeInvoice); if (invoice != null && invoice.Total > 0) { // TODO get the customer billing address, we still have to instantiate the address on the invoice invoice.BillingAddress = new BillingAddress(); await InvoiceDataService.CreateOrUpdateAsync(invoice); // TODO: Send invoice by email } break;
You can implement as many events in the controller as you need.
7. Building Note-Taking Functionality in Our App
The most important part of this SAAS application is to allow our customers to save notes. In order to create this functionality, let's start by creating the Note
class:
public class Note { public int Id { get; set; } [Required] [MaxLength(250)] public string Title { get; set; } [Required] public string Text { get; set; } public DateTime CreatedAt { get; set; } }
Add a One to Many relationship from ApplicationUser
to Note
:
public virtual ICollection<Note> Notes { get; set; }
Because the DbContext has changed, we need to add a new database Migration, so open Nuget Package Manager console and run:
PM> add-migration NotesAddedToModel
This is the generated code:
public partial class NotesAddedToModel : DbMigration { public override void Up() { CreateTable( "dbo.Notes", c => new { Id = c.Int(nullable: false, identity: true), Title = c.String(nullable: false, maxLength: 250), Text = c.String(nullable: false), CreatedAt = c.DateTime(nullable: false), ApplicationUser_Id = c.String(maxLength: 128), }) .PrimaryKey(t => t.Id) .ForeignKey("dbo.AspNetUsers", t => t.ApplicationUser_Id) .Index(t => t.ApplicationUser_Id); } public override void Down() { DropForeignKey("dbo.Notes", "ApplicationUser_Id", "dbo.AspNetUsers"); DropIndex("dbo.Notes", new[] { "ApplicationUser_Id" }); DropTable("dbo.Notes"); } }
The next thing we need is the Controller MyNotes. As we already have the model class Notes, we use the scaffold to create the controller class to have create, read, update and delete methods using Entity Framework. We also use the scaffold to generate the views.
At this point, after a user is registered successfully on My Notes, redirect the user to the Index
action of NotesController
:
TempData["flash"] = new FlashSuccessViewModel("Congratulations! Your account has been created."); return RedirectToAction("Index", "Notes");
So far, we have created a CRUD (Create / Read / Update / Delete) interface for Notes. We still need to check when users try to add notes, to make sure that they have enough space in their subscriptions.
Empty list of notes:
Create new note:
List of notes:
Note detail:
Edit note:
Confirm note deletion:
I'm going to edit slightly the default markup:
- In the form to create a note, I removed the
CreatedAt
field, and set the value in the controller. - In the form to edit a note, I changed
CreatedAt
to be a hidden field so that it's not editable. - I have slightly modified the CSS to make this form look a bit nicer too.
When we generated the Notes controller using Entity Framework, the list of notes was listing all the notes in the database, not only the notes for the logged-in user. For security we need to check that users can only see, modify or delete the notes that belong to them.
We also need to check how many notes a user has before allowing him or her to create a new one, to check that the subscription plan limits are met. Here is the new code for NotesController:
public class NotesController : Controller { private readonly ApplicationDbContext _db = new ApplicationDbContext(); private SubscriptionsFacade _subscriptionsFacade; private SubscriptionsFacade SubscriptionsFacade { get { return _subscriptionsFacade ?? (_subscriptionsFacade = new SubscriptionsFacade( new SubscriptionDataService<ApplicationDbContext, ApplicationUser> (HttpContext.GetOwinContext().Get<ApplicationDbContext>()), new SubscriptionProvider(ConfigurationManager.AppSettings["StripeApiSecretKey"]), new CardProvider(ConfigurationManager.AppSettings["StripeApiSecretKey"], new CardDataService<ApplicationDbContext, ApplicationUser>(Request.GetOwinContext().Get<ApplicationDbContext>())), new CardDataService<ApplicationDbContext, ApplicationUser>(Request.GetOwinContext().Get<ApplicationDbContext>()), new CustomerProvider(ConfigurationManager.AppSettings["StripeApiSecretKey"]), new SubscriptionPlanDataService<ApplicationDbContext, ApplicationUser>(Request.GetOwinContext().Get<ApplicationDbContext>()), new ChargeProvider(ConfigurationManager.AppSettings["StripeApiSecretKey"]))); } } // GET: Notes public async Task<ActionResult> Index() { var userId = User.Identity.GetUserId(); var userNotes = await _db.Users.Where(u => u.Id == userId) .Include(u => u.Notes) .SelectMany(u => u.Notes) .ToListAsync(); return View(userNotes); } // GET: Notes/Details/5 public async Task<ActionResult> Details(int? id) { if (id == null) { return new HttpStatusCodeResult(HttpStatusCode.BadRequest); } var userId = User.Identity.GetUserId(); ICollection<Note> userNotes = ( await _db.Users.Where(u => u.Id == userId) .Include(u => u.Notes).Select(u => u.Notes) .FirstOrDefaultAsync()); if (userNotes == null) { return HttpNotFound(); } Note note = userNotes.FirstOrDefault(n => n.Id == id); if (note == null) { return HttpNotFound(); } return View(note); } // GET: Notes/Create public ActionResult Create() { return View(); } // POST: Notes/Create // To protect from overposting attacks, please enable the specific properties you want to bind to, for // more details see http://go.microsoft.com/fwlink/?LinkId=317598. [HttpPost] [ValidateAntiForgeryToken] public async Task<ActionResult> Create([Bind(Include = "Id,Title,Text,CreatedAt")] Note note) { if (ModelState.IsValid) { if (await UserHasEnoughSpace(User.Identity.GetUserId())) { note.CreatedAt = DateTime.UtcNow; // The note is added to the user object so the Foreign Key is saved too var userId = User.Identity.GetUserId(); var user = await this._db.Users.Where(u => u.Id == userId).FirstOrDefaultAsync(); user.Notes.Add(note); await _db.SaveChangesAsync(); return RedirectToAction("Index"); } else { TempData.Add("flash", new FlashWarningViewModel("You can not add more notes, upgrade your subscription plan or delete some notes.")); } } return View(note); } private async Task<bool> UserHasEnoughSpace(string userId) { var subscription = (await SubscriptionsFacade.UserActiveSubscriptionsAsync(userId)).FirstOrDefault(); if (subscription == null) { return false; } var userNotes = await _db.Users.Where(u => u.Id == userId).Include(u => u.Notes).Select(u => u.Notes).CountAsync(); return subscription.SubscriptionPlan.GetPropertyInt("MaxNotes") > userNotes; } // GET: Notes/Edit/5 public async Task<ActionResult> Edit(int? id) { if (id == null) { return new HttpStatusCodeResult(HttpStatusCode.BadRequest); } if (!await NoteBelongToUser(User.Identity.GetUserId(), noteId: id.Value)) { return new HttpStatusCodeResult(HttpStatusCode.BadRequest); } Note note = await _db.Notes.FindAsync(id); if (note == null) { return HttpNotFound(); } return View(note); } // POST: Notes/Edit/5 // To protect from overposting attacks, please enable the specific properties you want to bind to, for // more details see http://go.microsoft.com/fwlink/?LinkId=317598. [HttpPost] [ValidateAntiForgeryToken] public async Task<ActionResult> Edit([Bind(Include = "Id,Title,Text,CreatedAt")] Note note) { if (ModelState.IsValid && await NoteBelongToUser(User.Identity.GetUserId(), note.Id)) { _db.Entry(note).State = EntityState.Modified; await _db.SaveChangesAsync(); return RedirectToAction("Index"); } return View(note); } // GET: Notes/Delete/5 public async Task<ActionResult> Delete(int? id) { if (id == null) { return new HttpStatusCodeResult(HttpStatusCode.BadRequest); } if (!await NoteBelongToUser(User.Identity.GetUserId(), noteId: id.Value)) { return new HttpStatusCodeResult(HttpStatusCode.BadRequest); } Note note = await _db.Notes.FindAsync(id); if (note == null) { return HttpNotFound(); } return View(note); } // POST: Notes/Delete/5 [HttpPost, ActionName("Delete")] [ValidateAntiForgeryToken] public async Task<ActionResult> DeleteConfirmed(int id) { if (!await NoteBelongToUser(User.Identity.GetUserId(), noteId: id)) { return new HttpStatusCodeResult(HttpStatusCode.BadRequest); } Note note = await _db.Notes.FindAsync(id); _db.Notes.Remove(note); await _db.SaveChangesAsync(); return RedirectToAction("Index"); } private async Task<bool> NoteBelongToUser(string userId, int noteId) { return await _db.Users.Where(u => u.Id == userId).Where(u => u.Notes.Any(n => n.Id == noteId)).AnyAsync(); } protected override void Dispose(bool disposing) { if (disposing) { _db.Dispose(); } base.Dispose(disposing); } }
This is it—we have the core functionality for our SAAS application.
8. Saving Customer's Location for European VAT Purposes
At the beginning of this year the legislation in the European Union for VAT for business supplying digital services to private consumers changed. The main difference is that businesses have to charge VAT to private customers, not business customers with a valid VAT number, according to the country in the EU in which they are based. To validate in which country they're based we need to keep a record of at least two of these forms:
- the billing address of the customer
- the Internet Protocol (IP) address of the device used by the customer
- customer’s bank details
- the country code of the SIM card used by the customer
- the location of the customer’s fixed land line through which the service is supplied
- other commercially relevant information (for example, product coding information which electronically links the sale to a particular jurisdiction)
For this reason we are going to geo-locate the user IP address, to save it along with the billing address and credit card country.
8.1 IP Address Geo-Location
For geo-location, I am going to use Maxmind GeoLite2. It's a free database that gives us the country where an IP is located.
Download, and add the database to App_Data, as you can see in the photo:
Create Extensions/GeoLocationHelper.cs.
public static class GeoLocationHelper { // ReSharper disable once InconsistentNaming /// <summary> /// Gets the country ISO code from IP. /// </summary> /// <param name="ipAddress">The ip address.</param> /// <returns></returns> public static string GetCountryFromIP(string ipAddress) { string country; try { using ( var reader = new DatabaseReader(HttpContext.Current.Server.MapPath("~/App_Data/GeoLite2-Country.mmdb"))) { var response = reader.Country(ipAddress); country = response.Country.IsoCode; } } catch (Exception ex) { country = null; } return country; } /// <summary> /// Selects the list countries. /// </summary> /// <param name="country">The country.</param> /// <returns></returns> public static List<SelectListItem> SelectListCountries(string country) { var getCultureInfo = CultureInfo.GetCultures(CultureTypes.SpecificCultures); var countries = getCultureInfo.Select(cultureInfo => new RegionInfo(cultureInfo.LCID)) .Select(getRegionInfo => new SelectListItem { Text = getRegionInfo.EnglishName, Value = getRegionInfo.TwoLetterISORegionName, Selected = country == getRegionInfo.TwoLetterISORegionName }).OrderBy(c => c.Text).DistinctBy(i => i.Text).ToList(); return countries; } public static IEnumerable<TSource> DistinctBy<TSource, TKey>(this IEnumerable<TSource> source, Func<TSource, TKey> keySelector) { var seenKeys = new HashSet<TKey>(); return source.Where(element => seenKeys.Add(keySelector(element))); } }
There are two methods implemented in this static class:
-
GetCountryFromIP
: Returns the country ISO Code given an IP Address. -
SelectListCountries
: Returns a list of countries to use in a drop-down field. It has the country ISO Code as a value for each country and the country name to be displayed.
8.2 Saving Customer Country on Registration
In the action Register
from AccountController
, when creating the user, save the IP and the country the IP belongs to:
var userIP = GeoLocation.GetUserIP(Request); var user = new ApplicationUser { UserName = model.Email, Email = model.Email, RegistrationDate = DateTime.UtcNow, LastLoginTime = DateTime.UtcNow, IPAddress = userIP, IPAddressCountry = GeoLocationHelper.GetCountryFromIP(userIP), };
Also, when we create the subscription in Stripe, we need to pass the Tax Percentage for this customer. We do that a few lines after creating the user:
// Create Stripe user var taxPercent = EuropeanVat.Countries.ContainsKey(user.IPAddressCountry) ? EuropeanVat.Countries[user.IPAddressCountry] : 0; await SubscriptionsFacade.SubscribeUserAsync(user, model.SubscriptionPlan, taxPercent: taxPercent);
By default, if a user is based in the European Union, I'm setting the tax percentage to that subscription. The rules are a bit more complex than that, but summarizing:
- If your business is registered in an EU country, you always charge VAT to customers in your country.
- If your business is registered in an EU country, you only charge VAT to the customers that are in other EU countries, and are not VAT-registered business.
- If your business is registered outside the EU, you only charge VAT to customers that are not businesses with a valid VAT number.
8.3 Adding a Billing Address to our Model
At the moment we are not allowing to our customers to save a Billing Address, and their VAT number if they are an EU VAT registered business. In that case, we need to change their tax percentage to 0.
SAAS Ecom provides the BillingAddress
class, but it's not attached to any entity of the model. The main reason for this is that in some SAAS applications it might make sense to assign this to an Organization class if multiple users have access to the same account. If this is not the case, as in our sample, we can safely add that relationship to the ApplicationUser
class:
public class ApplicationUser : SaasEcomUser { public virtual ICollection<Note> Notes { get; set; } public virtual BillingAddress BillingAddress { get; set; } public async Task<ClaimsIdentity> GenerateUserIdentityAsync(UserManager<ApplicationUser> manager) { // Note the authenticationType must match the one defined in CookieAuthenticationOptions.AuthenticationType var userIdentity = await manager.CreateIdentityAsync(this, DefaultAuthenticationTypes.ApplicationCookie); // Add custom user claims here return userIdentity; } }
As each time that we modify the model, we need to add a database migration, open Tools > NuGet Package Manager > Package Manager Console:
PM> add-migration BillingAddressAddedToUser
And this is the migration class that we get:
public partial class BillingAddressAddedToUser : DbMigration { public override void Up() { AddColumn("dbo.AspNetUsers", "BillingAddress_Name", c => c.String()); AddColumn("dbo.AspNetUsers", "BillingAddress_AddressLine1", c => c.String()); AddColumn("dbo.AspNetUsers", "BillingAddress_AddressLine2", c => c.String()); AddColumn("dbo.AspNetUsers", "BillingAddress_City", c => c.String()); AddColumn("dbo.AspNetUsers", "BillingAddress_State", c => c.String()); AddColumn("dbo.AspNetUsers", "BillingAddress_ZipCode", c => c.String()); AddColumn("dbo.AspNetUsers", "BillingAddress_Country", c => c.String()); AddColumn("dbo.AspNetUsers", "BillingAddress_Vat", c => c.String()); } public override void Down() { DropColumn("dbo.AspNetUsers", "BillingAddress_Vat"); DropColumn("dbo.AspNetUsers", "BillingAddress_Country"); DropColumn("dbo.AspNetUsers", "BillingAddress_ZipCode"); DropColumn("dbo.AspNetUsers", "BillingAddress_State"); DropColumn("dbo.AspNetUsers", "BillingAddress_City"); DropColumn("dbo.AspNetUsers", "BillingAddress_AddressLine2"); DropColumn("dbo.AspNetUsers", "BillingAddress_AddressLine1"); DropColumn("dbo.AspNetUsers", "BillingAddress_Name"); } }
To create these changes in the database, we execute in the Package Manager Console:
PM> update-database
One more detail that we need to fix is that in AccountController > Register, we need to set a default billing address as it's a non-nullable field.
var user = new ApplicationUser { UserName = model.Email, Email = model.Email, RegistrationDate = DateTime.UtcNow, LastLoginTime = DateTime.UtcNow, IPAddress = userIP, IPAddressCountry = GeoLocationHelper.GetCountryFromIP(userIP), BillingAddress = new BillingAddress() };
In the billing page, we need to display the Billing Address for the customer if it has been added, and also allow our customers to edit it. First, we need to modify the action Index
from BillingController
to pass the billing address to the view:
public async Task<ViewResult> Index() { var userId = User.Identity.GetUserId(); ViewBag.Subscriptions = await SubscriptionsFacade.UserActiveSubscriptionsAsync(userId); ViewBag.PaymentDetails = await SubscriptionsFacade.DefaultCreditCard(userId); ViewBag.Invoices = await InvoiceDataService.UserInvoicesAsync(userId); ViewBag.BillingAddress = (await UserManager.FindByIdAsync(userId)).BillingAddress; return View(); }
To display the address, we just need to edit the view "Billing/Index.cshtml", and add the view partial provided by SAAS Ecom for that:
<h2>Billing</h2> <br /> @Html.Partial("_Subscriptions", (List<Subscription>)ViewBag.Subscriptions) <br/> @Html.Partial("_PaymentDetails", (CreditCard)ViewBag.PaymentDetails) <br /> @Html.Partial("_BillingAddress", (BillingAddress)ViewBag.BillingAddress) <br /> @Html.Partial("_Invoices", (List<Invoice>)ViewBag.Invoices)
Now, if we navigate to Billing we can see the new section:
The next step is on the BillingController > BillingAddress action, we need to pass the Billing address to the view. Because we need to get the user's two-letter ISO country code, I've added a dropdown to select the country, which defaults to the country that the user IP belongs to:
public async Task<ViewResult> BillingAddress() { var model = (await UserManager.FindByIdAsync(User.Identity.GetUserId())).BillingAddress; // List for dropdown country select var userCountry = (await UserManager.FindByIdAsync(User.Identity.GetUserId())).IPAddressCountry; ViewBag.Countries = GeoLocationHelper.SelectListCountries(userCountry); return View(model); }
When the user submits the form, we need to save the billing address and update the tax percent if it's needed:
[HttpPost] public async Task<ActionResult> BillingAddress(BillingAddress model) { if (ModelState.IsValid) { var userId = User.Identity.GetUserId(); // Call your service to save the billing address var user = await UserManager.FindByIdAsync(userId); user.BillingAddress = model; await UserManager.UpdateAsync(user); // Model Country has to be 2 letter ISO Code if (!string.IsNullOrEmpty(model.Vat) && !string.IsNullOrEmpty(model.Country) && EuropeanVat.Countries.ContainsKey(model.Country)) { await UpdateSubscriptionTax(userId, 0); } else if (!string.IsNullOrEmpty(model.Country) && EuropeanVat.Countries.ContainsKey(model.Country)) { await UpdateSubscriptionTax(userId, EuropeanVat.Countries[model.Country]); } TempData.Add("flash", new FlashSuccessViewModel("Your billing address has been saved.")); return RedirectToAction("Index"); } return View(model); } private async Task UpdateSubscriptionTax(string userId, decimal tax) { var user = await UserManager.FindByIdAsync(userId); var subscription = (await SubscriptionsFacade.UserActiveSubscriptionsAsync(userId)).FirstOrDefault(); if (subscription != null && subscription.TaxPercent != tax) { await SubscriptionsFacade.UpdateSubscriptionTax(user, subscription.StripeId, tax); } }
This is what the form to add or edit a billing address looks like:
After adding the address, I get redirected back to the billing area:
As you can see in the screenshot above, because I set my country to United Kingdom, and I didn't enter a VAT number, 20% VAT is added to the monthly price. The code showed here is assuming that you are a non-EU-based company. If that's the case, you need to handle the case where your customer is in your country, and regardless of whether they have VAT or not, you'll have to charge VAT.
9. Deploy to Azure Websites (Web Hosting + SSL Free, SQL Database $5 Per Month)
9.1 Deploying the Website
Our SAAS project is ready to go live, and I've chosen Azure as the hosting platform. If you don't have an account yet, you can get a free trial for a month. We can deploy our app from Git (GitHub or BitBucket) on every commit if we like. I'm going to show you here how to deploy from Visual Studio 2013. In the solution explorer, right click on the project My Notes and select Publish from the context menu. The Publish Web wizard opens.
Select Microsoft Azure Websites and click New.
Fill in the details for your website and click Create. When your website has been created, you'll see this. Click Next.
In this step you can add the connection string for your Database if you have it, or you can add it later from the management portal. Click Next.
Now, if we click Publish, Visual Studio will upload the website to Azure.
9.2 Deploying the Database
To create the database, you have to go to Azure Management Portal, select Browse, and then Data + Storage > SQL Database. Fill in the form to create your database.
Once the database is created, select Open in Visual Studio and accept to add an exception to the firewall.
Your database will be open in the SQL Server Object Explorer from Visual Studio. As you can see there are no tables yet:
To generate a SQL Script to create the tables in the database, open Package Manager Console in Visual Studio, and type:
PM> update-database -SourceMigration:0 -Script
Copy the script, and back in SQL Server Object Explorer, right-click on your database, and select New Query. Paste the script, and execute it.
This script doesn't include the data that we were inserting in the database from the Seed method. We need to create a script manually to add that data to the database:
INSERT INTO [dbo].[SubscriptionPlans] ([Id],[Name],[Price],[Currency],[Interval],[TrialPeriodInDays],[Disabled]) VALUES('basic_monthly', 'Basic', 10, 'USD', 1, 30, 0) INSERT INTO [dbo].[SubscriptionPlans] ([Id],[Name],[Price],[Currency],[Interval],[TrialPeriodInDays],[Disabled]) VALUES('professional_monthly', 'Professional', 20, 'USD', 1, 30, 0) INSERT INTO [dbo].[SubscriptionPlans] ([Id],[Name],[Price],[Currency],[Interval],[TrialPeriodInDays],[Disabled]) VALUES('business_monthly', 'Business', 30, 'USD', 1, 30, 0) INSERT INTO [dbo].[SubscriptionPlanProperties] ([Key],[Value],[SubscriptionPlan_Id]) VALUES ('MaxNotes', '100', 'basic_monthly') INSERT INTO [dbo].[SubscriptionPlanProperties] ([Key],[Value],[SubscriptionPlan_Id]) VALUES ('MaxNotes', '10000', 'professional_monthly') INSERT INTO [dbo].[SubscriptionPlanProperties] ([Key],[Value],[SubscriptionPlan_Id]) VALUES ('MaxNotes', '1000000', 'business_monthly')
At this point My Notes SAAS is live. I have configured Stripe test API keys, so you can use test credit card details for testing if you like.
Comments