Статьи

Построение заметок SAAS с использованием ASP.NET MVC 5

Конечный продукт
Что вы будете создавать

В этом руководстве я собираюсь показать вам, как создать минимально жизнеспособный продукт Software-as-a-Service (SaaS) (MVP). Для простоты, программное обеспечение позволит нашим клиентам сохранить список заметок.

Я собираюсь предложить три плана подписки: базовый план будет иметь ограничение в 100 заметок на пользователя, профессиональный план позволит клиентам сэкономить до 10 000 заметок, а бизнес-план — миллион заметок. Планы обойдутся в 10, 20 и 30 долларов в месяц соответственно. Чтобы получать оплату от наших клиентов, я собираюсь использовать Stripe в качестве платежного шлюза, а веб-сайт будет развернут в Azure.

За очень короткое время Stripe стал очень известным платежным шлюзом, главным образом благодаря их удобному для разработчиков подходу с простыми и хорошо документированными API. Их цена также очень ясна: 2,9% за транзакцию + 30 центов. Никаких сборов за установку или скрытых платежей.

Данные кредитных карт также являются очень конфиденциальными данными, и чтобы иметь возможность получать и хранить эти данные на моем сервере, я должен быть PCI-совместимым . Поскольку это не простая или быстрая задача для большинства небольших компаний, подход, который используют многие платежные шлюзы, заключается в следующем: вы отображаете детали заказа, а когда клиент соглашается на покупку, вы перенаправляете клиента на страницу, размещенную на платежном шлюзе (банк , PayPal и т. Д.), А затем они перенаправляют клиента обратно.

Stripe имеет более хороший подход к этой проблеме. Они предлагают JavaScript API, поэтому мы можем отправить номер кредитной карты напрямую с внешнего сервера на серверы Stripe. Они возвращают токен одноразового использования, который мы можем сохранить в нашей базе данных. Теперь нам нужен только SSL-сертификат для нашего сайта, который мы можем быстро купить примерно за 5 долларов в год.

Теперь зарегистрируйте учетную запись Stripe , поскольку она понадобится вам для оплаты ваших клиентов.

Как разработчик, я не хочу иметь дело с задачами разработки и управления серверами, если мне это не нужно. Веб-сайты Azure — мой выбор для хостинга, потому что это полностью управляемая платформа как услуга. Это позволяет мне развертываться из Visual Studio или Git, я могу легко масштабировать его, если мой сервис успешен, и я могу сосредоточиться на улучшении своего приложения. Они предлагают 200 долларов на все услуги Azure в первый месяц для новых клиентов. Этого достаточно, чтобы оплатить услуги, которые я использую для этого MVP. Зарегистрируйтесь в Azure .

Отправка электронных писем из нашего приложения может показаться не очень сложной задачей, но я хотел бы отслеживать, сколько сообщений доставлено успешно, а также легко создавать адаптивные шаблоны. Это то, что предлагает Mandrill, и они также позволяют нам бесплатно отправлять до 12 000 электронных писем в месяц. Mandrill создан MailChimp, поэтому они знают о бизнесе отправки электронных писем. Кроме того, мы можем создавать наши шаблоны из MailChimp, экспортировать их в Mandrill и отправлять электронные письма из нашего приложения, используя наши шаблоны. Зарегистрируйтесь в Mandrill и зарегистрируйтесь в MailChimp .

И последнее, но не менее важное: нам нужна Visual Studio для написания нашего приложения. Это издание, выпущенное всего несколько месяцев назад, является полностью бесплатным и в значительной степени эквивалентно Visual Studio Professional. Вы можете скачать его здесь , и это все, что нам нужно, так что теперь мы можем сосредоточиться на разработке.

Первое, что нам нужно сделать, это открыть Visual Studio 2013. Создайте новое веб-приложение ASP.NET:

  • Перейдите в Файл> Новый проект и выберите Веб-приложение ASP.NET .
  • В диалоговом окне шаблона ASP.NET выберите шаблон MVC и выберите « Индивидуальные учетные записи пользователей» .
