Erstellen eines mit Notizen versehenen SAAS mit ASP.NET MVC 5
German (Deutsch) translation by Federicco Ancie (you can also view the original English article)



1. Einleitung
In diesem Tutorial zeige ich Ihnen, wie Sie ein Software-as-a-Service (SaaS) Minimum Viable Product (MVP) erstellen. Der Einfachheit halber wird die Software unseren Kunden ermöglichen, eine Liste mit Notizen zu speichern.
Ich werde drei Abonnements anbieten: Der Basic-Plan hat ein Limit von 100 Notizen pro Benutzer, der Professional-Plan ermöglicht es Kunden, bis zu 10.000 Notizen zu speichern, und der Business-Plan erlaubt eine Million Notizen. Die Pläne werden jeweils 10, 20 und 30 US-Dollar pro Monat kosten. Um Zahlungen von unseren Kunden zu erhalten, werde ich Stripe als Zahlungsgateway verwenden und die Website wird in Azure bereitgestellt.
2. Einrichtung
2.1 Streifen
Stripe hat sich in kürzester Zeit zu einem sehr bekannten Payment Gateway entwickelt, vor allem wegen seines entwicklerfreundlichen Ansatzes mit einfachen und gut dokumentierten APIs. Auch die Preisgestaltung ist sehr klar: 2,9% pro Transaktion + 30 Cent. Keine Einrichtungsgebühren oder versteckte Gebühren.
Kreditkartendaten sind ebenfalls sehr sensible Daten, und um diese Daten auf meinem Server empfangen und speichern zu können, muss ich PCI-konform sein. Da dies für die meisten kleinen Unternehmen keine leichte oder schnelle Aufgabe ist, gehen viele Zahlungsgateways vor: Sie zeigen die Bestelldetails an und wenn der Kunde dem Kauf zustimmt, leiten Sie den Kunden auf eine Seite des Zahlungsgateways (Bank , PayPal usw.) und leiten den Kunden dann zurück.
Stripe hat einen schöneren Ansatz für dieses Problem. Sie bieten eine JavaScript-API, sodass wir die Kreditkartennummer direkt vom Front-End an die Server von Stripe senden können. Sie geben ein Token zur einmaligen Verwendung zurück, das wir in unserer Datenbank speichern können. Jetzt benötigen wir für unsere Website nur noch ein SSL-Zertifikat, das wir schnell ab ca. 5 US-Dollar pro Jahr erwerben können.
Melden Sie sich jetzt für ein Stripe-Konto an, da Sie es zum Aufladen Ihrer Kunden benötigen.
2.2 Azure
Als Entwickler möchte ich mich nicht mit Dev-Ops-Aufgaben und der Verwaltung von Servern befassen, wenn ich nicht muss. Azure-Websites ist meine Wahl zum Hosten, da es sich um eine vollständig verwaltete Platform-as-a-Service handelt. Es ermöglicht mir die Bereitstellung aus Visual Studio oder Git, ich kann es leicht skalieren, wenn mein Dienst erfolgreich ist, und ich kann mich auf die Verbesserung meiner Anwendung konzentrieren. Sie bieten Neukunden 200 US-Dollar an, die sie im ersten Monat für alle Azure-Dienste ausgeben können. Das ist genug, um die Dienste zu bezahlen, die ich für dieses MVP nutze. Registrieren Sie sich für Azure.
2.3 Mandrill und Mailchimp: Transaktions-E-Mail
Das Versenden von E-Mails aus unserer Anwendung scheint keine sehr komplexe Aufgabe zu sein, aber ich möchte überwachen, wie viele E-Mails erfolgreich zugestellt werden, und auch responsive Vorlagen einfach entwerfen. Das bietet Mandrill und lässt uns bis zu 12.000 E-Mails pro Monat kostenlos versenden. Mandrill wurde von MailChimp entwickelt, damit sie über das Geschäft mit dem Versenden von E-Mails Bescheid wissen. Außerdem können wir unsere Vorlagen aus MailChimp erstellen, sie nach Mandrill exportieren und mit unseren Vorlagen E-Mails aus unserer App senden. Melden Sie sich für Mandrill an und melden Sie sich für MailChimp an.
2.4 Visual Studio 2013 Community-Edition
Zu guter Letzt benötigen wir Visual Studio, um unsere Anwendung zu schreiben. Diese Edition, die erst vor wenigen Monaten auf den Markt kam, ist völlig kostenlos und entspricht in etwa Visual Studio Professional. Sie können es hier herunterladen, und das ist alles, was wir brauchen, damit wir uns jetzt auf die Entwicklung konzentrieren können.
3. Erstellen der Website
Als erstes müssen wir Visual Studio 2013 öffnen. Erstellen Sie eine neue ASP.NET-Webanwendung:
- Gehen Sie zu Datei > Neues Projekt und wählen Sie ASP.NET-Webanwendung.
- Wählen Sie im Dialogfeld ASP.NET-Vorlage die MVC-Vorlage aus, und wählen Sie Individuelle Benutzerkonten aus.
Dieses Projekt erstellt eine Anwendung, bei der sich ein Benutzer anmelden kann, indem er ein Konto auf der Website registriert. Die Website ist mit Bootstrap gestaltet, und ich werde den Rest der App mit Bootstrap erstellen. Wenn Sie in Visual Studio F5 drücken, um die Anwendung auszuführen, sehen Sie Folgendes:
Dies ist die Standard-Landingpage, und diese Seite ist einer der wichtigsten Schritte, um unsere Besucher in Kunden zu verwandeln. Wir müssen das Produkt erklären, den Preis für jeden Plan zeigen und ihnen die Möglichkeit bieten, sich für eine kostenlose Testversion anzumelden. Für diese Anwendung erstelle ich drei verschiedene Abonnementpläne:
- Basis: 10 $ pro Monat
- Professionell: $20 pro Monat
- Geschäft: 30 $ pro Monat
3.1 Landingpage
Wenn Sie Hilfe beim Erstellen einer Zielseite benötigen, können Sie ThemeForest besuchen und eine Vorlage kaufen. Für dieses Beispiel verwende ich eine kostenlose Vorlage, und Sie können das Endergebnis auf dem Foto unten sehen.



