diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Controllers/WebhookController.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Controllers/WebhookController.cs index 78de2a019..90cbc2e4a 100644 --- a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Controllers/WebhookController.cs +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Controllers/WebhookController.cs @@ -4,11 +4,12 @@ using Microsoft.Extensions.Logging; using OrchardCore.Commerce.Payment.Stripe.Abstractions; using OrchardCore.Commerce.Payment.Stripe.Models; +using OrchardCore.Commerce.Payment.Stripe.Services; using OrchardCore.Settings; using Stripe; +using System.Collections.Generic; using System.IO; using System.Threading.Tasks; -using static Stripe.Events; namespace OrchardCore.Commerce.Payment.Stripe.Controllers; @@ -17,21 +18,21 @@ namespace OrchardCore.Commerce.Payment.Stripe.Controllers; [Authorize(AuthenticationSchemes = "Api"), IgnoreAntiforgeryToken, AllowAnonymous] public class WebhookController : Controller { - private readonly IStripePaymentService _stripePaymentService; private readonly ISiteService _siteService; private readonly IDataProtectionProvider _dataProtectionProvider; private readonly ILogger _logger; + private readonly IEnumerable _stripeWebhookEventHandlers; public WebhookController( - IStripePaymentService stripePaymentService, ISiteService siteService, IDataProtectionProvider dataProtectionProvider, - ILogger logger) + ILogger logger, + IEnumerable stripeWebhookEventHandlers) { - _stripePaymentService = stripePaymentService; _siteService = siteService; _dataProtectionProvider = dataProtectionProvider; _logger = logger; + _stripeWebhookEventHandlers = stripeWebhookEventHandlers; } [HttpPost] @@ -50,23 +51,13 @@ public async Task Index() webhookSigningKey, // Let the logic handle version mismatch. throwOnApiVersionMismatch: false); - if (stripeEvent.Type == ChargeSucceeded) + if (string.IsNullOrEmpty(stripeEvent.Id)) { - var charge = stripeEvent.Data.Object as Charge; - if (charge?.PaymentIntentId is not { } paymentIntentId) - { - return BadRequest(); - } - - var paymentIntent = await _stripePaymentService.GetPaymentIntentAsync(paymentIntentId); - await _stripePaymentService.UpdateOrderToOrderedAsync(paymentIntent, shoppingCartId: null); - } - else if (stripeEvent.Type == PaymentIntentPaymentFailed) - { - var paymentIntent = stripeEvent.Data.Object as PaymentIntent; - await _stripePaymentService.UpdateOrderToPaymentFailedAsync(paymentIntent.Id); + throw new StripeException("Invalid event or event Id."); } + await _stripeWebhookEventHandlers.AwaitEachAsync(handler => handler.ReceivedStripeEventAsync(stripeEvent)); + return Ok(); } catch (StripeException e) diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/EndPoints/Api/StripeCheckoutApiEndpoint.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/EndPoints/Api/StripeCheckoutApiEndpoint.cs index 2289d67b9..35ceb672f 100644 --- a/src/Modules/OrchardCore.Commerce.Payment.Stripe/EndPoints/Api/StripeCheckoutApiEndpoint.cs +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/EndPoints/Api/StripeCheckoutApiEndpoint.cs @@ -1,20 +1,18 @@ -using Azure; -using Lombiq.HelpfulLibraries.AspNetCore.Extensions; +using Lombiq.HelpfulLibraries.AspNetCore.Extensions; using Lombiq.HelpfulLibraries.OrchardCore.Mvc; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; -using OrchardCore.Commerce.Endpoints; -using OrchardCore.Commerce.Payment.Stripe.Abstractions; using OrchardCore.Commerce.Payment.Stripe.Endpoints.Models; using OrchardCore.Commerce.Payment.Stripe.Endpoints.Permissions; -using OrchardCore.ContentManagement; -using Stripe; +using OrchardCore.Commerce.Payment.Stripe.Services; +using OrchardCore.Environment.Shell; using Stripe.Checkout; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; + using static OrchardCore.Commerce.Payment.Stripe.Endpoints.Constants.Endpoints; namespace OrchardCore.Commerce.Payment.Stripe.Endpoints.Api; @@ -27,84 +25,12 @@ public static IEndpointRouteBuilder AddStripeCheckoutEndpoint(this IEndpointRout return builder; } - // Try to get customer, if not exists, create it - private static async Task GetOrCreateCustomerAsync( - SubscriptionCheckoutEndpointViewModel viewModel, - HttpContext httpContext, - IRequestOptionsService requestOptionsService) - { - if (string.IsNullOrEmpty(viewModel.CustomerId)) - { - return await CreateCustomerAsync(viewModel, httpContext, requestOptionsService); - } - - var customerService = new CustomerService(); - var customer = await customerService.GetAsync( - viewModel.CustomerId, - requestOptions: await requestOptionsService.SetIdempotencyKeyAsync(), - cancellationToken: httpContext.RequestAborted); - - if (customer?.Id != null) - { - return customer; - } - - return await CreateCustomerAsync(viewModel, httpContext, requestOptionsService); - } - - private static async Task CreateCustomerAsync( - SubscriptionCheckoutEndpointViewModel viewModel, - HttpContext httpContext, - IRequestOptionsService requestOptionsService) - { - var billingAddress = viewModel.Information.BillingAddress.Address; - var shippingAddress = viewModel.Information.ShippingAddress.Address; - var customerCreateOptions = new CustomerCreateOptions - { - Name = billingAddress.Name, - Email = viewModel.Information.Email.Text, - Phone = viewModel.Information.Phone.Text, - Address = new AddressOptions - { - City = billingAddress.City, - Country = billingAddress.Region, - Line1 = billingAddress.StreetAddress1, - Line2 = billingAddress.StreetAddress2, - PostalCode = billingAddress.PostalCode, - State = billingAddress.Province, - }, - Shipping = shippingAddress.Name != null - ? new ShippingOptions - { - Name = shippingAddress.Name, - Address = new AddressOptions - { - City = shippingAddress.City, - Country = shippingAddress.Region, - Line1 = shippingAddress.StreetAddress1, - Line2 = shippingAddress.StreetAddress2, - PostalCode = shippingAddress.PostalCode, - State = shippingAddress.Province, - }, - } - : null, - }; - - var customerService = new CustomerService(); - var customer = await customerService.CreateAsync( - customerCreateOptions, - await requestOptionsService.SetIdempotencyKeyAsync(), - cancellationToken: httpContext.RequestAborted); - - //TODO: Save customer id to DB, with current User data and other necessary data - - return customer; - } - private static async Task GetStripeCheckoutEndpointAsync( [FromBody] SubscriptionCheckoutEndpointViewModel viewModel, [FromServices] IAuthorizationService authorizationService, - [FromServices] IRequestOptionsService requestOptionsService, + [FromServices] IStripeCustomerService stripeCustomerService, + [FromServices] IStripeSessionService stripeSessionService, + [FromServices] ShellSettings shellSettings, HttpContext httpContext) { if (!await authorizationService.AuthorizeAsync(httpContext.User, ApiPermissions.CommerceApiStripePayment)) @@ -112,32 +38,44 @@ private static async Task GetStripeCheckoutEndpointAsync( return httpContext.ChallengeOrForbidApi(); } - var customer = await GetOrCreateCustomerAsync(viewModel, httpContext, requestOptionsService); - var mode = viewModel.PaymentMode == PaymentMode.Payment ? "payment" : "subscription"; + //TODO: We need update also + var customer = await stripeCustomerService.GetOrCreateCustomerAsync( + viewModel.BillingAddress, + viewModel.ShippingAddress, + viewModel.Email, + viewModel.Phone); + var mode = viewModel.PaymentMode == PaymentMode.Payment ? "payment" : "subscription"; var options = new SessionCreateOptions { LineItems = [.. viewModel.SessionLineItemOptions], Mode = mode, - Discounts = new List + SuccessUrl = viewModel.SuccessUrl, + CancelUrl = viewModel.CancelUrl, + Customer = customer.Id, + SubscriptionData = new SessionSubscriptionDataOptions { - new SessionDiscountOptions + Metadata = new Dictionary { - Coupon = "QTpgGHha" + { "tenantName", shellSettings.Name }, }, }, - SuccessUrl = viewModel.SuccessUrl, - CancelUrl = viewModel.CancelUrl, - Customer = customer.Id, }; - var service = new SessionService(); - var session = await service.CreateAsync( - options, - await requestOptionsService.SetIdempotencyKeyAsync(), - cancellationToken: httpContext.RequestAborted); + foreach (var lineItem in options.LineItems) + { + //TODO: Change this to use the actual tax rate + lineItem.TaxRates = ["txr_1F3586L1SJaDnrcsvfTTvknD"]; + } + + var session = await stripeSessionService.CreateSessionAsync(options); //Save session id to DB, with current User data and other necessary data + var result = await stripeSessionService.SaveSessionDataAsync(customer, session); + if (result.Errors?.Any() == true) + { + return TypedResults.BadRequest(result.Errors); + } return TypedResults.Ok(session.Url); } diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/EndPoints/Models/SubscriptionCheckoutEndpointViewModel.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/EndPoints/Models/SubscriptionCheckoutEndpointViewModel.cs index 0e5d7fe55..47e185640 100644 --- a/src/Modules/OrchardCore.Commerce.Payment.Stripe/EndPoints/Models/SubscriptionCheckoutEndpointViewModel.cs +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/EndPoints/Models/SubscriptionCheckoutEndpointViewModel.cs @@ -1,4 +1,4 @@ -using OrchardCore.Commerce.Abstractions.Models; +using OrchardCore.Commerce.AddressDataType; using Stripe.Checkout; using System.Collections.Generic; @@ -8,8 +8,10 @@ public class SubscriptionCheckoutEndpointViewModel { public string SuccessUrl { get; set; } public string CancelUrl { get; set; } - public string CustomerId { get; set; } public IList SessionLineItemOptions { get; set; } = new List(); public PaymentMode PaymentMode { get; set; } - public OrderPart Information { get; set; } + public string Phone { get; set; } + public string Email { get; set; } + public Address BillingAddress { get; set; } + public Address ShippingAddress { get; set; } } diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Indexes/StripeSessionDataIndex.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Indexes/StripeSessionDataIndex.cs new file mode 100644 index 000000000..b1358674e --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Indexes/StripeSessionDataIndex.cs @@ -0,0 +1,30 @@ +using OrchardCore.Commerce.Payment.Stripe.Models; +using YesSql.Indexes; + +namespace OrchardCore.Commerce.Payment.Stripe.Indexes; + +public class StripeSessionDataIndex : MapIndex +{ + public string UserId { get; set; } + public string StripeCustomerId { get; set; } + public string StripeSessionId { get; set; } + public string StripeSessionUrl { get; set; } + public string StripeInvoiceId { get; set; } + public string SerializedAdditionalData { get; set; } +} + +public class StripeSessionDataIndexProvider : IndexProvider +{ + public override void Describe(DescribeContext context) => + context.For() + .Map(sessionData => new StripeSessionDataIndex + { + UserId = sessionData.UserId, + StripeCustomerId = sessionData.StripeCustomerId, + StripeSessionId = sessionData.StripeSessionId, + StripeSessionUrl = sessionData.StripeSessionUrl, + StripeInvoiceId = sessionData.StripeInvoiceId, + SerializedAdditionalData = sessionData.SerializedAdditionalData, + + }); +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Migrations/StripeSessionMigrations.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Migrations/StripeSessionMigrations.cs new file mode 100644 index 000000000..164b4e83b --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Migrations/StripeSessionMigrations.cs @@ -0,0 +1,24 @@ +using OrchardCore.Commerce.Payment.Stripe.Indexes; +using OrchardCore.Data.Migration; +using System.Threading.Tasks; +using YesSql.Sql; + +namespace OrchardCore.Commerce.Payment.Stripe.Migrations; + +public class StripeSessionMigrations : DataMigration +{ + public async Task CreateAsync() + { + await SchemaBuilder + .CreateMapIndexTableAsync(table => table + .Column(nameof(StripeSessionDataIndex.UserId)) + .Column(nameof(StripeSessionDataIndex.StripeCustomerId)) + .Column(nameof(StripeSessionDataIndex.StripeSessionId)) + .Column(nameof(StripeSessionDataIndex.StripeSessionUrl)) + .Column(nameof(StripeSessionDataIndex.StripeInvoiceId)) + .Column(nameof(StripeSessionDataIndex.SerializedAdditionalData)) + ); + + return 1; + } +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/StripeCustomer.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/StripeCustomer.cs new file mode 100644 index 000000000..9e0c6b22a --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/StripeCustomer.cs @@ -0,0 +1,6 @@ +namespace OrchardCore.Commerce.Payment.Stripe.Models; + +public class StripeCustomer +{ + public string CustomerId { get; set; } +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/StripeSessionData.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/StripeSessionData.cs new file mode 100644 index 000000000..f257a58cd --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/StripeSessionData.cs @@ -0,0 +1,11 @@ +namespace OrchardCore.Commerce.Payment.Stripe.Models; + +public class StripeSessionData +{ + public string UserId { get; set; } + public string StripeCustomerId { get; set; } + public string StripeSessionId { get; set; } + public string StripeSessionUrl { get; set; } + public string StripeInvoiceId { get; set; } + public string SerializedAdditionalData { get; set; } +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/StripeSessionDataSave.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/StripeSessionDataSave.cs new file mode 100644 index 000000000..6e7de6bd1 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/StripeSessionDataSave.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace OrchardCore.Commerce.Payment.Stripe.Models; + +public class StripeSessionDataSave +{ + public StripeSessionData StripeSessionData { get; set; } + public IEnumerable Errors { get; set; } +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/DefaultStripeWebhookEventHandler.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/DefaultStripeWebhookEventHandler.cs new file mode 100644 index 000000000..16fd6c55c --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/DefaultStripeWebhookEventHandler.cs @@ -0,0 +1,40 @@ +using OrchardCore.Commerce.Payment.Stripe.Abstractions; +using Stripe; +using System.Threading.Tasks; +using static Stripe.Events; + +namespace OrchardCore.Commerce.Payment.Stripe.Services; + +public class DefaultStripeWebhookEventHandler : IStripeWebhookEventHandler +{ + private readonly IStripePaymentService _stripePaymentService; + + public DefaultStripeWebhookEventHandler(IStripePaymentService stripePaymentService) => + _stripePaymentService = stripePaymentService; + + public async Task ReceivedStripeEventAsync(Event stripeEvent) + { + if (stripeEvent.Type == ChargeSucceeded) + { + var charge = stripeEvent.Data.Object as Charge; + if (charge?.PaymentIntentId is not { } paymentIntentId) + { + return; + } + + // If the charge is associated with a customer, it means it's a subscription payment in the current implementation. + if (!string.IsNullOrEmpty(charge.CustomerId)) + { + return; + } + + var paymentIntent = await _stripePaymentService.GetPaymentIntentAsync(paymentIntentId); + await _stripePaymentService.UpdateOrderToOrderedAsync(paymentIntent, shoppingCartId: null); + } + else if (stripeEvent.Type == PaymentIntentPaymentFailed) + { + var paymentIntent = stripeEvent.Data.Object as PaymentIntent; + await _stripePaymentService.UpdateOrderToPaymentFailedAsync(paymentIntent.Id); + } + } +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/IStripeCustomerService.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/IStripeCustomerService.cs new file mode 100644 index 000000000..638d0677e --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/IStripeCustomerService.cs @@ -0,0 +1,16 @@ +using Stripe; +using System.Threading.Tasks; +using Address = OrchardCore.Commerce.AddressDataType.Address; + +namespace OrchardCore.Commerce.Payment.Stripe.Services; + +public interface IStripeCustomerService +{ + Task GetFirstCustomerByEmailAsync(string customerEmail); + Task GetCustomerByIdAsync(string customerId); + Task GetOrCreateCustomerAsync( + Address billingAddress, + Address shippingAddress, + string email, + string phone); +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/IStripeSessionEventHandler.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/IStripeSessionEventHandler.cs new file mode 100644 index 000000000..a65347c5b --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/IStripeSessionEventHandler.cs @@ -0,0 +1,11 @@ +using OrchardCore.Commerce.Payment.Stripe.Models; +using Stripe; +using Stripe.Checkout; +using System.Threading.Tasks; + +namespace OrchardCore.Commerce.Payment.Stripe.Services; + +public interface IStripeSessionEventHandler +{ + Task StripeSessionDataCreatingAsync(StripeSessionData sessionData, Session session, Customer customer); +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/IStripeSessionService.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/IStripeSessionService.cs new file mode 100644 index 000000000..7a8c3d063 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/IStripeSessionService.cs @@ -0,0 +1,18 @@ +using OrchardCore.Commerce.Payment.Stripe.Models; +using Stripe; +using Stripe.Checkout; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace OrchardCore.Commerce.Payment.Stripe.Services; + +public interface IStripeSessionService +{ + Task CreateSessionAsync(SessionCreateOptions options); + + Task SaveSessionDataAsync(Customer customer, Session session); + + Task> GetAllSessionDataAsync(string userId, string invoiceId, string sessionId); + + Task GetFirstSessionDataByInvoiceIdAsync(string invoiceId); +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/IStripeSubscriptionService.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/IStripeSubscriptionService.cs new file mode 100644 index 000000000..cf8ad857a --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/IStripeSubscriptionService.cs @@ -0,0 +1,10 @@ +using Stripe; +using System.Threading.Tasks; + +namespace OrchardCore.Commerce.Payment.Stripe.Services; + +public interface IStripeSubscriptionService +{ + Task UpdateSubscriptionAsync(string subscriptionId, SubscriptionUpdateOptions options); + Task GetSubscriptionAsync(string subscriptionId, SubscriptionGetOptions options); +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/IStripeWebhookEventHandler.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/IStripeWebhookEventHandler.cs new file mode 100644 index 000000000..3d5d989be --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/IStripeWebhookEventHandler.cs @@ -0,0 +1,9 @@ +using Stripe; +using System.Threading.Tasks; + +namespace OrchardCore.Commerce.Payment.Stripe.Services; + +public interface IStripeWebhookEventHandler +{ + Task ReceivedStripeEventAsync(Event stripeEvent); +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripeCustomerService.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripeCustomerService.cs new file mode 100644 index 000000000..29070adfe --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripeCustomerService.cs @@ -0,0 +1,126 @@ +using Lombiq.HelpfulLibraries.OrchardCore.Users; +using Microsoft.AspNetCore.Http; +using OrchardCore.Commerce.Payment.Stripe.Abstractions; +using Stripe; +using System; +using System.Linq; +using System.Threading.Tasks; +using Address = OrchardCore.Commerce.AddressDataType.Address; + +namespace OrchardCore.Commerce.Payment.Stripe.Services; + +public class StripeCustomerService : IStripeCustomerService +{ + private readonly IHttpContextAccessor _hca; + private readonly IRequestOptionsService _requestOptionsService; + private readonly CustomerService _customerService = new(); + private readonly ICachingUserManager _cachingUserManager; + + public StripeCustomerService( + IHttpContextAccessor httpContextAccessor, + IRequestOptionsService requestOptionsService, + ICachingUserManager cachingUserManager) + { + _hca = httpContextAccessor; + _requestOptionsService = requestOptionsService; + _cachingUserManager = cachingUserManager; + } + + public async Task GetCustomerByIdAsync(string customerId) + { + try + { + return await _customerService.GetAsync( + customerId, + requestOptions: await _requestOptionsService.SetIdempotencyKeyAsync(), + cancellationToken: _hca.HttpContext.RequestAborted); + } + catch (StripeException stripeException) + { + return null; + } + } + + public async Task GetFirstCustomerByEmailAsync(string customerEmail) + { + try + { + var list = await _customerService.ListAsync( + new CustomerListOptions + { + Email = customerEmail, + Limit = 1, + }, + requestOptions: await _requestOptionsService.SetIdempotencyKeyAsync(), + cancellationToken: _hca.HttpContext.RequestAborted); + return list.Data.FirstOrDefault(); + } + catch (StripeException stripeException) + { + + return null; + } + } + + public async Task GetOrCreateCustomerAsync( + Address billingAddress, + Address shippingAddress, + string email, + string phone) + { + if (string.IsNullOrEmpty(email)) + { + email = (await _cachingUserManager.GetUserByClaimsPrincipalAsync(_hca.HttpContext.User))?.Email; + } + + var customer = await GetFirstCustomerByEmailAsync(email); + + if (customer?.Id != null) + { + return customer; + } + + return await CreateCustomerAsync(billingAddress, shippingAddress, email, phone); + } + + private async Task CreateCustomerAsync(Address billingAddress, Address shippingAddress, string email, string phone) + { + var customerCreateOptions = new CustomerCreateOptions + { + Name = billingAddress.Name, + Email = email, + Phone = phone, + Address = new AddressOptions + { + City = billingAddress.City, + Country = billingAddress.Region, + Line1 = billingAddress.StreetAddress1, + Line2 = billingAddress.StreetAddress2, + PostalCode = billingAddress.PostalCode, + State = billingAddress.Province, + }, + Shipping = shippingAddress?.Name != null + ? new ShippingOptions + { + Name = shippingAddress.Name, + Address = new AddressOptions + { + City = shippingAddress.City, + Country = shippingAddress.Region, + Line1 = shippingAddress.StreetAddress1, + Line2 = shippingAddress.StreetAddress2, + PostalCode = shippingAddress.PostalCode, + State = shippingAddress.Province, + }, + } + : null, + }; + + var customer = await _customerService.CreateAsync( + customerCreateOptions, + await _requestOptionsService.SetIdempotencyKeyAsync(), + cancellationToken: _hca.HttpContext.RequestAborted); + + return customer; + } +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripeSessionService.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripeSessionService.cs new file mode 100644 index 000000000..116cf4144 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripeSessionService.cs @@ -0,0 +1,101 @@ +#nullable enable +using Lombiq.HelpfulLibraries.OrchardCore.Users; +using Microsoft.AspNetCore.Http; +using OrchardCore.Commerce.Payment.Stripe.Abstractions; +using OrchardCore.Commerce.Payment.Stripe.Indexes; +using OrchardCore.Commerce.Payment.Stripe.Models; +using Stripe; +using Stripe.Checkout; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading.Tasks; +using YesSql; +using ISession = YesSql.ISession; +using Session = Stripe.Checkout.Session; + +namespace OrchardCore.Commerce.Payment.Stripe.Services; + +public class StripeSessionService : IStripeSessionService +{ + private readonly SessionService _sessionService = new(); + private readonly IRequestOptionsService _requestOptionsService; + private readonly IHttpContextAccessor _hca; + private readonly ISession _session; + private readonly ICachingUserManager _cachingUserManager; + private readonly IEnumerable _stripeSessionEventHandlers; + + public StripeSessionService( + IRequestOptionsService requestOptionsService, + IHttpContextAccessor httpContextAccessor, + ISession session, + ICachingUserManager cachingUserManager, + IEnumerable stripeSessionEventHandlers) + { + _requestOptionsService = requestOptionsService; + _hca = httpContextAccessor; + _session = session; + _cachingUserManager = cachingUserManager; + _stripeSessionEventHandlers = stripeSessionEventHandlers; + } + + public async Task CreateSessionAsync(SessionCreateOptions options) => + await _sessionService.CreateAsync( + options, + await _requestOptionsService.SetIdempotencyKeyAsync(), + cancellationToken: _hca.HttpContext.RequestAborted); + + // Create session content item and save it to the database + public async Task SaveSessionDataAsync(Customer customer, Session session) + { + var user = await _cachingUserManager.GetUserByEmailAsync(customer.Email); + if (user == null) + { + return new StripeSessionDataSave { Errors = ["User not found with email: " + customer.Email] }; + } + + var sessionData = await GetSessionDataQuery(user.UserId, session.InvoiceId, session.Id).FirstOrDefaultAsync(); + sessionData ??= new StripeSessionData(); + sessionData.UserId = user.UserId; + sessionData.StripeSessionId = session.Id; + sessionData.StripeSessionUrl = session.Url; + sessionData.StripeInvoiceId = session.InvoiceId; + sessionData.StripeCustomerId = customer.Id; + sessionData.SerializedAdditionalData = JsonSerializer.Serialize(session.Metadata); + + // Here you can override to the session data that will be saved. And e.g. give additional data in the serializeddata. + await _stripeSessionEventHandlers.AwaitEachAsync(handler => + handler.StripeSessionDataCreatingAsync(sessionData, session, customer)); + + await _session.SaveAsync(sessionData); + + return new StripeSessionDataSave { StripeSessionData = sessionData }; + } + + public Task> GetAllSessionDataAsync(string userId, string invoiceId, string sessionId) => + GetSessionDataQuery(userId, invoiceId, sessionId).ListAsync(); + + // Get session data by invoice id + public Task GetFirstSessionDataByInvoiceIdAsync(string invoiceId) => + GetSessionDataQuery(userId: string.Empty, invoiceId, sessionId: string.Empty).FirstOrDefaultAsync(); + + public IQuery GetSessionDataQuery(string userId, string invoiceId, string sessionId) + { + var query = _session.Query(); + if (!string.IsNullOrEmpty(userId)) + { + query = query.Where(item => item.UserId == userId); + } + + if (!string.IsNullOrEmpty(invoiceId)) + { + query = query.Where(item => item.StripeInvoiceId == invoiceId); + } + + if (!string.IsNullOrEmpty(sessionId)) + { + query = query.Where(item => item.StripeSessionId == sessionId); + } + + return query; + } +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripeSubscriptionService.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripeSubscriptionService.cs new file mode 100644 index 000000000..ee5e46c98 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripeSubscriptionService.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Http; +using OrchardCore.Commerce.Payment.Stripe.Abstractions; +using Stripe; +using System.Threading.Tasks; + +namespace OrchardCore.Commerce.Payment.Stripe.Services; + +public class StripeSubscriptionService : IStripeSubscriptionService +{ + private readonly SubscriptionService _subscriptionService = new(); + private readonly IRequestOptionsService _requestOptionsService; + private readonly IHttpContextAccessor _hca; + + public StripeSubscriptionService(IRequestOptionsService requestOptionsService, IHttpContextAccessor httpContextAccessor) + { + _requestOptionsService = requestOptionsService; + _hca = httpContextAccessor; + } + + public async Task UpdateSubscriptionAsync(string subscriptionId, SubscriptionUpdateOptions options) => + await _subscriptionService.UpdateAsync( + subscriptionId, + options, + requestOptions: await _requestOptionsService.SetIdempotencyKeyAsync(), + cancellationToken: _hca.HttpContext.RequestAborted); + + public async Task GetSubscriptionAsync(string subscriptionId, SubscriptionGetOptions options) => + await _subscriptionService.GetAsync( + subscriptionId, + options: options, + requestOptions: await _requestOptionsService.SetIdempotencyKeyAsync(), + cancellationToken: _hca.HttpContext.RequestAborted); +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/SubscriptionStripeWebhookEventHandler.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/SubscriptionStripeWebhookEventHandler.cs new file mode 100644 index 000000000..e3c403965 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/SubscriptionStripeWebhookEventHandler.cs @@ -0,0 +1,68 @@ +using Lombiq.HelpfulLibraries.OrchardCore.Users; +using Microsoft.Extensions.Logging; +using OrchardCore.Commerce.Models; +using OrchardCore.Commerce.Services; +using OrchardCore.Modules; +using Stripe; +using System.Text.Json; +using System.Threading.Tasks; +using static Stripe.Events; + +namespace OrchardCore.Commerce.Payment.Stripe.Services; + +public class SubscriptionStripeWebhookEventHandler : IStripeWebhookEventHandler +{ + private readonly ICachingUserManager _cachingUserManager; + private readonly IClock _clock; + private readonly ISubscriptionService _subscriptionService; + private readonly ILogger _logger; + private readonly IStripeSubscriptionService _stripeSubscriptionService; + + public SubscriptionStripeWebhookEventHandler( + ICachingUserManager cachingUserManager, + IClock clock, + ISubscriptionService subscriptionService, + IStripeSubscriptionService stripeSubscriptionService, + ILogger logger) + { + _cachingUserManager = cachingUserManager; + _clock = clock; + _subscriptionService = subscriptionService; + _stripeSubscriptionService = stripeSubscriptionService; + _logger = logger; + } + + public async Task ReceivedStripeEventAsync(Event stripeEvent) + { + if (stripeEvent.Type == InvoicePaid) + { + var invoice = stripeEvent.Data.Object as Invoice; + if (invoice?.Status == "paid") + { + var user = await _cachingUserManager.GetUserByEmailAsync(invoice.CustomerEmail); + if (user == null) + { + _logger.LogError( + "User not found for email {Email}, while invoice was payed. Invoice data: {InvoiceData}", + invoice.CustomerEmail, + JsonSerializer.Serialize(invoice)); + return; + } + + // Get session data for the invoice Id + var subscriptionPart = new SubscriptionPart(); + subscriptionPart.UserId.Text = user.UserId; + subscriptionPart.Status.Text = SubscriptionStatuses.Active; + subscriptionPart.StartDateUtc.Value = _clock.UtcNow; + subscriptionPart.EndDateUtc.Value = invoice.PeriodEnd; + subscriptionPart.PaymentProviderName.Text = "Stripe"; + subscriptionPart.IdInPaymentProvider.Text = invoice.SubscriptionId; + + var stripeSubscription = await _stripeSubscriptionService.GetSubscriptionAsync(invoice.SubscriptionId, options: null); + subscriptionPart.Metadata = stripeSubscription.Metadata; + + await _subscriptionService.CreateOrUpdateActiveSubscriptionAsync(invoice.SubscriptionId, subscriptionPart); + } + } + } +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Startup.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Startup.cs index 23c7d100f..57157f68b 100644 --- a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Startup.cs +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Startup.cs @@ -12,6 +12,7 @@ using OrchardCore.Commerce.Payment.Stripe.Models; using OrchardCore.Commerce.Payment.Stripe.Services; using OrchardCore.ContentManagement; +using OrchardCore.Data; using OrchardCore.Data.Migration; using OrchardCore.DisplayManagement.Handlers; using OrchardCore.Modules; @@ -52,6 +53,15 @@ public override void ConfigureServices(IServiceCollection services) services.Configure(option => option.MemberAccessStrategy.Register()); services.AddContentSecurityPolicyProvider(); + + services.AddDataMigration(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddIndexProvider(); } public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) => diff --git a/src/Modules/OrchardCore.Commerce/Indexes/SubscriptionPartIndex.cs b/src/Modules/OrchardCore.Commerce/Indexes/SubscriptionPartIndex.cs new file mode 100644 index 000000000..853578570 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce/Indexes/SubscriptionPartIndex.cs @@ -0,0 +1,44 @@ +using OrchardCore.Commerce.Models; +using OrchardCore.ContentManagement; +using System; +using System.Text.Json; +using YesSql.Indexes; + +namespace OrchardCore.Commerce.Indexes; + +public class SubscriptionPartIndex : MapIndex +{ + public string Status { get; set; } + public string IdInPaymentProvider { get; set; } + public string PaymentProviderName { get; set; } + public string UserId { get; set; } + public string SerializedMetadata { get; set; } + public DateTime StartDateUtc { get; set; } + public DateTime EndDateUtc { get; set; } +} + +/// +/// Creates an index of content items (products in this case) by SKU. +/// +public class SubscriptionPartIndexProvider : IndexProvider +{ + // Notice that ContentItem is what we are describing the provider for not the part. + public override void Describe(DescribeContext context) => + context.For() + .When(contentItem => contentItem.Has()) + .Map(contentItem => + { + var subscriptionPart = contentItem.As(); + + return new SubscriptionPartIndex + { + Status = subscriptionPart.Status.Text, + IdInPaymentProvider = subscriptionPart.IdInPaymentProvider.Text, + PaymentProviderName = subscriptionPart.PaymentProviderName.Text, + UserId = subscriptionPart.UserId.Text, + StartDateUtc = subscriptionPart.StartDateUtc.Value!.Value, + EndDateUtc = subscriptionPart.EndDateUtc.Value!.Value, + SerializedMetadata = JsonSerializer.Serialize(subscriptionPart.Metadata), + }; + }); +} diff --git a/src/Modules/OrchardCore.Commerce/Migrations/SubscriptionMigrations.cs b/src/Modules/OrchardCore.Commerce/Migrations/SubscriptionMigrations.cs index 4d7ffa677..3639c5d52 100644 --- a/src/Modules/OrchardCore.Commerce/Migrations/SubscriptionMigrations.cs +++ b/src/Modules/OrchardCore.Commerce/Migrations/SubscriptionMigrations.cs @@ -1,9 +1,12 @@ -using OrchardCore.Commerce.Models; +using OrchardCore.Commerce.Indexes; +using OrchardCore.Commerce.Models; using OrchardCore.ContentFields.Settings; using OrchardCore.ContentManagement.Metadata; using OrchardCore.ContentManagement.Metadata.Settings; using OrchardCore.Data.Migration; +using System; using System.Threading.Tasks; +using YesSql.Sql; using static Lombiq.HelpfulLibraries.OrchardCore.Contents.ContentFieldEditorEnums.TextFieldEditors; using static OrchardCore.Commerce.Abstractions.Constants.ContentTypes; using static OrchardCore.Commerce.Constants.SubscriptionStatuses; @@ -45,14 +48,43 @@ await _contentDefinitionManager.AlterPartDefinitionAsync(build .WithField(part => part.UserId, field => field .WithDisplayName("User Id") .WithDescription("The user ID of the subscriber.")) + .WithField(part => part.SerializedMetadata, field => field + .WithDisplayName("Additional data") + .WithDescription("Additional data about the subscription in JSON serialized form.")) .WithField(part => part.StartDateUtc, field => field + .WithSettings(new DateTimeFieldSettings { Required = true }) .WithDisplayName("Start date") .WithDescription("The date when the subscription started.")) .WithField(part => part.EndDateUtc, field => field + .WithSettings(new DateTimeFieldSettings { Required = true }) .WithDisplayName("End date") .WithDescription("The date when the subscription ends.")) ); - return 1; + await SchemaBuilder.CreateMapIndexTableAsync(table => table + .Column(nameof(SubscriptionPartIndex.Status)) + .Column(nameof(SubscriptionPartIndex.IdInPaymentProvider)) + .Column(nameof(SubscriptionPartIndex.PaymentProviderName)) + .Column(nameof(SubscriptionPartIndex.UserId)) + .Column(nameof(SubscriptionPartIndex.SerializedMetadata)) + .Column(nameof(SubscriptionPartIndex.StartDateUtc)) + .Column(nameof(SubscriptionPartIndex.EndDateUtc)) + ); + + return 2; + } + + public async Task UpdateFrom1Async() + { + await SchemaBuilder.CreateMapIndexTableAsync(table => table + .Column(nameof(SubscriptionPartIndex.Status)) + .Column(nameof(SubscriptionPartIndex.IdInPaymentProvider)) + .Column(nameof(SubscriptionPartIndex.PaymentProviderName)) + .Column(nameof(SubscriptionPartIndex.UserId)) + .Column(nameof(SubscriptionPartIndex.StartDateUtc)) + .Column(nameof(SubscriptionPartIndex.EndDateUtc)) + ); + + return 2; } } diff --git a/src/Modules/OrchardCore.Commerce/Models/SubscriptionPart.cs b/src/Modules/OrchardCore.Commerce/Models/SubscriptionPart.cs index 487700ebd..82d4fc60f 100644 --- a/src/Modules/OrchardCore.Commerce/Models/SubscriptionPart.cs +++ b/src/Modules/OrchardCore.Commerce/Models/SubscriptionPart.cs @@ -1,5 +1,8 @@ using OrchardCore.ContentFields.Fields; using OrchardCore.ContentManagement; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; namespace OrchardCore.Commerce.Models; @@ -11,4 +14,13 @@ public class SubscriptionPart : ContentPart public TextField UserId { get; set; } = new(); public DateField StartDateUtc { get; set; } = new(); public DateField EndDateUtc { get; set; } = new(); + + public TextField SerializedMetadata { get; set; } = new(); + + [JsonIgnore] + public IDictionary Metadata + { + get => JsonSerializer.Deserialize>(SerializedMetadata.Text); + set => SerializedMetadata.Text = JsonSerializer.Serialize(value); + } } diff --git a/src/Modules/OrchardCore.Commerce/Services/ISubscriptionService.cs b/src/Modules/OrchardCore.Commerce/Services/ISubscriptionService.cs new file mode 100644 index 000000000..5d33ca8e4 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce/Services/ISubscriptionService.cs @@ -0,0 +1,9 @@ +using OrchardCore.Commerce.Models; +using System.Threading.Tasks; + +namespace OrchardCore.Commerce.Services; + +public interface ISubscriptionService +{ + Task CreateOrUpdateActiveSubscriptionAsync(string subscriptionId, SubscriptionPart subscriptionPart); +} diff --git a/src/Modules/OrchardCore.Commerce/Services/SubscriptionService.cs b/src/Modules/OrchardCore.Commerce/Services/SubscriptionService.cs new file mode 100644 index 000000000..edbea643d --- /dev/null +++ b/src/Modules/OrchardCore.Commerce/Services/SubscriptionService.cs @@ -0,0 +1,39 @@ +using OrchardCore.Commerce.Constants; +using OrchardCore.Commerce.Indexes; +using OrchardCore.Commerce.Models; +using OrchardCore.ContentManagement; +using System.Threading.Tasks; +using YesSql; +using static OrchardCore.Commerce.Abstractions.Constants.ContentTypes; + +namespace OrchardCore.Commerce.Services; + +public class SubscriptionService : ISubscriptionService +{ + private readonly IContentManager _contentManager; + private readonly ISession _session; + + public SubscriptionService(IContentManager contentManager, ISession session) + { + _contentManager = contentManager; + _session = session; + } + + public async Task CreateOrUpdateActiveSubscriptionAsync(string subscriptionId, SubscriptionPart subscriptionPart) + { + var subscription = await _session.Query( + item => item.IdInPaymentProvider == subscriptionId) + .FirstOrDefaultAsync(); + + subscription ??= await _contentManager.NewAsync(Subscription); + subscription.Apply(subscriptionPart); + if (subscription.IsNew()) + { + await _contentManager.CreateAsync(subscription); + } + else + { + await _contentManager.UpdateAsync(subscription); + } + } +} diff --git a/src/Modules/OrchardCore.Commerce/Startup.cs b/src/Modules/OrchardCore.Commerce/Startup.cs index 37254c43b..39756f2a3 100644 --- a/src/Modules/OrchardCore.Commerce/Startup.cs +++ b/src/Modules/OrchardCore.Commerce/Startup.cs @@ -413,6 +413,9 @@ public override void ConfigureServices(IServiceCollection services) { services .AddContentPart() - .WithMigration(); + .WithMigration() + .WithIndex(); + + services.AddScoped(); } }