Новый проект ASPNET MVC

В этом проекте создается приложение, в которое пользователь может войти, зарегистрировав аккаунт на сайте. Веб-сайт оформлен с использованием Bootstrap , и я продолжу создавать остальную часть приложения с помощью Bootstrap. Если вы нажмете F5 в Visual Studio, чтобы запустить приложение, вы увидите следующее:

Пример домашней страницы

Это целевая страница по умолчанию, и эта страница является одним из наиболее важных шагов для превращения наших посетителей в клиентов. Нам нужно объяснить продукт, показать цену для каждого плана и предложить им возможность подписаться на бесплатную пробную версию. Для этого приложения я создаю три разных плана подписки:

  • Базовый : 10 долларов в месяц
  • Профессиональный : 20 долларов в месяц
  • Бизнес : 30 долларов в месяц

Чтобы получить помощь в создании целевой страницы, вы можете посетить ThemeForest и приобрести шаблон. Для этого примера я использую бесплатный шаблон , и вы можете увидеть окончательный результат на фотографии ниже.

Целевая страница

На веб-сайте, который мы создали на предыдущем этапе, мы также получаем шаблон регистрационной формы. На целевой странице, когда вы переходите к ценам и нажимаете « Бесплатная пробная версия» , вы переходите на страницу регистрации. Это дизайн по умолчанию:

Страница регистрации

Нам нужно только одно дополнительное поле, чтобы указать план подписки, к которому присоединяется пользователь. Если вы видите на панели навигации фотографии, я передаю это как параметр GET. Для этого я генерирую разметку для ссылок на целевой странице, используя следующую строку кода:

1
2
3
<a href=»@Url.Action(«Register», «Account», new { plan = «business» })»>
    Free Trial
</a>