3.2 Registrierungsseite
Auf der Website, die wir im vorherigen Schritt erstellt haben, erhalten wir auch eine Registrierungsformularvorlage. Wenn Sie von der Zielseite zu Preise navigieren und auf Kostenlose Testversion klicken, navigieren Sie zur Registrierungsseite. Dies ist das Standarddesign:
Wir benötigen hier nur ein zusätzliches Feld, um den Abonnementplan zu identifizieren, dem der Benutzer beitritt. Wenn Sie in der Navigationsleiste des Fotos sehen können, übergebe ich das als GET-Parameter. Dazu generiere ich das Markup für die Links auf der Landingpage mit dieser Codezeile:
1 |
<a href="@Url.Action("Register", "Account", new { plan = "business" })"> |
2 |
Free Trial |
3 |
</a>
|
Um den Abonnementplan an das Back-End zu binden, muss ich die Klasse RegisterViewModel
ändern und die neue Eigenschaft hinzufügen.
1 |
public class RegisterViewModel |
2 |
{
|
3 |
[Required] |
4 |
[EmailAddress] |
5 |
[Display(Name = "Email")] |
6 |
public string Email { get; set; } |
7 |
|
8 |
[Required] |
9 |
[StringLength(100, ErrorMessage = "The {0} must be at least {2} characters long.", MinimumLength = 6)] |
10 |
[DataType(DataType.Password)] |
11 |
[Display(Name = "Password")] |
12 |
public string Password { get; set; } |
13 |
|
14 |
[DataType(DataType.Password)] |
15 |
[Display(Name = "Confirm password")] |
16 |
[Compare("Password", ErrorMessage = "The password and confirmation password do not match.")] |
17 |
public string ConfirmPassword { get; set; } |
18 |
|
19 |
public string SubscriptionPlan { get; set; } |
20 |
}
|
Ich muss auch AccountController.cs bearbeiten und das Aktionsregister ändern, um den Plan zu erhalten:
1 |
[AllowAnonymous] |
2 |
public ActionResult Register(string plan) |
3 |
{
|
4 |
return View(new RegisterViewModel |
5 |
{
|
6 |
SubscriptionPlan = plan |
7 |
});
|
8 |
}
|
Jetzt muss ich die Plankennung in einem versteckten Feld im Registrierungsformular rendern:
1 |
@Html.HiddenFor(m => m.SubscriptionPlan) |
Der letzte Schritt besteht darin, den Benutzer für den Plan zu abonnieren, aber dazu kommen wir etwas später. Außerdem aktualisiere ich das Design des Anmeldeformulars.
3.3 Login-Seite
Im Template bekommen wir auch eine Login-Seite und Action-Controller implementiert. Das einzige, was ich tun muss, ist, dass es hübscher aussieht.
3.4 Passwort vergessen
Schauen Sie sich den vorherigen Screenshot noch einmal an und Sie werden feststellen, dass ich ein "Passwort vergessen?" hinzugefügt habe. Verknüpfung. Dies ist bereits in der Vorlage implementiert, aber standardmäßig auskommentiert. Ich mag das Standardverhalten nicht, bei dem der Benutzer die E-Mail-Adresse bestätigen muss, um das Passwort zurücksetzen zu können. Lassen Sie uns diese Einschränkung aufheben. Bearbeiten Sie in der Datei AccountController.cs die Aktion ForgotPassword
:
1 |
[HttpPost] |
2 |
[AllowAnonymous] |
3 |
[ValidateAntiForgeryToken] |
4 |
public async Task<ActionResult> ForgotPassword(ForgotPasswordViewModel model) |
5 |
{
|
6 |
if (ModelState.IsValid) |
7 |
{
|
8 |
var user = await UserManager.FindByNameAsync(model.Email); |
9 |
if (user == null) |
10 |
{
|
11 |
// Don't reveal that the user does not exist or is not confirmed
|
12 |
return View("ForgotPasswordConfirmation"); |
13 |
}
|
14 |
|
15 |
// For more information on how to enable account confirmation and password reset please visit http://go.microsoft.com/fwlink/?LinkID=320771
|
16 |
// Send an email with this link
|
17 |
// string code = await UserManager.GeneratePasswordResetTokenAsync(user.Id);
|
18 |
// var callbackUrl = Url.Action("ResetPassword", "Account", new { userId = user.Id, code = code }, protocol: Request.Url.Scheme);
|
19 |
// await UserManager.SendEmailAsync(user.Id, "Reset Password", "Please reset your password by clicking <a href=\"" + callbackUrl + "\">here</a>");
|
20 |
// return RedirectToAction("ForgotPasswordConfirmation", "Account");
|
21 |
}
|
22 |
|
23 |
// If we got this far, something failed, redisplay form
|
24 |
return View(model); |
25 |
}
|
Der Code zum Senden der E-Mail mit dem Link zum Zurücksetzen des Passworts ist auskommentiert. Ich werde etwas später zeigen, wie man diesen Teil implementiert. Jetzt bleibt nur noch das Design der Seiten zu aktualisieren:
- ForgotPassword.cshtml: Formular, das dem Benutzer angezeigt wird, um seine E-Mail-Adresse einzugeben.
- ForgotPasswordConfirmation.cshtml: Bestätigungsnachricht, nachdem der Link zum Zurücksetzen per E-Mail an den Benutzer gesendet wurde.
- ResetPassword.cshtml: Formular zum Zurücksetzen des Passworts nach dem Navigieren zum Zurücksetzen-Link aus der E-Mail.
- ResetPasswordConfirmation.cshtml: Bestätigungsmeldung nach dem Zurücksetzen des Passworts.
4. ASP.NET-Identität 2.0
ASP.NET Identity ist eine ziemlich neue Bibliothek, die auf der Annahme basiert, dass sich Benutzer nicht mehr nur mit Benutzernamen und Kennwort anmelden. Die OAuth-Integration, mit der sich Benutzer über soziale Kanäle wie Facebook, Twitter und andere anmelden können, ist jetzt sehr einfach. Außerdem kann diese Bibliothek mit Web-API und SignalR verwendet werden.
Andererseits kann die Persistenzschicht ersetzt werden, und es ist einfach, verschiedene Speichermechanismen wie NoSQL-Datenbanken einzubinden. Für die Zwecke dieser Anwendung verwende ich Entity Framework und SQL Server.
Das gerade erstellte Projekt enthält die folgenden drei NuGet-Pakete für ASP.NET Identity:
- Microsoft.AspNet.Identity.Core: Dieses Paket enthält die Kernschnittstellen für ASP.NET Identity.
- Microsoft.AspNet.Identity.EntityFramework: Dieses Paket enthält die Entity Framework-Implementierung der vorherigen Bibliothek. Die Daten werden in SQL Server gespeichert.
- Microsoft.AspNet.Identity.Owin: Dieses Paket verbindet die Middleware-OWIN-Authentifizierung mit ASP.NET Identity.
Die Hauptkonfiguration für Identity befindet sich in App_Start/IdentityConfig.cs. Dies ist der Code, der Identity initialisiert.
1 |
public static ApplicationUserManager Create(IdentityFactoryOptions<ApplicationUserManager> options, IOwinContext context) |
2 |
{
|
3 |
var manager = new ApplicationUserManager(new UserStore<ApplicationUser>(context.Get<ApplicationDbContext>())); |
4 |
// Configure validation logic for usernames
|
5 |
manager.UserValidator = new UserValidator<ApplicationUser>(manager) |
6 |
{
|
7 |
AllowOnlyAlphanumericUserNames = false, |
8 |
RequireUniqueEmail = true |
9 |
};
|
10 |
|
11 |
// Configure validation logic for passwords
|
12 |
manager.PasswordValidator = new PasswordValidator |
13 |
{
|
14 |
RequiredLength = 6, |
15 |
RequireNonLetterOrDigit = true, |
16 |
RequireDigit = true, |
17 |
RequireLowercase = true, |
18 |
RequireUppercase = true, |
19 |
};
|
20 |
|
21 |
// Configure user lockout defaults
|
22 |
manager.UserLockoutEnabledByDefault = true; |
23 |
manager.DefaultAccountLockoutTimeSpan = TimeSpan.FromMinutes(5); |
24 |
manager.MaxFailedAccessAttemptsBeforeLockout = 5; |
25 |
|
26 |
// Register two factor authentication providers. This application uses Phone and Emails as a step of receiving a code for verifying the user
|
27 |
// You can write your own provider and plug it in here.
|
28 |
manager.RegisterTwoFactorProvider("Phone Code", new PhoneNumberTokenProvider<ApplicationUser> |
29 |
{
|
30 |
MessageFormat = "Your security code is {0}" |
31 |
});
|
32 |
manager.RegisterTwoFactorProvider("Email Code", new EmailTokenProvider<ApplicationUser> |
33 |
{
|
34 |
Subject = "Security Code", |
35 |
BodyFormat = "Your security code is {0}" |
36 |
});
|
37 |
manager.EmailService = new EmailService(); |
38 |
manager.SmsService = new SmsService(); |
39 |
var dataProtectionProvider = options.DataProtectionProvider; |
40 |
if (dataProtectionProvider != null) |
41 |
{
|
42 |
manager.UserTokenProvider = |
43 |
new DataProtectorTokenProvider<ApplicationUser>(dataProtectionProvider.Create("ASP.NET Identity")); |
44 |
}
|
45 |
return manager; |
46 |
}
|
Wie Sie im Code sehen können, ist es ziemlich einfach, die Validatoren und Passwort-Validatoren der Benutzer zu konfigurieren, und auch die Zwei-Faktor-Authentifizierung kann aktiviert werden. Für diese Anwendung verwende ich eine Cookie-basierte Authentifizierung. Das Cookie wird vom Framework generiert und ist verschlüsselt. Auf diese Weise können wir horizontal skalieren und weitere Server hinzufügen, wenn unsere Anwendung dies benötigt.
5. E-Mails mit Mandrill senden
Sie können MailChimp verwenden, um E-Mail-Vorlagen zu entwerfen, und Mandrill, um E-Mails aus Ihrer Anwendung zu senden. Zunächst müssen Sie Ihr Mandrill-Konto mit Ihrem MailChimp-Konto verknüpfen:
- Melden Sie sich bei MailChimp an, klicken Sie im rechten Bereich auf Ihren Benutzernamen und wählen Sie Konto aus der Dropdown-Liste.
- Klicken Sie auf Integrationen und suchen Sie die Option Mandrill in der Liste der Integrationen.
- Klicken Sie darauf, um die Integrationsdetails anzuzeigen, und klicken Sie auf die Schaltfläche Verbindung autorisieren. Sie werden zu Mandrill weitergeleitet. Lassen Sie die Verbindung zu und die Integration wird abgeschlossen.
5.1 E-Mail-Vorlage „Willkommen bei meinen Notizen“ erstellen
Navigieren Sie in MailChimp zu Vorlagen und klicken Sie auf Vorlage erstellen.
Wählen Sie nun eine der von MailChimp angebotenen Vorlagen aus. Ich habe das erste gewählt:
Im Vorlageneditor ändern wir den Inhalt nach Belieben. Beachten Sie, wie Sie unten sehen können, dass wir Variablen verwenden können. Das Format ist *|VARIABLE_NAME|*
. Anhand des Codes legen wir diese für jeden Kunden fest. Wenn Sie fertig sind, klicken Sie unten rechts auf Speichern und beenden.
Klicken Sie in der Liste Vorlagen auf der rechten Seite auf Bearbeiten und wählen Sie An Mandrill senden. Nach einigen Sekunden erhalten Sie eine Bestätigungsmeldung.



