Skip to content

Commit

Permalink
OFFI-126: Adding several Stripe API endpoints and basics of Subscript…
Browse files Browse the repository at this point in the history
…ion (#510)

* 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 <[email protected]>

* 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 <[email protected]>
  • Loading branch information
wAsnk and sarahelsaig authored Nov 29, 2024
1 parent 16bedc4 commit 2786470
Show file tree
Hide file tree
Showing 105 changed files with 2,679 additions and 397 deletions.
4 changes: 3 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@
should use Orchard Core references for the latest patch version to pull all versions up in the final app. -->
<OrchardCoreVersion>2.0.0</OrchardCoreVersion>

<LombiqHelpfulLibrariesVersion>11.0.0</LombiqHelpfulLibrariesVersion>
<LombiqHelpfulLibrariesVersion>11.0.1-alpha.0.offi-126</LombiqHelpfulLibrariesVersion>
<LombiqTestsUIVersion>11.0.0</LombiqTestsUIVersion>
</PropertyGroup>

<ItemGroup>
<PackageVersion Include="Lombiq.Analyzers.OrchardCore" Version="5.0.0" />
<PackageVersion Include="Lombiq.HelpfulLibraries.OrchardCore" Version="$(LombiqHelpfulLibrariesVersion)" />
<PackageVersion Include="Lombiq.HelpfulLibraries.AspNetCore" Version="$(LombiqHelpfulLibrariesVersion)" />
<PackageVersion Include="Lombiq.HelpfulLibraries.Refit" Version="$(LombiqHelpfulLibrariesVersion)" />
<PackageVersion Include="Lombiq.NodeJs.Extensions" Version="2.1.0" />
<PackageVersion Include="Lombiq.Tests" Version="3.0.0" />
Expand All @@ -29,6 +30,7 @@
<PackageVersion Include="OrchardCore.ContentManagement.Abstractions" Version="$(OrchardCoreVersion)" />
<PackageVersion Include="OrchardCore.ContentTypes" Version="$(OrchardCoreVersion)" />
<PackageVersion Include="OrchardCore.ContentTypes.Abstractions" Version="$(OrchardCoreVersion)" />
<PackageVersion Include="OrchardCore.Flows" Version="$(OrchardCoreVersion)" />
<PackageVersion Include="OrchardCore.Html" Version="$(OrchardCoreVersion)" />
<PackageVersion Include="OrchardCore.Indexing.Abstractions" Version="$(OrchardCoreVersion)" />
<PackageVersion Include="OrchardCore.Localization" Version="$(OrchardCoreVersion)" />
Expand Down
5 changes: 5 additions & 0 deletions docs/releases/3.0.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<LocalizedHtmlString> InvalidReasons { get; } = new List<LocalizedHtmlString>();

[JsonIgnore]
public IList<LocalizedHtmlString> Headers { get; } = new List<LocalizedHtmlString>();

[JsonIgnore]
public IList<List<IShape>> TableShapes { get; } = new List<List<IShape>>();
public IList<ShoppingCartLineViewModel> Lines { get; } = new List<ShoppingCartLineViewModel>();
public IList<Amount> Totals { get; } = new List<Amount>();
Expand Down
30 changes: 30 additions & 0 deletions src/Libraries/OrchardCore.Commerce.MoneyDataType/Amount.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -82,6 +83,35 @@ public int CompareTo(Amount other)
public Amount GetRounded() =>
new(Math.Round(Value, Currency.DecimalPlaces), Currency);

/// <summary>
/// Converts the <see cref="Amount"/> to a fixed-point fractional value by keeping some digits based on the <see
/// cref="ICurrency.CurrencyIsoCode"/>.
/// </summary>
/// <param name="roundingByCurrencyCode">
/// Provides exceptional rounding rules for currencies that aren't converted according to the default. The key is
/// the <see cref="Currency"/>'s ISO code, the value pairs follow the same logic as the matching default parameters.
/// </param>
/// <param name="defaultKeepDigits">Indicates how many digits should be kept after the decimal point.</param>
/// <param name="defaultRoundTens">
/// If positive, the <see cref="Amount"/> is rounded to this many digits before converted to a fixed-point
/// fractional. Ignored otherwise.
/// </param>
public long GetFixedPointAmount(
IDictionary<string, (int KeepDigits, int RoundTens)> 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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Stripe;
using System.Threading.Tasks;

namespace OrchardCore.Commerce.Payment.Stripe.Abstractions;

/// <summary>
/// Service for managing Stripe confirmation tokens.
/// </summary>
public interface IStripeConfirmationTokenService
{
/// <summary>
/// Gets the Stripe confirmation token with an Id of <paramref name="confirmationTokenId"/>.
/// </summary>
/// <returns>The Stripe <see cref="ConfirmationToken"/>.</returns>
Task<ConfirmationToken> GetConfirmationTokenAsync(string confirmationTokenId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using Stripe;
using System.Threading.Tasks;
using Address = OrchardCore.Commerce.AddressDataType.Address;

namespace OrchardCore.Commerce.Payment.Stripe.Abstractions;

/// <summary>
/// Stripe customer related services.
/// </summary>
public interface IStripeCustomerService
{
/// <summary>
/// Search for customers in Stripe with the given <paramref name="options"/>.
/// </summary>
Task<StripeSearchResult<Customer>> SearchCustomersAsync(CustomerSearchOptions options);

/// <summary>
/// Get the first customer with the given email in Stripe.
/// </summary>
Task<Customer> GetFirstCustomerByEmailAsync(string customerEmail);

/// <summary>
/// Returns <see cref="Customer"/> with the given Id in Stripe.
/// </summary>
Task<Customer> GetCustomerByIdAsync(string customerId);

/// <summary>
/// Returns <see cref="Customer"/> with the given email in Stripe. If not found, create a new customer.
/// </summary>
/// <param name="email">If not provided the current user's email will be used.</param>
Task<Customer> GetAndUpdateOrCreateCustomerAsync(
Address billingAddress,
Address shippingAddress,
string email,
string phone);

/// <summary>
/// Create a new customer in Stripe with the given <paramref name="customerCreateOptions"/>.
/// </summary>
/// <returns>The created Stripe <see cref="Customer"/>.</returns>
Task<Customer> CreateCustomerAsync(CustomerCreateOptions customerCreateOptions);

/// <summary>
/// Create the customer in Stripe with the given details which will be used to create the
/// <see cref="CustomerCreateOptions"/>.
/// </summary>
/// <returns>The created Stripe <see cref="Customer"/>.</returns>
Task<Customer> CreateCustomerAsync(
Address billingAddress,
Address shippingAddress,
string email,
string phone);

/// <summary>
/// Update the customer in Stripe with the given details.
/// </summary>
/// <returns>The updated Stripe <see cref="Customer"/>.</returns>
Task<Customer> UpdateCustomerAsync(
string customerId,
Address billingAddress,
Address shippingAddress,
string email,
string phone);

/// <summary>
/// Populate the returned <see cref="CustomerCreateOptions"/> with the given details.
/// </summary>
CustomerCreateOptions PopulateCustomerCreateOptions(
Address billingAddress,
Address shippingAddress,
string email,
string phone);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using Stripe;

namespace OrchardCore.Commerce.Payment.Stripe.Abstractions;

/// <summary>
/// Stripe helping services, needed so we can mock this part of Stripe also.
/// </summary>
public interface IStripeHelperService
{
/// <summary>
/// Parses a JSON string from a Stripe webhook into a <see cref="Event"/> object, while
/// verifying the <a href="https://stripe.com/docs/webhooks/signatures">webhook's
/// signature</a>.
/// </summary>
/// <param name="json">The JSON string to parse.</param>
/// <param name="stripeSignatureHeader">
/// The value of the <c>Stripe-Signature</c> header from the webhook request.
/// </param>
/// <param name="secret">The webhook endpoint's signing secret.</param>
/// <param name="throwOnApiVersionMismatch">
/// If <see langword="true"/> (default), the method will throw a <see cref="StripeException"/> if the
/// API version of the event doesn't match Stripe.net's default API version (see
/// <see cref="StripeConfiguration.ApiVersion"/>).
/// </param>
/// <returns>The deserialized <see cref="Event"/>.</returns>
/// <exception cref="StripeException">
/// 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.
/// </exception>
Event PrepareStripeEvent(string json, string stripeSignatureHeader, string secret, bool throwOnApiVersionMismatch);
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Service for managing Stripe Payment Intents.
/// </summary>
public interface IStripePaymentIntentService
{
/// <summary>
/// Gets a PaymentIntent by its Stripe Id.
/// </summary>
/// <returns>Stripe <see cref="PaymentIntent"/> model.</returns>
Task<PaymentIntent> GetPaymentIntentAsync(string paymentIntentId);

/// <summary>
/// Gets the PaymentIntent by its Stripe Id if it is <see cref="PaymentIntentStatuses.Succeeded"/> or
/// <see cref="PaymentIntentStatuses.Processing"/>. Otherwise, updates it with the provided
/// <paramref name="defaultTotal"/>.
/// </summary>
/// <returns>Updated or original Stripe <see cref="PaymentIntent"/> model.</returns>
Task<PaymentIntent> GetOrUpdatePaymentIntentAsync(
string paymentIntentId,
Amount defaultTotal);

/// <summary>
/// Creates a PaymentIntent with the provided <paramref name="total"/>. And adds description and other values to
/// the payment intent. Check the implementation for more details.
/// </summary>
/// <returns>Created Stripe <see cref="PaymentIntent"/>.</returns>
Task<PaymentIntent> CreatePaymentIntentAsync(Amount total);

/// <summary>
/// Creates a PaymentIntent with the provided <paramref name="options"/>.
/// </summary>
/// <returns>Created Stripe <see cref="PaymentIntent"/> model.</returns>
Task<PaymentIntent> CreatePaymentIntentAsync(PaymentIntentCreateOptions options);
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,14 @@ namespace OrchardCore.Commerce.Payment.Stripe.Abstractions;
public interface IStripePaymentService
{
/// <summary>
/// Handles the payment and authentication, sends back the necessary data to the client./>.
/// </summary>
Task<string> CreateClientSecretAsync(Amount total, ShoppingCartViewModel cart);

/// <summary>
/// Returns a <see cref="PaymentIntent"/> object for the given <paramref name="paymentIntentId"/>.
/// Returns the public key of the Stripe account.
/// </summary>
Task<PaymentIntent> GetPaymentIntentAsync(string paymentIntentId);
Task<string> GetPublicKeyAsync();

/// <summary>
/// Returns a <see cref="PaymentIntent"/> object based on the given <paramref name="total"/>.
/// Handles the payment and authentication, sends back the necessary data to the client./>.
/// </summary>
Task<PaymentIntent> CreatePaymentIntentAsync(Amount total);
Task<string> CreateClientSecretAsync(Amount total, ShoppingCartViewModel cart);

/// <summary>
/// Creates an order content item in the database, based on the stored <see cref="PaymentIntent"/> and on the
Expand All @@ -39,7 +34,8 @@ public interface IStripePaymentService
Task<ContentItem> CreateOrUpdateOrderFromShoppingCartAsync(
IUpdateModelAccessor updateModelAccessor,
string shoppingCartId,
string paymentIntentId = null);
string paymentIntentId = null,
OrderPart orderPart = null);

/// <summary>
/// Updates the corresponding order status to <see cref="OrderStatuses.Ordered"/> for the given
Expand All @@ -57,6 +53,11 @@ Task<ContentItem> CreateOrUpdateOrderFromShoppingCartAsync(
/// </summary>
Task<OrderPayment> GetOrderPaymentByPaymentIntentIdAsync(string paymentIntentId);

/// <summary>
/// Save the order payment for the given <paramref name="orderContentItemId"/> and <paramref name="paymentIntentId"/>.
/// </summary>
Task SaveOrderPaymentAsync(string orderContentItemId, string paymentIntentId);

/// <summary>
/// A shortcut method for updating the <paramref name="order"/> status to <see cref="OrderStatuses.Ordered"/>, doing
/// final modifications and then redirecting to the success page.
Expand All @@ -70,8 +71,10 @@ string shoppingCartId
/// <summary>
/// Get the confirmation parameters for Stripe.
/// </summary>
/// <param name="middlewareAbsoluteUrl">The url for the middleware of Stripe.</param>
Task<PaymentIntentConfirmOptions> GetStripeConfirmParametersAsync(string middlewareAbsoluteUrl);
/// <param name="returnUrl">The url for the middleware of Stripe.</param>
Task<PaymentIntentConfirmOptions> GetStripeConfirmParametersAsync(
string returnUrl,
ContentItem order = null);

/// <summary>
/// Confirm the result of Stripe payment.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using Stripe.Checkout;
using System.Threading.Tasks;

namespace OrchardCore.Commerce.Payment.Stripe.Abstractions;

/// <summary>
/// Event handler for Stripe sessions.
/// </summary>
public interface IStripeSessionEventHandler
{
/// <summary>
/// Called before a Stripe session is created with a pre-populated <see cref="SessionCreateOptions"/>
/// <paramref name="options"/>. Here you can modify the options before the session is created.
/// </summary>
Task StripeSessionCreatingAsync(SessionCreateOptions options) => Task.CompletedTask;

/// <summary>
/// Called after a Stripe session is created with the created <paramref name="session"/> and the
/// <paramref name="options"/> used during creation.
/// </summary>
Task StripeSessionCreatedAsync(Session session, SessionCreateOptions options) => Task.CompletedTask;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Stripe.Checkout;
using System.Threading.Tasks;

namespace OrchardCore.Commerce.Payment.Stripe.Abstractions;

/// <summary>
/// Service for managing Stripe sessions.
/// </summary>
public interface IStripeSessionService
{
/// <summary>
/// Creates a Stripe session using the given <paramref name="options"/>.
/// </summary>
/// <returns>The created Stripe <see cref="Session"/>.</returns>
Task<Session> CreateSessionAsync(SessionCreateOptions options);
}
Loading

0 comments on commit 2786470

Please sign in to comment.