Чтобы связать план подписки с серверной частью, мне нужно изменить класс RegisterViewModel и добавить новое свойство.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
public class RegisterViewModel
{
    [Required]
    [EmailAddress]
    [Display(Name = «Email»)]
    public string Email { get;
 
    [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;
 
    [DataType(DataType.Password)]
    [Display(Name = «Confirm password»)]
    [Compare(«Password», ErrorMessage = «The password and confirmation password do not match.»)]
    public string ConfirmPassword { get;
 
    public string SubscriptionPlan { get;
}

Я также должен отредактировать AccountController.cs и изменить реестр действий, чтобы получить план:

1
2
3
4
5
6
7
8
[AllowAnonymous]
public ActionResult Register(string plan)
{
    return View(new RegisterViewModel
    {
        SubscriptionPlan = plan
    });
}

Теперь я должен отобразить идентификатор плана в скрытом поле в форме реестра:

1
@Html.HiddenFor(m => m.SubscriptionPlan)

Последним шагом будет подписка пользователя на план, но мы вернемся к этому чуть позже. Я также обновляю дизайн регистрационной формы.

Обновленная регистрационная форма
Страница авторизации

В шаблоне мы также получаем страницу входа и реализованные контроллеры действий. Единственное, что мне нужно сделать, это сделать его красивее.

Обновленная страница входа

Посмотрите на предыдущий скриншот еще раз, и вы заметите, что я добавил «Забыли пароль?» ссылка на сайт. Это уже реализовано в шаблоне, но по умолчанию закомментировано. Мне не нравится поведение по умолчанию, когда пользователю необходимо подтвердить адрес электронной почты, чтобы иметь возможность сбросить пароль. Давайте уберем это ограничение. В файле AccountController.cs отредактируйте действие ForgotPassword :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
[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: сообщение с подтверждением после сброса пароля.
Страница забытого пароля

ASP.NET Identity — это довольно новая библиотека, созданная исходя из предположения, что пользователи больше не будут входить в систему, используя только имя пользователя и пароль. Интеграция OAuth, позволяющая пользователям входить в систему через социальные каналы, такие как Facebook, Twitter и другие, теперь очень проста. Также эту библиотеку можно использовать с Web API и SignalR.

С другой стороны, слой постоянства можно заменить, и его легко подключить к различным механизмам хранения, таким как базы данных NoSQL. Для целей этого приложения я буду использовать Entity Framework и SQL Server.

Проект, который мы только что создали, содержит следующие три пакета NuGet для удостоверения ASP.NET:

  • Microsoft.AspNet.Identity.Core: этот пакет содержит основные интерфейсы для ASP.NET Identity.
  • Microsoft.AspNet.Identity.EntityFramework: этот пакет имеет реализацию Entity Framework предыдущей библиотеки. Это сохранит данные в SQL Server.
  • Microsoft.AspNet.Identity.Owin. Этот пакет подключает промежуточную проверку подлинности OWIN к ASP.NET Identity.

Основная конфигурация для Identity находится в App_Start / IdentityConfig.cs. Это код, который инициализирует идентичность.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
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.
    // 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 генерируется платформой и шифруется. Таким образом, мы можем масштабировать горизонтально, добавляя больше серверов, если наше приложение нуждается в этом.

Вы можете использовать MailChimp для разработки шаблонов электронной почты и Mandrill для отправки электронных писем из вашего приложения. Прежде всего вам необходимо связать свою учетную запись Mandrill с вашей учетной записью MailChimp:

  • Войдите в MailChimp, щелкните свое имя пользователя на правой панели и выберите « Аккаунт» в раскрывающемся списке.
  • Нажмите на Интеграции и найдите опцию Mandrill в списке интеграций.
  • Нажмите на нее, чтобы увидеть подробности интеграции, и нажмите кнопку « Авторизовать соединение» . Вы будете перенаправлены на Mandrill. Разрешите соединение, и интеграция будет завершена.
Настройка Mandrill

Перейдите к шаблонам в MailChimp и нажмите « Создать шаблон» .

Создание шаблона

Теперь выберите один из шаблонов, предлагаемых MailChimp. Я выбрал первый:

Выбор шаблона

В редакторе шаблонов мы изменяем содержимое по своему усмотрению. Как вы можете видеть ниже, стоит отметить, что мы можем использовать переменные. Формат: *|VARIABLE_NAME|* . Из кода мы установим их для каждого клиента. Когда вы будете готовы, нажмите Сохранить и выйти в правом нижнем углу.

Заполнение шаблона

В списке шаблонов нажмите « Изменить» с правой стороны и выберите « Отправить на Mandrill» . Через несколько секунд вы получите подтверждающее сообщение.

Отправка в мандрил

Чтобы подтвердить, что шаблон был экспортирован, перейдите к Mandrill и войдите в систему. Выберите Outbound в левом меню, а затем Templates в верхнем меню. На изображении ниже вы можете видеть, что шаблон был экспортирован.

Исходящие данные

Если вы нажмете на название шаблона, вы увидите больше информации о шаблоне. Поле «Template Slug» — это текстовый идентификатор, который мы будем использовать в нашем приложении, чтобы позволить Mandrill API знать, какой шаблон мы хотим использовать для отправляемого нами электронного письма.

Разметка шаблона

Я оставляю вам в качестве упражнения создание шаблона «Сброс пароля».

Сбросить шаблон пароля
Отправка писем

Прежде всего, установите Mandrill от NuGet. После этого добавьте свой ключ API Mandrill в настройки приложения Web.config. Теперь откройте App_Start / IdentityConfig.cs, и вы увидите скелет класса EmailService ожидающий реализации:

1
2
3
4
5
6
7
8
public class EmailService : IIdentityMessageService
{
    public Task SendAsync(IdentityMessage message)
    {
        // Plug in your email service here to send an email.
        return Task.FromResult(0);
    }
}

Хотя в этом классе есть только метод SendAsync , так как у нас есть два разных шаблона (Welcome Email Template и Reset Password Template), мы будем реализовывать новые методы. Окончательная реализация будет выглядеть следующим образом.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
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;
    }
}

Чтобы отправить электронное письмо через Mandrill API:

  1. Создать электронное сообщение.
  2. Установите значения переменных сообщения.
  3. Отправить письмо с указанием шаблона слаг.

В AccountController -> Register action это фрагмент кода для отправки приветственного письма:

1
await _userManager.EmailService.SendWelcomeEmail(user.UserName, user.Email);

В AccountController -> действие ForgotPassword это код для отправки электронного письма:

1
2
3
4
// 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);

Одна важная вещь в приложениях SAAS — выставление счетов. У нас должен быть способ взимать плату с наших клиентов периодически, в этом примере ежемесячно. Поскольку эта часть требует много работы, но не добавляет ничего ценного в продукт, который мы продаем, мы будем использовать библиотеку с открытым исходным кодом SAAS Ecom, созданную для этой цели.

SAAS Ecom имеет зависимость от Entity Framework Code First . Для тех из вас, кто не знаком с ним, Entity Framework Code First позволяет вам сосредоточиться на создании классов C # POCO, позволяя Entity Framework отображать классы в таблицы базы данных. Он следует идее соглашения по конфигурации, но вы все равно можете указать отображения, внешние ключи и так далее, если это необходимо.

Чтобы добавить SAAS Ecom в наш проект, просто установите зависимость с помощью NuGet. Библиотека разделена на два пакета: SaasEcom.Core, который содержит бизнес-логику, и SaasEcom.FrontEnd, который содержит несколько помощников представления для использования в приложении MVC. Продолжайте и установите SaasEcom.FrontEnd.

Установка SaasEcom

Вы можете видеть, что некоторые файлы были добавлены в ваше решение:

  • Значки содержимого / карты: значки кредитных карт для отображения в области выставления счетов
  • Контроллеры / BillingController: Главный контроллер
  • Контроллеры / StripeWebhooksController: Stripe Webhooks
  • Scripts / saasecom.card.form.js: скрипт для добавления кредитной карты в Stripe
  • Представления / Выставление счетов: Представления и частичные представления
Файлы, добавленные в проект

Для интеграции SAAS Ecom еще осталось несколько шагов, поэтому получите ключи Stripe API и добавьте их в Web.config.

1
2
3
4
<appSettings>
  <add key=»StripeApiSecretKey» value=»your_key_here» />
  <add key=»StripeApiPublishableKey» value=»your_key_here» />
</appSettings>

Если вы попытаетесь скомпилировать, вы увидите ошибки:

Откройте файл Models / IdentityModels.cs и затем наследуйте класс ApplicationUser от SaasEcomUser .

1
ApplicationUser : SaasEcomUser { /* your class methods*/ }

Откройте файл Models / IdentityModels.cs, а затем ваш класс ApplicationDbContext должен унаследовать от SaasEcomDbContext <ApplicationUser> .

1
2
ApplicationDbContext : SaasEcomDbContext<ApplicationUser>
{ /* Your Db context properties */ }

Поскольку ApplicationUser наследуется от SaasEcomUser , стандартным поведением Entity Framework будет создание двух таблиц в базе данных. Поскольку в этом случае нам это не нужно, нам нужно добавить этот метод в класс ApplicationDbContext чтобы указать, что он должен использовать только одну таблицу:

1
2
3
4
5
protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
    modelBuilder.Entity<ApplicationUser>().Map(m => m.MapInheritedProperties());
    base.OnModelCreating(modelBuilder);
}

Поскольку мы только что обновили DbContext , чтобы он унаследовал от SaasEcomDbContext , база данных также должна быть обновлена. Для этого включите миграцию кода и обновите базу данных, открыв диспетчер пакетов NuGet из меню Инструменты> Диспетчер пакетов NuGet> Консоль диспетчера пакетов :

1
2
3
PM > enable-migrations
PM > add-migration Initial
PM > update-database

Если вы получаете ошибку при запуске update-database , база данных (SQL Compact) находится внутри вашей папки AppData, поэтому откройте базу данных, удалите все таблицы в ней, а затем снова запустите update-database .

Следующим шагом в проекте является интеграция Stripe, чтобы ежемесячно взимать плату с наших клиентов, и для этого нам нужно создать тарифные планы и тарифы в Stripe. Войдите в свою панель Stripe и создайте планы подписки, как показано на рисунках.

нашивка
План полоска
План полосовых испытаний

Как только мы создадим планы подписки в Stripe, давайте добавим их в базу данных. Мы делаем это так, чтобы нам не приходилось запрашивать Stripe API каждый раз, когда нам нужна какая-либо информация, связанная с планами подписки.

Кроме того, мы можем хранить определенные свойства, связанные с каждым планом. В этом примере я сохраняю в качестве свойства каждого плана количество заметок, которые может сохранить пользователь: 100 заметок для базового плана, 10000 для специалиста и 1 миллион для бизнес-плана. Мы добавляем эту информацию в метод Seed, который выполняется каждый раз, когда база данных обновляется при запуске update-database из консоли NuGet Package Manager.

Откройте файл Migrations / Configuration.cs и добавьте этот метод:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
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);
}