Um zu bestätigen, dass die Vorlage exportiert wurde, navigieren Sie zu Mandrill und melden Sie sich an. Wählen Sie Ausgehend aus dem linken Menü und dann Vorlagen aus dem oberen Menü. Im Bild unten sehen Sie, dass die Vorlage exportiert wurde.
Wenn Sie auf den Namen der Vorlage klicken, werden weitere Informationen zur Vorlage angezeigt. Das Feld "Template Slug" ist die Textkennung, die wir in unserer Anwendung verwenden, um Mandrill API mitzuteilen, welche Vorlage wir für die von uns gesendete E-Mail verwenden möchten.
Ich überlasse es Ihnen als Übung, eine Vorlage "Passwort zurücksetzen" zu erstellen.
5.2 Senden von E-Mails aus meinen Notizen
Installieren Sie zunächst Mandrill von NuGet. Fügen Sie anschließend Ihren Mandrill-API-Schlüssel zu den Web.config-App-Einstellungen hinzu. Öffnen Sie nun App_Start/IdentityConfig.cs und Sie sehen die ausstehende Implementierung der Klasse EmailService
:
1 |
public class EmailService : IIdentityMessageService |
2 |
{
|
3 |
public Task SendAsync(IdentityMessage message) |
4 |
{
|
5 |
// Plug in your email service here to send an email.
|
6 |
return Task.FromResult(0); |
7 |
}
|
8 |
}
|
Obwohl diese Klasse nur die Methode SendAsync
hat, werden wir neue Methoden implementieren, da wir zwei verschiedene Vorlagen haben (Welcome Email Template und Reset Password Template). Die endgültige Implementierung sieht so aus.
1 |
public class EmailService : IIdentityMessageService |
2 |
{
|
3 |
private readonly MandrillApi _mandrill; |
4 |
private const string EmailFromAddress = "no-reply@mynotes.com"; |
5 |
private const string EmailFromName = "My Notes"; |
6 |
|
7 |
public EmailService() |
8 |
{
|
9 |
_mandrill = new MandrillApi(ConfigurationManager.AppSettings["MandrillApiKey"]); |
10 |
}
|
11 |
|
12 |
public Task SendAsync(IdentityMessage message) |
13 |
{
|
14 |
var task = _mandrill.SendMessageAsync(new EmailMessage |
15 |
{
|
16 |
from_email = EmailFromAddress, |
17 |
from_name = EmailFromName, |
18 |
subject = message.Subject, |
19 |
to = new List<Mandrill.EmailAddress> { new EmailAddress(message.Destination) }, |
20 |
html = message.Body |
21 |
});
|
22 |
|
23 |
return task; |
24 |
}
|
25 |
|
26 |
public Task SendWelcomeEmail(string firstName, string email) |
27 |
{
|
28 |
const string subject = "Welcome to My Notes"; |
29 |
|
30 |
var emailMessage = new EmailMessage |
31 |
{
|
32 |
from_email = EmailFromAddress, |
33 |
from_name = EmailFromName, |
34 |
subject = subject, |
35 |
to = new List<Mandrill.EmailAddress> { new EmailAddress(email) }, |
36 |
merge = true, |
37 |
};
|
38 |
|
39 |
emailMessage.AddGlobalVariable("subject", subject); |
40 |
emailMessage.AddGlobalVariable("first_name", firstName); |
41 |
|
42 |
var task = _mandrill.SendMessageAsync(emailMessage, "welcome-my-notes-saas", null); |
43 |
|
44 |
task.Wait(); |
45 |
|
46 |
return task; |
47 |
}
|
48 |
|
49 |
public Task SendResetPasswordEmail(string firstName, string email, string resetLink) |
50 |
{
|
51 |
const string subject = "Reset My Notes Password Request"; |
52 |
|
53 |
var emailMessage = new EmailMessage |
54 |
{
|
55 |
from_email = EmailFromAddress, |
56 |
from_name = EmailFromName, |
57 |
subject = subject, |
58 |
to = new List<Mandrill.EmailAddress> { new EmailAddress(email) } |
59 |
};
|
60 |
emailMessage.AddGlobalVariable("subject", subject); |
61 |
emailMessage.AddGlobalVariable("FIRST_NAME", firstName); |
62 |
emailMessage.AddGlobalVariable("RESET_PASSWORD_LINK", resetLink); |
63 |
|
64 |
var task = _mandrill.SendMessageAsync(emailMessage, "reset-password-my-notes-saas", null); |
65 |
|
66 |
return task; |
67 |
}
|
68 |
}
|
So senden Sie eine E-Mail über die Mandrill-API:
- E-Mail-Nachricht erstellen.
- Legen Sie die Werte der Nachrichtenvariablen fest.
- Senden Sie eine E-Mail mit Angabe des Vorlagen-Slugs.
In AccountController -> Aktion registrieren ist dies das Code-Snippet zum Versenden der Willkommens-E-Mail:
1 |
await _userManager.EmailService.SendWelcomeEmail(user.UserName, user.Email); |
In AccountController -> ForgotPassword-Aktion ist dies der Code zum Senden der E-Mail:
1 |
// Send an email to reset password
|
2 |
string code = await UserManager.GeneratePasswordResetTokenAsync(user.Id); |
3 |
var callbackUrl = Url.Action("ResetPassword", "Account", new { userId = user.Id, code = code }, protocol: Request.Url.Scheme); |
4 |
await UserManager.EmailService.SendResetPasswordEmail(user.UserName, user.Email, callbackUrl); |
6. Integration von SAAS Ecom für die Abrechnung
Eine wichtige Sache bei SAAS-Anwendungen ist die Abrechnung. Wir müssen eine Möglichkeit haben, unseren Kunden regelmäßig, in diesem Beispiel monatlich, Gebühren zu berechnen. Da dieser Teil viel Arbeit erfordert, aber dem von uns verkauften Produkt nichts Wertvolles hinzufügt, werden wir die zu diesem Zweck erstellte Open-Source-Bibliothek SAAS Ecom verwenden.
6.1 Datenmodell: Entity Framework Code First
SAAS Ecom ist von Entity Framework Code First abhängig. Für diejenigen unter Ihnen, die damit nicht vertraut sind, können Sie sich mit Entity Framework Code First auf das Erstellen von C#-POCO-Klassen konzentrieren, sodass Entity Framework die Klassen Datenbanktabellen zuordnen kann. Es folgt der Idee der Konvention gegenüber der Konfiguration, aber Sie können bei Bedarf weiterhin Zuordnungen, Fremdschlüssel usw. angeben.
Um SAAS Ecom zu unserem Projekt hinzuzufügen, installieren Sie einfach die Abhängigkeit mit NuGet. Die Bibliothek ist in zwei Pakete aufgeteilt: SaasEcom.Core, das die Geschäftslogik enthält, und SaasEcom.FrontEnd, das einige Ansichtshelfer zur Verwendung in einer MVC-Anwendung enthält. Fahren Sie fort und installieren Sie SaasEcom.FrontEnd.
Sie können sehen, dass Ihrer Lösung einige Dateien hinzugefügt wurden:
- Inhalt/Karten-Icons: Kreditkarten-Icons zur Anzeige im Abrechnungsbereich
- Controller/BillingController: Hauptcontroller
- Controller/StripeWebhooksController: Stripe-Webhooks
- Scripts/saasecom.card.form.js: Skript zum Hinzufügen einer Kreditkarte zu Stripe
- Aufrufe/Abrechnung: Aufrufe und Teilansichten
Es sind noch einige Schritte erforderlich, um SAAS Ecom zu integrieren, also besorgen Sie sich Ihre Stripe-API-Schlüssel und fügen Sie sie zu Web.config hinzu.
1 |
<appSettings>
|
2 |
<add key="StripeApiSecretKey" value="your_key_here" /> |
3 |
<add key="StripeApiPublishableKey" value="your_key_here" /> |
4 |
</appSettings>
|
Wenn Sie versuchen zu kompilieren, werden Fehler angezeigt:
Öffnen Sie die Datei Models/IdentityModels.cs, und lassen Sie dann die Klasse ApplicationUser von SaasEcomUser erben.
1 |
ApplicationUser : SaasEcomUser { /* your class methods*/ } |
Öffnen Sie die Datei Models/IdentityModels.cs, und dann sollte Ihre Klasse ApplicationDbContext von SaasEcomDbContext<ApplicationUser> erben.
1 |
ApplicationDbContext : SaasEcomDbContext<ApplicationUser> |
2 |
{ /* Your Db context properties */ } |
Da ApplicationUser
von SaasEcomUser
erbt, besteht das Standardverhalten für Entity Framework darin, zwei Tabellen in der Datenbank zu erstellen. Da wir das in diesem Fall nicht benötigen, müssen wir diese Methode der Klasse ApplicationDbContext
hinzufügen, um anzugeben, dass sie nur eine Tabelle verwenden soll:
1 |
protected override void OnModelCreating(DbModelBuilder modelBuilder) |
2 |
{
|
3 |
modelBuilder.Entity<ApplicationUser>().Map(m => m.MapInheritedProperties()); |
4 |
base.OnModelCreating(modelBuilder); |
5 |
}
|
Da wir gerade den DbContext
aktualisiert haben, muss auch die Datenbank aktualisiert werden, damit er von SaasEcomDbContext
erbt. Aktivieren Sie dazu Codemigrationen und aktualisieren Sie die Datenbank, indem Sie den NuGet-Paket-Manager über das Menü Extras > NuGet-Paket-Manager > Paket-Manager-Konsole öffnen:
1 |
PM > enable-migrations |
2 |
PM > add-migration Initial |
3 |
PM > update-database |
Wenn Sie beim Ausführen von update-database
einen Fehler erhalten, befindet sich die Datenbank (SQL Compact) in Ihrem AppData-Ordner. Öffnen Sie die Datenbank, löschen Sie alle darin enthaltenen Tabellen und führen Sie update-database
erneut aus.
6.2 Erstellen der Abonnementpläne in Stripe und Datenbank
Der nächste Schritt im Projekt besteht darin, Stripe zu integrieren, um unseren Kunden monatliche Gebühren zu berechnen, und dafür müssen wir die Abonnementpläne und Preise in Stripe erstellen. Melden Sie sich also bei Ihrem Stripe-Dashboard an und erstellen Sie Ihre Abonnementpläne, wie Sie auf den Bildern sehen können.
Nachdem wir die Abonnementpläne in Stripe erstellt haben, fügen wir sie der Datenbank hinzu. Wir tun dies, damit wir die Stripe-API nicht jedes Mal abfragen müssen, wenn wir Informationen zu Abonnementplänen benötigen.
Außerdem können wir spezifische Eigenschaften für jeden Plan speichern. In diesem Beispiel speichere ich als Eigenschaft jedes Plans die Anzahl der Notizen, die ein Benutzer speichern kann: 100 Notizen für den Basisplan, 10.000 für den Profiplan und 1 Million für den Geschäftsplan. Wir fügen diese Informationen der Seed-Methode hinzu, die jedes Mal ausgeführt wird, wenn die Datenbank aktualisiert wird, wenn wir update-database
über die NuGet Package Manager-Konsole ausführen.
Öffnen Sie die Datei Migrations/Configuration.cs und fügen Sie diese Methode hinzu:
1 |
protected override void Seed(MyNotes.Models.ApplicationDbContext context) |
2 |
{
|
3 |
// This method will be called after migrating to the latest version.
|
4 |
|
5 |
var basicMonthly = new SubscriptionPlan |
6 |
{
|
7 |
Id = "basic_monthly", |
8 |
Name = "Basic", |
9 |
Interval = SubscriptionPlan.SubscriptionInterval.Monthly, |
10 |
TrialPeriodInDays = 30, |
11 |
Price = 10.00, |
12 |
Currency = "USD" |
13 |
};
|
14 |
basicMonthly.Properties.Add(new SubscriptionPlanProperty { Key = "MaxNotes", Value = "100" }); |
15 |
|
16 |
var professionalMonthly = new SubscriptionPlan |
17 |
{
|
18 |
Id = "professional_monthly", |
19 |
Name = "Professional", |
20 |
Interval = SubscriptionPlan.SubscriptionInterval.Monthly, |
21 |
TrialPeriodInDays = 30, |
22 |
Price = 20.00, |
23 |
Currency = "USD" |
24 |
};
|
25 |
professionalMonthly.Properties.Add(new SubscriptionPlanProperty |
26 |
{
|
27 |
Key = "MaxNotes", |
28 |
Value = "10000" |
29 |
});
|
30 |
|
31 |
var businessMonthly = new SubscriptionPlan |
32 |
{
|
33 |
Id = "business_monthly", |
34 |
Name = "Business", |
35 |
Interval = SubscriptionPlan.SubscriptionInterval.Monthly, |
36 |
TrialPeriodInDays = 30, |
37 |
Price = 30.00, |
38 |
Currency = "USD" |
39 |
};
|
40 |
businessMonthly.Properties.Add(new SubscriptionPlanProperty |
41 |
{
|
42 |
Key = "MaxNotes", |
43 |
Value = "1000000" |
44 |
});
|
45 |
|
46 |
context.SubscriptionPlans.AddOrUpdate( |
47 |
sp => sp.Id, |
48 |
basicMonthly, |
49 |
professionalMonthly, |
50 |
businessMonthly); |
51 |
}
|
6.3 Abonnieren eines Kunden für einen Plan bei der Anmeldung
Als nächstes müssen wir sicherstellen, dass wir jedes Mal, wenn sich ein Benutzer für unsere App registriert, den Benutzer auch in Stripe mithilfe seiner API erstellen. Dazu verwenden wir die SAAS Ecom API, und wir müssen nur die Aktion Register on AccountController bearbeiten und diese Zeilen hinzufügen, nachdem der Benutzer in der Datenbank erstellt wurde:
1 |
// Create Stripe user
|
2 |
await SubscriptionsFacade.SubscribeUserAsync(user, model.SubscriptionPlan); |
3 |
await UserManager.UpdateAsync(user); |
Die Methode SubscribeUserAsync
abonniert den Benutzer für den Plan in Stripe, und wenn der Benutzer noch nicht in Stripe vorhanden ist, wird er ebenfalls erstellt. Dies ist nützlich, wenn Sie ein Freemium-SAAS haben und Benutzer in Stripe nur erstellen, wenn sie einen kostenpflichtigen Plan haben. Eine weitere kleine Änderung in der Registrierungsaktion
von AccountController
besteht darin, das RegistrationDate
und LastLoginTime
beim Erstellen des Benutzers zu speichern:
1 |
var user = new ApplicationUser |
2 |
{ |
3 |
UserName = model.Email, |
4 |
Email = model.Email, |
5 |
RegistrationDate = DateTime.UtcNow, |
6 |
LastLoginTime = DateTime.UtcNow |
7 |
}; |
8 |
var result = await UserManager.CreateAsync(user, model.Password); |
Da wir die Abhängigkeit SubscriptionsFacade von SAAS Ecom benötigen, fügen Sie sie als Eigenschaft zu Account Controller hinzu:
1 |
private SubscriptionsFacade _subscriptionsFacade; |
2 |
private SubscriptionsFacade SubscriptionsFacade |
3 |
{
|
4 |
get
|
5 |
{
|
6 |
return _subscriptionsFacade ?? (_subscriptionsFacade = new SubscriptionsFacade( |
7 |
new SubscriptionDataService<ApplicationDbContext, ApplicationUser> |
8 |
(HttpContext.GetOwinContext().Get<ApplicationDbContext>()), |
9 |
new SubscriptionProvider(ConfigurationManager.AppSettings["StripeApiSecretKey"]), |
10 |
new CardProvider(ConfigurationManager.AppSettings["StripeApiSecretKey"], |
11 |
new CardDataService<ApplicationDbContext, ApplicationUser>(Request.GetOwinContext().Get<ApplicationDbContext>())), |
12 |
new CardDataService<ApplicationDbContext, ApplicationUser>(Request.GetOwinContext().Get<ApplicationDbContext>()), |
13 |
new CustomerProvider(ConfigurationManager.AppSettings["StripeApiSecretKey"]), |
14 |
new SubscriptionPlanDataService<ApplicationDbContext, ApplicationUser>(Request.GetOwinContext().Get<ApplicationDbContext>()), |
15 |
new ChargeProvider(ConfigurationManager.AppSettings["StripeApiSecretKey"]))); |
16 |
}
|
17 |
}
|
Sie können die Instanziierung mit Dependency Injection vereinfachen, aber dies kann in einem anderen Artikel behandelt werden.
6.4 Abrechnungsansichten integrieren
Als wir SAAS Ecom zum Projekt hinzugefügt haben, wurden auch einige Teilansichten hinzugefügt. Sie verwenden das Hauptlayout _Layout.cshtml, aber dieses Layout wird von der Zielseite verwendet. Wir müssen ein anderes Layout für den Webanwendungsbereich oder das Kunden-Dashboard hinzufügen.
Ich habe eine sehr ähnliche Version der _Layout.cshtml erstellt, die erstellt wird, wenn Sie ein neues MVC-Projekt in Visual Studio hinzufügen. Sie können die _DashboardLayout.cshtml in GitHub sehen.
Die Hauptunterschiede sind, dass ich font-awesome und einen Bereich hinzugefügt habe, um Bootstrap-Benachrichtigungen anzuzeigen, wenn sie vorhanden sind:
1 |
<div id="bootstrap_alerts"> |
2 |
@if (TempData.ContainsKey("flash")) |
3 |
{ |
4 |
@Html.Partial("_Alert", TempData["flash"]); |
5 |
} |
6 |
</div>
|
Legen Sie für die Ansichten im Ordner Ansichten/Billing das Layout auf _DashboardLayout fest, andernfalls wird das Standardlayout _Layout.cshtml verwendet. Gehen Sie für Ansichten im Ordner Ansichten/Verwalten genauso vor:
1 |
Layout = "~/Views/Shared/_DashboardLayout.cshtml"; |
Ich habe "DashboardLayout" leicht modifiziert, um einige Stile von der Hauptwebsite zu verwenden, und es sieht so aus, nachdem ich mich angemeldet und zum Abschnitt Abrechnung navigiert habe:
Im Abrechnungsbereich kann ein Kunde ein Abonnement kündigen oder upgraden / downgraden. Fügen Sie Zahlungsdetails mithilfe der Stripe-JavaScript-API hinzu, damit wir nicht PCI-konform sein müssen und nur SSL im Server benötigen, um Zahlungen von unseren Kunden entgegenzunehmen.
Um Ihre neue Anwendung richtig zu testen, können Sie mehrere von Stripe bereitgestellte Kreditkartennummern verwenden.
Das letzte, was Sie vielleicht tun möchten, ist die Einrichtung von Stripe Webhooks. Dies wird verwendet, damit Stripe Sie über Ereignisse in Ihrer Abrechnung benachrichtigt, z. B. Zahlung erfolgreich, Zahlung überfällig, Testversion läuft bald ab usw. Sie können eine vollständige Liste aus der Stripe-Dokumentation abrufen. Das Stripe-Ereignis wird als JSON an eine öffentlich zugängliche URL gesendet. Um dies lokal zu testen, möchten Sie wahrscheinlich Ngrok verwenden.
Bei der Installation von SAAS Ecom wurde ein neuer Controller hinzugefügt, um die Webhooks von Stripe zu verarbeiten: StripeWebhooksController.cs. Dort sehen Sie, wie das Ereignis Rechnungserstellung behandelt wird:
1 |
case "invoice.payment_succeeded": // Occurs whenever an invoice attempts to be paid, and the payment succeeds. |
2 |
StripeInvoice stripeInvoice = Stripe.Mapper<StripeInvoice>.MapFromJson(stripeEvent.Data.Object.ToString()); |
3 |
Invoice invoice = SaasEcom.Core.Infrastructure.Mappers.Map(stripeInvoice); |
4 |
if (invoice != null && invoice.Total > 0) |
5 |
{
|
6 |
// TODO get the customer billing address, we still have to instantiate the address on the invoice
|
7 |
invoice.BillingAddress = new BillingAddress(); |
8 |
|
9 |
await InvoiceDataService.CreateOrUpdateAsync(invoice); |
10 |
|
11 |
// TODO: Send invoice by email
|
12 |
|
13 |
}
|
14 |
break; |
Sie können beliebig viele Events im Controller implementieren.
7. Erstellen von Notizfunktionen in unserer App
Der wichtigste Teil dieser SAAS-Anwendung besteht darin, unseren Kunden das Speichern von Notizen zu ermöglichen. Um diese Funktionalität zu erstellen, erstellen wir zunächst die Note
-Klasse:
1 |
public class Note |
2 |
{
|
3 |
public int Id { get; set; } |
4 |
|
5 |
[Required] |
6 |
[MaxLength(250)] |
7 |
public string Title { get; set; } |
8 |
|
9 |
[Required] |
10 |
public string Text { get; set; } |
11 |
public DateTime CreatedAt { get; set; } |
12 |
}
|
Fügen Sie eine Eins-zu-Viele-Beziehung von ApplicationUser
zu Note
hinzu:
1 |
public virtual ICollection<Note> Notes { get; set; } |
Da sich der DbContext geändert hat, müssen wir eine neue Datenbankmigration hinzufügen, also öffnen Sie die Nuget Package Manager-Konsole und führen Sie Folgendes aus:
1 |
PM> add-migration NotesAddedToModel |
Dies ist der generierte Code:
1 |
public partial class NotesAddedToModel : DbMigration |
2 |
{
|
3 |
public override void Up() |
4 |
{
|
5 |
CreateTable( |
6 |
"dbo.Notes", |
7 |
c => new |
8 |
{
|
9 |
Id = c.Int(nullable: false, identity: true), |
10 |
Title = c.String(nullable: false, maxLength: 250), |
11 |
Text = c.String(nullable: false), |
12 |
CreatedAt = c.DateTime(nullable: false), |
13 |
ApplicationUser_Id = c.String(maxLength: 128), |
14 |
})
|
15 |
.PrimaryKey(t => t.Id) |
16 |
.ForeignKey("dbo.AspNetUsers", t => t.ApplicationUser_Id) |
17 |
.Index(t => t.ApplicationUser_Id); |
18 |
|
19 |
}
|
20 |
|
21 |
public override void Down() |
22 |
{
|
23 |
DropForeignKey("dbo.Notes", "ApplicationUser_Id", "dbo.AspNetUsers"); |
24 |
DropIndex("dbo.Notes", new[] { "ApplicationUser_Id" }); |
25 |
DropTable("dbo.Notes"); |
26 |
}
|
27 |
}
|
Als nächstes brauchen wir den Controller MyNotes. Da wir bereits über die Modellklasse Notes verfügen, verwenden wir das Gerüst, um die Controller-Klasse zu erstellen, um Methoden mit Entity Framework erstellen, lesen, aktualisieren und löschen zu können. Wir verwenden das Gerüst auch, um die Ansichten zu generieren.
Nachdem ein Benutzer erfolgreich bei My Notes registriert wurde, leiten Sie den Benutzer an dieser Stelle zur Index
-Aktion von NotesController
um:
1 |
TempData["flash"] = new FlashSuccessViewModel("Congratulations! Your account has been created."); |
2 |
return RedirectToAction("Index", "Notes"); |
Bisher haben wir eine CRUD-Schnittstelle (Create / Read / Update / Delete) für Notes erstellt. Wir müssen weiterhin überprüfen, wann Benutzer versuchen, Notizen hinzuzufügen, um sicherzustellen, dass sie genügend Speicherplatz in ihren Abonnements haben.
Leere Liste mit Notizen:
Neue Notiz erstellen:
Liste der Anmerkungen:
Hinweisdetails:
Notiz bearbeiten:
Löschen der Notiz bestätigen:
Ich werde das Standard-Markup leicht bearbeiten:
- Im Formular zum Erstellen einer Notiz habe ich das Feld
CreatedAt
entfernt und den Wert im Controller festgelegt. - Im Formular zum Bearbeiten einer Notiz habe ich
CreatedAt
in ein ausgeblendetes Feld geändert, sodass es nicht bearbeitet werden kann. - Ich habe das CSS leicht modifiziert, damit dieses Formular auch ein bisschen schöner aussieht.
Als wir den Notes-Controller mit Entity Framework generierten, listete die Liste der Notizen alle Notizen in der Datenbank auf, nicht nur die Notizen für den angemeldeten Benutzer. Aus Sicherheitsgründen müssen wir überprüfen, dass Benutzer nur die Notizen sehen, ändern oder löschen können, die ihnen gehören.
Wir müssen auch überprüfen, wie viele Notizen ein Benutzer hat, bevor wir ihm erlauben, eine neue zu erstellen, um zu überprüfen, ob die Grenzen des Abonnementplans eingehalten werden. Hier ist der neue Code für NotesController:
1 |
public class NotesController : Controller |
2 |
{
|
3 |
private readonly ApplicationDbContext _db = new ApplicationDbContext(); |
4 |
|
5 |
private SubscriptionsFacade _subscriptionsFacade; |
6 |
private SubscriptionsFacade SubscriptionsFacade |
7 |
{
|
8 |
get
|
9 |
{
|
10 |
return _subscriptionsFacade ?? (_subscriptionsFacade = new SubscriptionsFacade( |
11 |
new SubscriptionDataService<ApplicationDbContext, ApplicationUser> |
12 |
(HttpContext.GetOwinContext().Get<ApplicationDbContext>()), |
13 |
new SubscriptionProvider(ConfigurationManager.AppSettings["StripeApiSecretKey"]), |
14 |
new CardProvider(ConfigurationManager.AppSettings["StripeApiSecretKey"], |
15 |
new CardDataService<ApplicationDbContext, ApplicationUser>(Request.GetOwinContext().Get<ApplicationDbContext>())), |
16 |
new CardDataService<ApplicationDbContext, ApplicationUser>(Request.GetOwinContext().Get<ApplicationDbContext>()), |
17 |
new CustomerProvider(ConfigurationManager.AppSettings["StripeApiSecretKey"]), |
18 |
new SubscriptionPlanDataService<ApplicationDbContext, ApplicationUser>(Request.GetOwinContext().Get<ApplicationDbContext>()), |
19 |
new ChargeProvider(ConfigurationManager.AppSettings["StripeApiSecretKey"]))); |
20 |
}
|
21 |
}
|
22 |
|
23 |
// GET: Notes
|
24 |
public async Task<ActionResult> Index() |
25 |
{
|
26 |
var userId = User.Identity.GetUserId(); |
27 |
|
28 |
var userNotes = |
29 |
await
|
30 |
_db.Users.Where(u => u.Id == userId) |
31 |
.Include(u => u.Notes) |
32 |
.SelectMany(u => u.Notes) |
33 |
.ToListAsync(); |
34 |
|
35 |
return View(userNotes); |
36 |
}
|
37 |
|
38 |
// GET: Notes/Details/5
|
39 |
public async Task<ActionResult> Details(int? id) |
40 |
{
|
41 |
if (id == null) |
42 |
{
|
43 |
return new HttpStatusCodeResult(HttpStatusCode.BadRequest); |
44 |
}
|
45 |
|
46 |
var userId = User.Identity.GetUserId(); |
47 |
ICollection<Note> userNotes = ( |
48 |
await _db.Users.Where(u => u.Id == userId) |
49 |
.Include(u => u.Notes).Select(u => u.Notes) |
50 |
.FirstOrDefaultAsync()); |
51 |
|
52 |
if (userNotes == null) |
53 |
{
|
54 |
return HttpNotFound(); |
55 |
}
|
56 |
|
57 |
Note note = userNotes.FirstOrDefault(n => n.Id == id); |
58 |
if (note == null) |
59 |
{
|
60 |
return HttpNotFound(); |
61 |
}
|
62 |
return View(note); |
63 |
}
|
64 |
|
65 |
// GET: Notes/Create
|
66 |
public ActionResult Create() |
67 |
{
|
68 |
return View(); |
69 |
}
|
70 |
|
71 |
// POST: Notes/Create
|
72 |
// To protect from overposting attacks, please enable the specific properties you want to bind to, for
|
73 |
// more details see http://go.microsoft.com/fwlink/?LinkId=317598.
|
74 |
[HttpPost] |
75 |
[ValidateAntiForgeryToken] |
76 |
public async Task<ActionResult> Create([Bind(Include = "Id,Title,Text,CreatedAt")] Note note) |
77 |
{
|
78 |
if (ModelState.IsValid) |
79 |
{
|
80 |
if (await UserHasEnoughSpace(User.Identity.GetUserId())) |
81 |
{
|
82 |
note.CreatedAt = DateTime.UtcNow; |
83 |
|
84 |
// The note is added to the user object so the Foreign Key is saved too
|
85 |
var userId = User.Identity.GetUserId(); |
86 |
var user = await this._db.Users.Where(u => u.Id == userId).FirstOrDefaultAsync(); |
87 |
user.Notes.Add(note); |
88 |
|
89 |
await _db.SaveChangesAsync(); |
90 |
return RedirectToAction("Index"); |
91 |
}
|
92 |
else
|
93 |
{
|
94 |
TempData.Add("flash", new FlashWarningViewModel("You can not add more notes, upgrade your subscription plan or delete some notes.")); |
95 |
}
|
96 |
}
|
97 |
|
98 |
return View(note); |
99 |
}
|
100 |
|
101 |
private async Task<bool> UserHasEnoughSpace(string userId) |
102 |
{
|
103 |
var subscription = (await SubscriptionsFacade.UserActiveSubscriptionsAsync(userId)).FirstOrDefault(); |
104 |
|
105 |
if (subscription == null) |
106 |
{
|
107 |
return false; |
108 |
}
|
109 |
|
110 |
var userNotes = await _db.Users.Where(u => u.Id == userId).Include(u => u.Notes).Select(u => u.Notes).CountAsync(); |
111 |
|
112 |
return subscription.SubscriptionPlan.GetPropertyInt("MaxNotes") > userNotes; |
113 |
}
|
114 |
|
115 |
// GET: Notes/Edit/5
|
116 |
public async Task<ActionResult> Edit(int? id) |
117 |
{
|
118 |
if (id == null) |
119 |
{
|
120 |
return new HttpStatusCodeResult(HttpStatusCode.BadRequest); |
121 |
}
|
122 |
if (!await NoteBelongToUser(User.Identity.GetUserId(), noteId: id.Value)) |
123 |
{
|
124 |
return new HttpStatusCodeResult(HttpStatusCode.BadRequest); |
125 |
}
|
126 |
|
127 |
Note note = await _db.Notes.FindAsync(id); |
128 |
if (note == null) |
129 |
{
|
130 |
return HttpNotFound(); |
131 |
}
|
132 |
return View(note); |
133 |
}
|
134 |
|
135 |
// POST: Notes/Edit/5
|
136 |
// To protect from overposting attacks, please enable the specific properties you want to bind to, for
|
137 |
// more details see http://go.microsoft.com/fwlink/?LinkId=317598.
|
138 |
[HttpPost] |
139 |
[ValidateAntiForgeryToken] |
140 |
public async Task<ActionResult> Edit([Bind(Include = "Id,Title,Text,CreatedAt")] Note note) |
141 |
{
|
142 |
if (ModelState.IsValid && await NoteBelongToUser(User.Identity.GetUserId(), note.Id)) |
143 |
{
|
144 |
_db.Entry(note).State = EntityState.Modified; |
145 |
await _db.SaveChangesAsync(); |
146 |
return RedirectToAction("Index"); |
147 |
}
|
148 |
return View(note); |
149 |
}
|
150 |
|
151 |
// GET: Notes/Delete/5
|
152 |
public async Task<ActionResult> Delete(int? id) |
153 |
{
|
154 |
if (id == null) |
155 |
{
|
156 |
return new HttpStatusCodeResult(HttpStatusCode.BadRequest); |
157 |
}
|
158 |
if (!await NoteBelongToUser(User.Identity.GetUserId(), noteId: id.Value)) |
159 |
{
|
160 |
return new HttpStatusCodeResult(HttpStatusCode.BadRequest); |
161 |
}
|
162 |
|
163 |
Note note = await _db.Notes.FindAsync(id); |
164 |
if (note == null) |
165 |
{
|
166 |
return HttpNotFound(); |
167 |
}
|
168 |
return View(note); |
169 |
}
|
170 |
|
171 |
// POST: Notes/Delete/5
|
172 |
[HttpPost, ActionName("Delete")] |
173 |
[ValidateAntiForgeryToken] |
174 |
public async Task<ActionResult> DeleteConfirmed(int id) |
175 |
{
|
176 |
if (!await NoteBelongToUser(User.Identity.GetUserId(), noteId: id)) |
177 |
{
|
178 |
return new HttpStatusCodeResult(HttpStatusCode.BadRequest); |
179 |
}
|
180 |
|
181 |
Note note = await _db.Notes.FindAsync(id); |
182 |
_db.Notes.Remove(note); |
183 |
await _db.SaveChangesAsync(); |
184 |
return RedirectToAction("Index"); |
185 |
}
|
186 |
private async Task<bool> NoteBelongToUser(string userId, int noteId) |
187 |
{
|
188 |
return await _db.Users.Where(u => u.Id == userId).Where(u => u.Notes.Any(n => n.Id == noteId)).AnyAsync(); |
189 |
}
|
190 |
|
191 |
protected override void Dispose(bool disposing) |
192 |
{
|
193 |
if (disposing) |
194 |
{
|
195 |
_db.Dispose(); |
196 |
}
|
197 |
base.Dispose(disposing); |
198 |
}
|
199 |
}
|
Das ist es – wir haben die Kernfunktionalität für unsere SAAS-Anwendung.
8. Speicherung des Kundenstandorts für europäische Mehrwertsteuerzwecke
Zu Beginn dieses Jahres hat sich die Gesetzgebung in der Europäischen Union zur Mehrwertsteuer für Unternehmen, die digitale Dienstleistungen an private Verbraucher erbringen, geändert. Der Hauptunterschied besteht darin, dass Unternehmen Privatkunden und nicht Geschäftskunden mit gültiger USt-IdNr. Um zu überprüfen, in welchem Land sie ansässig sind, müssen wir mindestens zwei dieser Formulare aufzeichnen:
- die Rechnungsadresse des Kunden
- die Internet Protocol (IP)-Adresse des vom Kunden verwendeten Geräts
- Bankverbindung des Kunden
- die Landesvorwahl der vom Kunden verwendeten SIM-Karte
- der Standort des Festnetzanschlusses des Kunden, über den die Dienstleistung erbracht wird
- andere kommerziell relevante Informationen (z. B. Produktcodierungsinformationen, die den Verkauf elektronisch mit einer bestimmten Gerichtsbarkeit verknüpfen)
Aus diesem Grund werden wir die IP-Adresse des Benutzers geolokalisieren, um sie zusammen mit der Rechnungsadresse und dem Kreditkartenland zu speichern.
8.1 Geo-Standort der IP-Adresse
Für die Geolokalisierung verwende ich Maxmind GeoLite2. Es ist eine kostenlose Datenbank, die uns das Land angibt, in dem sich eine IP befindet.



