1.简介
在本教程中,我将向您展示如何构建软件即服务(SaaS)最低可行产品(MVP)。 为了简单起见,该软件将允许我们的客户保存注释列表。
我将提供三种订阅计划:“基本”计划每位用户最多只能有100个便笺;“专业”计划将允许客户最多保存10,000个便笺;“商业”计划将允许一百万个便笺。 这些计划的费用分别为每月10美元,20美元和30美元。 为了接收来自客户的付款,我将使用Stripe作为付款网关,并将该网站部署到Azure。
2.设定
2.1条纹
在很短的时间内,Stripe已成为众所周知的支付网关,这主要是因为它们具有开发人员友好的方法以及简单且文档完善的API。 他们的定价也非常明确:每笔交易2.9%+ 30美分。 没有安装费或隐藏费用。
信用卡数据也是非常敏感的数据,为了允许在我的服务器中接收和存储该数据,我需要与PCI兼容 。 因为对于大多数小型公司而言,这不是一件容易或快速的任务,所以许多付款网关采用的方法是:显示订单明细,并且当客户同意购买时,将客户重定向到付款网关(银行)托管的页面,贝宝(PayPal)等),然后他们将客户重定向回去。
Stripe对这个问题有更好的解决方法。 他们提供了JavaScript API,因此我们可以将信用卡号直接从前端发送到Stripe的服务器。 他们返回一次使用令牌,我们可以将其保存到数据库中。 现在,我们只需要网站的SSL证书即可快速购买,每年只需$ 5。
现在, 注册一个Stripe帐户 ,因为您需要用它来向客户收费。
2.2天青
作为开发人员,我不想在不需要的情况下处理dev-ops任务和管理服务器。 我选择托管Azure网站,因为它是完全托管的平台即服务。 它允许我从Visual Studio或Git进行部署,如果我的服务成功,我可以轻松扩展它,并且可以专注于改进应用程序。 他们提供200美元,用于在第一个月内向新客户购买所有Azure服务。 这足以支付我为此MVP使用的服务。 注册Azure 。
2.3 Mandrill和Mailchimp:交易电子邮件
从我们的应用程序发送电子邮件似乎不是一个非常复杂的任务,但是我想监视成功发送了多少电子邮件,还可以轻松设计响应式模板。 这就是Mandrill提供的功能,他们还让我们每月免费发送多达12,000封电子邮件。 Mandrill由MailChimp构建,因此他们了解发送电子邮件的业务。 此外,我们可以从MailChimp创建模板,将其导出到Mandrill,并使用模板从我们的应用发送电子邮件。 注册Mandrill ,然后注册MailChimp 。
2.4 Visual Studio 2013社区版
最后但并非最不重要的一点是,我们需要Visual Studio来编写我们的应用程序。 该版本仅在几个月前才发布,完全免费,与Visual Studio Professional相当。 您可以在此处下载它 ,这是我们所需的全部,因此现在我们可以专注于开发。
3.创建网站
我们需要做的第一件事是打开Visual Studio2013。创建一个新的ASP.NET Web应用程序:
- 转到“ 文件”>“新建项目”,然后选择“ ASP.NET Web应用程序” 。
- 在ASP.NET模板对话框中,选择MVC模板,然后选择单个用户帐户 。
该项目创建了一个应用程序,用户可以在其中通过在网站上注册帐户来登录。 该网站使用Bootstrap进行样式设置,我将继续使用Bootstrap构建该应用程序的其余部分。 如果在Visual Studio中按F5键运行该应用程序,则将看到以下内容:
这是默认的登录页面,并且此页面是将我们的访问者转化为客户的最重要步骤之一。 我们需要说明产品,显示每个计划的价格,并为他们提供注册免费试用的机会。 我为此应用程序创建了三种不同的订阅计划:
- 基础版 :每月10美元
- 专业人士 :每月$ 20
- 业务 :每月30美元
3.1登陆页面
有关创建登录页面的帮助,您可以访问ThemeForest并购买模板。 对于此样本,我使用的是免费模板 ,您可以在下面的照片中看到最终结果。
![登陆页面](https://cms-assets.tutsplus.com/uploads/users/581/posts/22922/image/My-Notes---SAAS-Ecom-Sample.jpg)
3.2注册页面
在上一步创建的网站中,我们还获得了“注册表”模板。 在登录页面上,当您导航到Price ,然后单击Free Trial时 ,您导航到注册页面。 这是默认设计:
我们在这里只需要一个额外的字段即可识别用户正在加入的订阅计划。 如果您可以在照片的导航栏中看到,则将其作为GET参数传递。 为此,我使用以下代码行为登录页面中的链接生成标记:
<a href="@Url.Action("Register", "Account", new { plan = "business" })">
Free Trial
</a>
要将订阅计划绑定到后端,我需要修改类RegisterViewModel
并添加新属性。
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; }
}
我还必须编辑AccountController.cs,并修改操作寄存器以接收计划:
[AllowAnonymous]
public ActionResult Register(string plan)
{
return View(new RegisterViewModel
{
SubscriptionPlan = plan
});
}
现在,我必须在“注册”表单的隐藏字段中呈现“计划标识符”:
@Html.HiddenFor(m => m.SubscriptionPlan)
最后一步是让用户订阅该计划,但是稍后我们将进行介绍。 我还更新了注册表的设计。
3.3登录页面
在模板中,我们还获得了登录页面和实现的动作控制器。 我唯一需要做的就是使它看起来更漂亮。
3.4忘记密码
再看一下前面的屏幕截图,您会注意到我添加了“忘记密码了?”。 链接。 这已经在模板中实现,但是默认情况下已注释掉。 我不喜欢默认行为,在默认行为下,用户需要确认电子邮件地址才能重设密码。 让我们删除该限制。 在AccountController.cs文件中,编辑动作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);
}
发送带有链接以重置密码的电子邮件的代码已被注释掉。 稍后我将展示如何实现该部分。 现在剩下的唯一事情就是更新页面的设计:
- ForgotPassword.cshtml:显示给用户以输入他或她的电子邮件的表单。
- ForgotPasswordConfirmation.cshtml:重设链接通过电子邮件发送给用户后的确认消息。
- ResetPassword.cshtml:从电子邮件导航到重置链接后用于重置密码的表单。
- ResetPasswordConfirmation.cshtml:重置密码后的确认消息。
4. ASP.NET Identity 2.0
ASP.NET Identity是一个相当新的库,它是基于以下假设而建立的:用户将不再仅使用用户名和密码登录。 OAuth集成允许用户通过Facebook,Twitter等社交渠道登录非常简单。 此外,该库可与Web API和SignalR一起使用。
另一方面,可以替换持久层,并且很容易插入不同的存储机制,例如NoSQL数据库。 出于本应用程序的目的,我将使用实体框架和SQL Server。
我们刚刚创建的项目包含以下三个ASP.NET Identity的NuGet程序包:
- Microsoft.AspNet.Identity.Core:此程序包包含ASP.NET Identity的核心接口。
- Microsoft.AspNet.Identity.EntityFramework:此程序包具有先前库的Entity Framework实现。 它将数据持久保存到SQL Server。
- Microsoft.AspNet.Identity.Owin:此程序包将中间件OWIN身份验证插入ASP.NET Identity。
身份的主要配置在App_Start / IdentityConfig.cs中。 这是初始化身份的代码。
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;
}
正如您在代码中看到的那样,配置用户的验证器和密码验证器非常容易,并且还可以启用两因素验证。 对于此应用程序,我使用基于cookie的身份验证。 cookie由框架生成并被加密。 这样,我们可以水平扩展,并在应用程序需要时添加更多服务器。
5.使用山d发送电子邮件
您可以使用MailChimp设计电子邮件模板,并使用Mandrill从应用程序发送电子邮件。 首先,您需要将您的Mandrill帐户链接到您的MailChimp帐户:
- 登录MailChimp,在右侧面板中单击您的用户名,然后从下拉列表中选择“ 帐户 ”。
- 单击“ 集成”,然后在集成列表中找到“ Mandrill”选项。
- 单击它以查看集成详细信息,然后单击“ 授权连接”按钮。 您将被重定向到Mandrill。 允许连接,集成将完成。
5.1创建“欢迎使用我的笔记”电子邮件模板
导航到MailChimp中的模板 ,然后单击创建模板 。
现在,选择MailChimp提供的模板之一。 我选择了第一个:
在模板编辑器中,我们根据需要修改内容。 正如您在下面看到的,要注意的一件事是我们可以使用变量。 格式为*|VARIABLE_NAME|*
。 从代码中,我们将为每个客户设置这些代码。 准备就绪后,单击右下角的“ 保存并退出 ”。
在模板列表中,点击右侧的编辑 ,然后选择发送至Mandrill 。 几秒钟后,您将收到一条确认消息。
要确认模板已导出,请导航至Mandrill并登录。从左侧菜单中选择Outbound ,然后从顶部菜单中选择Templates 。 在下图中,您可以看到模板已导出。
如果单击模板的名称,则会看到有关模板的更多信息。 字段“ Template Slug”是我们将在应用程序中使用的文本标识符,以使Mandrill API知道我们要用于发送电子邮件的模板。
我将其作为练习来创建一个“重置密码”模板。
5.2从我的笔记发送电子邮件
首先,从NuGet安装Mandrill。 之后,将您的Mandrill API密钥添加到Web.config应用程序设置。 现在,打开App_Start / IdentityConfig.cs,您将看到类EmailService
框架待执行:
public class EmailService : IIdentityMessageService
{
public Task SendAsync(IdentityMessage message)
{
// Plug in your email service here to send an email.
return Task.FromResult(0);
}
}
尽管此类仅具有方法SendAsync
,但由于我们有两个不同的模板(“欢迎电子邮件模板”和“重置密码模板”),我们将实现新方法。 最终的实现将如下所示。
public class EmailService : IIdentityMessageService
{
private readonly MandrillApi _mandrill;
private const string EmailFromAddress = "no-reply@mynotes.com";
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;
}
}
通过Mandrill API发送电子邮件:
- 创建电子邮件。
- 设置消息变量的值。
- 发送电子邮件,指定模板段。
在AccountController-> Register action中,这是发送欢迎电子邮件的代码段:
await _userManager.EmailService.SendWelcomeEmail(user.UserName, user.Email);
在AccountController-> ForgotPassword操作中,这是发送电子邮件的代码:
// 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.集成SAAS Ecom进行计费
在SAAS应用程序中,重要的一件事是计费。 在此示例中,我们需要一种定期向客户收取费用的方法。 因为这部分需要很多工作,但是不会对我们销售的产品增加任何有价值的东西,所以我们将使用为此目的创建的开源库SAAS Ecom。
6.1数据模型:实体框架代码优先
SAAS Ecom依赖于Entity Framework Code First 。 对于那些不熟悉它的人,Entity Framework Code First允许您专注于创建C#POCO类,让Entity Framework将这些类映射到数据库表。 它遵循约定优于配置的思想,但是如果需要,您仍然可以指定映射,外键等。
要将SAAS Ecom添加到我们的项目中,只需使用NuGet安装依赖项。 该库分为两个包:包含业务逻辑的SaasEcom.Core和包含一些在MVC应用程序中使用的视图助手的SaasEcom.FrontEnd。 继续安装SaasEcom.FrontEnd。
您可以看到一些文件已添加到您的解决方案中:
- 内容/卡片图标:信用卡图标显示在计费区域中
- 控制器/计费控制器:主控制器
- Controllers / StripeWebhooksController:条纹Webhooks
- 脚本/saasecom.card.form.js:将信用卡添加到Stripe的脚本
- 视图/账单:视图和视图局部
集成SAAS Ecom尚需完成一些步骤,因此请获取您的Stripe API密钥并将其添加到Web.config。
<appSettings>
<add key="StripeApiSecretKey" value="your_key_here" />
<add key="StripeApiPublishableKey" value="your_key_here" />
</appSettings>
如果尝试编译,则会看到错误:
打开文件Models / IdentityModels.cs,然后使类ApplicationUser从SaasEcomUser继承。
ApplicationUser : SaasEcomUser { /* your class methods*/ }
打开文件Models / IdentityModels.cs,然后您的类ApplicationDbContext应该从SaasEcomDbContext <ApplicationUser>继承。
ApplicationDbContext : SaasEcomDbContext<ApplicationUser>
{ /* Your Db context properties */ }
由于ApplicationUser
是从SaasEcomUser
继承的,因此Entity Framework的默认行为是在数据库中创建两个表。 因为在这种情况下我们不需要这样做,所以我们需要将此方法添加到类ApplicationDbContext
以指定仅应使用一个表:
protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
modelBuilder.Entity<ApplicationUser>().Map(m => m.MapInheritedProperties());
base.OnModelCreating(modelBuilder);
}
当我们刚刚更新DbContext
,以使其继承自SaasEcomDbContext
,也必须更新数据库。 为此,请从菜单工具> NuGet软件包管理器>软件包管理器控制台中启用代码迁移并更新数据库,以打开NuGet软件包管理器 :
PM > enable-migrations
PM > add-migration Initial
PM > update-database
如果在运行update-database
时出错,则数据库(SQL Compact)位于AppData文件夹中,因此请打开数据库,删除其中的所有表,然后再次运行update-database
。
6.2在条带和数据库中创建订阅计划
该项目的下一步是集成Stripe,以向我们的客户每月收取费用,为此,我们需要在Stripe中创建订阅计划和定价。 因此,登录到Stripe仪表板,然后创建您的订阅计划,如图所示。
在Stripe中创建订阅计划后,让我们将其添加到数据库中。 我们这样做是为了不必每次需要与订阅计划有关的任何信息时都查询Stripe API。
另外,我们可以存储与每个计划相关的特定属性。 在此示例中,我将用户可以保存的便笺数量保存为每个计划的属性:基本计划100个便笺,专业人士10,000个便笺,商业计划100万个便笺。 当我们从NuGet Package Manager控制台运行update-database
时,每次数据库更新时,我们都会将该信息添加到Seed方法中。
打开文件Migrations / Configuration.cs并添加此方法:
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订阅客户订阅计划
接下来需要做的是确保每次用户注册我们的应用程序时,我们还使用其API在Stripe中创建用户。 为此,我们使用SAAS Ecom API,我们只需要在AccountController中编辑Action Register并在数据库中创建用户后添加以下行:
// Create Stripe user
await SubscriptionsFacade.SubscribeUserAsync(user, model.SubscriptionPlan);
await UserManager.UpdateAsync(user);
方法SubscribeUserAsync
将用户预订到Stripe中的计划,如果该用户不存在于Stripe中,则也会创建该计划。 如果您拥有免费的SAAS,并且仅在Stripe中创建了付费计划的用户,此功能将非常有用。 来自AccountController
的Register
操作的另一个小更改是在创建用户时保存RegistrationDate
和LastLoginTime
:
var user = new ApplicationUser
{
UserName = model.Email,
Email = model.Email,
RegistrationDate = DateTime.UtcNow,
LastLoginTime = DateTime.UtcNow
};
var result = await UserManager.CreateAsync(user, model.Password);
由于我们需要SAAS Ecom的依赖项SubscriptionsFacade ,因此将其作为属性添加到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"])));
}
}
您可以简化使用依赖项注入实例化此方法的方式,但这可以在另一篇文章中介绍。
6.4整合账单视图
当我们将SAAS Ecom添加到项目中时,也添加了一些视图部分。 他们使用主要的_Layout.cshtml,但是该布局是着陆页使用的布局。 我们需要为Web应用程序区域或客户仪表板添加不同的布局。
我创建了与_Layout.cshtml非常相似的版本,该版本是在Visual Studio中添加新的MVC项目时创建的-您可以在GitHub中看到_DashboardLayout.cshtml 。
主要区别在于,我添加了font-awesome和一个用于显示Bootstrap通知(如果存在)的区域:
<div id="bootstrap_alerts">
@if (TempData.ContainsKey("flash"))
{
@Html.Partial("_Alert", TempData["flash"]);
}
</div>
对于“视图/账单”文件夹中的视图,将布局设置为_DashboardLayout,否则它将使用默认布局_Layout.cshtml。 对“视图/管理”文件夹中的视图执行相同的操作:
Layout = "~/Views/Shared/_DashboardLayout.cshtml";
我对“ DashboardLayout”做了一些修改,以使用主网站上的某些样式,并且在注册并导航到“ 结算”部分后,看起来像这样:
客户可以在计费区域中取消或升级/降级订阅。 使用Stripe JavaScript API添加付款详细信息,因此我们不需要与PCI兼容,只需要服务器中的SSL即可从客户处付款。
要正确测试新应用程序,可以使用Stripe提供的几个信用卡号 。
您可能想要做的最后一件事是设置Stripe Webhooks 。 这用于让Stripe通知您帐单中发生的事件,例如付款成功,付款逾期,试用即将到期等等,您可以从Stripe文档中获取完整列表。 Stripe事件以JSON的形式发送到面向公众的URL。 要在本地测试,您可能要使用Ngrok 。
安装SAAS Ecom后,添加了一个新的控制器来处理Stripe中的webhooks: StripeWebhooksController.cs 。 您可以在此处看到如何处理发票创建事件:
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;
您可以根据需要在控制器中实现尽可能多的事件。
7.在我们的应用程序中建立笔记功能
此SAAS应用程序最重要的部分是允许我们的客户保存笔记。 为了创建此功能,让我们从创建Note
类开始:
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; }
}
从ApplicationUser
添加一对多关系到Note
:
public virtual ICollection<Note> Notes { get; set; }
由于DbContext已更改,我们需要添加一个新的数据库Migration,因此打开Nuget Package Manager控制台并运行:
PM> add-migration NotesAddedToModel
这是生成的代码:
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");
}
}
接下来我们需要的是Controller MyNotes。 因为我们已经有了模型类Notes,所以我们使用支架来创建控制器类,以使用Entity Framework创建,读取,更新和删除方法。 我们还使用支架来生成视图。
此时,在用户成功在My Notes上注册后,将用户重定向到NotesController
的Index
操作:
TempData["flash"] = new FlashSuccessViewModel("Congratulations! Your account has been created.");
return RedirectToAction("Index", "Notes");
到目前为止,我们已经为Notes创建了一个CRUD(创建/读取/更新/删除)界面。 我们仍然需要检查用户何时尝试添加注释,以确保他们的订阅中有足够的空间。
空笔记清单:
创建新笔记:
笔记清单:
注意事项:
编辑笔记:
确认删除笔记:
我将略微修改默认标记:
- 在创建注释的表单中,我删除了
CreatedAt
字段,并在控制器中设置了值。 - 在用于编辑便笺的表单中,我将
CreatedAt
更改为隐藏字段,以使其不可编辑。 - 我对CSS进行了一些修改,以使此表单看起来也更好。
当我们使用Entity Framework生成Notes控制器时,注释列表列出了数据库中的所有注释,而不仅仅是列出了登录用户的注释。 为了安全起见,我们需要检查用户只能看到,修改或删除属于他们的注释。
我们还需要在允许用户创建新笔记之前检查用户拥有多少笔记,以检查是否满足订购计划的限制。 这是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);
}
}
就是这样-我们具有SAAS应用程序的核心功能。
8.出于欧洲增值税目的保存客户的位置
今年年初, 欧盟针对向私人消费者提供数字服务的企业征收增值税的法律发生了变化。 主要区别在于,根据企业所在的欧盟国家/地区,企业必须向私人客户而不是具有有效增值税号的企业客户收取增值税。 为了验证他们所基于的国家/地区,我们需要保留以下至少两种形式的记录:
- 客户的帐单地址
- 客户使用的设备的Internet协议(IP)地址
- 客户的银行资料
- 客户使用的SIM卡的国家/地区代码
- 提供服务的客户固定陆线的位置
- 其他与商业相关的信息(例如,通过电子方式将销售链接到特定司法管辖区的产品编码信息)
因此,我们将对用户IP地址进行地理位置定位,以将其与账单地址和信用卡国家/地区一起保存。
8.1 IP地址地理位置
对于地理位置,我将使用Maxmind GeoLite2 。 这是一个免费的数据库,可向我们提供IP所在的国家/地区。
下载,然后将数据库添加到App_Data中,如照片所示:
创建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)));
}
}
在此静态类中实现了两种方法:
-
GetCountryFromIP
:返回给定IP地址的国家/地区ISO代码。 -
SelectListCountries
:在下拉字段中返回要使用的国家/地区列表。 它具有国家/地区ISO代码作为每个国家/地区的值以及要显示的国家/地区名称。
8.2在注册时节省客户国家
在操作Register
的AccountController
,创建用户时,保存IP和IP所属的国家:
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),
};
另外,当我们在Stripe中创建订阅时,我们需要为此客户传递税率。 我们在创建用户后执行以下几行操作:
// Create Stripe user
var taxPercent = EuropeanVat.Countries.ContainsKey(user.IPAddressCountry) ?
EuropeanVat.Countries[user.IPAddressCountry] : 0;
await SubscriptionsFacade.SubscribeUserAsync(user, model.SubscriptionPlan, taxPercent: taxPercent);
默认情况下,如果用户来自欧盟,则将税率设置为该订阅。 规则要比这复杂一些,但总结一下:
- 如果您的公司在欧盟国家/地区注册,则始终向您所在国家/地区的客户收取增值税。
- 如果您的公司在欧盟国家/地区注册,则仅向位于其他欧盟国家/地区的客户收取增值税,而不是在增值税中注册的企业。
- 如果您的企业在欧盟以外的地区注册,则只会向不是具有有效增值税号码的企业的客户收取增值税。
8.3在我们的模型中添加账单地址
目前,我们不允许客户保存帐单邮寄地址和增值税号(如果他们是欧盟增值税注册企业)。 在这种情况下,我们需要将其税率更改为0。
SAAS Ecom提供BillingAddress
类,但未附加到模型的任何实体。 这样做的主要原因是,在多个SAAS应用程序中,如果多个用户可以访问同一帐户,则可以将其分配给Organization类。 如果不是这种情况,例如在我们的示例中,我们可以安全地将该关系添加到ApplicationUser
类:
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;
}
}
每次修改模型时,我们都需要添加数据库迁移,打开工具> NuGet软件包管理器>软件包管理器控制台 :
PM> add-migration BillingAddressAddedToUser
这是我们获得的迁移类:
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");
}
}
要在数据库中创建这些更改,我们在程序包管理器控制台中执行:
PM> update-database
我们需要解决的另一个细节是,在AccountController>注册中,我们需要设置默认的帐单邮寄地址,因为它是一个不可为空的字段。
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()
};
在帐单页面中,我们需要为客户显示帐单地址(如果已添加),并且还允许我们的客户进行编辑。 首先,我们需要从BillingController
修改操作Index
,以将帐单地址传递给视图:
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();
}
要显示地址,我们只需要编辑视图“ Billing / Index.cshtml”,并添加SAAS Ecom为此提供的部分视图:
<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)
现在,如果我们导航到Billing,我们可以看到新部分:
下一步是在BillingController> BillingAddress操作上,我们需要将Billing地址传递给视图。 因为我们需要获取用户的两个字母的ISO国家/地区代码,所以我添加了一个下拉列表来选择国家/地区,该国家/地区默认为用户IP所属的国家/地区:
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);
}
用户提交表单时,我们需要保存帐单地址并根据需要更新税率:
[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);
}
}
这是添加或编辑帐单地址的表单,如下所示:
添加地址后,我将被重定向回计费区域:
如您在上面的屏幕快照中所见,由于我将国家/地区设置为英国,并且没有输入增值税号,所以每月价格会加上20%的增值税。 此处显示的代码假设您是非欧盟公司。 如果是这样,您需要处理客户在您所在国家/地区的情况,无论他们是否有增值税,都必须收取增值税。
9.部署到Azure网站(虚拟主机+ SSL免费,SQL数据库$ 5每月)
9.1部署网站
我们的SAAS项目已准备就绪,可以投入使用,我已经选择Azure作为托管平台。 如果您还没有帐户,则可以免费试用一个月。 如果愿意,我们可以在每次提交时从Git(GitHub或BitBucket)部署应用程序。 我将在这里向您展示如何从Visual Studio 2013进行部署。在解决方案资源管理器中,右键单击“ 我的笔记 ”项目,然后从上下文菜单中选择“ 发布 ”。 将打开“发布Web”向导。
选择Microsoft Azure网站 ,然后单击新建 。
填写您的网站的详细信息,然后点击创建 。 创建网站后,您会看到此信息。 单击下一步 。
在此步骤中,您可以为数据库添加连接字符串,也可以稍后从管理门户添加它。 单击下一步 。
现在,如果我们单击“ 发布” ,Visual Studio将把网站上传到Azure。
9.2部署数据库
要创建数据库,您必须转到Azure管理门户 ,选择浏览 ,然后选择数据+存储> SQL数据库 。 填写表格以创建数据库。
创建数据库后,选择“ 在Visual Studio中打开”,然后接受以向防火墙添加例外。
您的数据库将在Visual Studio的SQL Server对象资源管理器中打开。 如您所见,还没有表格:
若要生成SQL脚本以在数据库中创建表,请在Visual Studio中打开“程序包管理器控制台”,然后键入:
PM> update-database -SourceMigration:0 -Script
复制脚本,然后返回到SQL Server Object Explorer,右键单击数据库,然后选择New Query 。 粘贴脚本,然后执行它。
该脚本不包含我们从Seed方法插入到数据库中的数据。 我们需要手动创建一个脚本以将该数据添加到数据库中:
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')
至此, 我的笔记SAAS已上线 。 我已经配置了Stripe测试API密钥,因此您可以根据需要使用测试信用卡详细信息进行测试。
翻译自: https://code.tutsplus.com/tutorials/building-a-note-taking-saas-using-aspnet-mvc-5--cms-22922