Следующее, что нам нужно сделать, это убедиться, что каждый раз, когда пользователь регистрируется в нашем приложении, мы также создаем пользователя в Stripe, используя его API. Для этого мы используем SAAS Ecom API, и нам просто нужно отредактировать действие Register в AccountController и добавить эти строки после создания пользователя в базе данных:

1
2
3
// Create Stripe user
await SubscriptionsFacade.SubscribeUserAsync(user, model.SubscriptionPlan);
await UserManager.UpdateAsync(user);

Метод SubscribeUserAsync подписывает пользователя на план в Stripe, и, если пользователь еще не существует в Stripe, он также создается. Это полезно, если у вас есть freemium SAAS и вы создаете пользователей в Stripe только тогда, когда они находятся на платном плане. Еще одно небольшое изменение в действии Register от AccountController — сохранение RegistrationDate и LastLoginTime при создании пользователя:

1
2
3
4
5
6
7
8
var user = new ApplicationUser
{
    UserName = model.Email,
    Email = model.Email,
    RegistrationDate = DateTime.UtcNow,
    LastLoginTime = DateTime.UtcNow
};
var result = await UserManager.CreateAsync(user, model.Password);

Поскольку нам нужна зависимость SubscriptionFacade от SAAS Ecom, добавьте ее в качестве свойства в Account Controller:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
private SubscriptionsFacade _subscriptionsFacade;
private SubscriptionsFacade SubscriptionsFacade
{
    get
    {
        return _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»])));
    }
}