Laden Sie die Datenbank herunter und fügen Sie sie zu App_Data hinzu, wie Sie auf dem Foto sehen können:
Erstellen Sie Erweiterungen/GeoLocationHelper.cs.
1 |
public static class GeoLocationHelper |
2 |
{
|
3 |
// ReSharper disable once InconsistentNaming
|
4 |
/// <summary>
|
5 |
/// Gets the country ISO code from IP.
|
6 |
/// </summary>
|
7 |
/// <param name="ipAddress">The ip address.</param>
|
8 |
/// <returns></returns>
|
9 |
public static string GetCountryFromIP(string ipAddress) |
10 |
{
|
11 |
string country; |
12 |
try
|
13 |
{
|
14 |
using ( |
15 |
var reader = |
16 |
new DatabaseReader(HttpContext.Current.Server.MapPath("~/App_Data/GeoLite2-Country.mmdb"))) |
17 |
{
|
18 |
var response = reader.Country(ipAddress); |
19 |
country = response.Country.IsoCode; |
20 |
}
|
21 |
}
|
22 |
catch (Exception ex) |
23 |
{
|
24 |
country = null; |
25 |
}
|
26 |
|
27 |
return country; |
28 |
}
|
29 |
|
30 |
/// <summary>
|
31 |
/// Selects the list countries.
|
32 |
/// </summary>
|
33 |
/// <param name="country">The country.</param>
|
34 |
/// <returns></returns>
|
35 |
public static List<SelectListItem> SelectListCountries(string country) |
36 |
{
|
37 |
var getCultureInfo = CultureInfo.GetCultures(CultureTypes.SpecificCultures); |
38 |
var countries = |
39 |
getCultureInfo.Select(cultureInfo => new RegionInfo(cultureInfo.LCID)) |
40 |
.Select(getRegionInfo => new SelectListItem |
41 |
{
|
42 |
Text = getRegionInfo.EnglishName, |
43 |
Value = getRegionInfo.TwoLetterISORegionName, |
44 |
Selected = country == getRegionInfo.TwoLetterISORegionName |
45 |
}).OrderBy(c => c.Text).DistinctBy(i => i.Text).ToList(); |
46 |
return countries; |
47 |
}
|
48 |
|
49 |
public static IEnumerable<TSource> DistinctBy<TSource, TKey>(this IEnumerable<TSource> source, Func<TSource, TKey> keySelector) |
50 |
{
|
51 |
var seenKeys = new HashSet<TKey>(); |
52 |
return source.Where(element => seenKeys.Add(keySelector(element))); |
53 |
}
|
54 |
}
|
In dieser statischen Klasse sind zwei Methoden implementiert:
-
GetCountryFromIP
: Gibt den ISO-Code des Landes mit einer IP-Adresse zurück. -
SelectListCountries
: Gibt eine Liste der zu verwendenden Länder in einem Dropdown-Feld zurück. Es enthält den Länder-ISO-Code als Wert für jedes Land und den anzuzeigenden Ländernamen.
8.2 Kundenland bei Registrierung speichern
Speichern Sie in der Aktion Register
from AccountController
beim Anlegen des Benutzers die IP und das Land, zu dem die IP gehört:
1 |
var userIP = GeoLocation.GetUserIP(Request); |
2 |
var user = new ApplicationUser |
3 |
{
|
4 |
UserName = model.Email, |
5 |
Email = model.Email, |
6 |
RegistrationDate = DateTime.UtcNow, |
7 |
LastLoginTime = DateTime.UtcNow, |
8 |
IPAddress = userIP, |
9 |
IPAddressCountry = GeoLocationHelper.GetCountryFromIP(userIP), |
10 |
};
|
Außerdem müssen wir beim Erstellen des Abonnements in Stripe den Steuerprozentsatz für diesen Kunden übergeben. Das machen wir ein paar Zeilen nach dem Anlegen des Benutzers:
1 |
// Create Stripe user
|
2 |
var taxPercent = EuropeanVat.Countries.ContainsKey(user.IPAddressCountry) ? |
3 |
EuropeanVat.Countries[user.IPAddressCountry] : 0; |
4 |
await SubscriptionsFacade.SubscribeUserAsync(user, model.SubscriptionPlan, taxPercent: taxPercent); |
Wenn ein Benutzer in der Europäischen Union ansässig ist, lege ich den Steuerprozentsatz standardmäßig auf dieses Abonnement fest. Die Regeln sind etwas komplexer, aber zusammenfassend:
- Wenn Ihr Unternehmen in einem EU-Land registriert ist, berechnen Sie Kunden in Ihrem Land immer Mehrwertsteuer.
- Wenn Ihr Unternehmen in einem EU-Land registriert ist, berechnen Sie nur den Kunden, die sich in anderen EU-Ländern befinden und kein umsatzsteuerpflichtiges Unternehmen sind.
- Wenn Ihr Unternehmen außerhalb der EU registriert ist, berechnen Sie nur Kunden, die keine Unternehmen mit einer gültigen Umsatzsteuer-Identifikationsnummer sind, Mehrwertsteuer.
8.3 Hinzufügen einer Rechnungsadresse zu unserem Modell
Im Moment gestatten wir unseren Kunden nicht, eine Rechnungsadresse und ihre Umsatzsteuer-Identifikationsnummer zu speichern, wenn sie ein in der EU mehrwertsteuerpflichtiges Unternehmen sind. In diesem Fall müssen wir ihren Steuersatz auf 0 ändern.
SAAS Ecom stellt die BillingAddress
-Klasse bereit, die jedoch keiner Entität des Modells zugeordnet ist. Der Hauptgrund dafür ist, dass es in einigen SAAS-Anwendungen sinnvoll sein kann, diese einer Organisationsklasse zuzuordnen, wenn mehrere Benutzer Zugriff auf dasselbe Konto haben. Wenn dies nicht der Fall ist, wie in unserem Beispiel, können wir diese Beziehung sicher zur ApplicationUser
-Klasse hinzufügen:
1 |
public class ApplicationUser : SaasEcomUser |
2 |
{
|
3 |
public virtual ICollection<Note> Notes { get; set; } |
4 |
|
5 |
public virtual BillingAddress BillingAddress { get; set; } |
6 |
|
7 |
public async Task<ClaimsIdentity> GenerateUserIdentityAsync(UserManager<ApplicationUser> manager) |
8 |
{
|
9 |
// Note the authenticationType must match the one defined in CookieAuthenticationOptions.AuthenticationType
|
10 |
var userIdentity = await manager.CreateIdentityAsync(this, DefaultAuthenticationTypes.ApplicationCookie); |
11 |
// Add custom user claims here
|
12 |
return userIdentity; |
13 |
}
|
14 |
}
|
Da wir jedes Mal, wenn wir das Modell ändern, eine Datenbankmigration hinzufügen müssen, öffnen Sie Tools > NuGet Package Manager > Package Manager Console:
1 |
PM> add-migration BillingAddressAddedToUser |
Und das ist die Migrationsklasse, die wir erhalten:
1 |
public partial class BillingAddressAddedToUser : DbMigration |
2 |
{
|
3 |
public override void Up() |
4 |
{
|
5 |
AddColumn("dbo.AspNetUsers", "BillingAddress_Name", c => c.String()); |
6 |
AddColumn("dbo.AspNetUsers", "BillingAddress_AddressLine1", c => c.String()); |
7 |
AddColumn("dbo.AspNetUsers", "BillingAddress_AddressLine2", c => c.String()); |
8 |
AddColumn("dbo.AspNetUsers", "BillingAddress_City", c => c.String()); |
9 |
AddColumn("dbo.AspNetUsers", "BillingAddress_State", c => c.String()); |
10 |
AddColumn("dbo.AspNetUsers", "BillingAddress_ZipCode", c => c.String()); |
11 |
AddColumn("dbo.AspNetUsers", "BillingAddress_Country", c => c.String()); |
12 |
AddColumn("dbo.AspNetUsers", "BillingAddress_Vat", c => c.String()); |
13 |
}
|
14 |
|
15 |
public override void Down() |
16 |
{
|
17 |
DropColumn("dbo.AspNetUsers", "BillingAddress_Vat"); |
18 |
DropColumn("dbo.AspNetUsers", "BillingAddress_Country"); |
19 |
DropColumn("dbo.AspNetUsers", "BillingAddress_ZipCode"); |
20 |
DropColumn("dbo.AspNetUsers", "BillingAddress_State"); |
21 |
DropColumn("dbo.AspNetUsers", "BillingAddress_City"); |
22 |
DropColumn("dbo.AspNetUsers", "BillingAddress_AddressLine2"); |
23 |
DropColumn("dbo.AspNetUsers", "BillingAddress_AddressLine1"); |
24 |
DropColumn("dbo.AspNetUsers", "BillingAddress_Name"); |
25 |
}
|
26 |
}
|
Um diese Änderungen in der Datenbank zu erstellen, führen wir in der Package Manager Console aus:
1 |
PM> update-database |
Ein weiteres Detail, das wir korrigieren müssen, besteht darin, dass wir in AccountController > Registrieren eine Standard-Rechnungsadresse festlegen müssen, da es sich um ein Feld handelt, das keine NULL-Werte enthält.
1 |
var user = new ApplicationUser |
2 |
{
|
3 |
UserName = model.Email, |
4 |
Email = model.Email, |
5 |
RegistrationDate = DateTime.UtcNow, |
6 |
LastLoginTime = DateTime.UtcNow, |
7 |
IPAddress = userIP, |
8 |
IPAddressCountry = GeoLocationHelper.GetCountryFromIP(userIP), |
9 |
BillingAddress = new BillingAddress() |
10 |
};
|
Auf der Rechnungsseite müssen wir die Rechnungsadresse für den Kunden anzeigen, falls sie hinzugefügt wurde, und unseren Kunden auch erlauben, sie zu bearbeiten. Zuerst müssen wir den Index
-Aktion von BillingController
ändern, um die Rechnungsadresse an die Ansicht zu übergeben:
1 |
public async Task<ViewResult> Index() |
2 |
{
|
3 |
var userId = User.Identity.GetUserId(); |
4 |
|
5 |
ViewBag.Subscriptions = await SubscriptionsFacade.UserActiveSubscriptionsAsync(userId); |
6 |
ViewBag.PaymentDetails = await SubscriptionsFacade.DefaultCreditCard(userId); |
7 |
ViewBag.Invoices = await InvoiceDataService.UserInvoicesAsync(userId); |
8 |
ViewBag.BillingAddress = (await UserManager.FindByIdAsync(userId)).BillingAddress; |
9 |
|
10 |
return View(); |
11 |
}
|
Um die Adresse anzuzeigen, müssen wir nur die Ansicht "Billing/Index.cshtml" bearbeiten und die von SAAS Ecom dafür bereitgestellte Ansicht teilweise hinzufügen:
1 |
<h2>Billing</h2> |
2 |
<br /> |
3 |
@Html.Partial("_Subscriptions", (List<Subscription>)ViewBag.Subscriptions)
|
4 |
<br/>
|
5 |
@Html.Partial("_PaymentDetails", (CreditCard)ViewBag.PaymentDetails) |
6 |
<br /> |
7 |
@Html.Partial("_BillingAddress", (BillingAddress)ViewBag.BillingAddress) |
8 |
<br /> |
9 |
@Html.Partial("_Invoices", (List<Invoice>)ViewBag.Invoices)
|
Wenn wir nun zu Abrechnung navigieren, sehen wir den neuen Abschnitt:
Der nächste Schritt ist die Aktion BillingController > BillingAddress, wir müssen die Rechnungsadresse an die Ansicht übergeben. Da wir den zweibuchstabigen ISO-Ländercode des Benutzers benötigen, habe ich ein Dropdown-Menü hinzugefügt, um das Land auszuwählen, das standardmäßig das Land ist, zu dem die Benutzer-IP gehört:
1 |
public async Task<ViewResult> BillingAddress() |
2 |
{
|
3 |
var model = (await UserManager.FindByIdAsync(User.Identity.GetUserId())).BillingAddress; |
4 |
|
5 |
// List for dropdown country select
|
6 |
var userCountry = (await UserManager.FindByIdAsync(User.Identity.GetUserId())).IPAddressCountry; |
7 |
ViewBag.Countries = GeoLocationHelper.SelectListCountries(userCountry); |
8 |
|
9 |
return View(model); |
10 |
}
|
Wenn der Benutzer das Formular abschickt, müssen wir die Rechnungsadresse speichern und den Steuerprozentsatz bei Bedarf aktualisieren:
1 |
[HttpPost] |
2 |
public async Task<ActionResult> BillingAddress(BillingAddress model) |
3 |
{
|
4 |
if (ModelState.IsValid) |
5 |
{
|
6 |
var userId = User.Identity.GetUserId(); |
7 |
|
8 |
// Call your service to save the billing address
|
9 |
var user = await UserManager.FindByIdAsync(userId); |
10 |
user.BillingAddress = model; |
11 |
await UserManager.UpdateAsync(user); |
12 |
|
13 |
// Model Country has to be 2 letter ISO Code
|
14 |
if (!string.IsNullOrEmpty(model.Vat) && !string.IsNullOrEmpty(model.Country) && |
15 |
EuropeanVat.Countries.ContainsKey(model.Country)) |
16 |
{
|
17 |
await UpdateSubscriptionTax(userId, 0); |
18 |
}
|
19 |
else if (!string.IsNullOrEmpty(model.Country) && EuropeanVat.Countries.ContainsKey(model.Country)) |
20 |
{
|
21 |
await UpdateSubscriptionTax(userId, EuropeanVat.Countries[model.Country]); |
22 |
}
|
23 |
|
24 |
TempData.Add("flash", new FlashSuccessViewModel("Your billing address has been saved.")); |
25 |
|
26 |
return RedirectToAction("Index"); |
27 |
}
|
28 |
|
29 |
return View(model); |
30 |
}
|
31 |
|
32 |
private async Task UpdateSubscriptionTax(string userId, decimal tax) |
33 |
{
|
34 |
var user = await UserManager.FindByIdAsync(userId); |
35 |
var subscription = (await SubscriptionsFacade.UserActiveSubscriptionsAsync(userId)).FirstOrDefault(); |
36 |
if (subscription != null && subscription.TaxPercent != tax) |
37 |
{
|
38 |
await SubscriptionsFacade.UpdateSubscriptionTax(user, subscription.StripeId, tax); |
39 |
}
|
40 |
}
|
So sieht das Formular zum Hinzufügen oder Bearbeiten einer Rechnungsadresse aus:
Nach dem Hinzufügen der Adresse werde ich zurück in den Abrechnungsbereich weitergeleitet:
Wie Sie im obigen Screenshot sehen können, werden 20 % Mehrwertsteuer zum monatlichen Preis hinzugefügt, da ich mein Land auf Großbritannien eingestellt habe und keine Umsatzsteuer-Identifikationsnummer eingegeben habe. Der hier gezeigte Code geht davon aus, dass Sie ein nicht in der EU ansässiges Unternehmen sind. In diesem Fall müssen Sie den Fall bearbeiten, in dem sich Ihr Kunde in Ihrem Land befindet, und unabhängig davon, ob er Mehrwertsteuer hat oder nicht, müssen Sie Mehrwertsteuer berechnen.
9. Bereitstellen auf Azure-Websites (Webhosting + SSL kostenlos, SQL-Datenbank 5 USD pro Monat)
9.1 Bereitstellung der Website
Unser SAAS-Projekt ist bereit, live zu gehen, und ich habe Azure als Hosting-Plattform gewählt. Wenn Sie noch kein Konto haben, können Sie einen Monat lang kostenlos testen. Wir können unsere App von Git (GitHub oder BitBucket) bei jedem Commit bereitstellen, wenn wir möchten. Ich zeige Ihnen hier, wie Sie aus Visual Studio 2013 bereitstellen. Klicken Sie im Projektmappen-Explorer mit der rechten Maustaste auf das Projekt Meine Notizen und wählen Sie im Kontextmenü Veröffentlichen. Der Assistent zum Veröffentlichen von Web wird geöffnet.
Wählen Sie Microsoft Azure-Websites aus und klicken Sie auf Neu.
Geben Sie die Details für Ihre Website ein und klicken Sie auf Erstellen. Wenn Ihre Website erstellt wurde, sehen Sie dies. Weiter klicken.
In diesem Schritt können Sie die Verbindungszeichenfolge für Ihre Datenbank hinzufügen, falls vorhanden, oder Sie können sie später über das Verwaltungsportal hinzufügen. Weiter klicken.
Wenn wir nun auf Veröffentlichen klicken, lädt Visual Studio die Website in Azure hoch.
9.2 Bereitstellen der Datenbank
Um die Datenbank zu erstellen, müssen Sie zum Azure-Verwaltungsportal gehen, Durchsuchen und dann Daten + Speicher > SQL-Datenbank auswählen. Füllen Sie das Formular aus, um Ihre Datenbank zu erstellen.
Nachdem die Datenbank erstellt wurde, wählen Sie In Visual Studio öffnen und akzeptieren Sie, um der Firewall eine Ausnahme hinzuzufügen.
Ihre Datenbank wird im SQL Server-Objekt-Explorer von Visual Studio geöffnet. Wie Sie sehen, gibt es noch keine Tabellen:
Um ein SQL-Skript zum Erstellen der Tabellen in der Datenbank zu generieren, öffnen Sie die Paket-Manager-Konsole in Visual Studio und geben Sie Folgendes ein:
1 |
PM> update-database -SourceMigration:0 -Script |
Kopieren Sie das Skript und wieder in den SQL Server-Objekt-Explorer, klicken Sie mit der rechten Maustaste auf Ihre Datenbank und wählen Sie Neue Abfrage aus. Fügen Sie das Skript ein und führen Sie es aus.
Dieses Skript enthält nicht die Daten, die wir aus der Seed-Methode in die Datenbank eingefügt haben. Wir müssen ein Skript manuell erstellen, um diese Daten zur Datenbank hinzuzufügen:
1 |
INSERT INTO [dbo].[SubscriptionPlans] ([Id],[Name],[Price],[Currency],[Interval],[TrialPeriodInDays],[Disabled]) |
2 |
VALUES('basic_monthly', 'Basic', 10, 'USD', 1, 30, 0) |
3 |
INSERT INTO [dbo].[SubscriptionPlans] ([Id],[Name],[Price],[Currency],[Interval],[TrialPeriodInDays],[Disabled]) |
4 |
VALUES('professional_monthly', 'Professional', 20, 'USD', 1, 30, 0) |
5 |
INSERT INTO [dbo].[SubscriptionPlans] ([Id],[Name],[Price],[Currency],[Interval],[TrialPeriodInDays],[Disabled]) |
6 |
VALUES('business_monthly', 'Business', 30, 'USD', 1, 30, 0) |
7 |
|
8 |
INSERT INTO [dbo].[SubscriptionPlanProperties] ([Key],[Value],[SubscriptionPlan_Id]) |
9 |
VALUES ('MaxNotes', '100', 'basic_monthly') |
10 |
INSERT INTO [dbo].[SubscriptionPlanProperties] ([Key],[Value],[SubscriptionPlan_Id]) |
11 |
VALUES ('MaxNotes', '10000', 'professional_monthly') |
12 |
INSERT INTO [dbo].[SubscriptionPlanProperties] ([Key],[Value],[SubscriptionPlan_Id]) |
13 |
VALUES ('MaxNotes', '1000000', 'business_monthly') |
Zu diesem Zeitpunkt ist My Notes SAAS live. Ich habe Stripe-Test-API-Schlüssel konfiguriert, sodass Sie die Testkreditkartendaten zum Testen verwenden können, wenn Sie möchten.