Skip to content

Commit

Permalink
Separating and adding logic supporting stripe subscription
Browse files Browse the repository at this point in the history
  • Loading branch information
wAsnk committed Oct 30, 2024
1 parent 08d5be4 commit 593d5f7
Show file tree
Hide file tree
Showing 25 changed files with 711 additions and 119 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<WebhookController> _logger;
private readonly IEnumerable<IStripeWebhookEventHandler> _stripeWebhookEventHandlers;

public WebhookController(
IStripePaymentService stripePaymentService,
ISiteService siteService,
IDataProtectionProvider dataProtectionProvider,
ILogger<WebhookController> logger)
ILogger<WebhookController> logger,
IEnumerable<IStripeWebhookEventHandler> stripeWebhookEventHandlers)
{
_stripePaymentService = stripePaymentService;
_siteService = siteService;
_dataProtectionProvider = dataProtectionProvider;
_logger = logger;
_stripeWebhookEventHandlers = stripeWebhookEventHandlers;
}

[HttpPost]
Expand All @@ -50,23 +51,13 @@ public async Task<IActionResult> 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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -27,117 +25,57 @@ public static IEndpointRouteBuilder AddStripeCheckoutEndpoint(this IEndpointRout
return builder;
}

// Try to get customer, if not exists, create it
private static async Task<Customer> 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<Customer> 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<IResult> 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))
{
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<SessionDiscountOptions>
SuccessUrl = viewModel.SuccessUrl,
CancelUrl = viewModel.CancelUrl,
Customer = customer.Id,
SubscriptionData = new SessionSubscriptionDataOptions
{
new SessionDiscountOptions
Metadata = new Dictionary<string, string>
{
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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using OrchardCore.Commerce.Abstractions.Models;
using OrchardCore.Commerce.AddressDataType;
using Stripe.Checkout;
using System.Collections.Generic;

Expand All @@ -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> SessionLineItemOptions { get; set; } = new List<SessionLineItemOptions>();
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; }
}
Original file line number Diff line number Diff line change
@@ -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<StripeSessionData>
{
public override void Describe(DescribeContext<StripeSessionData> context) =>
context.For<StripeSessionDataIndex>()
.Map(sessionData => new StripeSessionDataIndex
{
UserId = sessionData.UserId,
StripeCustomerId = sessionData.StripeCustomerId,
StripeSessionId = sessionData.StripeSessionId,
StripeSessionUrl = sessionData.StripeSessionUrl,
StripeInvoiceId = sessionData.StripeInvoiceId,
SerializedAdditionalData = sessionData.SerializedAdditionalData,
});
}
Original file line number Diff line number Diff line change
@@ -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<int> CreateAsync()
{
await SchemaBuilder
.CreateMapIndexTableAsync<StripeSessionDataIndex>(table => table
.Column<string>(nameof(StripeSessionDataIndex.UserId))
.Column<string>(nameof(StripeSessionDataIndex.StripeCustomerId))
.Column<string>(nameof(StripeSessionDataIndex.StripeSessionId))
.Column<string>(nameof(StripeSessionDataIndex.StripeSessionUrl))
.Column<string>(nameof(StripeSessionDataIndex.StripeInvoiceId))
.Column<string>(nameof(StripeSessionDataIndex.SerializedAdditionalData))
);

return 1;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace OrchardCore.Commerce.Payment.Stripe.Models;

public class StripeCustomer
{
public string CustomerId { get; set; }
}
Original file line number Diff line number Diff line change
@@ -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; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using System.Collections.Generic;

namespace OrchardCore.Commerce.Payment.Stripe.Models;

public class StripeSessionDataSave
{
public StripeSessionData StripeSessionData { get; set; }
public IEnumerable<string> Errors { get; set; }
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Loading

0 comments on commit 593d5f7

Please sign in to comment.