Вы можете упростить способ создания этого экземпляра, используя внедрение зависимостей, но об этом можно рассказать в другой статье.

Когда мы добавили SAAS Ecom в проект, также были добавлены некоторые детали просмотра. Они используют основной _Layout.cshtml, но именно этот макет используется целевой страницей. Нам нужно добавить другой макет для области веб-приложения или панели пользователя.

Я создал очень похожую версию на _Layout.cshtml, которая создается при добавлении нового проекта MVC в Visual Studio — вы можете увидеть _DashboardLayout.cshtml в GitHub.

Основные отличия в том, что я добавил font-awesome и область для отображения уведомлений Bootstrap, если они присутствуют:

1
2
3
4
5
6
<div id=»bootstrap_alerts»>
    @if (TempData.ContainsKey(«flash»))
    {
        @Html.Partial(«_Alert», TempData[«flash»]);
    }
</div>

Для представлений в папке Views / Billing установите макет на _DashboardLayout, в противном случае он будет использовать стандартный макет _Layout.cshtml. Сделайте то же самое для представлений в папке Views / Manage:

1
Layout = «~/Views/Shared/_DashboardLayout.cshtml»;

Я немного изменил «DashboardLayout», чтобы использовать некоторые стили основного сайта, и это выглядит так после регистрации и перехода в раздел « Оплата »:

Шаблон биллинга

