From 2786470430f7ec08284add0355ca570fa1bbaf24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kriszti=C3=A1n=20N=C3=A9meth?= Date: Fri, 29 Nov 2024 15:15:43 +0100 Subject: [PATCH] OFFI-126: Adding several Stripe API endpoints and basics of Subscription (#510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * WIP: Stripe headless * Fix reference * Make stripe workflow possible on headless * Fixing possible NRE * Deleting not used viewmodels * Fixing folder typo * Code cleanup * Code cleanup * More cleanup * Adding changes so order creation is easy through minimal api * Adding subscription elements * Adding stripe product part * Adding logic for subscription and stripe checkout * Separating and adding logic supporting stripe subscription * Code refactoring and drying * Using actual function * Fixing null issue * Drying and refactoring * Drying and refactoring and documenting * Start date should not be overriden every time * Should be datetime not date * Added permissions * Adding new part of the subscription update logic * Use the actual start date * Making populate function public * Removing update step * Refactoring and documenting * Adding search * Moving stripe services to use DI * Adding get subscription endpoint * Mocking services * Adding testing possibility * Simple textfield * Creating new event data for everything * Deleting not needed using * Fixing altering * Small refactoring * Fixing spelling stuff * Adding reference * Adding version * Adding newest version * Update Directory.Packages.props Co-authored-by: Sára El-Saig * Abstracting logic so it is not that stripe related * Renaming * Sorting out endpoint paths * Adding breaking changes * Not needed using * Removing not needed usings * Using list style --------- Co-authored-by: Sára El-Saig --- Directory.Packages.props | 4 +- docs/releases/3.0.0.md | 5 + .../Constants/ContentTypes.cs | 1 + .../ViewModels/ShoppingCartViewModel.cs | 7 + .../Amount.cs | 30 ++++ .../IStripeConfirmationTokenService.cs | 16 ++ .../Abstractions/IStripeCustomerService.cs | 73 ++++++++ .../Abstractions/IStripeHelperService.cs | 31 ++++ .../IStripePaymentIntentService.cs | 41 +++++ .../Abstractions/IStripePaymentService.cs | 27 +-- .../IStripeSessionEventHandler.cs | 22 +++ .../Abstractions/IStripeSessionService.cs | 16 ++ .../IStripeSubscriptionService.cs | 34 ++++ .../IStripeWebhookEventHandler.cs | 16 ++ .../Assets/Scripts/stripe-payment-form.js | 4 +- .../Constants/ContentTypes.cs | 8 + .../Constants/FeatureIds.cs | 1 + .../Constants/PricePeriods.cs | 10 ++ .../Controllers/StripeController.cs | 10 +- .../Controllers/WebhookController.cs | 32 ++-- .../EndPoints/Api/StripeEndpoint.cs | 30 ---- .../Api/StripeCheckoutApiEndpoint.cs | 57 ++++++ .../Api/StripeConfirmPaymentEndpoint.cs | 41 +++++ .../Api/StripeConfirmationTokenEndpoint.cs | 36 ++++ .../Endpoints/Api/StripeCustomerEndpoint.cs | 57 ++++++ .../Endpoints/Api/StripeParametersEndpoint.cs | 110 ++++++++++++ .../Api/StripePaymentIntentEndpoint.cs | 72 ++++++++ .../Api/StripeSubscriptionEndpoint.cs | 112 ++++++++++++ .../Endpoints/Constants/Endpoints.cs | 6 + .../Endpoints/Extensions/Endpoints.cs | 26 +++ .../Models/ConfirmParametersViewModel.cs | 7 + .../CreatePaymentIntentWithOrderViewModel.cs | 9 + .../Endpoints/Models/OrderViewModel.cs | 15 ++ .../Endpoints/Models/PaymentMode.cs | 7 + .../SubscriptionCheckoutEndpointViewModel.cs | 21 +++ .../Endpoints/Permissions/ApiPermissions.cs | 20 +++ .../Extensions/AdditionalDataExtensions.cs | 32 ++++ .../DefaultStripeWebhookEventHandler.cs | 46 +++++ .../SubscriptionStripeWebhookEventHandler.cs | 88 +++++++++ .../Helpers/AmountHelpers.cs | 17 ++ .../Indexes/StripeSessionDataIndex.cs | 29 +++ .../Manifest.cs | 21 +++ .../Migrations/StripeProductMigrations.cs | 106 +++++++++++ .../Migrations/StripeSessionMigrations.cs | 24 +++ .../Models/FeatureCollectionPart.cs | 5 + .../Models/PriceCollectionPart.cs | 5 + .../Models/StripeCustomer.cs | 6 + .../Models/StripePricePart.cs | 13 ++ .../Models/StripeProductFeaturePart.cs | 9 + .../Models/StripeProductPart.cs | 9 + .../Models/StripeSessionData.cs | 11 ++ .../Models/StripeSessionDataSave.cs | 9 + .../Models/SubscriptionCreateResponse.cs | 7 + ...OrchardCore.Commerce.Payment.Stripe.csproj | 2 + .../Services/DummyCustomerService.cs | 23 +++ .../Services/DummySessionService.cs | 20 +++ .../Services/DummyStripeHelperService.cs | 18 ++ .../Services/DummySubscriptionService.cs | 26 +++ .../Services/PaymentIntentPersistence.cs | 29 ++- .../StripeConfirmationTokenService.cs | 29 +++ .../Services/StripeCustomerService.cs | 170 ++++++++++++++++++ .../Services/StripeHelperService.cs | 14 ++ .../Services/StripePaymentIntentService.cs | 98 ++++++++++ .../Services/StripePaymentProvider.cs | 11 +- .../Services/StripePaymentService.cs | 155 ++++++---------- .../Services/StripeSessionService.cs | 41 +++++ .../Services/StripeSubscriptionService.cs | 90 ++++++++++ .../Startup.cs | 60 ++++++- .../StripeCreateSubscriptionViewModel.cs | 13 ++ .../ViewModels/StripeCustomerViewModel.cs | 8 + .../StripeGetSubscriptionViewModel.cs | 9 + .../Abstractions/IPaymentService.cs | 5 +- .../Endpoints/Api/PaymentEndpoint.cs | 131 ++++++++++++++ .../Endpoints/Extensions/Endpoints.cs | 17 ++ .../Endpoints/Models/AddCallbackViewModel.cs | 11 ++ .../Endpoints/Permissions/ApiPermissions.cs | 17 ++ .../OrchardCore.Commerce.Payment.csproj | 7 + .../Services/PaymentService.cs | 11 +- .../OrchardCore.Commerce.Payment/Startup.cs | 7 + .../OrchardCore.Commerce/CommerceConstants.cs | 1 + .../Endpoints/Api/OrderEndpoint.cs | 40 +++++ .../Endpoints/Api/PaymentEndpoint.cs | 72 -------- .../Endpoints/Api/ShoppingCartLineEndpoint.cs | 132 +++++++------- .../ConvertLocalizedHtmlString.cs | 0 .../Endpoints/Extensions/Endpoints.cs | 19 ++ .../ServiceCollectionExtensions.cs | 4 +- .../Endpoints/Permissions/ApiPermissions.cs | 16 +- .../Services/IShoppingCartService.cs | 8 + .../Endpoints/Services/ShoppingCartService.cs | 4 + .../Endpoints/ViewModels/AddItemViewModel.cs | 1 - .../CreateOrderLineItemViewModel.cs | 9 - .../ViewModels/CreateShoppingCartViewModel.cs | 9 - .../ViewModels/EstimateProductViewModel.cs | 9 - .../ViewModels/ProductListViewModel.cs | 14 -- .../ViewModels/RemoveLineViewModel.cs | 1 - .../Endpoints/ViewModels/UpdateViewModel.cs | 1 - .../Indexes/SubscriptionPartIndex.cs | 44 +++++ src/Modules/OrchardCore.Commerce/Manifest.cs | 12 ++ .../Migrations/SubscriptionMigrations.cs | 65 +++++++ .../Models/SubscriptionPart.cs | 29 +++ .../Services/ISubscriptionService.cs | 22 +++ .../SessionShoppingCartPersistence.cs | 15 +- .../Services/ShoppingCartPersistenceBase.cs | 5 +- .../Services/SubscriptionService.cs | 43 +++++ src/Modules/OrchardCore.Commerce/Startup.cs | 28 +-- 105 files changed, 2679 insertions(+), 397 deletions(-) create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripeConfirmationTokenService.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripeCustomerService.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripeHelperService.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripePaymentIntentService.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripeSessionEventHandler.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripeSessionService.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripeSubscriptionService.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripeWebhookEventHandler.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Constants/ContentTypes.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Constants/PricePeriods.cs delete mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/EndPoints/Api/StripeEndpoint.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Api/StripeCheckoutApiEndpoint.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Api/StripeConfirmPaymentEndpoint.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Api/StripeConfirmationTokenEndpoint.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Api/StripeCustomerEndpoint.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Api/StripeParametersEndpoint.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Api/StripePaymentIntentEndpoint.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Api/StripeSubscriptionEndpoint.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Constants/Endpoints.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Extensions/Endpoints.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Models/ConfirmParametersViewModel.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Models/CreatePaymentIntentWithOrderViewModel.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Models/OrderViewModel.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Models/PaymentMode.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Models/SubscriptionCheckoutEndpointViewModel.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Permissions/ApiPermissions.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Extensions/AdditionalDataExtensions.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Handlers/DefaultStripeWebhookEventHandler.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Handlers/SubscriptionStripeWebhookEventHandler.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Helpers/AmountHelpers.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Indexes/StripeSessionDataIndex.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Migrations/StripeProductMigrations.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Migrations/StripeSessionMigrations.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/FeatureCollectionPart.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/PriceCollectionPart.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/StripeCustomer.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/StripePricePart.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/StripeProductFeaturePart.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/StripeProductPart.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/StripeSessionData.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/StripeSessionDataSave.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/SubscriptionCreateResponse.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/DummyCustomerService.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/DummySessionService.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/DummyStripeHelperService.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/DummySubscriptionService.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripeConfirmationTokenService.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripeCustomerService.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripeHelperService.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripePaymentIntentService.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripeSessionService.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripeSubscriptionService.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/ViewModels/StripeCreateSubscriptionViewModel.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/ViewModels/StripeCustomerViewModel.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment.Stripe/ViewModels/StripeGetSubscriptionViewModel.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment/Endpoints/Api/PaymentEndpoint.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment/Endpoints/Extensions/Endpoints.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment/Endpoints/Models/AddCallbackViewModel.cs create mode 100644 src/Modules/OrchardCore.Commerce.Payment/Endpoints/Permissions/ApiPermissions.cs create mode 100644 src/Modules/OrchardCore.Commerce/Endpoints/Api/OrderEndpoint.cs delete mode 100644 src/Modules/OrchardCore.Commerce/Endpoints/Api/PaymentEndpoint.cs rename src/Modules/OrchardCore.Commerce/Endpoints/{Extentions => Extensions}/ConvertLocalizedHtmlString.cs (100%) create mode 100644 src/Modules/OrchardCore.Commerce/Endpoints/Extensions/Endpoints.cs rename src/Modules/OrchardCore.Commerce/Endpoints/{Extentions => Extensions}/ServiceCollectionExtensions.cs (71%) delete mode 100644 src/Modules/OrchardCore.Commerce/Endpoints/ViewModels/CreateOrderLineItemViewModel.cs delete mode 100644 src/Modules/OrchardCore.Commerce/Endpoints/ViewModels/CreateShoppingCartViewModel.cs delete mode 100644 src/Modules/OrchardCore.Commerce/Endpoints/ViewModels/EstimateProductViewModel.cs delete mode 100644 src/Modules/OrchardCore.Commerce/Endpoints/ViewModels/ProductListViewModel.cs create mode 100644 src/Modules/OrchardCore.Commerce/Indexes/SubscriptionPartIndex.cs create mode 100644 src/Modules/OrchardCore.Commerce/Migrations/SubscriptionMigrations.cs create mode 100644 src/Modules/OrchardCore.Commerce/Models/SubscriptionPart.cs create mode 100644 src/Modules/OrchardCore.Commerce/Services/ISubscriptionService.cs create mode 100644 src/Modules/OrchardCore.Commerce/Services/SubscriptionService.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 757188262..b826ef82d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,13 +8,14 @@ should use Orchard Core references for the latest patch version to pull all versions up in the final app. --> 2.0.0 - 11.0.0 + 11.0.1-alpha.0.offi-126 11.0.0 + @@ -29,6 +30,7 @@ + diff --git a/docs/releases/3.0.0.md b/docs/releases/3.0.0.md index 53b0b3b42..da44de7ce 100644 --- a/docs/releases/3.0.0.md +++ b/docs/releases/3.0.0.md @@ -24,6 +24,11 @@ The `order_line_item_view_models_and_tax_rates` Liquid filter has been removed. The new `amount_to_string` filter processes the input object as `Amount` (like the `amount` filter) and correctly formats it just like the `Amount.ToString()` override in C#. You can use `amount_to_string: dot: ","` to make it display a comma as the decimal separator when it would use a dot. Unlike `amount`, you can also use this filter on a number with the `currency: "three-letter-code""` argument (e.g. `{{ value | amount_to_string: currency: "EUR" }}`). This will display any numeric value as the given currency. +### Stripe controllers and endpoints + +- The `PaymentConfirmationMiddleware` action with the `checkout/middleware/Stripe` path in `StripeController` was changed to `PaymentConfirmation` and its path to `stripe/middleware`. +- The `ConfirmPaymentParameters` action with the `checkout/params/Stripe` path in `StripeController` was change to `stripe/params` path. + ## Change Logs Please check the GitHub release entry [here](https://github.com/OrchardCMS/OrchardCore.Commerce/releases/tag/v3.0.0). diff --git a/src/Libraries/OrchardCore.Commerce.Abstractions/Constants/ContentTypes.cs b/src/Libraries/OrchardCore.Commerce.Abstractions/Constants/ContentTypes.cs index 5347ea43b..2011496ea 100644 --- a/src/Libraries/OrchardCore.Commerce.Abstractions/Constants/ContentTypes.cs +++ b/src/Libraries/OrchardCore.Commerce.Abstractions/Constants/ContentTypes.cs @@ -7,4 +7,5 @@ public static class ContentTypes public const string ShoppingCartWidget = nameof(ShoppingCartWidget); public const string UserAddresses = nameof(UserAddresses); public const string UserDetails = nameof(UserDetails); + public const string Subscription = nameof(Subscription); } diff --git a/src/Libraries/OrchardCore.Commerce.Abstractions/ViewModels/ShoppingCartViewModel.cs b/src/Libraries/OrchardCore.Commerce.Abstractions/ViewModels/ShoppingCartViewModel.cs index 930e1638c..8f45777be 100644 --- a/src/Libraries/OrchardCore.Commerce.Abstractions/ViewModels/ShoppingCartViewModel.cs +++ b/src/Libraries/OrchardCore.Commerce.Abstractions/ViewModels/ShoppingCartViewModel.cs @@ -4,14 +4,21 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json.Serialization; namespace OrchardCore.Commerce.Abstractions.ViewModels; public class ShoppingCartViewModel { public string Id { get; set; } + + [JsonIgnore] public IList InvalidReasons { get; } = new List(); + + [JsonIgnore] public IList Headers { get; } = new List(); + + [JsonIgnore] public IList> TableShapes { get; } = new List>(); public IList Lines { get; } = new List(); public IList Totals { get; } = new List(); diff --git a/src/Libraries/OrchardCore.Commerce.MoneyDataType/Amount.cs b/src/Libraries/OrchardCore.Commerce.MoneyDataType/Amount.cs index 7aeccad36..cfc3ab00a 100644 --- a/src/Libraries/OrchardCore.Commerce.MoneyDataType/Amount.cs +++ b/src/Libraries/OrchardCore.Commerce.MoneyDataType/Amount.cs @@ -3,6 +3,7 @@ using OrchardCore.Commerce.MoneyDataType.Abstractions; using OrchardCore.Commerce.MoneyDataType.Serialization; using System; +using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.Text.Json.Serialization; @@ -82,6 +83,35 @@ public int CompareTo(Amount other) public Amount GetRounded() => new(Math.Round(Value, Currency.DecimalPlaces), Currency); + /// + /// Converts the to a fixed-point fractional value by keeping some digits based on the . + /// + /// + /// Provides exceptional rounding rules for currencies that aren't converted according to the default. The key is + /// the 's ISO code, the value pairs follow the same logic as the matching default parameters. + /// + /// Indicates how many digits should be kept after the decimal point. + /// + /// If positive, the is rounded to this many digits before converted to a fixed-point + /// fractional. Ignored otherwise. + /// + public long GetFixedPointAmount( + IDictionary roundingByCurrencyCode, + int defaultKeepDigits = 2, + int defaultRoundTens = 0) + { + static int Tens(int zeroes) => (int)Math.Pow(10, zeroes); + + var (keepDigits, roundTens) = roundingByCurrencyCode.TryGetValue(Currency.CurrencyIsoCode, out var pair) + ? pair + : (defaultKeepDigits, defaultRoundTens); + + return roundTens > 0 + ? (long)Math.Round(Value / Tens(roundTens)) * Tens(roundTens + keepDigits) + : (long)Math.Round(Value * Tens(keepDigits)); + } + private void ThrowIfCurrencyDoesntMatch(Amount other, string operation = "compare") { if (Currency.Equals(other.Currency)) return; diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripeConfirmationTokenService.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripeConfirmationTokenService.cs new file mode 100644 index 000000000..f2597a098 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripeConfirmationTokenService.cs @@ -0,0 +1,16 @@ +using Stripe; +using System.Threading.Tasks; + +namespace OrchardCore.Commerce.Payment.Stripe.Abstractions; + +/// +/// Service for managing Stripe confirmation tokens. +/// +public interface IStripeConfirmationTokenService +{ + /// + /// Gets the Stripe confirmation token with an Id of . + /// + /// The Stripe . + Task GetConfirmationTokenAsync(string confirmationTokenId); +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripeCustomerService.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripeCustomerService.cs new file mode 100644 index 000000000..f1e1eb8a2 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripeCustomerService.cs @@ -0,0 +1,73 @@ +using Stripe; +using System.Threading.Tasks; +using Address = OrchardCore.Commerce.AddressDataType.Address; + +namespace OrchardCore.Commerce.Payment.Stripe.Abstractions; + +/// +/// Stripe customer related services. +/// +public interface IStripeCustomerService +{ + /// + /// Search for customers in Stripe with the given . + /// + Task> SearchCustomersAsync(CustomerSearchOptions options); + + /// + /// Get the first customer with the given email in Stripe. + /// + Task GetFirstCustomerByEmailAsync(string customerEmail); + + /// + /// Returns with the given Id in Stripe. + /// + Task GetCustomerByIdAsync(string customerId); + + /// + /// Returns with the given email in Stripe. If not found, create a new customer. + /// + /// If not provided the current user's email will be used. + Task GetAndUpdateOrCreateCustomerAsync( + Address billingAddress, + Address shippingAddress, + string email, + string phone); + + /// + /// Create a new customer in Stripe with the given . + /// + /// The created Stripe . + Task CreateCustomerAsync(CustomerCreateOptions customerCreateOptions); + + /// + /// Create the customer in Stripe with the given details which will be used to create the + /// . + /// + /// The created Stripe . + Task CreateCustomerAsync( + Address billingAddress, + Address shippingAddress, + string email, + string phone); + + /// + /// Update the customer in Stripe with the given details. + /// + /// The updated Stripe . + Task UpdateCustomerAsync( + string customerId, + Address billingAddress, + Address shippingAddress, + string email, + string phone); + + /// + /// Populate the returned with the given details. + /// + CustomerCreateOptions PopulateCustomerCreateOptions( + Address billingAddress, + Address shippingAddress, + string email, + string phone); +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripeHelperService.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripeHelperService.cs new file mode 100644 index 000000000..d5560a1c5 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripeHelperService.cs @@ -0,0 +1,31 @@ +using Stripe; + +namespace OrchardCore.Commerce.Payment.Stripe.Abstractions; + +/// +/// Stripe helping services, needed so we can mock this part of Stripe also. +/// +public interface IStripeHelperService +{ + /// + /// Parses a JSON string from a Stripe webhook into a object, while + /// verifying the webhook's + /// signature. + /// + /// The JSON string to parse. + /// + /// The value of the Stripe-Signature header from the webhook request. + /// + /// The webhook endpoint's signing secret. + /// + /// If (default), the method will throw a if the + /// API version of the event doesn't match Stripe.net's default API version (see + /// ). + /// + /// The deserialized . + /// + /// Thrown if the signature verification fails for any reason, of if the API version of the + /// event doesn't match Stripe.net's default API version. + /// + Event PrepareStripeEvent(string json, string stripeSignatureHeader, string secret, bool throwOnApiVersionMismatch); +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripePaymentIntentService.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripePaymentIntentService.cs new file mode 100644 index 000000000..dd309c495 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripePaymentIntentService.cs @@ -0,0 +1,41 @@ +using OrchardCore.Commerce.MoneyDataType; +using OrchardCore.Commerce.Payment.Stripe.Constants; +using Stripe; +using System.Threading.Tasks; + +namespace OrchardCore.Commerce.Payment.Stripe.Abstractions; + +/// +/// Service for managing Stripe Payment Intents. +/// +public interface IStripePaymentIntentService +{ + /// + /// Gets a PaymentIntent by its Stripe Id. + /// + /// Stripe model. + Task GetPaymentIntentAsync(string paymentIntentId); + + /// + /// Gets the PaymentIntent by its Stripe Id if it is or + /// . Otherwise, updates it with the provided + /// . + /// + /// Updated or original Stripe model. + Task GetOrUpdatePaymentIntentAsync( + string paymentIntentId, + Amount defaultTotal); + + /// + /// Creates a PaymentIntent with the provided . And adds description and other values to + /// the payment intent. Check the implementation for more details. + /// + /// Created Stripe . + Task CreatePaymentIntentAsync(Amount total); + + /// + /// Creates a PaymentIntent with the provided . + /// + /// Created Stripe model. + Task CreatePaymentIntentAsync(PaymentIntentCreateOptions options); +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripePaymentService.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripePaymentService.cs index 2ea163818..91fc0bd0b 100644 --- a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripePaymentService.cs +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripePaymentService.cs @@ -18,19 +18,14 @@ namespace OrchardCore.Commerce.Payment.Stripe.Abstractions; public interface IStripePaymentService { /// - /// Handles the payment and authentication, sends back the necessary data to the client./>. - /// - Task CreateClientSecretAsync(Amount total, ShoppingCartViewModel cart); - - /// - /// Returns a object for the given . + /// Returns the public key of the Stripe account. /// - Task GetPaymentIntentAsync(string paymentIntentId); + Task GetPublicKeyAsync(); /// - /// Returns a object based on the given . + /// Handles the payment and authentication, sends back the necessary data to the client./>. /// - Task CreatePaymentIntentAsync(Amount total); + Task CreateClientSecretAsync(Amount total, ShoppingCartViewModel cart); /// /// Creates an order content item in the database, based on the stored and on the @@ -39,7 +34,8 @@ public interface IStripePaymentService Task CreateOrUpdateOrderFromShoppingCartAsync( IUpdateModelAccessor updateModelAccessor, string shoppingCartId, - string paymentIntentId = null); + string paymentIntentId = null, + OrderPart orderPart = null); /// /// Updates the corresponding order status to for the given @@ -57,6 +53,11 @@ Task CreateOrUpdateOrderFromShoppingCartAsync( /// Task GetOrderPaymentByPaymentIntentIdAsync(string paymentIntentId); + /// + /// Save the order payment for the given and . + /// + Task SaveOrderPaymentAsync(string orderContentItemId, string paymentIntentId); + /// /// A shortcut method for updating the status to , doing /// final modifications and then redirecting to the success page. @@ -70,8 +71,10 @@ string shoppingCartId /// /// Get the confirmation parameters for Stripe. /// - /// The url for the middleware of Stripe. - Task GetStripeConfirmParametersAsync(string middlewareAbsoluteUrl); + /// The url for the middleware of Stripe. + Task GetStripeConfirmParametersAsync( + string returnUrl, + ContentItem order = null); /// /// Confirm the result of Stripe payment. diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripeSessionEventHandler.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripeSessionEventHandler.cs new file mode 100644 index 000000000..42c4990ca --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripeSessionEventHandler.cs @@ -0,0 +1,22 @@ +using Stripe.Checkout; +using System.Threading.Tasks; + +namespace OrchardCore.Commerce.Payment.Stripe.Abstractions; + +/// +/// Event handler for Stripe sessions. +/// +public interface IStripeSessionEventHandler +{ + /// + /// Called before a Stripe session is created with a pre-populated + /// . Here you can modify the options before the session is created. + /// + Task StripeSessionCreatingAsync(SessionCreateOptions options) => Task.CompletedTask; + + /// + /// Called after a Stripe session is created with the created and the + /// used during creation. + /// + Task StripeSessionCreatedAsync(Session session, SessionCreateOptions options) => Task.CompletedTask; +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripeSessionService.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripeSessionService.cs new file mode 100644 index 000000000..8421fa073 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripeSessionService.cs @@ -0,0 +1,16 @@ +using Stripe.Checkout; +using System.Threading.Tasks; + +namespace OrchardCore.Commerce.Payment.Stripe.Abstractions; + +/// +/// Service for managing Stripe sessions. +/// +public interface IStripeSessionService +{ + /// + /// Creates a Stripe session using the given . + /// + /// The created Stripe . + Task CreateSessionAsync(SessionCreateOptions options); +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripeSubscriptionService.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripeSubscriptionService.cs new file mode 100644 index 000000000..d73d934ac --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripeSubscriptionService.cs @@ -0,0 +1,34 @@ +using OrchardCore.Commerce.Payment.Stripe.Models; +using OrchardCore.Commerce.Payment.Stripe.ViewModels; +using Stripe; +using System.Threading.Tasks; + +namespace OrchardCore.Commerce.Payment.Stripe.Abstractions; + +/// +/// Service for managing Stripe subscriptions. +/// +public interface IStripeSubscriptionService +{ + /// + /// Updates a Stripe subscription. + /// + Task UpdateSubscriptionAsync(string subscriptionId, SubscriptionUpdateOptions options); + + /// + /// Gets a Stripe subscription. + /// + Task GetSubscriptionAsync(string subscriptionId, SubscriptionGetOptions options); + + /// + /// Creates a Stripe subscription using the given . + /// + /// The created Stripe . + Task CreateSubscriptionAsync(SubscriptionCreateOptions options); + + /// + /// Creates a Stripe subscription using the given . + /// + /// The created Stripe . + Task CreateSubscriptionAsync(StripeCreateSubscriptionViewModel viewModel); +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripeWebhookEventHandler.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripeWebhookEventHandler.cs new file mode 100644 index 000000000..e0050c533 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Abstractions/IStripeWebhookEventHandler.cs @@ -0,0 +1,16 @@ +using Stripe; +using System.Threading.Tasks; + +namespace OrchardCore.Commerce.Payment.Stripe.Abstractions; + +/// +/// Event handler for the Stripe webhook. +/// +public interface IStripeWebhookEventHandler +{ + /// + /// Called when a Stripe event is received. This is where you can handle the event. + /// + /// Contains the Stripe Event parameters. + Task ReceivedStripeEventAsync(Event stripeEvent); +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Assets/Scripts/stripe-payment-form.js b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Assets/Scripts/stripe-payment-form.js index 85a726916..94bb661d5 100644 --- a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Assets/Scripts/stripe-payment-form.js +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Assets/Scripts/stripe-payment-form.js @@ -7,8 +7,8 @@ window.stripePaymentForm = function stripePaymentForm( errorText, missingText, updatePaymentIntentUrl, - validateUrl = 'checkout/validate/Stripe', - paramsUrl = 'checkout/params/Stripe', + validateUrl = 'checkout/validate/stripe', + paramsUrl = 'stripe/params', priceUrl = 'checkout/price', errorContainerSelector = '.message-error', stripeFieldErrorSelector = '.stripe-field-error', diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Constants/ContentTypes.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Constants/ContentTypes.cs new file mode 100644 index 000000000..90b0b443e --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Constants/ContentTypes.cs @@ -0,0 +1,8 @@ +namespace OrchardCore.Commerce.Payment.Stripe.Constants; + +public static class ContentTypes +{ + public const string StripeProduct = nameof(StripeProduct); + public const string StripePrice = nameof(StripePrice); + public const string StripeProductFeature = nameof(StripeProductFeature); +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Constants/FeatureIds.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Constants/FeatureIds.cs index 356aa1be0..c57992c9e 100644 --- a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Constants/FeatureIds.cs +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Constants/FeatureIds.cs @@ -3,4 +3,5 @@ public static class FeatureIds { public const string Area = "OrchardCore.Commerce.Payment.Stripe"; + public const string DummyStripeServices = Area + ".DummyStripeServices"; } diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Constants/PricePeriods.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Constants/PricePeriods.cs new file mode 100644 index 000000000..5ab728409 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Constants/PricePeriods.cs @@ -0,0 +1,10 @@ +namespace OrchardCore.Commerce.Payment.Stripe.Constants; + +public static class PricePeriods +{ + public const string Daily = nameof(Daily); + public const string Weekly = nameof(Weekly); + public const string Monthly = nameof(Monthly); + public const string Yearly = nameof(Yearly); + public const string Custom = nameof(Custom); +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Controllers/StripeController.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Controllers/StripeController.cs index 33d0a28c6..68adb69de 100644 --- a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Controllers/StripeController.cs +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Controllers/StripeController.cs @@ -34,8 +34,8 @@ public IActionResult UpdatePaymentIntent(string paymentIntent) } [AllowAnonymous] - [HttpGet("checkout/middleware/Stripe")] - public async Task PaymentConfirmationMiddleware( + [HttpGet("stripe/middleware")] + public async Task PaymentConfirmation( [FromQuery(Name = "payment_intent")] string paymentIntent = null, [FromQuery] string shoppingCartId = null) { @@ -43,11 +43,11 @@ public async Task PaymentConfirmationMiddleware( return await ProduceActionResultAsync(result); } - [HttpPost("checkout/params/Stripe")] + [HttpPost("stripe/params")] [ValidateAntiForgeryToken] - public async Task GetConfirmPaymentParameters() + public async Task ConfirmPaymentParameters() { - var middlewareUrl = Url.ToAbsoluteUrl("~/checkout/middleware/Stripe"); + var middlewareUrl = Url.ToAbsoluteUrl("~/stripe/params"); var model = await _stripePaymentService.GetStripeConfirmParametersAsync(middlewareUrl); // Newtonsoft is used, because the external Stripe library that defined PaymentIntentConfirmOptions does not diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Controllers/WebhookController.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Controllers/WebhookController.cs index d5e835216..e0daf09b3 100644 --- a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Controllers/WebhookController.cs +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Controllers/WebhookController.cs @@ -6,6 +6,7 @@ using OrchardCore.Commerce.Payment.Stripe.Models; using OrchardCore.Settings; using Stripe; +using System.Collections.Generic; using System.IO; using System.Threading.Tasks; @@ -16,21 +17,24 @@ 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 IStripeHelperService _stripeHelperService; + private readonly IEnumerable _stripeWebhookEventHandlers; public WebhookController( - IStripePaymentService stripePaymentService, ISiteService siteService, IDataProtectionProvider dataProtectionProvider, - ILogger logger) + ILogger logger, + IStripeHelperService stripeHelperService, + IEnumerable stripeWebhookEventHandlers) { - _stripePaymentService = stripePaymentService; _siteService = siteService; _dataProtectionProvider = dataProtectionProvider; _logger = logger; + _stripeHelperService = stripeHelperService; + _stripeWebhookEventHandlers = stripeWebhookEventHandlers; } [HttpPost] @@ -43,30 +47,20 @@ public async Task Index() var stripeApiSettings = (await _siteService.GetSiteSettingsAsync()).As(); var webhookSigningKey = stripeApiSettings.DecryptWebhookSigningSecret(_dataProtectionProvider, _logger); - var stripeEvent = EventUtility.ConstructEvent( + var stripeEvent = _stripeHelperService.PrepareStripeEvent( json, Request.Headers["Stripe-Signature"], webhookSigningKey, // Let the logic handle version mismatch. throwOnApiVersionMismatch: false); - if (stripeEvent.Type == Events.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 == Events.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/StripeEndpoint.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/EndPoints/Api/StripeEndpoint.cs deleted file mode 100644 index a1a6112a7..000000000 --- a/src/Modules/OrchardCore.Commerce.Payment.Stripe/EndPoints/Api/StripeEndpoint.cs +++ /dev/null @@ -1,30 +0,0 @@ -#nullable enable -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Routing; -using OrchardCore.Commerce.Payment.Stripe.Abstractions; -using System.Threading.Tasks; - -namespace OrchardCore.Commerce.Payment.Stripe.EndPoints.Api; -public static class StripeEndpoint -{ - public static IEndpointRouteBuilder AddStripeMiddlewareEndpoint(this IEndpointRouteBuilder builder) - { - builder.MapGet("api/checkout/middleware/Stripe/{shoppingCartId?}", AddStripeMiddlewareAsync) - .AllowAnonymous() - .DisableAntiforgery(); - - return builder; - } - - private static async Task AddStripeMiddlewareAsync( - [FromRoute] string? shoppingCartId, - [FromServices] IStripePaymentService stripePaymentService, - [FromQuery(Name = "payment_intent")] string? paymentIntent = null - ) - { - var result = await stripePaymentService.PaymentConfirmationAsync(paymentIntent, shoppingCartId); - return TypedResults.Ok(result); - } -} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Api/StripeCheckoutApiEndpoint.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Api/StripeCheckoutApiEndpoint.cs new file mode 100644 index 000000000..5120d7f9a --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Api/StripeCheckoutApiEndpoint.cs @@ -0,0 +1,57 @@ +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.Payment.Stripe.Abstractions; +using OrchardCore.Commerce.Payment.Stripe.Endpoints.Models; +using OrchardCore.Commerce.Payment.Stripe.Endpoints.Permissions; +using Stripe.Checkout; +using System.Threading.Tasks; + +using static OrchardCore.Commerce.Payment.Stripe.Endpoints.Constants.Endpoints; + +namespace OrchardCore.Commerce.Payment.Stripe.Endpoints.Api; + +public static class StripeCheckoutApiEndpoint +{ + public static IEndpointRouteBuilder AddStripeCheckoutEndpoint(this IEndpointRouteBuilder builder) + { + builder.MapPostWithDefaultSettings($"{StripePaymentApiPath}/checkout-session", GetStripeCheckoutEndpointAsync); + return builder; + } + + private static async Task GetStripeCheckoutEndpointAsync( + [FromBody] SubscriptionCheckoutEndpointViewModel viewModel, + [FromServices] IAuthorizationService authorizationService, + [FromServices] IStripeCustomerService stripeCustomerService, + [FromServices] IStripeSessionService stripeSessionService, + HttpContext httpContext) + { + if (!await authorizationService.AuthorizeAsync(httpContext.User, ApiPermissions.CommerceApiStripePayment)) + { + return httpContext.ChallengeOrForbidApi(); + } + + var customer = await stripeCustomerService.GetAndUpdateOrCreateCustomerAsync( + 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, + SuccessUrl = viewModel.SuccessUrl, + CancelUrl = viewModel.CancelUrl, + Customer = customer.Id, + }; + + var session = await stripeSessionService.CreateSessionAsync(options); + + return TypedResults.Ok(session.Url); + } +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Api/StripeConfirmPaymentEndpoint.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Api/StripeConfirmPaymentEndpoint.cs new file mode 100644 index 000000000..96fb6c3f7 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Api/StripeConfirmPaymentEndpoint.cs @@ -0,0 +1,41 @@ +#nullable enable +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.Payment.Stripe.Abstractions; +using OrchardCore.Commerce.Payment.Stripe.Endpoints.Permissions; +using System.Threading.Tasks; +using static OrchardCore.Commerce.Payment.Stripe.Endpoints.Constants.Endpoints; + +namespace OrchardCore.Commerce.Payment.Stripe.Endpoints.Api; +public static class StripeConfirmPaymentEndpoint +{ + public static IEndpointRouteBuilder AddStripePaymentOrderConfirmationEndpoint(this IEndpointRouteBuilder builder) + { + builder.MapPostWithDefaultSettings($"{StripePaymentApiPath}/middleware", StripePaymentOrderConfirmationAsync); + return builder; + } + + private static async Task StripePaymentOrderConfirmationAsync( + [FromQuery] string? shoppingCartId, + [FromServices] IStripePaymentService stripePaymentService, + [FromServices] IAuthorizationService authorizationService, + HttpContext httpContext, + [FromQuery(Name = "payment_intent")] string? paymentIntentId = null) + { + if (!await authorizationService.AuthorizeAsync(httpContext.User, ApiPermissions.CommerceApiStripePayment)) + { + return httpContext.ChallengeOrForbidApi(); + } + + var result = await stripePaymentService.PaymentConfirmationAsync( + paymentIntentId, + shoppingCartId, + paymentIntentId == null); + + return TypedResults.Ok(result); + } +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Api/StripeConfirmationTokenEndpoint.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Api/StripeConfirmationTokenEndpoint.cs new file mode 100644 index 000000000..0bc89a89f --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Api/StripeConfirmationTokenEndpoint.cs @@ -0,0 +1,36 @@ +#nullable enable +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.Payment.Stripe.Abstractions; +using OrchardCore.Commerce.Payment.Stripe.Endpoints.Permissions; +using System.Threading.Tasks; +using static OrchardCore.Commerce.Payment.Stripe.Endpoints.Constants.Endpoints; + +namespace OrchardCore.Commerce.Payment.Stripe.Endpoints.Api; +public static class StripeConfirmationTokenEndpoint +{ + public static IEndpointRouteBuilder AddStripeConfirmationTokenEndpoint(this IEndpointRouteBuilder builder) + { + builder.MapGetWithDefaultSettings($"{StripePaymentApiPath}/confirmation-token", GetStripeConfirmationTokenAsync); + return builder; + } + + private static async Task GetStripeConfirmationTokenAsync( + [FromQuery] string? confirmationTokenId, + [FromServices] IStripeConfirmationTokenService stripeConfirmationTokenService, + [FromServices] IAuthorizationService authorizationService, + HttpContext httpContext) + { + if (!await authorizationService.AuthorizeAsync(httpContext.User, ApiPermissions.CommerceApiStripePayment)) + { + return httpContext.ChallengeOrForbidApi(); + } + + var confirmationToken = await stripeConfirmationTokenService.GetConfirmationTokenAsync(confirmationTokenId); + return TypedResults.Ok(confirmationToken); + } +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Api/StripeCustomerEndpoint.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Api/StripeCustomerEndpoint.cs new file mode 100644 index 000000000..043b06552 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Api/StripeCustomerEndpoint.cs @@ -0,0 +1,57 @@ +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.Payment.Stripe.Abstractions; +using OrchardCore.Commerce.Payment.Stripe.Endpoints.Permissions; +using Stripe; +using System.Threading.Tasks; +using static OrchardCore.Commerce.Payment.Stripe.Endpoints.Constants.Endpoints; + +namespace OrchardCore.Commerce.Payment.Stripe.Endpoints.Api; +public static class StripeCustomerEndpoint +{ + public static IEndpointRouteBuilder AddStripeGetCustomerEndpoint(this IEndpointRouteBuilder builder) + { + builder.MapGetWithDefaultSettings($"{StripePaymentApiPath}/customer", GetStripeCustomerAsync); + return builder; + } + + private static async Task GetStripeCustomerAsync( + [FromQuery] string customerId, + [FromServices] IStripeCustomerService stripeCustomerService, + [FromServices] IAuthorizationService authorizationService, + HttpContext httpContext) + { + if (!await authorizationService.AuthorizeAsync(httpContext.User, ApiPermissions.CommerceApiStripePayment)) + { + return httpContext.ChallengeOrForbidApi(); + } + + var customer = await stripeCustomerService.GetCustomerByIdAsync(customerId); + return TypedResults.Ok(customer); + } + + public static IEndpointRouteBuilder AddStripeCreateCustomerEndpoint(this IEndpointRouteBuilder builder) + { + builder.MapPostWithDefaultSettings($"{StripePaymentApiPath}/customer", GetStripeCreateCustomerAsync); + return builder; + } + + private static async Task GetStripeCreateCustomerAsync( + [FromBody] CustomerCreateOptions customerCreateOptions, + [FromServices] IStripeCustomerService stripeCustomerService, + [FromServices] IAuthorizationService authorizationService, + HttpContext httpContext) + { + if (!await authorizationService.AuthorizeAsync(httpContext.User, ApiPermissions.CommerceApiStripePayment)) + { + return httpContext.ChallengeOrForbidApi(); + } + + var customer = await stripeCustomerService.CreateCustomerAsync(customerCreateOptions); + return TypedResults.Ok(customer); + } +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Api/StripeParametersEndpoint.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Api/StripeParametersEndpoint.cs new file mode 100644 index 000000000..04756b170 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Api/StripeParametersEndpoint.cs @@ -0,0 +1,110 @@ +#nullable enable +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.Commerce.Payment.Stripe.Helpers; +using OrchardCore.ContentManagement; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using static OrchardCore.Commerce.Payment.Stripe.Endpoints.Constants.Endpoints; + +namespace OrchardCore.Commerce.Payment.Stripe.Endpoints.Api; +public static class StripeParametersEndpoint +{ + // Create stripe confirm parameters endpoint + public static IEndpointRouteBuilder AddStripeConfirmParametersEndpoint(this IEndpointRouteBuilder builder) + { + builder.MapPostWithDefaultSettings($"{StripePaymentApiPath}/confirm-parameters", GetStripeConfirmParametersAsync); + return builder; + } + + // The GetConfirmPaymentParametersAsync method is used to get the confirm payment parameters. + private static async Task GetStripeConfirmParametersAsync( + [FromBody] ConfirmParametersViewModel confirmParametersViewModel, + [FromServices] IStripePaymentService stripePaymentService, + [FromServices] IContentManager contentManager, + [FromServices] IAuthorizationService authorizationService, + HttpContext httpContext) + { + // Check if the user is authorized to get the confirm payment parameters. + if (!await authorizationService.AuthorizeAsync(httpContext.User, ApiPermissions.CommerceApiStripePayment)) + { + return httpContext.ChallengeOrForbidApi(); + } + + var order = await contentManager.GetAsync(confirmParametersViewModel.OrderId); + var model = await stripePaymentService.GetStripeConfirmParametersAsync( + confirmParametersViewModel.ReturnUrl, + order); + + // Return the model as a JSON result. We use the JsonSerializerOptions to configure the JSON serialization for + // the specific configuration Stripe requires as ConfirmParameters. + return TypedResults.Json( + model, + new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }); + } + + public static IEndpointRouteBuilder AddStripeTotalEndpoint(this IEndpointRouteBuilder builder) + { + builder.MapGetWithDefaultSettings($"{StripePaymentApiPath}/total", GetStripeTotalAsync); + return builder; + } + + private static async Task GetStripeTotalAsync( + [FromQuery] string? shoppingCartId, + [FromServices] IShoppingCartService shoppingCartService, + [FromServices] IAuthorizationService authorizationService, + HttpContext httpContext) + { + if (!await authorizationService.AuthorizeAsync(httpContext.User, ApiPermissions.CommerceApiStripePayment)) + { + return httpContext.ChallengeOrForbidApi(); + } + + var shoppingCartViewModel = await shoppingCartService.GetAsync(shoppingCartId); + if (shoppingCartViewModel == null) + { + return TypedResults.Ok(); + } + + var total = shoppingCartViewModel.Totals.Single(); + return TypedResults.Ok(new + { + Amount = AmountHelpers.GetPaymentAmount(total), + total.Currency, + }); + } + + public static IEndpointRouteBuilder AddStripePublicKeyEndpoint(this IEndpointRouteBuilder builder) + { + builder.MapGetWithDefaultSettings($"{StripePaymentApiPath}/public-key", GetStripePublicKeyAsync); + return builder; + } + + private static async Task GetStripePublicKeyAsync( + [FromServices] IStripePaymentService stripePaymentService, + [FromServices] IAuthorizationService authorizationService, + HttpContext httpContext) + { + if (!await authorizationService.AuthorizeAsync(httpContext.User, ApiPermissions.CommerceApiStripePayment)) + { + return httpContext.ChallengeOrForbidApi(); + } + + var publicKey = await stripePaymentService.GetPublicKeyAsync(); + return TypedResults.Ok(publicKey); + } +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Api/StripePaymentIntentEndpoint.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Api/StripePaymentIntentEndpoint.cs new file mode 100644 index 000000000..345734f87 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Api/StripePaymentIntentEndpoint.cs @@ -0,0 +1,72 @@ +#nullable enable +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 System.Linq; +using System.Threading.Tasks; +using static OrchardCore.Commerce.Payment.Stripe.Endpoints.Constants.Endpoints; + +namespace OrchardCore.Commerce.Payment.Stripe.Endpoints.Api; +public static class StripePaymentIntentEndpoint +{ + public static IEndpointRouteBuilder AddStripePaymentIntentGetEndpoint(this IEndpointRouteBuilder builder) + { + builder.MapGetWithDefaultSettings($"{StripePaymentApiPath}/payment-intent", GetPaymentIntentAsync); + return builder; + } + + private static async Task GetPaymentIntentAsync( + [FromQuery] string paymentIntentId, + [FromServices] IStripePaymentIntentService stripePaymentIntentService, + [FromServices] IAuthorizationService authorizationService, + HttpContext httpContext + ) + { + if (!await authorizationService.AuthorizeAsync(httpContext.User, ApiPermissions.CommerceApiStripePayment)) + { + return httpContext.ChallengeOrForbidApi(); + } + + var paymentIntent = await stripePaymentIntentService.GetPaymentIntentAsync(paymentIntentId); + return TypedResults.Ok(paymentIntent); + } + + public static IEndpointRouteBuilder AddStripePaymentIntentPostEndpoint(this IEndpointRouteBuilder builder) + { + builder.MapPostWithDefaultSettings($"{StripePaymentApiPath}/payment-intent", CreatePaymentIntentAsync); + return builder; + } + + private static async Task CreatePaymentIntentAsync( + [FromBody] CreatePaymentIntentWithOrderViewModel viewModel, + [FromServices] IStripePaymentIntentService stripePaymentIntentService, + [FromServices] IStripePaymentService stripePaymentService, + [FromServices] IShoppingCartService shoppingCartService, + [FromServices] IAuthorizationService authorizationService, + HttpContext httpContext) + { + if (!await authorizationService.AuthorizeAsync(httpContext.User, ApiPermissions.CommerceApiStripePayment)) + { + return httpContext.ChallengeOrForbidApi(); + } + + var shoppingCartViewModel = await shoppingCartService.GetAsync(viewModel.ShoppingCartId); + var total = shoppingCartViewModel.Totals.Single(); + var paymentIntent = await stripePaymentIntentService.CreatePaymentIntentAsync(total); + + var order = await stripePaymentService.CreateOrUpdateOrderFromShoppingCartAsync( + updateModelAccessor: null, + viewModel.ShoppingCartId, + paymentIntent.Id, + viewModel.OrderPart); + + return TypedResults.Ok(new { clientSecret = paymentIntent.ClientSecret, orderContentItemId = order.ContentItemId }); + } +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Api/StripeSubscriptionEndpoint.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Api/StripeSubscriptionEndpoint.cs new file mode 100644 index 000000000..5bec4e2c7 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Api/StripeSubscriptionEndpoint.cs @@ -0,0 +1,112 @@ +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.Permissions; +using OrchardCore.Commerce.Payment.Stripe.Extensions; +using OrchardCore.Commerce.Payment.Stripe.ViewModels; +using Stripe; +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; +public static class StripeSubscriptionEndpoint +{ + public static IEndpointRouteBuilder AddStripeCreateSubscriptionEndpoint(this IEndpointRouteBuilder builder) + { + builder.MapPostWithDefaultSettings($"{StripePaymentApiPath}/subscription", GetStripeCreateSubscriptionAsync); + return builder; + } + + private static async Task GetStripeCreateSubscriptionAsync( + [FromBody] StripeCreateSubscriptionViewModel viewModel, + [FromServices] IStripeCustomerService stripeCustomerService, + [FromServices] IStripeSubscriptionService stripeSubscriptionService, + [FromServices] IShoppingCartService shoppingCartService, + [FromServices] IAuthorizationService authorizationService, + HttpContext httpContext) + { + if (!await authorizationService.AuthorizeAsync(httpContext.User, ApiPermissions.CommerceApiStripePayment)) + { + return httpContext.ChallengeOrForbidApi(); + } + + // Get price IDs from the shopping cart. + var shoppingCartViewModel = await shoppingCartService.GetAsync(viewModel.ShoppingCartId); + var priceIds = shoppingCartViewModel.Lines.SelectMany(line => line.AdditionalData.GetPriceIds()).ToList(); + viewModel.PriceIds.AddRange(priceIds); + + // Create customer if it doesn't exist. + if (string.IsNullOrEmpty(viewModel.CustomerId)) + { + var orderPart = viewModel.OrderPart; + var shippingAddress = orderPart.ShippingAddress.Address; + var billingAddress = orderPart.BillingAddress.Address; + var options = new CustomerCreateOptions + { + Email = orderPart.Email.Text, + Name = orderPart.BillingAddress.Address.Name, + Shipping = string.IsNullOrEmpty(shippingAddress.Name) + ? null + : new ShippingOptions + { + Address = new AddressOptions + { + City = shippingAddress.City, + Country = shippingAddress.Region, + Line1 = shippingAddress.StreetAddress1, + Line2 = shippingAddress.StreetAddress2, + PostalCode = shippingAddress.PostalCode, + State = shippingAddress.Province, + }, + Name = shippingAddress.Name, + }, + Address = new AddressOptions + { + City = billingAddress.City, + Country = billingAddress.Region, + Line1 = billingAddress.StreetAddress1, + Line2 = billingAddress.StreetAddress2, + PostalCode = billingAddress.PostalCode, + State = billingAddress.Province, + }, + }; + var customer = await stripeCustomerService.CreateCustomerAsync(options); + viewModel.CustomerId = customer.Id; + } + + // Create the subscription. + var response = await stripeSubscriptionService.CreateSubscriptionAsync(viewModel); + return TypedResults.Ok(response); + } + + public static IEndpointRouteBuilder AddStripeGetSubscriptionEndpoint(this IEndpointRouteBuilder builder) + { + builder.MapGetWithDefaultSettings($"{StripePaymentApiPath}/subscription", GetStripeGetSubscriptionAsync); + return builder; + } + + private static async Task GetStripeGetSubscriptionAsync( + [FromQuery] string subscriptionId, + [FromServices] IStripeSubscriptionService stripeSubscriptionService, + [FromServices] IAuthorizationService authorizationService, + HttpContext httpContext) + { + if (!await authorizationService.AuthorizeAsync(httpContext.User, ApiPermissions.CommerceApiStripePayment)) + { + return httpContext.ChallengeOrForbidApi(); + } + + // Get the subscription. + var response = await stripeSubscriptionService.GetSubscriptionAsync( + subscriptionId, + options: null); + return TypedResults.Ok(response); + } +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Constants/Endpoints.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Constants/Endpoints.cs new file mode 100644 index 000000000..4de2d0ee4 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Constants/Endpoints.cs @@ -0,0 +1,6 @@ +namespace OrchardCore.Commerce.Payment.Stripe.Endpoints.Constants; + +public static class Endpoints +{ + public const string StripePaymentApiPath = "api/stripe"; +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Extensions/Endpoints.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Extensions/Endpoints.cs new file mode 100644 index 000000000..99f01a793 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Extensions/Endpoints.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Routing; +using OrchardCore.Commerce.Payment.Stripe.Endpoints.Api; + +namespace OrchardCore.Commerce.Payment.Stripe.Endpoints.Extensions; + +public static class Endpoints +{ + public static IEndpointRouteBuilder AddStripePaymentApiEndpoints(this IEndpointRouteBuilder router) + { + router + .AddStripeConfirmationTokenEndpoint() + .AddStripePublicKeyEndpoint() + .AddStripePaymentIntentPostEndpoint() + .AddStripePaymentIntentGetEndpoint() + .AddStripeTotalEndpoint() + .AddStripeConfirmParametersEndpoint() + .AddStripePaymentOrderConfirmationEndpoint() + .AddStripeGetCustomerEndpoint() + .AddStripeCreateCustomerEndpoint() + .AddStripeCreateSubscriptionEndpoint() + .AddStripeGetSubscriptionEndpoint() + .AddStripeCheckoutEndpoint(); + + return router; + } +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Models/ConfirmParametersViewModel.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Models/ConfirmParametersViewModel.cs new file mode 100644 index 000000000..9e5969ea6 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Models/ConfirmParametersViewModel.cs @@ -0,0 +1,7 @@ +namespace OrchardCore.Commerce.Payment.Stripe.Endpoints.Models; + +public class ConfirmParametersViewModel +{ + public string ReturnUrl { get; set; } + public string OrderId { get; set; } +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Models/CreatePaymentIntentWithOrderViewModel.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Models/CreatePaymentIntentWithOrderViewModel.cs new file mode 100644 index 000000000..e54b6ef8a --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Models/CreatePaymentIntentWithOrderViewModel.cs @@ -0,0 +1,9 @@ +using OrchardCore.Commerce.Abstractions.Models; + +namespace OrchardCore.Commerce.Payment.Stripe.Endpoints.Models; + +public class CreatePaymentIntentWithOrderViewModel +{ + public string ShoppingCartId { get; set; } + public OrderPart OrderPart { get; set; } +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Models/OrderViewModel.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Models/OrderViewModel.cs new file mode 100644 index 000000000..686b95af4 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Models/OrderViewModel.cs @@ -0,0 +1,15 @@ +using OrchardCore.Commerce.Abstractions.Models; +using OrchardCore.Commerce.Payment.Stripe.Models; +using OrchardCore.Html.Models; +using OrchardCore.Title.Models; + +namespace OrchardCore.Commerce.Payment.Stripe.Endpoints.Models; + +public class OrderViewModel +{ + public TitlePart TitlePart { get; set; } + public HtmlBodyPart HtmlBodyPart { get; set; } + public OrderPart OrderPart { get; set; } + public StripePaymentPart StripePaymentPart { get; set; } + public string PaymentIntentId { get; set; } +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Models/PaymentMode.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Models/PaymentMode.cs new file mode 100644 index 000000000..7f22cefb5 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Models/PaymentMode.cs @@ -0,0 +1,7 @@ +namespace OrchardCore.Commerce.Payment.Stripe.Endpoints.Models; + +public enum PaymentMode +{ + Subscription, + Payment, +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Models/SubscriptionCheckoutEndpointViewModel.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Models/SubscriptionCheckoutEndpointViewModel.cs new file mode 100644 index 000000000..45a393c6b --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Models/SubscriptionCheckoutEndpointViewModel.cs @@ -0,0 +1,21 @@ +using OrchardCore.Commerce.AddressDataType; +using Stripe.Checkout; +using System.Collections.Generic; + +namespace OrchardCore.Commerce.Payment.Stripe.Endpoints.Models; + +public class SubscriptionCheckoutEndpointViewModel +{ + public string SuccessUrl { get; set; } + public string CancelUrl { get; set; } + + // This is an API model so we don't need to make it read-only. +#pragma warning disable CA2227 // CA2227: Change 'SessionLineItemOptions' to be read-only by removing the property setter + public IList SessionLineItemOptions { get; set; } = new List(); +#pragma warning restore CA2227 + public PaymentMode PaymentMode { 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/Endpoints/Permissions/ApiPermissions.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Permissions/ApiPermissions.cs new file mode 100644 index 000000000..04c5c2de8 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Endpoints/Permissions/ApiPermissions.cs @@ -0,0 +1,20 @@ +using Lombiq.HelpfulLibraries.OrchardCore.Users; +using OrchardCore.Security.Permissions; +using System.Collections.Generic; + +namespace OrchardCore.Commerce.Payment.Stripe.Endpoints.Permissions; + +public class ApiPermissions : AdminPermissionBase +{ + public static readonly Permission CommerceApiStripePayment = + new(nameof(CommerceApiStripePayment), "Access Commerce Stripe Payment APIs"); + + public static readonly Permission CommerceApiOrderStripe = + new(nameof(CommerceApiStripePayment), "Access Commerce Stripe Order APIs"); + + private static readonly IReadOnlyList _adminPermissions = new[] + { + CommerceApiStripePayment, + }; + protected override IEnumerable AdminPermissions => _adminPermissions; +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Extensions/AdditionalDataExtensions.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Extensions/AdditionalDataExtensions.cs new file mode 100644 index 000000000..507727282 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Extensions/AdditionalDataExtensions.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using System.Text.Json.Nodes; + +namespace OrchardCore.Commerce.Payment.Stripe.Extensions; + +public static class AdditionalDataExtensions +{ + private const string PriceIds = nameof(PriceIds); + + public static IEnumerable GetPriceIds(this IDictionary additionalData) => + additionalData + .GetMaybe(PriceIds)? + .ToObject>() ?? []; + + public static void SetPriceIds( + this IDictionary additionalData, + IEnumerable priceIds) => + additionalData[PriceIds] = JArray.FromObject(priceIds ?? []); + + public static IDictionary> GetPriceIdsByProduct( + this IDictionary additionalData) => + additionalData + .GetMaybe(PriceIds)? + .ToObject>>() + ?? []; + + public static void SetPriceIdsByProduct( + this IDictionary additionalData, + IDictionary> priceIds) => + additionalData[PriceIds] = JObject.FromObject( + priceIds ?? new Dictionary>()); +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Handlers/DefaultStripeWebhookEventHandler.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Handlers/DefaultStripeWebhookEventHandler.cs new file mode 100644 index 000000000..ff66a991d --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Handlers/DefaultStripeWebhookEventHandler.cs @@ -0,0 +1,46 @@ +using OrchardCore.Commerce.Payment.Stripe.Abstractions; +using Stripe; +using System.Threading.Tasks; +using static Stripe.Events; + +namespace OrchardCore.Commerce.Payment.Stripe.Handlers; + +public class DefaultStripeWebhookEventHandler : IStripeWebhookEventHandler +{ + private readonly IStripePaymentIntentService _stripePaymentIntentService; + private readonly IStripePaymentService _stripePaymentService; + + public DefaultStripeWebhookEventHandler( + IStripePaymentIntentService stripePaymentIntentService, + IStripePaymentService stripePaymentService) + { + _stripePaymentIntentService = stripePaymentIntentService; + _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 _stripePaymentIntentService.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/Handlers/SubscriptionStripeWebhookEventHandler.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Handlers/SubscriptionStripeWebhookEventHandler.cs new file mode 100644 index 000000000..b9fea220c --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Handlers/SubscriptionStripeWebhookEventHandler.cs @@ -0,0 +1,88 @@ +using Lombiq.HelpfulLibraries.OrchardCore.Users; +using Microsoft.Extensions.Logging; +using OrchardCore.Commerce.Models; +using OrchardCore.Commerce.Payment.Stripe.Abstractions; +using OrchardCore.Commerce.Payment.Stripe.Services; +using OrchardCore.Commerce.Services; +using OrchardCore.ContentManagement; +using Stripe; +using System.Text.Json; +using System.Threading.Tasks; +using static Stripe.Events; + +namespace OrchardCore.Commerce.Payment.Stripe.Handlers; + +public class SubscriptionStripeWebhookEventHandler : IStripeWebhookEventHandler +{ + private readonly ICachingUserManager _cachingUserManager; + private readonly ISubscriptionService _subscriptionService; + private readonly ILogger _logger; + private readonly IStripeSubscriptionService _stripeSubscriptionService; + private readonly IContentManager _contentManager; + + public SubscriptionStripeWebhookEventHandler( + ICachingUserManager cachingUserManager, + ISubscriptionService subscriptionService, + IStripeSubscriptionService stripeSubscriptionService, + IContentManager contentManager, + ILogger logger) + { + _cachingUserManager = cachingUserManager; + _subscriptionService = subscriptionService; + _stripeSubscriptionService = stripeSubscriptionService; + _contentManager = contentManager; + _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; + } + + var subscriptionPart = new SubscriptionPart(); + subscriptionPart.UserId.Text = user.UserId; + subscriptionPart.Status.Text = SubscriptionStatuses.Active; + subscriptionPart.EndDateUtc.Value = invoice.PeriodEnd; + subscriptionPart.PaymentProviderName.Text = StripePaymentProvider.ProviderName; + subscriptionPart.IdInPaymentProvider.Text = invoice.SubscriptionId; + + var stripeSubscription = await _stripeSubscriptionService.GetSubscriptionAsync(invoice.SubscriptionId, options: null); + subscriptionPart.Metadata = stripeSubscription.Metadata; + subscriptionPart.StartDateUtc.Value = stripeSubscription.StartDate; + + await _subscriptionService.CreateOrUpdateSubscriptionAsync(invoice.SubscriptionId, subscriptionPart); + } + } + else if (stripeEvent.Type is CustomerSubscriptionUpdated or + CustomerSubscriptionDeleted or + CustomerSubscriptionResumed or + CustomerSubscriptionPaused) + { + var stripeSubscription = stripeEvent.Data.Object as Subscription; + // Get the subscription content item for this subscription and set its status to the new status. + var subscription = await _subscriptionService.GetSubscriptionAsync(stripeSubscription!.Id); + if (subscription != null) + { + subscription.Alter(part => + { + part.Status.Text = stripeSubscription.Status; + part.EndDateUtc.Value = stripeSubscription.CurrentPeriodEnd; + }); + + await _contentManager.UpdateAsync(subscription); + } + } + } +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Helpers/AmountHelpers.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Helpers/AmountHelpers.cs new file mode 100644 index 000000000..767298bbb --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Helpers/AmountHelpers.cs @@ -0,0 +1,17 @@ +using OrchardCore.Commerce.MoneyDataType; +using System.Linq; +using static OrchardCore.Commerce.Payment.Constants.CurrencyCollectionConstants; + +namespace OrchardCore.Commerce.Payment.Stripe.Helpers; + +public static class AmountHelpers +{ + public static long GetPaymentAmount(Amount total) + { + var rounding = ZeroDecimalCurrencies.Select(code => (Code: code, KeepDigits: 0, RoundTens: 0)) + .Concat(SpecialCases.Select(code => (Code: code, KeepDigits: 2, RoundTens: 2))) + .ToDictionary(item => item.Code, item => (item.KeepDigits, item.RoundTens)); + + return total.GetFixedPointAmount(rounding); + } +} 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..cd0d8cb79 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Indexes/StripeSessionDataIndex.cs @@ -0,0 +1,29 @@ +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/Manifest.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Manifest.cs index 2b486e5e9..3c7fc5adb 100644 --- a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Manifest.cs +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Manifest.cs @@ -1,3 +1,4 @@ +using OrchardCore.Commerce.Payment.Stripe.Constants; using OrchardCore.Modules.Manifest; using static OrchardCore.Commerce.Payment.Constants.FeatureIds; using static OrchardCore.Commerce.Promotion.Constants.FeatureIds; @@ -13,3 +14,23 @@ Category = "Commerce", Dependencies = [Payment, Promotion] )] + +[assembly: Feature( + Id = FeatureIds.Area, + Name = "Orchard Core Commerce - Payment - Stripe", + Description = + "Stripe payment provider for Orchard Core Commerce. Note: you must configure it in Admin > Configuration > " + + "Commerce > Stripe API or it won't appear in the front end.", + Category = "Commerce", + Dependencies = [Payment, Promotion] +)] + +[assembly: Feature( + Id = FeatureIds.DummyStripeServices, + Name = "Orchard Core Commerce - Payment - Stripe - Dummy Stripe Services", + Category = "Commerce", + Description = + "WARNING: Only enable this feature in the UI testing environment." + + "Simulates Stripe services for testing purposes.", + Dependencies = [FeatureIds.Area] +)] diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Migrations/StripeProductMigrations.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Migrations/StripeProductMigrations.cs new file mode 100644 index 000000000..335d38452 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Migrations/StripeProductMigrations.cs @@ -0,0 +1,106 @@ +using OrchardCore.Commerce.ContentFields.Settings; +using OrchardCore.Commerce.Models; +using OrchardCore.Commerce.Payment.Stripe.Models; +using OrchardCore.ContentFields.Settings; +using OrchardCore.ContentManagement.Metadata; +using OrchardCore.ContentManagement.Metadata.Settings; +using OrchardCore.Data.Migration; +using OrchardCore.Flows.Models; +using OrchardCore.Title.Models; +using System.Threading.Tasks; +using static Lombiq.HelpfulLibraries.OrchardCore.Contents.ContentFieldEditorEnums.TextFieldEditors; +using static OrchardCore.Commerce.Payment.Stripe.Constants.ContentTypes; +using static OrchardCore.Commerce.Payment.Stripe.Constants.PricePeriods; + +namespace OrchardCore.Commerce.Payment.Stripe.Migrations; + +public class StripeProductMigrations : DataMigration +{ + private readonly IContentDefinitionManager _contentDefinitionManager; + + public StripeProductMigrations(IContentDefinitionManager contentDefinitionManager) => + _contentDefinitionManager = contentDefinitionManager; + + public async Task CreateAsync() + { + await _contentDefinitionManager.AlterPartDefinitionAsync(builder => builder + .WithField(part => part.PriceId, field => field + .WithDisplayName("Price Id") + .WithDescription("Stripe's price Id.")) + .WithField(part => part.Name) + .WithField(part => part.Price, field => field + .WithSettings(new PriceFieldSettings + { + Required = true, + Hint = "The price of the product set in Stripe.", + })) + .WithField(part => part.Period, field => field + .WithEditor(nameof(PredefinedList)) + .WithSettings(new TextFieldPredefinedListEditorSettings + { + Options = + [ + new ListValueOption { Name = Daily, Value = Daily }, + new ListValueOption { Name = Weekly, Value = Weekly }, + new ListValueOption { Name = Monthly, Value = Monthly }, + new ListValueOption { Name = Yearly, Value = Yearly }, + new ListValueOption { Name = Custom, Value = Custom }, + ], + DefaultValue = Monthly, + Editor = EditorOption.Dropdown, + }))); + + await _contentDefinitionManager.AlterTypeDefinitionAsync(StripePrice, type => type + .Listable() + .Securable() + .WithPart(nameof(StripePricePart))); + + await _contentDefinitionManager.AlterPartDefinitionAsync(builder => builder + .WithField(part => part.FeatureName, field => field + .WithDisplayName("Feature name") + .WithDescription("Short feature description"))); + + await _contentDefinitionManager.AlterTypeDefinitionAsync(StripeProductFeature, type => type + .Listable() + .Securable() + .WithPart(nameof(StripeProductFeaturePart))); + + await _contentDefinitionManager.AlterPartDefinitionAsync(builder => builder + .WithField(part => part.StripeProductId, field => field.WithDisplayName("Stripe Product Id"))); + + await _contentDefinitionManager.AlterTypeDefinitionAsync(StripeProduct, type => type + .Listable() + .Creatable() + .Securable() + .WithPart(nameof(TitlePart), part => part + .WithDisplayName("Name") + .WithSettings(new TitlePartSettings + { + Options = TitlePartOptions.EditableRequired, + })) + .WithPart(nameof(StripeProductPart)) + .WithPart(nameof(ProductPart)) + .WithPart(nameof(FeatureCollectionPart), nameof(BagPart), part => part + .WithDescription("Collection of features.") + .WithDisplayName("Features") + .WithSettings(new BagPartSettings + { + ContainedContentTypes = + [ + StripeProductFeature, + ], + })) + .WithPart(nameof(PriceCollectionPart), nameof(BagPart), part => part + .WithDescription("Collection of prices.") + .WithDisplayName("Prices") + .WithSettings(new BagPartSettings + { + ContainedContentTypes = + [ + StripePrice, + ], + }))); + + return 1; + } +} 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/FeatureCollectionPart.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/FeatureCollectionPart.cs new file mode 100644 index 000000000..f8a457fb4 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/FeatureCollectionPart.cs @@ -0,0 +1,5 @@ +using OrchardCore.Flows.Models; + +namespace OrchardCore.Commerce.Payment.Stripe.Models; + +public class FeatureCollectionPart : BagPart; diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/PriceCollectionPart.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/PriceCollectionPart.cs new file mode 100644 index 000000000..370883cdf --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/PriceCollectionPart.cs @@ -0,0 +1,5 @@ +using OrchardCore.Flows.Models; + +namespace OrchardCore.Commerce.Payment.Stripe.Models; + +public class PriceCollectionPart : BagPart; 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/StripePricePart.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/StripePricePart.cs new file mode 100644 index 000000000..c1718d870 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/StripePricePart.cs @@ -0,0 +1,13 @@ +using OrchardCore.Commerce.ContentFields.Models; +using OrchardCore.ContentFields.Fields; +using OrchardCore.ContentManagement; + +namespace OrchardCore.Commerce.Payment.Stripe.Models; + +public class StripePricePart : ContentPart +{ + public TextField PriceId { get; set; } = new(); + public TextField Name { get; set; } = new(); + public PriceField Price { get; set; } = new(); + public TextField Period { get; set; } = new(); +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/StripeProductFeaturePart.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/StripeProductFeaturePart.cs new file mode 100644 index 000000000..0c0526c2f --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/StripeProductFeaturePart.cs @@ -0,0 +1,9 @@ +using OrchardCore.ContentFields.Fields; +using OrchardCore.ContentManagement; + +namespace OrchardCore.Commerce.Payment.Stripe.Models; + +public class StripeProductFeaturePart : ContentPart +{ + public TextField FeatureName { get; set; } = new(); +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/StripeProductPart.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/StripeProductPart.cs new file mode 100644 index 000000000..cd1fb3e70 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/StripeProductPart.cs @@ -0,0 +1,9 @@ +using OrchardCore.ContentFields.Fields; +using OrchardCore.ContentManagement; + +namespace OrchardCore.Commerce.Payment.Stripe.Models; + +public class StripeProductPart : ContentPart +{ + public TextField StripeProductId { get; set; } = new(); +} 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/Models/SubscriptionCreateResponse.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/SubscriptionCreateResponse.cs new file mode 100644 index 000000000..12d05bafe --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Models/SubscriptionCreateResponse.cs @@ -0,0 +1,7 @@ +namespace OrchardCore.Commerce.Payment.Stripe.Models; + +public class SubscriptionCreateResponse +{ + public string Type { get; set; } + public string ClientSecret { get; set; } +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/OrchardCore.Commerce.Payment.Stripe.csproj b/src/Modules/OrchardCore.Commerce.Payment.Stripe/OrchardCore.Commerce.Payment.Stripe.csproj index 0078e72a7..e74ff1d0f 100644 --- a/src/Modules/OrchardCore.Commerce.Payment.Stripe/OrchardCore.Commerce.Payment.Stripe.csproj +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/OrchardCore.Commerce.Payment.Stripe.csproj @@ -28,6 +28,7 @@ + @@ -41,6 +42,7 @@ + diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/DummyCustomerService.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/DummyCustomerService.cs new file mode 100644 index 000000000..73f4f2b2c --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/DummyCustomerService.cs @@ -0,0 +1,23 @@ +using Stripe; +using System.Threading; +using System.Threading.Tasks; + +namespace OrchardCore.Commerce.Payment.Stripe.Services; + +public class DummyCustomerService : CustomerService +{ + public const string TestCustomerId = "cus_TESTID00000000"; // #spell-check-ignore-line + + public override Customer Create(CustomerCreateOptions options, RequestOptions requestOptions = null) => new(); + + public override Task CreateAsync( + CustomerCreateOptions options, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + Task.FromResult(new Customer { Id = TestCustomerId }); + + public override Task> SearchAsync( + CustomerSearchOptions options = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => Task.FromResult>(null); +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/DummySessionService.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/DummySessionService.cs new file mode 100644 index 000000000..fe7324851 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/DummySessionService.cs @@ -0,0 +1,20 @@ +using Stripe; +using Stripe.Checkout; +using System.Threading; +using System.Threading.Tasks; + +namespace OrchardCore.Commerce.Payment.Stripe.Services; + +public class DummySessionService : SessionService +{ + public const string TestSessionId = "cs_test_testsessionid000000000000000000000000000000000000000000000"; // #spell-check-ignore-line + public const string TestSessionUrl = "https://localhost"; + + public override Session Create(SessionCreateOptions options, RequestOptions requestOptions = null) => new(); + + public override Task CreateAsync( + SessionCreateOptions options, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + Task.FromResult(new Session { Id = TestSessionId, Url = TestSessionUrl }); +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/DummyStripeHelperService.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/DummyStripeHelperService.cs new file mode 100644 index 000000000..3829c5fa0 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/DummyStripeHelperService.cs @@ -0,0 +1,18 @@ +using OrchardCore.Commerce.Payment.Stripe.Abstractions; +using Stripe; + +namespace OrchardCore.Commerce.Payment.Stripe.Services; + +public class DummyStripeHelperService : IStripeHelperService +{ + // Set the event type to what you want to return. + public static string Type { get; set; } + public static IHasObject EventDataObject { get; set; } + + public Event PrepareStripeEvent( + string json, + string stripeSignatureHeader, + string secret, + bool throwOnApiVersionMismatch) => + new() { Id = "evt_exampleEventId0000000000", Type = Type, Data = new EventData { Object = EventDataObject } }; // #spell-check-ignore-line +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/DummySubscriptionService.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/DummySubscriptionService.cs new file mode 100644 index 000000000..fb9f3e45a --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/DummySubscriptionService.cs @@ -0,0 +1,26 @@ +using Stripe; +using System.Threading; +using System.Threading.Tasks; + +namespace OrchardCore.Commerce.Payment.Stripe.Services; + +public class DummySubscriptionService : SubscriptionService +{ + public const string TestSubscriptionId = "sub_exampleid000000000000000"; // #spell-check-ignore-line + public static Subscription Subscription { get; set; } = new() { Id = TestSubscriptionId }; + + public override Subscription Create(SubscriptionCreateOptions options, RequestOptions requestOptions = null) => new(); + + public override Task CreateAsync( + SubscriptionCreateOptions options, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + Task.FromResult(Subscription); + + public override Task GetAsync( + string id, + SubscriptionGetOptions options = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + Task.FromResult(Subscription); +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/PaymentIntentPersistence.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/PaymentIntentPersistence.cs index a6c02fc22..52b41281a 100644 --- a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/PaymentIntentPersistence.cs +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/PaymentIntentPersistence.cs @@ -5,16 +5,37 @@ namespace OrchardCore.Commerce.Payment.Stripe.Services; public class PaymentIntentPersistence : IPaymentIntentPersistence { - private const string PaymentIntentKey = "OrchardCore:Commerce:PaymentIntent"; + // Using _ as a separator to avoid separator character conflicts. + private const string PaymentIntentKey = "OrchardCore_Commerce_PaymentIntent"; private readonly IHttpContextAccessor _httpContextAccessor; private ISession Session => _httpContextAccessor.HttpContext?.Session; public PaymentIntentPersistence(IHttpContextAccessor httpContextAccessor) => _httpContextAccessor = httpContextAccessor; - public string Retrieve() => Session.GetString(PaymentIntentKey); + public string Retrieve() + { + var serialized = Session.GetString(PaymentIntentKey); + if (serialized == null && _httpContextAccessor.HttpContext != null) + { + _httpContextAccessor.HttpContext.Request.Cookies.TryGetValue(PaymentIntentKey, out var serializedCart); + return serializedCart; + } - public void Store(string paymentIntentId) => Session.SetString(PaymentIntentKey, paymentIntentId); + return serialized; + } - public void Remove() => Session.Remove(PaymentIntentKey); + public void Store(string paymentIntentId) + { + if (Session.GetString(PaymentIntentKey) == paymentIntentId) return; + + Session.SetString(PaymentIntentKey, paymentIntentId); + _httpContextAccessor.SetCookieForever(PaymentIntentKey, paymentIntentId); + } + + public void Remove() + { + Session.Remove(PaymentIntentKey); + _httpContextAccessor.HttpContext?.Response.Cookies.Delete(PaymentIntentKey); + } } diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripeConfirmationTokenService.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripeConfirmationTokenService.cs new file mode 100644 index 000000000..d5ac1728d --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripeConfirmationTokenService.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Http; +using OrchardCore.Commerce.Payment.Stripe.Abstractions; +using Stripe; +using System.Threading.Tasks; + +namespace OrchardCore.Commerce.Payment.Stripe.Services; + +public class StripeConfirmationTokenService : IStripeConfirmationTokenService +{ + private readonly ConfirmationTokenService _confirmationTokenService; + private readonly IHttpContextAccessor _hca; + private readonly IRequestOptionsService _requestOptionsService; + + public StripeConfirmationTokenService( + ConfirmationTokenService confirmationTokenService, + IHttpContextAccessor httpContextAccessor, + IRequestOptionsService requestOptionsService) + { + _confirmationTokenService = confirmationTokenService; + _hca = httpContextAccessor; + _requestOptionsService = requestOptionsService; + } + + public async Task GetConfirmationTokenAsync(string confirmationTokenId) => + await _confirmationTokenService.GetAsync( + confirmationTokenId, + cancellationToken: _hca.HttpContext.RequestAborted, + requestOptions: await _requestOptionsService.SetIdempotencyKeyAsync()); +} 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..3ad7a88c0 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripeCustomerService.cs @@ -0,0 +1,170 @@ +using Lombiq.HelpfulLibraries.OrchardCore.Users; +using Microsoft.AspNetCore.Http; +using OrchardCore.Commerce.Payment.Stripe.Abstractions; +using Stripe; +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; + private readonly ICachingUserManager _cachingUserManager; + + public StripeCustomerService( + CustomerService customerService, + IHttpContextAccessor httpContextAccessor, + IRequestOptionsService requestOptionsService, + ICachingUserManager cachingUserManager) + { + _customerService = customerService; + _hca = httpContextAccessor; + _requestOptionsService = requestOptionsService; + _cachingUserManager = cachingUserManager; + } + + public async Task> SearchCustomersAsync(CustomerSearchOptions options) + { + var list = await _customerService.SearchAsync( + options, + requestOptions: await _requestOptionsService.SetIdempotencyKeyAsync(), + cancellationToken: _hca.HttpContext.RequestAborted); + return list; + } + + public async Task GetCustomerByIdAsync(string customerId) => + await _customerService.GetAsync( + customerId, + requestOptions: await _requestOptionsService.SetIdempotencyKeyAsync(), + cancellationToken: _hca.HttpContext.RequestAborted); + + public async Task GetFirstCustomerByEmailAsync(string customerEmail) + { + var list = await _customerService.ListAsync( + new CustomerListOptions + { + Email = customerEmail, + Limit = 1, + }, + requestOptions: await _requestOptionsService.SetIdempotencyKeyAsync(), + cancellationToken: _hca.HttpContext.RequestAborted); + return list.Data.FirstOrDefault(); + } + + public async Task GetAndUpdateOrCreateCustomerAsync( + 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) + { + customer = await UpdateCustomerAsync(customer.Id, billingAddress, shippingAddress, email, phone); + return customer; + } + + return await CreateCustomerAsync(billingAddress, shippingAddress, email, phone); + } + + public async Task CreateCustomerAsync( + Address billingAddress, + Address shippingAddress, + string email, + string phone) + { + var customerCreateOptions = PopulateCustomerCreateOptions(billingAddress, shippingAddress, email, phone); + + var customer = await _customerService.CreateAsync( + customerCreateOptions, + await _requestOptionsService.SetIdempotencyKeyAsync(), + cancellationToken: _hca.HttpContext.RequestAborted); + + return customer; + } + + public async Task CreateCustomerAsync(CustomerCreateOptions customerCreateOptions) + { + var customer = await _customerService.CreateAsync( + customerCreateOptions, + await _requestOptionsService.SetIdempotencyKeyAsync(), + cancellationToken: _hca.HttpContext.RequestAborted); + + return customer; + } + + public async Task UpdateCustomerAsync( + string customerId, + Address billingAddress, + Address shippingAddress, + string email, + string phone) + { + var customerUpdateOptions = PopulateCustomerUpdateOptions(billingAddress, shippingAddress, email, phone); + + var customer = await _customerService.UpdateAsync( + customerId, + customerUpdateOptions, + await _requestOptionsService.SetIdempotencyKeyAsync(), + cancellationToken: _hca.HttpContext.RequestAborted); + + return customer; + } + + private static CustomerUpdateOptions PopulateCustomerUpdateOptions( + Address billingAddress, + Address shippingAddress, + string email, + string phone) => + new() + { + Name = billingAddress.Name, + Email = email, + Phone = phone, + Address = CreateAddressOptions(billingAddress), + Shipping = CreateShippingOptions(shippingAddress), + }; + + public CustomerCreateOptions PopulateCustomerCreateOptions( + Address billingAddress, + Address shippingAddress, + string email, + string phone) => + new() + { + Name = billingAddress.Name, + Email = email, + Phone = phone, + Address = CreateAddressOptions(billingAddress), + Shipping = CreateShippingOptions(shippingAddress), + }; + + private static AddressOptions CreateAddressOptions(Address address) => new() + { + City = address.City, + Country = address.Region, + Line1 = address.StreetAddress1, + Line2 = address.StreetAddress2, + PostalCode = address.PostalCode, + State = address.Province, + }; + + private static ShippingOptions CreateShippingOptions(Address shippingAddress) => + shippingAddress?.Name != null + ? new ShippingOptions + { + Name = shippingAddress.Name, + Address = CreateAddressOptions(shippingAddress), + } + : null; +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripeHelperService.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripeHelperService.cs new file mode 100644 index 000000000..2ee50dab3 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripeHelperService.cs @@ -0,0 +1,14 @@ +using OrchardCore.Commerce.Payment.Stripe.Abstractions; +using Stripe; + +namespace OrchardCore.Commerce.Payment.Stripe.Services; + +public class StripeHelperService : IStripeHelperService +{ + public Event PrepareStripeEvent(string json, string stripeSignatureHeader, string secret, bool throwOnApiVersionMismatch) => + EventUtility.ConstructEvent( + json, + stripeSignatureHeader, + secret, + throwOnApiVersionMismatch: throwOnApiVersionMismatch); +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripePaymentIntentService.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripePaymentIntentService.cs new file mode 100644 index 000000000..69bf39ff2 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripePaymentIntentService.cs @@ -0,0 +1,98 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Localization; +using OrchardCore.Commerce.MoneyDataType; +using OrchardCore.Commerce.Payment.Stripe.Abstractions; +using OrchardCore.Commerce.Payment.Stripe.Constants; +using OrchardCore.Commerce.Payment.Stripe.Extensions; +using OrchardCore.Commerce.Payment.Stripe.Helpers; +using OrchardCore.Settings; +using Stripe; +using System.Threading.Tasks; + +namespace OrchardCore.Commerce.Payment.Stripe.Services; + +public class StripePaymentIntentService : IStripePaymentIntentService +{ + private readonly PaymentIntentService _paymentIntentService; + private readonly IHttpContextAccessor _hca; + private readonly IRequestOptionsService _requestOptionsService; + private readonly ISiteService _siteService; + private readonly IPaymentIntentPersistence _paymentIntentPersistence; + private readonly IStringLocalizer T; + + public StripePaymentIntentService( + PaymentIntentService paymentIntentService, + IHttpContextAccessor httpContextAccessor, + IRequestOptionsService requestOptionsService, + ISiteService siteService, + IPaymentIntentPersistence paymentIntentPersistence, + IStringLocalizer localizer) + { + _paymentIntentService = paymentIntentService; + _hca = httpContextAccessor; + _requestOptionsService = requestOptionsService; + _siteService = siteService; + _paymentIntentPersistence = paymentIntentPersistence; + T = localizer; + } + + public async Task GetPaymentIntentAsync(string paymentIntentId) + { + var paymentIntentGetOptions = new PaymentIntentGetOptions(); + paymentIntentGetOptions.AddExpansions(); + return await _paymentIntentService.GetAsync( + paymentIntentId, + paymentIntentGetOptions, + await _requestOptionsService.SetIdempotencyKeyAsync(), + _hca.HttpContext.RequestAborted); + } + + public async Task CreatePaymentIntentAsync(Amount total) + { + var siteSettings = await _siteService.GetSiteSettingsAsync(); + var paymentIntentOptions = new PaymentIntentCreateOptions + { + Amount = AmountHelpers.GetPaymentAmount(total), + Currency = total.Currency.CurrencyIsoCode, + Description = T["User checkout on {0}", siteSettings.SiteName].Value, + AutomaticPaymentMethods = new PaymentIntentAutomaticPaymentMethodsOptions { Enabled = true, }, + }; + + var paymentIntent = await CreatePaymentIntentAsync(paymentIntentOptions); + + _paymentIntentPersistence.Store(paymentIntent.Id); + + return paymentIntent; + } + + public async Task CreatePaymentIntentAsync(PaymentIntentCreateOptions options) => + await _paymentIntentService.CreateAsync( + options, + await _requestOptionsService.SetIdempotencyKeyAsync(), + _hca.HttpContext.RequestAborted); + + public async Task GetOrUpdatePaymentIntentAsync( + string paymentIntentId, + Amount defaultTotal) + { + var paymentIntent = await GetPaymentIntentAsync(paymentIntentId); + + if (paymentIntent?.Status is PaymentIntentStatuses.Succeeded or PaymentIntentStatuses.Processing) + { + return paymentIntent; + } + + var updateOptions = new PaymentIntentUpdateOptions + { + Amount = AmountHelpers.GetPaymentAmount(defaultTotal), + Currency = defaultTotal.Currency.CurrencyIsoCode, + }; + + updateOptions.AddExpansions(); + return await _paymentIntentService.UpdateAsync( + paymentIntentId, + updateOptions, + await _requestOptionsService.SetIdempotencyKeyAsync(), + _hca.HttpContext.RequestAborted); + } +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripePaymentProvider.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripePaymentProvider.cs index 2c86124a9..ae3338b37 100644 --- a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripePaymentProvider.cs +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripePaymentProvider.cs @@ -15,11 +15,12 @@ namespace OrchardCore.Commerce.Payment.Stripe.Services; public class StripePaymentProvider : IPaymentProvider { - public const string ProviderName = "Stripe"; + public const string ProviderName = "stripe"; private readonly IPaymentIntentPersistence _paymentIntentPersistence; private readonly ISession _session; private readonly ISiteService _siteService; + private readonly IStripePaymentIntentService _stripePaymentIntentService; private readonly IStripePaymentService _stripePaymentService; public string Name => ProviderName; @@ -28,12 +29,14 @@ public StripePaymentProvider( IPaymentIntentPersistence paymentIntentPersistence, ISession session, ISiteService siteService, - IStripePaymentService stripePaymentService) + IStripePaymentService stripePaymentService, + IStripePaymentIntentService stripePaymentIntentService) { _paymentIntentPersistence = paymentIntentPersistence; _session = session; _siteService = siteService; _stripePaymentService = stripePaymentService; + _stripePaymentIntentService = stripePaymentIntentService; } public async Task CreatePaymentProviderDataAsync(IPaymentViewModel model, bool isPaymentRequest = false) @@ -42,7 +45,7 @@ public async Task CreatePaymentProviderDataAsync(IPaymentViewModel model try { - paymentIntent = await _stripePaymentService.CreatePaymentIntentAsync(model.SingleCurrencyTotal); + paymentIntent = await _stripePaymentIntentService.CreatePaymentIntentAsync(model.SingleCurrencyTotal); } catch (StripeException exception) when (exception.Message.StartsWithOrdinal("No API key provided.")) { @@ -81,5 +84,5 @@ public Task UpdateAndRedirectToFinishedOrderAsy ContentItem order, string shoppingCartId) => throw new NotSupportedException( - "This code should never be reached, because Stripe payment uses ~/checkout/middleware/Stripe, not ~/checkout/callback/Stripe."); + "This code should never be reached, because Stripe payment uses ~/stripe/middleware, not ~/checkout/callback/Stripe."); } diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripePaymentService.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripePaymentService.cs index 32f6b051d..019189ce8 100644 --- a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripePaymentService.cs +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripePaymentService.cs @@ -7,7 +7,6 @@ using OrchardCore.Commerce.Abstractions.ViewModels; using OrchardCore.Commerce.MoneyDataType; using OrchardCore.Commerce.Payment.Abstractions; -using OrchardCore.Commerce.Payment.Constants; using OrchardCore.Commerce.Payment.Stripe.Abstractions; using OrchardCore.Commerce.Payment.Stripe.Constants; using OrchardCore.Commerce.Payment.Stripe.Extensions; @@ -31,39 +30,44 @@ namespace OrchardCore.Commerce.Payment.Stripe.Services; public class StripePaymentService : IStripePaymentService { - private readonly PaymentIntentService _paymentIntentService = new(); - private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IHttpContextAccessor _hca; private readonly IContentManager _contentManager; private readonly ISiteService _siteService; - private readonly IRequestOptionsService _requestOptionsService; private readonly IStringLocalizer T; private readonly YesSql.ISession _session; private readonly IPaymentIntentPersistence _paymentIntentPersistence; private readonly IPaymentService _paymentService; private readonly IHtmlLocalizer H; + private readonly IStripePaymentIntentService _stripePaymentIntentService; #pragma warning disable S107 // Methods should not have too many parameters public StripePaymentService( IContentManager contentManager, ISiteService siteService, - IRequestOptionsService requestOptionsService, IStringLocalizer stringLocalizer, YesSql.ISession session, IPaymentIntentPersistence paymentIntentPersistence, IPaymentService paymentService, IHtmlLocalizer htmlLocalizer, - IHttpContextAccessor httpContextAccessor) + IHttpContextAccessor hca, + IStripePaymentIntentService stripePaymentIntentService) #pragma warning restore S107 // Methods should not have too many parameters { _contentManager = contentManager; _siteService = siteService; - _requestOptionsService = requestOptionsService; _session = session; _paymentIntentPersistence = paymentIntentPersistence; T = stringLocalizer; _paymentService = paymentService; H = htmlLocalizer; - _httpContextAccessor = httpContextAccessor; + _hca = hca; + _stripePaymentIntentService = stripePaymentIntentService; + } + + public async Task GetPublicKeyAsync() + { + var stripeApiSettings = (await _siteService.GetSiteSettingsAsync()).As(); + return stripeApiSettings.PublishableKey; } public async Task CreateClientSecretAsync(Amount total, ShoppingCartViewModel cart) @@ -85,23 +89,12 @@ public async Task CreateClientSecretAsync(Amount total, ShoppingCartView var defaultTotal = totals.SingleOrDefault(); var initPaymentIntent = string.IsNullOrEmpty(paymentIntentId) - ? await CreatePaymentIntentAsync(defaultTotal) - : await GetOrUpdatePaymentIntentAsync(paymentIntentId, defaultTotal); + ? await _stripePaymentIntentService.CreatePaymentIntentAsync(defaultTotal) + : await _stripePaymentIntentService.GetOrUpdatePaymentIntentAsync(paymentIntentId, defaultTotal); return initPaymentIntent.ClientSecret; } - public async Task GetPaymentIntentAsync(string paymentIntentId) - { - var paymentIntentGetOptions = new PaymentIntentGetOptions(); - paymentIntentGetOptions.AddExpansions(); - return await _paymentIntentService.GetAsync( - paymentIntentId, - paymentIntentGetOptions, - await _requestOptionsService.SetIdempotencyKeyAsync(), - _httpContextAccessor.HttpContext.RequestAborted); - } - public async Task UpdateOrderToOrderedAsync(PaymentIntent paymentIntent, string shoppingCartId) => await _paymentService.UpdateOrderToOrderedAsync( await GetOrderByPaymentIntentIdAsync(paymentIntent.Id), @@ -117,10 +110,10 @@ string shoppingCartId try { return _paymentService.UpdateAndRedirectToFinishedOrderAsync( - order, - shoppingCartId, - StripePaymentProvider.ProviderName, - CreateChargesProvider(paymentIntent)); + order, + shoppingCartId, + StripePaymentProvider.ProviderName, + CreateChargesProvider(paymentIntent)); } catch (Exception ex) { @@ -151,13 +144,21 @@ public Task GetOrderPaymentByPaymentIntentIdAsync(string paymentIn .Query(index => index.PaymentIntentId == paymentIntentId) .FirstOrDefaultAsync(); + public Task SaveOrderPaymentAsync(string orderContentItemId, string paymentIntentId) => + _session.SaveAsync(new OrderPayment + { + OrderId = orderContentItemId, + PaymentIntentId = paymentIntentId, + }); + public async Task CreateOrUpdateOrderFromShoppingCartAsync( IUpdateModelAccessor updateModelAccessor, string shoppingCartId, - string paymentIntentId = null) + string paymentIntentId = null, + OrderPart orderPart = null) { var innerPaymentIntentId = paymentIntentId ?? _paymentIntentPersistence.Retrieve(); - var paymentIntent = await GetPaymentIntentAsync(innerPaymentIntentId); + var paymentIntent = await _stripePaymentIntentService.GetPaymentIntentAsync(innerPaymentIntentId); // Stripe doesn't support multiple shopping cart IDs because we can't send that info to the middleware anyway. var (order, isNew) = await _paymentService.CreateOrUpdateOrderFromShoppingCartAsync( @@ -190,9 +191,10 @@ public async Task CreateOrUpdateOrderFromShoppingCartAsync( part.PaymentIntentId = new TextField { ContentItem = order, Text = paymentIntent.Id }); return Task.CompletedTask; - }); + }, + orderPart); - if (!order.As().LineItems.Any()) + if (!order.As().LineItems.Any() && updateModelAccessor != null) { updateModelAccessor.ModelUpdater.ModelState.AddModelError( nameof(OrderPart.LineItems), @@ -201,11 +203,7 @@ public async Task CreateOrUpdateOrderFromShoppingCartAsync( if (isNew) { - await _session.SaveAsync(new OrderPayment - { - OrderId = order.ContentItemId, - PaymentIntentId = paymentIntent.Id, - }); + await SaveOrderPaymentAsync(order.ContentItemId, paymentIntent.Id); } return order; @@ -214,8 +212,7 @@ await _session.SaveAsync(new OrderPayment public async Task PaymentConfirmationAsync( string paymentIntentId, string shoppingCartId, - bool needToJudgeIntentStorage = true - ) + bool needToJudgeIntentStorage = true) { // If it is null it means the session was not loaded yet and a redirect is needed. if (needToJudgeIntentStorage && string.IsNullOrEmpty(_paymentIntentPersistence.Retrieve())) @@ -223,13 +220,13 @@ public async Task PaymentConfirmationAsync( return new PaymentOperationStatusViewModel { Status = PaymentOperationStatus.WaitingForRedirect, - Url = _httpContextAccessor.HttpContext.Request.GetDisplayUrl(), + Url = _hca.HttpContext.Request.GetDisplayUrl(), }; } // If we can't find a valid payment intent based on ID or if we can't find the associated order, then something // went wrong and continuing from here would only cause a crash anyway. - if (await GetPaymentIntentAsync(paymentIntentId) is not { PaymentMethod: not null } fetchedPaymentIntent || + if (await _stripePaymentIntentService.GetPaymentIntentAsync(paymentIntentId) is not { PaymentMethod: not null } fetchedPaymentIntent || (await GetOrderPaymentByPaymentIntentIdAsync(paymentIntentId))?.OrderId is not { } orderId || await _contentManager.GetAsync(orderId) is not { } order) { @@ -285,7 +282,7 @@ await _contentManager.GetAsync(orderId) is not { } order) return new PaymentOperationStatusViewModel { Status = PaymentOperationStatus.WaitingForRedirect, - Url = _httpContextAccessor.HttpContext.Request.GetDisplayUrl(), + Url = _hca.HttpContext.Request.GetDisplayUrl(), }; } @@ -298,51 +295,23 @@ await _contentManager.GetAsync(orderId) is not { } order) }; } - private static long GetPaymentAmount(Amount total) + public async Task GetStripeConfirmParametersAsync( + string returnUrl, + ContentItem order = null) { - if (CurrencyCollectionConstants.ZeroDecimalCurrencies.Contains(total.Currency.CurrencyIsoCode)) + if (order == null) { - return (long)Math.Round(total.Value); + order = await _contentManager.NewAsync(Commerce.Abstractions.Constants.ContentTypes.Order); + await _paymentService.UpdateOrderWithDriversAsync(order); } - return CurrencyCollectionConstants.SpecialCases.Contains(total.Currency.CurrencyIsoCode) - ? (long)Math.Round(total.Value / 100m) * 10000 - : (long)Math.Round(total.Value * 100); - } - - public async Task CreatePaymentIntentAsync(Amount total) - { - var siteSettings = await _siteService.GetSiteSettingsAsync(); - var paymentIntentOptions = new PaymentIntentCreateOptions - { - Amount = GetPaymentAmount(total), - Currency = total.Currency.CurrencyIsoCode, - Description = T["User checkout on {0}", siteSettings.SiteName].Value, - AutomaticPaymentMethods = new PaymentIntentAutomaticPaymentMethodsOptions { Enabled = true, }, - }; - - var paymentIntent = await _paymentIntentService.CreateAsync( - paymentIntentOptions, - await _requestOptionsService.SetIdempotencyKeyAsync(), - _httpContextAccessor.HttpContext.RequestAborted); - - _paymentIntentPersistence.Store(paymentIntent.Id); - - return paymentIntent; - } - - public async Task GetStripeConfirmParametersAsync(string middlewareAbsoluteUrl) - { - var order = await _contentManager.NewAsync(Commerce.Abstractions.Constants.ContentTypes.Order); - await _paymentService.UpdateOrderWithDriversAsync(order); - var part = order.As(); var billing = part.BillingAddress.Address ?? new Address(); var shipping = part.ShippingAddress.Address ?? new Address(); var model = new PaymentIntentConfirmOptions { - ReturnUrl = middlewareAbsoluteUrl, + ReturnUrl = returnUrl, PaymentMethodData = new PaymentIntentPaymentMethodDataOptions { BillingDetails = new PaymentIntentPaymentMethodDataBillingDetailsOptions @@ -353,12 +322,14 @@ public async Task GetStripeConfirmParametersAsync(s Address = CreateAddressOptions(billing), }, }, - Shipping = new ChargeShippingOptions - { - Name = shipping.Name, - Phone = part.Phone?.Text, - Address = CreateAddressOptions(shipping), - }, + Shipping = string.IsNullOrEmpty(shipping.Name) + ? null + : new ChargeShippingOptions + { + Name = shipping.Name, + Phone = part.Phone?.Text, + Address = CreateAddressOptions(shipping), + }, }; return model; } @@ -373,30 +344,6 @@ private static AddressOptions CreateAddressOptions(Address address) => PostalCode = address.PostalCode ?? string.Empty, State = address.Province ?? string.Empty, }; - private async Task GetOrUpdatePaymentIntentAsync( - string paymentIntentId, - Amount defaultTotal) - { - var paymentIntent = await GetPaymentIntentAsync(paymentIntentId); - - if (paymentIntent?.Status is PaymentIntentStatuses.Succeeded or PaymentIntentStatuses.Processing) - { - return paymentIntent; - } - - var updateOptions = new PaymentIntentUpdateOptions - { - Amount = GetPaymentAmount(defaultTotal), - Currency = defaultTotal.Currency.CurrencyIsoCode, - }; - - updateOptions.AddExpansions(); - return await _paymentIntentService.UpdateAsync( - paymentIntentId, - updateOptions, - await _requestOptionsService.SetIdempotencyKeyAsync(), - _httpContextAccessor.HttpContext.RequestAborted); - } private async Task GetOrderByPaymentIntentIdAsync(string paymentIntentId) { 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..f5931a1f3 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripeSessionService.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Http; +using OrchardCore.Commerce.Payment.Stripe.Abstractions; +using Stripe.Checkout; +using System.Collections.Generic; +using System.Threading.Tasks; +using Session = Stripe.Checkout.Session; + +namespace OrchardCore.Commerce.Payment.Stripe.Services; + +public class StripeSessionService : IStripeSessionService +{ + private readonly SessionService _sessionService; + private readonly IRequestOptionsService _requestOptionsService; + private readonly IHttpContextAccessor _hca; + private readonly IEnumerable _stripeSessionEventHandlers; + + public StripeSessionService( + SessionService sessionService, + IRequestOptionsService requestOptionsService, + IHttpContextAccessor httpContextAccessor, + IEnumerable stripeSessionEventHandlers) + { + _sessionService = sessionService; + _requestOptionsService = requestOptionsService; + _hca = httpContextAccessor; + _stripeSessionEventHandlers = stripeSessionEventHandlers; + } + + public async Task CreateSessionAsync(SessionCreateOptions options) + { + await _stripeSessionEventHandlers.AwaitEachAsync(handler => handler.StripeSessionCreatingAsync(options)); + + var session = await _sessionService.CreateAsync( + options, + await _requestOptionsService.SetIdempotencyKeyAsync(), + cancellationToken: _hca.HttpContext!.RequestAborted); + + await _stripeSessionEventHandlers.AwaitEachAsync(handler => handler.StripeSessionCreatedAsync(session, options)); + return session; + } +} 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..76cd45a7e --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Services/StripeSubscriptionService.cs @@ -0,0 +1,90 @@ +using Microsoft.AspNetCore.Http; +using OrchardCore.Commerce.Payment.Stripe.Abstractions; +using OrchardCore.Commerce.Payment.Stripe.Models; +using OrchardCore.Commerce.Payment.Stripe.ViewModels; +using Stripe; +using System.Threading.Tasks; + +namespace OrchardCore.Commerce.Payment.Stripe.Services; + +public class StripeSubscriptionService : IStripeSubscriptionService +{ + private readonly SubscriptionService _subscriptionService; + private readonly IRequestOptionsService _requestOptionsService; + private readonly IHttpContextAccessor _hca; + + public StripeSubscriptionService( + SubscriptionService subscriptionService, + IRequestOptionsService requestOptionsService, + IHttpContextAccessor httpContextAccessor) + { + _subscriptionService = subscriptionService; + _requestOptionsService = requestOptionsService; + _hca = httpContextAccessor; + } + + public async Task CreateSubscriptionAsync(StripeCreateSubscriptionViewModel viewModel) + { + // Automatically save the payment method to the subscription + // when the first payment is successful. + var paymentSettings = new SubscriptionPaymentSettingsOptions + { + SaveDefaultPaymentMethod = "on_subscription", + }; + + var subscriptionOptions = new SubscriptionCreateOptions + { + Customer = viewModel.CustomerId, + PaymentSettings = paymentSettings, + PaymentBehavior = "default_incomplete", + }; + + foreach (var priceId in viewModel.PriceIds) + { + subscriptionOptions.Items.Add(new SubscriptionItemOptions { Price = priceId }); + } + + subscriptionOptions.AddExpand("latest_invoice.payment_intent"); + subscriptionOptions.AddExpand("pending_setup_intent"); + + var subscription = await _subscriptionService.CreateAsync( + subscriptionOptions, + requestOptions: await _requestOptionsService.SetIdempotencyKeyAsync(), + cancellationToken: _hca.HttpContext.RequestAborted); + + if (subscription.PendingSetupIntent != null) + { + return new SubscriptionCreateResponse + { + Type = "setup", + ClientSecret = subscription.PendingSetupIntent.ClientSecret, + }; + } + + return new SubscriptionCreateResponse + { + Type = "payment", + ClientSecret = subscription.LatestInvoice.PaymentIntent.ClientSecret, + }; + } + + public async Task CreateSubscriptionAsync(SubscriptionCreateOptions options) => + await _subscriptionService.CreateAsync( + options, + requestOptions: await _requestOptionsService.SetIdempotencyKeyAsync(), + cancellationToken: _hca.HttpContext.RequestAborted); + + 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/Startup.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Startup.cs index 4deb0b442..0da5e9bb6 100644 --- a/src/Modules/OrchardCore.Commerce.Payment.Stripe/Startup.cs +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/Startup.cs @@ -5,20 +5,28 @@ using Microsoft.Extensions.Options; using OrchardCore.Commerce.Payment.Abstractions; using OrchardCore.Commerce.Payment.Stripe.Abstractions; +using OrchardCore.Commerce.Payment.Stripe.Constants; using OrchardCore.Commerce.Payment.Stripe.Drivers; -using OrchardCore.Commerce.Payment.Stripe.EndPoints.Api; +using OrchardCore.Commerce.Payment.Stripe.Endpoints.Extensions; +using OrchardCore.Commerce.Payment.Stripe.Endpoints.Permissions; +using OrchardCore.Commerce.Payment.Stripe.Handlers; using OrchardCore.Commerce.Payment.Stripe.Indexes; using OrchardCore.Commerce.Payment.Stripe.Migrations; 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; using OrchardCore.Navigation; using OrchardCore.ResourceManagement; using OrchardCore.Security.Permissions; using OrchardCore.Settings; +using Stripe; +using Stripe.Checkout; using System; +using SubscriptionService = Stripe.SubscriptionService; namespace OrchardCore.Commerce.Payment.Stripe; @@ -29,6 +37,7 @@ public override void ConfigureServices(IServiceCollection services) services.AddTransient, ResourceManagementOptionsConfiguration>(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -37,6 +46,13 @@ public override void ConfigureServices(IServiceCollection services) services.AddTransient, StripeApiSettingsConfiguration>(); services.AddContentPart().WithMigration().WithIndex(); + services.AddContentPart(); + services.AddContentPart(); + services.AddContentPart(); + services.AddContentPart(); + services.AddContentPart(); + services.AddDataMigration(); + services.AddScoped, StripeApiSettingsDisplayDriver>(); services.AddScoped(); @@ -44,8 +60,48 @@ 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.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + + services.AddIndexProvider(); } public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) => - routes.AddStripeMiddlewareEndpoint(); + routes.AddStripePaymentApiEndpoints(); +} + +[Feature(FeatureIds.DummyStripeServices)] +[RequireFeatures(FeatureIds.Area)] +public class TestStripeStartup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services.RemoveByImplementation(); + services.AddScoped(); + + services.RemoveByImplementation(); + services.AddScoped(); + + services.RemoveByImplementation(); + services.AddScoped(); + + services.RemoveImplementationsOf(); + services.AddScoped(); + } } diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/ViewModels/StripeCreateSubscriptionViewModel.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/ViewModels/StripeCreateSubscriptionViewModel.cs new file mode 100644 index 000000000..53f4f29a5 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/ViewModels/StripeCreateSubscriptionViewModel.cs @@ -0,0 +1,13 @@ +using OrchardCore.Commerce.Abstractions.Models; +using System.Collections.Generic; + +namespace OrchardCore.Commerce.Payment.Stripe.ViewModels; + +public class StripeCreateSubscriptionViewModel +{ + public string ShoppingCartId { get; set; } + public string CustomerId { get; set; } + public IList PriceIds { get; } = new List(); + + public OrderPart OrderPart { get; set; } +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/ViewModels/StripeCustomerViewModel.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/ViewModels/StripeCustomerViewModel.cs new file mode 100644 index 000000000..e2184490d --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/ViewModels/StripeCustomerViewModel.cs @@ -0,0 +1,8 @@ +using Stripe; + +namespace OrchardCore.Commerce.Payment.Stripe.ViewModels; + +public class StripeCustomerViewModel +{ + public CustomerCreateOptions CustomerCreateOptions { get; set; } +} diff --git a/src/Modules/OrchardCore.Commerce.Payment.Stripe/ViewModels/StripeGetSubscriptionViewModel.cs b/src/Modules/OrchardCore.Commerce.Payment.Stripe/ViewModels/StripeGetSubscriptionViewModel.cs new file mode 100644 index 000000000..f3948b83b --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment.Stripe/ViewModels/StripeGetSubscriptionViewModel.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace OrchardCore.Commerce.Payment.Stripe.ViewModels; + +public class StripeGetSubscriptionViewModel +{ + [Required] + public string SubscriptionId { get; set; } +} diff --git a/src/Modules/OrchardCore.Commerce.Payment/Abstractions/IPaymentService.cs b/src/Modules/OrchardCore.Commerce.Payment/Abstractions/IPaymentService.cs index 3355e17e8..993cd97ae 100644 --- a/src/Modules/OrchardCore.Commerce.Payment/Abstractions/IPaymentService.cs +++ b/src/Modules/OrchardCore.Commerce.Payment/Abstractions/IPaymentService.cs @@ -68,10 +68,11 @@ Task UpdateOrderToOrderedAsync( /// /// Thrown if the order validation failed. Task<(ContentItem Order, bool IsNew)> CreateOrUpdateOrderFromShoppingCartAsync( - IUpdateModelAccessor updateModelAccessor, + IUpdateModelAccessor? updateModelAccessor, string? orderId, string? shoppingCartId, - AlterOrderAsyncDelegate? alterOrderAsync = null); + AlterOrderAsyncDelegate? alterOrderAsync = null, + OrderPart? orderPart = null); /// /// Updates the provided Order content item from the update model as if it was just edited. diff --git a/src/Modules/OrchardCore.Commerce.Payment/Endpoints/Api/PaymentEndpoint.cs b/src/Modules/OrchardCore.Commerce.Payment/Endpoints/Api/PaymentEndpoint.cs new file mode 100644 index 000000000..5bbf08149 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment/Endpoints/Api/PaymentEndpoint.cs @@ -0,0 +1,131 @@ +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.Abstractions.Constants; +using OrchardCore.Commerce.Abstractions.Models; +using OrchardCore.Commerce.MoneyDataType.Extensions; +using OrchardCore.Commerce.Payment.Abstractions; +using OrchardCore.Commerce.Payment.Endpoints.Models; +using OrchardCore.Commerce.Payment.Endpoints.Permissions; +using OrchardCore.Commerce.Payment.ViewModels; +using OrchardCore.ContentManagement; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace OrchardCore.Commerce.Payment.Endpoints.Api; +public static class PaymentEndpoint +{ + public static IEndpointRouteBuilder AddFreeEndpoint(this IEndpointRouteBuilder builder) + { + builder.MapPostWithDefaultSettings("api/checkout/free/{shoppingCartId?}", AddFreeAsync); + return builder; + } + + private static async Task AddFreeAsync( + [FromRoute] string? shoppingCartId, + [FromServices] IAuthorizationService authorizationService, + HttpContext httpContext, + [FromServices] IPaymentService paymentService + ) + { + if (!await authorizationService.AuthorizeAsync(httpContext.User, ApiPermissions.CommerceApiPayment)) + { + return httpContext.ChallengeOrForbidApi(); + } + + var result = await paymentService.CheckoutWithoutPaymentAsync(shoppingCartId); + return TypedResults.Ok(result); + } + + public static IEndpointRouteBuilder AddCallbackEndpoint(this IEndpointRouteBuilder builder) + { + builder.MapPostWithDefaultSettings("api/checkout/callback", AddCallbackAsync); + return builder; + } + + private static async Task AddCallbackAsync( + [FromBody] AddCallbackViewModel viewModel, + [FromServices] IAuthorizationService authorizationService, + HttpContext httpContext, + [FromServices] IPaymentService paymentService + ) + { + if (!await authorizationService.AuthorizeAsync(httpContext.User, ApiPermissions.CommerceApiPayment)) + { + return httpContext.ChallengeOrForbidApi(); + } + + if (viewModel.PaymentProviderName == null) + { + return TypedResults.BadRequest($"${nameof(AddCallbackViewModel.PaymentProviderName)} is required."); + } + + if (viewModel.PaymentProviderName.EqualsOrdinalIgnoreCase("Stripe")) + { + return TypedResults.BadRequest("Stripe payment uses ~/checkout/stripe/middleware, not ~/checkout/callback/Stripe."); + } + + if (await paymentService.CallBackAsync( + viewModel.PaymentProviderName, + viewModel.OrderId, + viewModel.ShoppingCartId) is { } result) + { + return TypedResults.Ok(result); + } + + return TypedResults.NotFound(); + } + + public static IEndpointRouteBuilder AddPaymentRequestEndpoint(this IEndpointRouteBuilder builder) + { + builder.MapGetWithDefaultSettings("api/checkout/payment-request/{orderId}", PaymentRequestAsync); + return builder; + } + + private static async Task PaymentRequestAsync( + [FromRoute] string orderId, + [FromServices] IContentManager contentManager, + [FromServices] IAuthorizationService authorizationService, + [FromServices] IEnumerable paymentProviders, + HttpContext httpContext) + { + if (!await authorizationService.AuthorizeAsync(httpContext.User, ApiPermissions.CommerceApiPayment)) + { + return httpContext.ChallengeOrForbidApi(); + } + + if (await contentManager.GetAsync(orderId) is not { } order || + order.As() is not { } orderPart) + { + return TypedResults.BadRequest(); + } + + // If there are no line items, there is nothing to be done. + if (!orderPart.LineItems.Any()) + { + return TypedResults.Ok("This Order contains no line items, so there is nothing to be paid."); + } + + // If status is not Pending, there is nothing to be done. + if (!string.Equals(orderPart.Status.Text, OrderStatuses.Pending, StringComparison.OrdinalIgnoreCase)) + { + return TypedResults.Ok("This Order is no longer pending."); + } + + var singleCurrencyTotal = orderPart.LineItems.Select(item => item.LinePrice).Sum(); + if (singleCurrencyTotal.Value <= 0) + { + return TypedResults.Ok("This Order's line items have no cost, so there is nothing to be paid."); + } + + var viewModel = new PaymentViewModel(orderPart, singleCurrencyTotal, singleCurrencyTotal); + await viewModel.WithProviderDataAsync(paymentProviders, isPaymentRequest: true); + + return TypedResults.Ok(viewModel); + } +} diff --git a/src/Modules/OrchardCore.Commerce.Payment/Endpoints/Extensions/Endpoints.cs b/src/Modules/OrchardCore.Commerce.Payment/Endpoints/Extensions/Endpoints.cs new file mode 100644 index 000000000..be04e9f4e --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment/Endpoints/Extensions/Endpoints.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Routing; +using OrchardCore.Commerce.Payment.Endpoints.Api; + +namespace OrchardCore.Commerce.Payment.Endpoints.Extensions; + +public static class Endpoints +{ + public static IEndpointRouteBuilder AddPaymentApiEndpoints(this IEndpointRouteBuilder router) + { + router + .AddFreeEndpoint() + .AddCallbackEndpoint() + .AddPaymentRequestEndpoint(); + + return router; + } +} diff --git a/src/Modules/OrchardCore.Commerce.Payment/Endpoints/Models/AddCallbackViewModel.cs b/src/Modules/OrchardCore.Commerce.Payment/Endpoints/Models/AddCallbackViewModel.cs new file mode 100644 index 000000000..53db0b5e7 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment/Endpoints/Models/AddCallbackViewModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace OrchardCore.Commerce.Payment.Endpoints.Models; + +public class AddCallbackViewModel +{ + [Required] + public string? PaymentProviderName { get; set; } + public string? OrderId { get; set; } + public string? ShoppingCartId { get; set; } +} diff --git a/src/Modules/OrchardCore.Commerce.Payment/Endpoints/Permissions/ApiPermissions.cs b/src/Modules/OrchardCore.Commerce.Payment/Endpoints/Permissions/ApiPermissions.cs new file mode 100644 index 000000000..90e8877fe --- /dev/null +++ b/src/Modules/OrchardCore.Commerce.Payment/Endpoints/Permissions/ApiPermissions.cs @@ -0,0 +1,17 @@ +using Lombiq.HelpfulLibraries.OrchardCore.Users; +using OrchardCore.Security.Permissions; +using System.Collections.Generic; + +namespace OrchardCore.Commerce.Payment.Endpoints.Permissions; + +public class ApiPermissions : AdminPermissionBase +{ + public static readonly Permission CommerceApiPayment = + new(nameof(CommerceApiPayment), "Access Commerce Payment APIs"); + + private static readonly IReadOnlyList _adminPermissions = new[] + { + CommerceApiPayment, + }; + protected override IEnumerable AdminPermissions => _adminPermissions; +} diff --git a/src/Modules/OrchardCore.Commerce.Payment/OrchardCore.Commerce.Payment.csproj b/src/Modules/OrchardCore.Commerce.Payment/OrchardCore.Commerce.Payment.csproj index fe12aa63c..d55ceec31 100644 --- a/src/Modules/OrchardCore.Commerce.Payment/OrchardCore.Commerce.Payment.csproj +++ b/src/Modules/OrchardCore.Commerce.Payment/OrchardCore.Commerce.Payment.csproj @@ -46,6 +46,13 @@ + + + + + + + diff --git a/src/Modules/OrchardCore.Commerce.Payment/Services/PaymentService.cs b/src/Modules/OrchardCore.Commerce.Payment/Services/PaymentService.cs index ff210ee33..51c95166b 100644 --- a/src/Modules/OrchardCore.Commerce.Payment/Services/PaymentService.cs +++ b/src/Modules/OrchardCore.Commerce.Payment/Services/PaymentService.cs @@ -360,23 +360,28 @@ public async Task UpdateOrderToOrderedAsync( } public async Task<(ContentItem Order, bool IsNew)> CreateOrUpdateOrderFromShoppingCartAsync( - IUpdateModelAccessor updateModelAccessor, + IUpdateModelAccessor? updateModelAccessor, string? orderId, string? shoppingCartId, - AlterOrderAsyncDelegate? alterOrderAsync = null) + AlterOrderAsyncDelegate? alterOrderAsync = null, + OrderPart? orderPart = null) { var order = await _contentManager.GetAsync(orderId) ?? await _contentManager.NewAsync(Order); var isNew = order.IsNew(); var part = order.As(); var cart = await _shoppingCartHelpers.RetrieveAsync(shoppingCartId); - if (cart.Items.Any() && !order.As().LineItems.Any()) + if (cart.Items.Any() && !order.As().LineItems.Any() && updateModelAccessor != null) { await _contentItemDisplayManager.UpdateEditorAsync(order, updateModelAccessor.ModelUpdater, isNew: false); var errors = updateModelAccessor.ModelUpdater.GetModelErrorMessages().AsList(); FrontendException.ThrowIfAny(errors); } + else if (orderPart != null) + { + order.Apply(orderPart); + } // If there are line items in the Order, use data from Order instead of shopping cart. var lineItems = part.LineItems.Any() diff --git a/src/Modules/OrchardCore.Commerce.Payment/Startup.cs b/src/Modules/OrchardCore.Commerce.Payment/Startup.cs index a35a6f552..e1c985599 100644 --- a/src/Modules/OrchardCore.Commerce.Payment/Startup.cs +++ b/src/Modules/OrchardCore.Commerce.Payment/Startup.cs @@ -1,11 +1,15 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using OrchardCore.Commerce.Payment.Abstractions; using OrchardCore.Commerce.Payment.Constants; +using OrchardCore.Commerce.Payment.Endpoints.Extensions; using OrchardCore.Commerce.Payment.Services; using OrchardCore.Modules; using OrchardCore.ResourceManagement; using OrchardCore.Security.Permissions; +using System; namespace OrchardCore.Commerce.Payment; @@ -17,6 +21,9 @@ public override void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); } + + public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) => + routes.AddPaymentApiEndpoints(); } [Feature(FeatureIds.DummyProvider)] diff --git a/src/Modules/OrchardCore.Commerce/CommerceConstants.cs b/src/Modules/OrchardCore.Commerce/CommerceConstants.cs index 404e28e49..8468e8e0e 100644 --- a/src/Modules/OrchardCore.Commerce/CommerceConstants.cs +++ b/src/Modules/OrchardCore.Commerce/CommerceConstants.cs @@ -7,5 +7,6 @@ public static class Features public const string Core = "OrchardCore.Commerce"; public const string SessionCartStorage = "OrchardCore.Commerce.SessionCartStorage"; public const string CurrencySettingsSelector = "OrchardCore.Commerce.CurrencySettingsSelector"; + public const string Subscription = "OrchardCore.Commerce.Subscription"; } } diff --git a/src/Modules/OrchardCore.Commerce/Endpoints/Api/OrderEndpoint.cs b/src/Modules/OrchardCore.Commerce/Endpoints/Api/OrderEndpoint.cs new file mode 100644 index 000000000..cdf5dd7d7 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce/Endpoints/Api/OrderEndpoint.cs @@ -0,0 +1,40 @@ +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.Permissions; +using OrchardCore.ContentManagement; +using System.Threading.Tasks; +using static OrchardCore.Commerce.Abstractions.Constants.ContentTypes; + +namespace OrchardCore.Commerce.Endpoints.Api; + +public static class OrderEndpoint +{ + private const string ApiPath = "api/order"; + + public static IEndpointRouteBuilder AddNewOrderEndpoint(this IEndpointRouteBuilder builder) + { + builder.MapPostWithDefaultSettings(ApiPath + "/test", NewOrderAsync); + + return builder; + } + + // Create order content item + private static async Task NewOrderAsync( + [FromServices] IAuthorizationService authorizationService, + [FromServices] IContentManager contentManager, + HttpContext httpContext) + { + if (!await authorizationService.AuthorizeAsync(httpContext.User, ApiPermissions.CommerceOrderApi)) + { + return httpContext.ChallengeOrForbidApi(); + } + + var order = await contentManager.NewAsync(Order); + + return TypedResults.Ok(order); + } +} diff --git a/src/Modules/OrchardCore.Commerce/Endpoints/Api/PaymentEndpoint.cs b/src/Modules/OrchardCore.Commerce/Endpoints/Api/PaymentEndpoint.cs deleted file mode 100644 index 5687859b0..000000000 --- a/src/Modules/OrchardCore.Commerce/Endpoints/Api/PaymentEndpoint.cs +++ /dev/null @@ -1,72 +0,0 @@ -#nullable enable -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Routing; -using OrchardCore.Commerce.Endpoints.Permissions; -using OrchardCore.Commerce.Payment.Abstractions; -using OrchardCore.Modules; -using System.Threading.Tasks; - -namespace OrchardCore.Commerce.Endpoints.Api; -public static class PaymentEndpoint -{ - public static IEndpointRouteBuilder AddFreeEndpoint(this IEndpointRouteBuilder builder) - { - builder.MapPost("api/checkout/free/{shoppingCartId?}", AddFreeAsync) - .AllowAnonymous() - .DisableAntiforgery(); - - return builder; - } - - [Authorize(AuthenticationSchemes = "Api")] - private static async Task AddFreeAsync( - [FromRoute] string? shoppingCartId, - [FromServices] IAuthorizationService authorizationService, - [FromServices] HttpContext httpContext, - [FromServices] IPaymentService paymentService - ) - { - if (!await authorizationService.AuthorizeAsync(httpContext.User, ApiPermissions.CommerceApi)) - { - return httpContext.ChallengeOrForbid("Api"); - } - - var result = await paymentService.CheckoutWithoutPaymentAsync(shoppingCartId); - return TypedResults.Ok(result); - } - - public static IEndpointRouteBuilder AddCallbackEndpoint(this IEndpointRouteBuilder builder) - { - builder.MapPost("api/checkout/callback/{paymentProviderName}/{orderId?}", AddCallbackAsync) - .AllowAnonymous() - .DisableAntiforgery(); - - return builder; - } - - [Authorize(AuthenticationSchemes = "Api")] - private static async Task AddCallbackAsync( - [FromRoute] string paymentProviderName, - [FromRoute] string? orderId, - [FromQuery] string? shoppingCartId, - [FromServices] IAuthorizationService authorizationService, - [FromServices] HttpContext httpContext, - [FromServices] IPaymentService paymentService - ) - { - if (!await authorizationService.AuthorizeAsync(httpContext.User, ApiPermissions.CommerceApi)) - { - return httpContext.ChallengeOrForbid("Api"); - } - - if (await paymentService.CallBackAsync(paymentProviderName, orderId, shoppingCartId) is { } result) - { - return TypedResults.Ok(result); - } - - return TypedResults.NotFound(); - } -} diff --git a/src/Modules/OrchardCore.Commerce/Endpoints/Api/ShoppingCartLineEndpoint.cs b/src/Modules/OrchardCore.Commerce/Endpoints/Api/ShoppingCartLineEndpoint.cs index 6b3db2f8f..5e4fb0935 100644 --- a/src/Modules/OrchardCore.Commerce/Endpoints/Api/ShoppingCartLineEndpoint.cs +++ b/src/Modules/OrchardCore.Commerce/Endpoints/Api/ShoppingCartLineEndpoint.cs @@ -1,46 +1,68 @@ #nullable enable +using Lombiq.HelpfulLibraries.AspNetCore.Extensions; +using Lombiq.HelpfulLibraries.OrchardCore.Mvc; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Localization; using Microsoft.AspNetCore.Routing; -using OrchardCore.Commerce.Abstractions; using OrchardCore.Commerce.Endpoints.Permissions; using OrchardCore.Commerce.Endpoints.ViewModels; -using OrchardCore.Modules; -using System; using System.Threading.Tasks; namespace OrchardCore.Commerce.Endpoints.Api; + public static class ShoppingCartLineEndpoint { + private const string ApiPath = "api/shoppingcart/{shoppingCartId?}"; + + public static IEndpointRouteBuilder AddGetCartEndpoint(this IEndpointRouteBuilder builder) + { + builder.MapGetWithDefaultSettings(ApiPath, GetCartAsync); + + return builder; + } + + private static async Task GetCartAsync( + [FromRoute] string? shoppingCartId, + [FromServices] IAuthorizationService authorizationService, + [FromServices] IShoppingCartService shoppingCartService, + HttpContext httpContext) + { + if (!await authorizationService.AuthorizeAsync(httpContext.User, ApiPermissions.CommerceShoppingCartApi)) + { + return httpContext.ChallengeOrForbidApi(); + } + + var cart = await shoppingCartService.GetAsync(shoppingCartId); + + if (cart == null) + return TypedResults.NotFound(); + + return TypedResults.Ok(cart); + } + public static IEndpointRouteBuilder AddAddItemEndpoint(this IEndpointRouteBuilder builder) { - builder.MapPost("api/shoppingcart/add-item", AddItemAsync) - .AllowAnonymous() - .DisableAntiforgery(); + builder.MapPostWithDefaultSettings(ApiPath, AddItemAsync); return builder; } - [Authorize(AuthenticationSchemes = "Api")] private static async Task AddItemAsync( - [FromBody] AddItemViewModel addItemVM, - [FromServices] IAuthorizationService authorizationService, - [FromServices] HttpContext httpContext, - [FromServices] IShoppingCartService shoppingCartService, - [FromServices] IHtmlLocalizer htmlLocalizer - ) + [FromRoute] string? shoppingCartId, + [FromBody] AddItemViewModel viewModel, + [FromServices] IAuthorizationService authorizationService, + [FromServices] IShoppingCartService shoppingCartService, + [FromServices] IHtmlLocalizer htmlLocalizer, + HttpContext httpContext) { - if (!await authorizationService.AuthorizeAsync(httpContext.User, ApiPermissions.CommerceApi)) + if (!await authorizationService.AuthorizeAsync(httpContext.User, ApiPermissions.CommerceShoppingCartApi)) { - return httpContext.ChallengeOrForbid("Api"); + return httpContext.ChallengeOrForbidApi(); } - if (string.IsNullOrEmpty(addItemVM.ShoppingCartId)) { addItemVM.ShoppingCartId = Guid.NewGuid().ToString("n"); } - - var errored = await shoppingCartService.AddItemAsync(addItemVM.Line, addItemVM.Token, addItemVM.ShoppingCartId); + var errored = await shoppingCartService.AddItemAsync(viewModel.Line, viewModel.Token, shoppingCartId); if (string.IsNullOrEmpty(errored)) { return TypedResults.Created(); @@ -57,28 +79,26 @@ [FromServices] IHtmlLocalizer htmlLocalizer public static IEndpointRouteBuilder AddUpdateEndpoint(this IEndpointRouteBuilder builder) { - builder.MapPut("api/shoppingcart/update", UpdateAsync) - .AllowAnonymous() - .DisableAntiforgery(); + builder.MapPutWithDefaultSettings(ApiPath, UpdateAsync); return builder; } [Authorize(AuthenticationSchemes = "Api")] private static async Task UpdateAsync( - [FromBody] UpdateViewModel updateVM, - [FromServices] IAuthorizationService authorizationService, - [FromServices] HttpContext httpContext, - [FromServices] IShoppingCartService shoppingCartService, - [FromServices] IHtmlLocalizer htmlLocalizer - ) + [FromRoute] string? shoppingCartId, + [FromBody] UpdateViewModel viewModel, + [FromServices] IAuthorizationService authorizationService, + [FromServices] IShoppingCartService shoppingCartService, + [FromServices] IHtmlLocalizer htmlLocalizer, + HttpContext httpContext) { - if (!await authorizationService.AuthorizeAsync(httpContext.User, ApiPermissions.CommerceApi)) + if (!await authorizationService.AuthorizeAsync(httpContext.User, ApiPermissions.CommerceShoppingCartApi)) { - return httpContext.ChallengeOrForbid("Api"); + return httpContext.ChallengeOrForbidApi(); } - var errored = await shoppingCartService.UpdateAsync(updateVM.Cart, updateVM.Token, updateVM.ShoppingCartId); + var errored = await shoppingCartService.UpdateAsync(viewModel.Cart, viewModel.Token, shoppingCartId); if (string.IsNullOrEmpty(errored)) { return TypedResults.NoContent(); @@ -95,28 +115,26 @@ [FromServices] IHtmlLocalizer htmlLocalizer public static IEndpointRouteBuilder AddRemoveLineEndpoint(this IEndpointRouteBuilder builder) { - builder.MapDelete("api/shoppingcart/delete", RemoveLineAsync) - .AllowAnonymous() - .DisableAntiforgery(); + builder.MapDeleteWithDefaultSettings(ApiPath, RemoveLineAsync); return builder; } [Authorize(AuthenticationSchemes = "Api")] private static async Task RemoveLineAsync( - [FromBody] RemoveLineViewModel removeLineVM, - [FromServices] IAuthorizationService authorizationService, - [FromServices] HttpContext httpContext, - [FromServices] IShoppingCartService shoppingCartService, - [FromServices] IHtmlLocalizer htmlLocalizer - ) + [FromRoute] string? shoppingCartId, + [FromBody] RemoveLineViewModel viewModel, + [FromServices] IAuthorizationService authorizationService, + [FromServices] IShoppingCartService shoppingCartService, + [FromServices] IHtmlLocalizer htmlLocalizer, + HttpContext httpContext) { if (!await authorizationService.AuthorizeAsync(httpContext.User, ApiPermissions.CommerceApi)) { - return httpContext.ChallengeOrForbid("Api"); + return httpContext.ChallengeOrForbidApi(); } - var errored = await shoppingCartService.RemoveLineAsync(removeLineVM.Line, removeLineVM.ShoppingCartId); + var errored = await shoppingCartService.RemoveLineAsync(viewModel.Line, shoppingCartId); if (string.IsNullOrEmpty(errored)) { return TypedResults.NoContent(); @@ -131,34 +149,4 @@ [FromServices] IHtmlLocalizer htmlLocalizer return TypedResults.Problem(problemDetails); } - - public static IEndpointRouteBuilder AddRetrieveAsyncEndpoint(this IEndpointRouteBuilder builder) - { - builder.MapGet("api/shoppingcart/retrieve-cart/{shoppingCartId?}", RetrieveAsync) - .AllowAnonymous() - .DisableAntiforgery(); - - return builder; - } - - [Authorize(AuthenticationSchemes = "Api")] - private static async Task RetrieveAsync( - [FromRoute] string? shoppingCartId, - [FromServices] IAuthorizationService authorizationService, - [FromServices] HttpContext httpContext, - [FromServices] IShoppingCartPersistence shoppingCartPersistence - ) - { - if (!await authorizationService.AuthorizeAsync(httpContext.User, ApiPermissions.CommerceApi)) - { - return httpContext.ChallengeOrForbid("Api"); - } - - var cart = await shoppingCartPersistence.RetrieveAsync(shoppingCartId); - - if (cart == null) - return TypedResults.NotFound(); - - return TypedResults.Ok(cart); - } } diff --git a/src/Modules/OrchardCore.Commerce/Endpoints/Extentions/ConvertLocalizedHtmlString.cs b/src/Modules/OrchardCore.Commerce/Endpoints/Extensions/ConvertLocalizedHtmlString.cs similarity index 100% rename from src/Modules/OrchardCore.Commerce/Endpoints/Extentions/ConvertLocalizedHtmlString.cs rename to src/Modules/OrchardCore.Commerce/Endpoints/Extensions/ConvertLocalizedHtmlString.cs diff --git a/src/Modules/OrchardCore.Commerce/Endpoints/Extensions/Endpoints.cs b/src/Modules/OrchardCore.Commerce/Endpoints/Extensions/Endpoints.cs new file mode 100644 index 000000000..d335c39e9 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce/Endpoints/Extensions/Endpoints.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Routing; +using OrchardCore.Commerce.Endpoints.Api; + +namespace OrchardCore.Commerce.Endpoints.Extensions; + +public static class Endpoints +{ + public static IEndpointRouteBuilder AddShoppingCartApiEndpoints(this IEndpointRouteBuilder router) + { + router + .AddNewOrderEndpoint() + .AddUpdateEndpoint() + .AddRemoveLineEndpoint() + .AddGetCartEndpoint() + .AddAddItemEndpoint(); + + return router; + } +} diff --git a/src/Modules/OrchardCore.Commerce/Endpoints/Extentions/ServiceCollectionExtensions.cs b/src/Modules/OrchardCore.Commerce/Endpoints/Extensions/ServiceCollectionExtensions.cs similarity index 71% rename from src/Modules/OrchardCore.Commerce/Endpoints/Extentions/ServiceCollectionExtensions.cs rename to src/Modules/OrchardCore.Commerce/Endpoints/Extensions/ServiceCollectionExtensions.cs index 99dbc8915..73cadb7b3 100644 --- a/src/Modules/OrchardCore.Commerce/Endpoints/Extentions/ServiceCollectionExtensions.cs +++ b/src/Modules/OrchardCore.Commerce/Endpoints/Extensions/ServiceCollectionExtensions.cs @@ -2,10 +2,10 @@ using OrchardCore.Commerce.Endpoints.Permissions; using OrchardCore.Security.Permissions; -namespace OrchardCore.Commerce.Endpoints.Extentions; +namespace OrchardCore.Commerce.Endpoints.Extensions; public static class ServiceCollectionExtensions { - public static IServiceCollection AddCommerceAPIs(this IServiceCollection services) + public static IServiceCollection AddCommerceApiServices(this IServiceCollection services) { services.AddScoped(); services.AddScoped(); diff --git a/src/Modules/OrchardCore.Commerce/Endpoints/Permissions/ApiPermissions.cs b/src/Modules/OrchardCore.Commerce/Endpoints/Permissions/ApiPermissions.cs index 1eecb4e91..e41749ed6 100644 --- a/src/Modules/OrchardCore.Commerce/Endpoints/Permissions/ApiPermissions.cs +++ b/src/Modules/OrchardCore.Commerce/Endpoints/Permissions/ApiPermissions.cs @@ -6,11 +6,19 @@ namespace OrchardCore.Commerce.Endpoints.Permissions; public class ApiPermissions : AdminPermissionBase { - public static readonly Permission CommerceApi = new("CommerceApi", "Manage Commerce APIs"); + public static readonly Permission CommerceApi = + new(nameof(CommerceApi), "Access Commerce APIs"); + public static readonly Permission CommerceShoppingCartApi = + new(nameof(CommerceShoppingCartApi), "Access Commerce Shopping Cart APIs"); + public static readonly Permission CommerceOrderApi = + new(nameof(CommerceOrderApi), "Access Commerce Order APIs"); - private static readonly IReadOnlyList _adminPermissions = new[] - { + private static readonly IReadOnlyList _adminPermissions = + [ CommerceApi, - }; + CommerceShoppingCartApi, + CommerceOrderApi + ]; + protected override IEnumerable AdminPermissions => _adminPermissions; } diff --git a/src/Modules/OrchardCore.Commerce/Endpoints/Services/IShoppingCartService.cs b/src/Modules/OrchardCore.Commerce/Endpoints/Services/IShoppingCartService.cs index eb7847438..6f7487e3b 100644 --- a/src/Modules/OrchardCore.Commerce/Endpoints/Services/IShoppingCartService.cs +++ b/src/Modules/OrchardCore.Commerce/Endpoints/Services/IShoppingCartService.cs @@ -1,3 +1,4 @@ +using OrchardCore.Commerce.Abstractions.ViewModels; using OrchardCore.Commerce.ViewModels; using System.Threading.Tasks; @@ -8,6 +9,13 @@ namespace OrchardCore.Commerce.Endpoints; /// public interface IShoppingCartService { + /// + /// Get shopping cart. + /// + /// Shopping cart Id. + /// A that contains all the necessary information a shopping cart have. + Task GetAsync(string shoppingCartId = null); + /// /// Update shopping cart. /// diff --git a/src/Modules/OrchardCore.Commerce/Endpoints/Services/ShoppingCartService.cs b/src/Modules/OrchardCore.Commerce/Endpoints/Services/ShoppingCartService.cs index a7569a69e..977cf851a 100644 --- a/src/Modules/OrchardCore.Commerce/Endpoints/Services/ShoppingCartService.cs +++ b/src/Modules/OrchardCore.Commerce/Endpoints/Services/ShoppingCartService.cs @@ -4,6 +4,7 @@ using OrchardCore.Commerce.Abstractions; using OrchardCore.Commerce.Abstractions.Abstractions; using OrchardCore.Commerce.Abstractions.Models; +using OrchardCore.Commerce.Abstractions.ViewModels; using OrchardCore.Commerce.Activities; using OrchardCore.Commerce.Controllers; using OrchardCore.Commerce.Endpoints.Extensions; @@ -91,6 +92,9 @@ await _workflowManagers.TriggerEventAsync( return errored; } + public Task GetAsync(string shoppingCartId = null) => + _shoppingCartHelpers.CreateShoppingCartViewModelAsync(shoppingCartId); + public async Task UpdateAsync(ShoppingCartUpdateModel cart, string token, string shoppingCartId = null) { string errored = string.Empty; diff --git a/src/Modules/OrchardCore.Commerce/Endpoints/ViewModels/AddItemViewModel.cs b/src/Modules/OrchardCore.Commerce/Endpoints/ViewModels/AddItemViewModel.cs index 4d7697fd0..b4c37c1e2 100644 --- a/src/Modules/OrchardCore.Commerce/Endpoints/ViewModels/AddItemViewModel.cs +++ b/src/Modules/OrchardCore.Commerce/Endpoints/ViewModels/AddItemViewModel.cs @@ -4,6 +4,5 @@ namespace OrchardCore.Commerce.Endpoints.ViewModels; public class AddItemViewModel { public string Token { get; set; } - public string ShoppingCartId { get; set; } public ShoppingCartLineUpdateModel Line { get; set; } } diff --git a/src/Modules/OrchardCore.Commerce/Endpoints/ViewModels/CreateOrderLineItemViewModel.cs b/src/Modules/OrchardCore.Commerce/Endpoints/ViewModels/CreateOrderLineItemViewModel.cs deleted file mode 100644 index 1c364ba2d..000000000 --- a/src/Modules/OrchardCore.Commerce/Endpoints/ViewModels/CreateOrderLineItemViewModel.cs +++ /dev/null @@ -1,9 +0,0 @@ -using OrchardCore.Commerce.Abstractions.Models; -using System.Collections.Generic; - -namespace OrchardCore.Commerce.Endpoints.ViewModels; -public class CreateOrderLineItemViewModel -{ - public IList LineItems { get; init; } - public OrderPart OrderPart { get; set; } -} diff --git a/src/Modules/OrchardCore.Commerce/Endpoints/ViewModels/CreateShoppingCartViewModel.cs b/src/Modules/OrchardCore.Commerce/Endpoints/ViewModels/CreateShoppingCartViewModel.cs deleted file mode 100644 index 46afb6a28..000000000 --- a/src/Modules/OrchardCore.Commerce/Endpoints/ViewModels/CreateShoppingCartViewModel.cs +++ /dev/null @@ -1,9 +0,0 @@ -using OrchardCore.Commerce.AddressDataType; - -namespace OrchardCore.Commerce.Endpoints.ViewModels; -public class CreateShoppingCartViewModel -{ - public string ShoppingCartId { get; set; } - public Address Shipping { get; set; } - public Address Billing { get; set; } -} diff --git a/src/Modules/OrchardCore.Commerce/Endpoints/ViewModels/EstimateProductViewModel.cs b/src/Modules/OrchardCore.Commerce/Endpoints/ViewModels/EstimateProductViewModel.cs deleted file mode 100644 index 87f460194..000000000 --- a/src/Modules/OrchardCore.Commerce/Endpoints/ViewModels/EstimateProductViewModel.cs +++ /dev/null @@ -1,9 +0,0 @@ -using OrchardCore.Commerce.AddressDataType; - -namespace OrchardCore.Commerce.Endpoints.ViewModels; -public class EstimateProductViewModel -{ - public string Sku { get; set; } - public Address Shipping { get; set; } - public Address Billing { get; set; } -} diff --git a/src/Modules/OrchardCore.Commerce/Endpoints/ViewModels/ProductListViewModel.cs b/src/Modules/OrchardCore.Commerce/Endpoints/ViewModels/ProductListViewModel.cs deleted file mode 100644 index c2445df70..000000000 --- a/src/Modules/OrchardCore.Commerce/Endpoints/ViewModels/ProductListViewModel.cs +++ /dev/null @@ -1,14 +0,0 @@ -using OrchardCore.Commerce.Models; - -namespace OrchardCore.Commerce.Endpoints.ViewModels; -public class ProductListViewModel : ProductList -{ - public ProductListViewModel(ProductList list) - { - Products = list.Products; - TotalItemCount = list.TotalItemCount; - } - - public int PageNum { get; set; } - public int PageSize { get; set; } -} diff --git a/src/Modules/OrchardCore.Commerce/Endpoints/ViewModels/RemoveLineViewModel.cs b/src/Modules/OrchardCore.Commerce/Endpoints/ViewModels/RemoveLineViewModel.cs index 39e431319..1f2f56a45 100644 --- a/src/Modules/OrchardCore.Commerce/Endpoints/ViewModels/RemoveLineViewModel.cs +++ b/src/Modules/OrchardCore.Commerce/Endpoints/ViewModels/RemoveLineViewModel.cs @@ -3,6 +3,5 @@ namespace OrchardCore.Commerce.Endpoints.ViewModels; public class RemoveLineViewModel { - public string ShoppingCartId { get; set; } public ShoppingCartLineUpdateModel Line { get; set; } } diff --git a/src/Modules/OrchardCore.Commerce/Endpoints/ViewModels/UpdateViewModel.cs b/src/Modules/OrchardCore.Commerce/Endpoints/ViewModels/UpdateViewModel.cs index 17b424d07..a3351b489 100644 --- a/src/Modules/OrchardCore.Commerce/Endpoints/ViewModels/UpdateViewModel.cs +++ b/src/Modules/OrchardCore.Commerce/Endpoints/ViewModels/UpdateViewModel.cs @@ -4,6 +4,5 @@ namespace OrchardCore.Commerce.Endpoints.ViewModels; public class UpdateViewModel { public string Token { get; set; } - public string ShoppingCartId { get; set; } public ShoppingCartUpdateModel Cart { get; set; } } 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/Manifest.cs b/src/Modules/OrchardCore.Commerce/Manifest.cs index 562af7f4b..071be1ae0 100644 --- a/src/Modules/OrchardCore.Commerce/Manifest.cs +++ b/src/Modules/OrchardCore.Commerce/Manifest.cs @@ -47,3 +47,15 @@ CommerceConstants.Features.Core, ] )] + +[assembly: Feature( + Id = CommerceConstants.Features.Subscription, + Name = "Orchard Core Commerce - Subscription", + Category = "Commerce", + Description = "Subscription management. Currently only supports Stripe.", + Dependencies = + [ + "OrchardCore.Contents", + CommerceConstants.Features.Core, + ] +)] diff --git a/src/Modules/OrchardCore.Commerce/Migrations/SubscriptionMigrations.cs b/src/Modules/OrchardCore.Commerce/Migrations/SubscriptionMigrations.cs new file mode 100644 index 000000000..af0f86391 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce/Migrations/SubscriptionMigrations.cs @@ -0,0 +1,65 @@ +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 OrchardCore.Commerce.Abstractions.Constants.ContentTypes; + +namespace OrchardCore.Commerce.Migrations; + +public class SubscriptionMigrations : DataMigration +{ + private readonly IContentDefinitionManager _contentDefinitionManager; + + public SubscriptionMigrations(IContentDefinitionManager contentDefinitionManager) => + _contentDefinitionManager = contentDefinitionManager; + + public async Task CreateAsync() + { + await _contentDefinitionManager.AlterTypeDefinitionAsync(Subscription, type => type + .Listable() + .Creatable() + .Securable() + .WithPart(nameof(SubscriptionPart))); + + await _contentDefinitionManager.AlterPartDefinitionAsync(builder => builder + .WithField(part => part.Status) + .WithField(part => part.IdInPaymentProvider, field => field.WithDisplayName("Id in payment provider")) + .WithField(part => part.PaymentProviderName, field => field.WithDisplayName("Payment provider name")) + .WithField(part => part.UserId, field => field + .WithDisplayName("User Id") + .WithDescription("The user ID of the subscriber.")) + .WithField(part => part.SerializedMetadata, field => field + .WithSettings(new TextFieldSettings + { + Hint = "Additional data about the subscription in Dictionary JSON serialized form.", + }) + .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 first started.")) + .WithField(part => part.EndDateUtc, field => field + .WithSettings(new DateTimeFieldSettings { Required = true }) + .WithDisplayName("End date") + .WithDescription("The date when the subscription ends.")) + ); + + 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 1; + } +} diff --git a/src/Modules/OrchardCore.Commerce/Models/SubscriptionPart.cs b/src/Modules/OrchardCore.Commerce/Models/SubscriptionPart.cs new file mode 100644 index 000000000..2a322d49b --- /dev/null +++ b/src/Modules/OrchardCore.Commerce/Models/SubscriptionPart.cs @@ -0,0 +1,29 @@ +using OrchardCore.ContentFields.Fields; +using OrchardCore.ContentManagement; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OrchardCore.Commerce.Models; + +public class SubscriptionPart : ContentPart +{ + public TextField Status { get; set; } = new(); + public TextField IdInPaymentProvider { get; set; } = new(); + public TextField PaymentProviderName { get; set; } = new(); + public TextField UserId { get; set; } = new(); + public DateTimeField StartDateUtc { get; set; } = new(); + public DateTimeField EndDateUtc { get; set; } = new(); + + public TextField SerializedMetadata { get; set; } = new(); + + [JsonIgnore] + // We are not directly setting the metadata field, but we are serializing it to a text field +#pragma warning disable CA2227 // CA2227: Change 'Metadata' to be read-only by removing the property setter + public IDictionary Metadata +#pragma warning restore CA2227 + { + 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..f992eb08c --- /dev/null +++ b/src/Modules/OrchardCore.Commerce/Services/ISubscriptionService.cs @@ -0,0 +1,22 @@ +using OrchardCore.Commerce.Models; +using OrchardCore.ContentManagement; +using System.Threading.Tasks; + +namespace OrchardCore.Commerce.Services; + +/// +/// Subscription content type services. +/// +public interface ISubscriptionService +{ + /// + /// Creates or updates a subscription if exists as a subscription content item + /// with the provided . + /// + Task CreateOrUpdateSubscriptionAsync(string idInPaymentProvider, SubscriptionPart subscriptionPart); + + /// + /// Returns the subscription content item with the given . + /// + Task GetSubscriptionAsync(string idInPaymentProvider); +} diff --git a/src/Modules/OrchardCore.Commerce/Services/SessionShoppingCartPersistence.cs b/src/Modules/OrchardCore.Commerce/Services/SessionShoppingCartPersistence.cs index 4631dd035..01119796d 100644 --- a/src/Modules/OrchardCore.Commerce/Services/SessionShoppingCartPersistence.cs +++ b/src/Modules/OrchardCore.Commerce/Services/SessionShoppingCartPersistence.cs @@ -23,8 +23,17 @@ public SessionShoppingCartPersistence( _shoppingCartSerializer = shoppingCartSerializer; } - protected override Task RetrieveInnerAsync(string key) => - _shoppingCartSerializer.DeserializeAsync(Session.GetString(key)); + protected override Task RetrieveInnerAsync(string key) + { + var serialized = Session.GetString(key); + if (serialized == null && _httpContextAccessor.HttpContext != null) + { + _httpContextAccessor.HttpContext.Request.Cookies.TryGetValue(key, out var serializedCart); + return _shoppingCartSerializer.DeserializeAsync(serializedCart); + } + + return _shoppingCartSerializer.DeserializeAsync(serialized); + } protected override async Task StoreInnerAsync(string key, ShoppingCart items) { @@ -32,6 +41,8 @@ protected override async Task StoreInnerAsync(string key, ShoppingCart ite if (Session.GetString(key) == cartString) return false; Session.SetString(key, cartString); + _httpContextAccessor.SetCookieForever(key, cartString); + return true; } } diff --git a/src/Modules/OrchardCore.Commerce/Services/ShoppingCartPersistenceBase.cs b/src/Modules/OrchardCore.Commerce/Services/ShoppingCartPersistenceBase.cs index d5584cd38..29062b5c1 100644 --- a/src/Modules/OrchardCore.Commerce/Services/ShoppingCartPersistenceBase.cs +++ b/src/Modules/OrchardCore.Commerce/Services/ShoppingCartPersistenceBase.cs @@ -9,7 +9,8 @@ namespace OrchardCore.Commerce.Services; public abstract class ShoppingCartPersistenceBase : IShoppingCartPersistence { - private const string ShoppingCartPrefix = "OrchardCore:Commerce:ShoppingCart"; + // Using _ as a separator to avoid separator character conflicts. + private const string ShoppingCartPrefix = "OrchardCore_Commerce_ShoppingCart"; private readonly Dictionary _scopeCache = []; @@ -68,5 +69,5 @@ public async Task StoreAsync(ShoppingCart items) protected abstract Task StoreInnerAsync(string key, ShoppingCart items); protected string GetCacheId(string shoppingCartId) => - string.IsNullOrEmpty(shoppingCartId) ? ShoppingCartPrefix : $"{ShoppingCartPrefix}:{shoppingCartId}"; + string.IsNullOrEmpty(shoppingCartId) ? ShoppingCartPrefix : $"{ShoppingCartPrefix}_{shoppingCartId}"; } diff --git a/src/Modules/OrchardCore.Commerce/Services/SubscriptionService.cs b/src/Modules/OrchardCore.Commerce/Services/SubscriptionService.cs new file mode 100644 index 000000000..642f45d33 --- /dev/null +++ b/src/Modules/OrchardCore.Commerce/Services/SubscriptionService.cs @@ -0,0 +1,43 @@ +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 Task GetSubscriptionAsync(string idInPaymentProvider) => + _session.Query( + item => item.IdInPaymentProvider == idInPaymentProvider) + .FirstOrDefaultAsync(); + + public async Task CreateOrUpdateSubscriptionAsync(string idInPaymentProvider, SubscriptionPart subscriptionPart) + { + var subscription = await _session.Query( + item => item.IdInPaymentProvider == idInPaymentProvider) + .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 e0bf0f7b9..39756f2a3 100644 --- a/src/Modules/OrchardCore.Commerce/Startup.cs +++ b/src/Modules/OrchardCore.Commerce/Startup.cs @@ -19,8 +19,7 @@ using OrchardCore.Commerce.ContentFields.Events; using OrchardCore.Commerce.Controllers; using OrchardCore.Commerce.Drivers; -using OrchardCore.Commerce.Endpoints.Api; -using OrchardCore.Commerce.Endpoints.Extentions; +using OrchardCore.Commerce.Endpoints.Extensions; using OrchardCore.Commerce.Events; using OrchardCore.Commerce.Fields; using OrchardCore.Commerce.Handlers; @@ -213,18 +212,11 @@ public override void ConfigureServices(IServiceCollection services) new BooleanProductAttributeDeserializer(), new NumericProductAttributeDeserializer()); - services.AddCommerceAPIs(); + services.AddCommerceApiServices(); } public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) => - routes - .AddRetrieveAsyncEndpoint() - .AddUpdateEndpoint() - .AddRemoveLineEndpoint() - .AddAddItemEndpoint() - .AddFreeEndpoint() - .AddCallbackEndpoint() - ; + routes.AddShoppingCartApiEndpoints(); } public sealed class FallbackPriceStartup : StartupBase @@ -413,3 +405,17 @@ public override void ConfigureServices(IServiceCollection services) public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) => app.UseMiddleware(); } + +[RequireFeatures(CommerceConstants.Features.Core, CommerceConstants.Features.Subscription)] +public class SubscriptionStartup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services + .AddContentPart() + .WithMigration() + .WithIndex(); + + services.AddScoped(); + } +}