В области выставления счетов клиент может отменить или обновить / понизить подписку. Добавьте детали платежа, используя Stripe JavaScript API , чтобы нам не нужно было поддерживать PCI, и нам нужен только SSL на сервере, чтобы принимать платежи от наших клиентов.

Шаблон оплаты

Чтобы правильно протестировать новое приложение, вы можете использовать несколько номеров кредитных карт, предоставленных Stripe .

Тестирование оплаты

Последнее, что вы можете сделать, это настроить Stripe Webhooks . Это используется для того, чтобы Stripe мог уведомлять вас о событиях, которые происходят в вашем биллинге, таких как успешный платеж, просроченный платеж, пробный срок истечения срока действия и т. Д. — вы можете получить полный список из документации Stripe . Событие Stripe отправляется в формате JSON на общедоступный URL-адрес. Чтобы проверить это локально, вы, вероятно, хотите использовать Ngrok .

Когда был установлен SAAS Ecom, был добавлен новый контроллер для обработки веб- крючков из Stripe: StripeWebhooksController.cs . Вы можете увидеть, как обрабатывается событие созданного счета:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
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;

Вы можете реализовать столько событий в контроллере, сколько вам нужно.

Самая важная часть этого приложения SAAS — позволить нашим клиентам сохранять заметки. Чтобы создать эту функциональность, давайте начнем с создания класса Note :

01
02
03
04
05
06
07
08
09
10
11
12
public class Note
{
    public int Id { get;
 
    [Required]
    [MaxLength(250)]
    public string Title { get;
 
    [Required]
    public string Text { get;
    public DateTime CreatedAt { get;
}

Добавьте отношение «один ко многим» из ApplicationUser в Note :

1
public virtual ICollection<Note> Notes { get;

Поскольку DbContext изменился, нам нужно добавить новую миграцию базы данных, поэтому откройте консоль диспетчера пакетов Nuget и запустите:

1
PM> add-migration NotesAddedToModel

Это сгенерированный код:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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»);
    }
}

Следующее, что нам нужно, это контроллер MyNotes. Поскольку у нас уже есть класс модели Notes, мы используем скаффолд для создания класса контроллера, чтобы иметь методы создания, чтения, обновления и удаления с использованием Entity Framework. Мы также используем эшафот для генерации просмотров.

Добавить строительные леса
Добавить контроллер

На этом этапе, после успешной регистрации пользователя в My Notes, перенаправьте пользователя на действие Index в NotesController :

1
2
TempData[«flash»] = new FlashSuccessViewModel(«Congratulations! Your account has been created.»);
return RedirectToAction(«Index», «Notes»);

До сих пор мы создали интерфейс CRUD (Создать / Читать / Обновить / Удалить) для Notes. Нам по-прежнему нужно проверять, когда пользователи пытаются добавлять заметки, чтобы убедиться, что в их подписках достаточно места.

Пустой список заметок:

Индекс шаблона

Создать новую заметку:

Создание заметки

Список заметок:

Листинг Примечания

Примечание подробно:

Подробные заметки

Редактировать заметку:

Редактирование заметки

Подтвердите удаление заметки:

Удаление заметки

Я собираюсь немного отредактировать разметку по умолчанию:

  • В форме для создания заметки я удалил поле CreatedAt и установил значение в контроллере.
  • В форме для редактирования заметки я изменил CreatedAt на скрытое поле, чтобы оно не редактировалось.
  • Я немного изменил CSS, чтобы эта форма выглядела немного лучше.

Когда мы сгенерировали контроллер Notes с помощью Entity Framework, в списке заметок были перечислены все заметки в базе данных, а не только заметки для вошедшего в систему пользователя. В целях безопасности нам необходимо убедиться, что пользователи могут только просматривать, изменять или удалять принадлежащие им заметки.

Нам также необходимо проверить, сколько заметок имеет пользователь, прежде чем разрешить ему или ей создать новую, чтобы убедиться, что ограничения плана подписки соблюдены. Вот новый код для NotesController:

001
002
003
004
005
006
007
008
009
010
011
012
013
014
+015
016
+017
018
019
020
021
022
023
024
025
026
027
028
029
+030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
+055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
public class NotesController : Controller
{
    private readonly ApplicationDbContext _db = new ApplicationDbContext();
 
    private SubscriptionsFacade _subscriptionsFacade;
    private SubscriptionsFacade SubscriptionsFacade
    {
        get
        {
            return _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.

В начале этого года в Европейском Союзе изменилось законодательство по НДС для бизнеса, предоставляющего цифровые услуги частным потребителям . Основное отличие заключается в том, что предприятия должны взимать НДС с частных клиентов, а не с коммерческих клиентов с действующим номером НДС, в зависимости от страны ЕС, в которой они базируются. Чтобы проверить, в какой стране они базируются, нам нужно сохранить как минимум две из этих форм:

  • платежный адрес клиента
  • Интернет-протокол (IP) адрес устройства, используемого клиентом
  • банковские реквизиты клиента
  • код страны SIM-карты, используемой клиентом
  • местоположение фиксированной наземной линии связи, по которой предоставляется услуга
  • другая коммерчески значимая информация (например, информация о кодировке продукта, которая в электронном виде связывает продажу с конкретной юрисдикцией)

По этой причине мы собираемся определить местоположение IP-адреса пользователя, чтобы сохранить его вместе с платежным адресом и страной кредитной карты.

Для геолокации я собираюсь использовать Maxmind GeoLite2 . Это бесплатная база данных, которая дает нам страну, где находится IP.

Maxmind Geo Lite2

Скачайте и добавьте базу данных в App_Data, как вы можете видеть на фотографии:

Добавление базы данных
Установить из NuGet MaxMind.GeoIP2.
Пакеты NuGet

Создать расширения / GeoLocationHelper.cs.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
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 : Возвращает код ISO страны с указанным IP-адресом.
  • SelectListCountries: Возвращает список стран для использования в раскрывающемся поле. Он имеет код ISO страны в качестве значения для каждой страны и название страны для отображения.

В действии Registerот AccountController, при создании пользователя, сохраните IP и страну, к которой принадлежит IP:

01
02
03
04
05
06
07
08
09
10
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, нам нужно передать процент налога для этого клиента. Мы делаем это через несколько строк после создания пользователя:

1
2
3
4
// Create Stripe user
var taxPercent = EuropeanVat.Countries.ContainsKey(user.IPAddressCountry) ?
    EuropeanVat.Countries[user.IPAddressCountry] : 0;
await SubscriptionsFacade.SubscribeUserAsync(user, model.SubscriptionPlan, taxPercent: taxPercent);

По умолчанию, если пользователь находится в Европейском Союзе, я устанавливаю процент налога для этой подписки. Правила немного сложнее, но суммируем:

  • Если ваш бизнес зарегистрирован в стране ЕС, вы всегда взимаете НДС с клиентов в вашей стране.
  • Если ваш бизнес зарегистрирован в стране ЕС, вы платите НДС только с тех клиентов, которые находятся в других странах ЕС и не являются зарегистрированными компаниями.
  • Если ваш бизнес зарегистрирован за пределами ЕС, вы платите НДС только с тех клиентов, которые не имеют действующий номер НДС.

В настоящее время мы не разрешаем нашим клиентам сохранять адрес для выставления счетов и их номер НДС, если они являются зарегистрированным в ЕС бизнесом с НДС. В этом случае нам нужно изменить их налоговый процент до 0.

SAAS Ecom предоставляет BillingAddressкласс, но он не привязан ни к какой сущности модели. Основная причина этого заключается в том, что в некоторых приложениях SAAS может иметь смысл назначить это классу Organization, если несколько пользователей имеют доступ к одной и той же учетной записи. Если это не так, как в нашем примере, мы можем безопасно добавить это отношение к ApplicationUserклассу:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
public class ApplicationUser : SaasEcomUser
{
    set;
 
    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> Консоль диспетчера пакетов :

1
PM> add-migration BillingAddressAddedToUser

И это класс миграции, который мы получаем:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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");
       }
   }

Чтобы создать эти изменения в базе данных, мы выполняем в консоли диспетчера пакетов:

1
PM> update-database

Еще одна деталь, которую нам нужно исправить, заключается в том, что в AccountController> Register нам нужно установить адрес для выставления счетов по умолчанию, так как это поле, которое не может быть пустым.

01
02
03
04
05
06
07
08
09
10
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()
};

На странице оплаты нам нужно отобразить адрес выставления счета для клиента, если он был добавлен, а также разрешить его редактирование нашими клиентами. Во- первых, нам нужно изменить действие Indexот BillingControllerпередать платежный адрес в виде:

01
02
03
04
05
06
07
08
09
10
11
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:

1
2
3
4
5
6
7
8
9
<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, нам нужно передать адрес выставления счета в представление. Поскольку нам нужно получить двухбуквенный код страны ISO пользователя, я добавил выпадающий список, чтобы выбрать страну, которая по умолчанию соответствует стране, которой принадлежит IP пользователя:

01
02
03
04
05
06
07
08
09
10
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);
}

Когда пользователь отправляет форму, нам нужно сохранить платежный адрес и обновить налоговый процент, если это необходимо:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
[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% НДС. Приведенный здесь код предполагает, что вы не являетесь представителем ЕС. Если это так, вам нужно разобраться со случаем, когда ваш клиент находится в вашей стране, и независимо от того, есть у него НДС или нет, вам придется взимать НДС.

Наш проект SAAS готов к запуску, и я выбрал Azure в качестве хостинг-платформы. Если у вас еще нет аккаунта, вы можете получить бесплатную пробную версию на месяц. Мы можем развернуть наше приложение из Git (GitHub или BitBucket) на каждом коммите, если захотим. Здесь я покажу вам, как выполнить развертывание из Visual Studio 2013. В обозревателе решений щелкните правой кнопкой мыши проект My Notes и выберите « Опубликовать» в контекстном меню. Откроется мастер публикации в Интернете.

Мастер публикации

Выберите веб-сайты Microsoft Azure и нажмите « Создать» .

Веб-сайты Microsoft Azure
Создание сайта

Заполните данные вашего сайта и нажмите « Создать» . Когда ваш сайт будет создан, вы увидите это. Нажмите Далее .

Публикация сайта
Публикация в Интернете

На этом этапе вы можете добавить строку подключения для вашей базы данных, если она у вас есть, или добавить ее позже на портале управления. Нажмите Далее .

Предварительный просмотр публикации

Теперь, если мы нажмем « Опубликовать» , Visual Studio загрузит веб-сайт в Azure.

Чтобы создать базу данных, нужно перейти на портал управления Azure , выбрать « Обзор» , а затем « Данные + хранилище»> «База данных SQL» . Заполните форму, чтобы создать базу данных.

Развертывание базы данных

После создания базы данных выберите « Открыть в Visual Studio» и подтвердите добавление исключения в брандмауэр.

Добавление исключения брандмауэра

Ваша база данных будет открыта в обозревателе объектов SQL Server из Visual Studio. Как видите, таблиц пока нет:

SQL Object Explorer

Чтобы создать сценарий SQL для создания таблиц в базе данных, откройте консоль диспетчера пакетов в Visual Studio и введите:

1
PM> update-database -SourceMigration:0 -Script

Скопируйте сценарий и вернитесь в обозреватель объектов SQL Server, щелкните правой кнопкой мыши базу данных и выберите « Новый запрос» . Вставьте скрипт и выполните его.

Создание сценария SQL

Этот скрипт не включает данные, которые мы вставляли в базу данных из метода Seed. Нам нужно создать скрипт вручную, чтобы добавить эти данные в базу данных:

01
02
03
04
05
06
07
08
09
10
11
12
13
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 живы . Я настроил ключи API тестов Stripe, так что вы можете использовать тестовые данные кредитной карты для тестирования, если хотите.