diff --git a/src/Application.Resources.Shared/Subscriptions.cs b/src/Application.Resources.Shared/Subscriptions.cs new file mode 100644 index 00000000..27289e44 --- /dev/null +++ b/src/Application.Resources.Shared/Subscriptions.cs @@ -0,0 +1,250 @@ +using Application.Interfaces.Resources; + +namespace Application.Resources.Shared; + + +public class Subscription : IIdentifiableResource +{ + public required string BuyerId { get; set; } + + public required string OwningEntityId { get; set; } + + public string? ProviderName { get; set; } + + public Dictionary ProviderState { get; set; } = new(); + + public required string Id { get; set; } +} + +public class SubscriptionForMigration : Subscription +{ +#pragma warning disable SAASAPP014 + public required Dictionary Buyer { get; set; } +#pragma warning restore SAASAPP014 +} + +public class SubscriptionWithPlan : Subscription +{ + public bool CanBeCancelled { get; set; } + + public bool CanBeUnsubscribed { get; set; } + + public DateTime? CancelledDateUtc { get; set; } + + public required InvoiceSummary Invoice { get; set; } + + public required SubscriptionPaymentMethod PaymentMethod { get; set; } + + public required PlanPeriod Period { get; set; } + + public required SubscriptionPlan Plan { get; set; } + + public SubscriptionStatus Status { get; set; } + + public required string SubscriptionId { get; set; } +} + +public class SubscriptionPlan : IIdentifiableResource +{ + public bool IsTrial { get; set; } + + public SubscriptionTier Tier { get; set; } + + public DateTime? TrialEndDateUtc { get; set; } + + public required string Id { get; set; } +} + +public class PlanPeriod +{ + public int Frequency { get; set; } + + public PeriodFrequencyUnit Unit { get; set; } +} + +public enum PeriodFrequencyUnit +{ + Eternity = 0, + Day = 1, + Week = 2, + Month = 3, + Year = 4 +} + +public enum SubscriptionStatus +{ + Unsubscribed = 0, + Activated = 1, + Cancelled = 2, + Cancelling = 3 +} + +public enum SubscriptionTier +{ + Unsubscribed = 0, + Basic = 1, + Premium = 2 +} + +public class Invoice : IIdentifiableResource +{ + public decimal Amount { get; set; } // In the denomination of the Currency + + public required string Currency { get; set; } // ISO4217 + + public bool IncludesTax { get; set; } + + public DateTime InvoicedOnUtc { get; set; } + + public List LineItems { get; set; } = new(); + + public List Notes { get; set; } = new(); + + public required InvoiceItemPayment Payment { get; set; } + + public DateTime? PeriodEndUtc { get; set; } + + public DateTime? PeriodStartUtc { get; set; } + + public InvoiceStatus Status { get; set; } + + public decimal TaxAmount { get; set; } // In the denomination of the Currency + + public required string Id { get; set; } +} + +public class InvoiceSummary +{ + public decimal Amount { get; set; } // In the denomination of the Currency + + public required string Currency { get; set; } // ISO4217 + + public DateTime? NextUtc { get; set; } +} + +public class InvoiceLineItem +{ + public decimal Amount { get; set; } // In the denomination of the Currency + + public required string Currency { get; set; } // ISO4217 + + public required string Description { get; set; } + + public bool IsTaxed { get; set; } + + public required string Reference { get; set; } + + public decimal TaxAmount { get; set; } // In the denomination of the Currency +} + +public class InvoiceItemPayment +{ + public decimal Amount { get; set; } // In the denomination of the Currency + + public required string Currency { get; set; } // ISO4217 + + public DateTime PaidOnUtc { get; set; } + + public required string Reference { get; set; } +} + +public class InvoiceNote +{ + public required string Description { get; set; } +} + +public enum InvoiceStatus +{ + Unpaid, + Paid +} + +public class SubscriptionPaymentMethod +{ + public static readonly SubscriptionPaymentMethod None = new() + { + Status = PaymentMethodStatus.Invalid, + Type = PaymentMethodType.None, + ExpiresOn = null + }; + + public DateOnly? ExpiresOn { get; set; } + + public PaymentMethodStatus Status { get; set; } + + public PaymentMethodType Type { get; set; } +} + +public enum PaymentMethodType +{ + None = 0, + Card = 1, + Other = 2 +} + +public enum PaymentMethodStatus +{ + Invalid = 0, + Valid = 1 +} + +public class PricingPlans +{ + public List Annually { get; set; } = new(); + + public List Daily { get; set; } = new(); + + public List Eternally { get; set; } = new(); + + public List Monthly { get; set; } = new(); + + public List Weekly { get; set; } = new(); +} + +public class PricingPlan : IIdentifiableResource +{ + public required PlanPeriod Billing { get; set; } + + public required string Currency { get; set; } // ISO4217 + + public required string Description { get; set; } + + public required string DisplayName { get; set; } + + public List FeatureSection { get; set; } = new(); + + public bool IsRecommended { get; set; } + + public required string Notes { get; set; } + + public required decimal Cost { get; set; } // In the denomination of the Currency + + public required decimal SetupCost { get; set; } // In the denomination of the Currency + + public required SubscriptionTrialPeriod Trial { get; set; } + + public required string Id { get; set; } +} + +public class SubscriptionTrialPeriod +{ + public int Frequency { get; set; } + + public bool HasTrial { get; set; } + + public PeriodFrequencyUnit Unit { get; set; } +} + +public class PricingFeatureSection +{ + public required string Description { get; set; } + + public List Features { get; set; } = new(); +} + +public class PricingFeatureItem +{ + public required string Description { get; set; } + + public bool IsIncluded { get; set; } +} \ No newline at end of file diff --git a/src/Application.Services.Shared/IBillingGatewayService.cs b/src/Application.Services.Shared/IBillingGatewayService.cs index e157521a..255642e3 100644 --- a/src/Application.Services.Shared/IBillingGatewayService.cs +++ b/src/Application.Services.Shared/IBillingGatewayService.cs @@ -13,7 +13,21 @@ public interface IBillingGatewayService /// Creates a new subscription the specified /// Task> SubscribeAsync(ICallerContext caller, SubscriptionBuyer buyer, - SubscribeOptions options); + SubscribeOptions options, CancellationToken cancellationToken); +} + +/// +/// Maintains the internal state for the billing provider +/// +public class BillingProviderState : Dictionary +{ + public BillingProviderState() + { + } + + public BillingProviderState(Dictionary properties) : base(properties) + { + } } /// @@ -54,14 +68,14 @@ public class SubscriptionBuyer { public required ProfileAddress Address { get; set; } - public required BuyerOrganization Organization { get; set; } - public required string EmailAddress { get; set; } public required string Id { get; set; } public required PersonName Name { get; set; } + public required BuyerOrganization Organization { get; set; } + public string? PhoneNumber { get; set; } } diff --git a/src/Application.Services.Shared/IBillingProvider.cs b/src/Application.Services.Shared/IBillingProvider.cs index 18786020..32ac67b5 100644 --- a/src/Application.Services.Shared/IBillingProvider.cs +++ b/src/Application.Services.Shared/IBillingProvider.cs @@ -1,3 +1,5 @@ +using Domain.Services.Shared; + namespace Application.Services.Shared; /// @@ -6,14 +8,14 @@ namespace Application.Services.Shared; public interface IBillingProvider { /// - /// returns the name of the provider + /// Returns the gateway service for the provider /// - string ProviderName { get; } + public IBillingGatewayService GatewayService { get; } /// - /// Returns the gateway service for the provider + /// returns the name of the provider /// - public IBillingGatewayService GatewayService { get; } + string ProviderName { get; } /// /// Returns the proxy to manage state changes to the provider diff --git a/src/Common.UnitTests/CurrencyCodesSpec.cs b/src/Common.UnitTests/CurrencyCodesSpec.cs new file mode 100644 index 00000000..7792c47e --- /dev/null +++ b/src/Common.UnitTests/CurrencyCodesSpec.cs @@ -0,0 +1,139 @@ +using Common.Extensions; +using FluentAssertions; +using ISO._4217; +using Xunit; + +namespace Common.UnitTests; + +[Trait("Category", "Unit")] +public class CurrencyCodesSpec +{ + [Fact] + public void WhenExistsAndUnknown_ThenReturnsFalse() + { + var result = CurrencyCodes.Exists("notacurrencycode"); + + result.Should().BeFalse(); + } + + [Fact] + public void WhenExistsByCode_ThenReturnsTrue() + { + var result = CurrencyCodes.Exists(CurrencyCodes.Default.Code); + + result.Should().BeTrue(); + } + + [Fact] + public void WhenExistsByNumeric_ThenReturnsTrue() + { + var result = CurrencyCodes.Exists(CurrencyCodes.Default.Numeric); + + result.Should().BeTrue(); + } + + [Fact] + public void WhenFindAndUnknown_ThenReturnsNull() + { + var result = CurrencyCodes.Find("notacurrencycode"); + + result.Should().BeNull(); + } + + [Fact] + public void WhenFindByCode_ThenReturnsTrue() + { + var result = CurrencyCodes.Find(CurrencyCodes.Default.Code); + + result.Should().Be(CurrencyCodes.Default); + } + + [Fact] + public void WhenFindByNumeric_ThenReturnsTrue() + { + var result = CurrencyCodes.Find(CurrencyCodes.Default.Numeric); + + result.Should().Be(CurrencyCodes.Default); + } + + [Fact] + public void WhenFindForEveryCurrency_ThenReturnsCode() + { + var currencies = CurrencyCodesResolver.Codes + .Where(cur => cur.Code.HasValue()) + .ToList(); + foreach (var currency in currencies) + { + var result = CurrencyCodes.Find(currency.Code); + + result.Should().NotBeNull($"{currency.Name} should have been found by Code"); + } + + foreach (var currency in currencies) + { + var result = CurrencyCodes.Find(currency.Num); + + result.Should().NotBeNull($"{currency.Name} should have been found by NumericCode"); + } + } + + [Fact] + public void WhenCreateIso4217_ThenReturnsInstance() + { + var result = CurrencyCodeIso4217.Create("ashortname", "analpha2", "100", CurrencyDecimalKind.TwoDecimal); + + result.ShortName.Should().Be("ashortname"); + result.Code.Should().Be("analpha2"); + result.Kind.Should().Be(CurrencyDecimalKind.TwoDecimal); + result.Numeric.Should().Be("100"); + } + + [Fact] + public void WhenEqualsAndNotTheSameNumeric_ThenReturnsFalse() + { + var currency1 = CurrencyCodeIso4217.Create("ashortname", "analpha2", "100", CurrencyDecimalKind.Unknown); + var currency2 = CurrencyCodeIso4217.Create("ashortname", "analpha2", "101", CurrencyDecimalKind.Unknown); + + var result = currency1 == currency2; + + result.Should().BeFalse(); + } + + [Fact] + public void WhenEqualsAndSameNumeric_ThenReturnsTrue() + { + var currency1 = CurrencyCodeIso4217.Create("ashortname1", "analpha21", "100", CurrencyDecimalKind.Unknown); + var currency2 = CurrencyCodeIso4217.Create("ashortname2", "analpha22", "100", CurrencyDecimalKind.Unknown); + + var result = currency1 == currency2; + + result.Should().BeTrue(); + } + + [Fact] + public void WhenToCurrencyWithAThousandAndOne_ThenReturnsUnitedStatesDollars() + { + var code = CurrencyCodes.UnitedStatesDollar.Code; + var result = CurrencyCodes.ToCurrency(code, 1001); + + result.Should().Be(10.01M); + } + + [Fact] + public void WhenToCurrencyWithAThousandAndOne_ThenReturnsKuwaitiDinars() + { + var code = CurrencyCodes.KuwaitiDinar.Code; + var result = CurrencyCodes.ToCurrency(code, 1001); + + result.Should().Be(1.001M); + } + + [Fact] + public void WhenToCurrencyWithAThousandAndOne_ThenReturnsChileanFomentos() + { + var code = CurrencyCodes.ChileanFomento.Code; + var result = CurrencyCodes.ToCurrency(code, 1001); + + result.Should().Be(0.1001M); + } +} \ No newline at end of file diff --git a/src/Common/Common.csproj b/src/Common/Common.csproj index f3470b9f..478a1ac1 100644 --- a/src/Common/Common.csproj +++ b/src/Common/Common.csproj @@ -12,6 +12,7 @@ + diff --git a/src/Common/CurrencyCodes.cs b/src/Common/CurrencyCodes.cs new file mode 100644 index 00000000..1051719b --- /dev/null +++ b/src/Common/CurrencyCodes.cs @@ -0,0 +1,230 @@ +using Common.Extensions; +using ISO._4217; + +namespace Common; + +/// +/// See: https://en.wikipedia.org/wiki/ISO_4217#Numeric_codes +/// +public static class CurrencyCodes +{ + public static readonly CurrencyCodeIso4217 ChileanFomento = + CurrencyCodeIso4217.Create("Unidad de Fomento", "CLF", "990", CurrencyDecimalKind.FourDecimal); + public static readonly CurrencyCodeIso4217 NewZealandDollar = + CurrencyCodeIso4217.Create("New Zealand dollar", "NZD", "554", CurrencyDecimalKind.TwoDecimal); + public static readonly CurrencyCodeIso4217 Default = NewZealandDollar; + public static readonly CurrencyCodeIso4217 KuwaitiDinar = + CurrencyCodeIso4217.Create("Kuwaiti dinar", "KWD", "414", CurrencyDecimalKind.ThreeDecimal); + +#if TESTINGONLY + public static readonly CurrencyCodeIso4217 Test = + CurrencyCodeIso4217.Create("Test", "XXX", "001", CurrencyDecimalKind.TwoDecimal); +#endif + public static readonly CurrencyCodeIso4217 UnitedStatesDollar = + CurrencyCodeIso4217.Create("United States dollar", "USD", "840", CurrencyDecimalKind.TwoDecimal); + + /// + /// Whether the specified currency by its exists + /// + public static bool Exists(string currencyCodeOrNumber) + { + return Find(currencyCodeOrNumber).Exists(); + } + + /// + /// Returns the specified currency by its if it exists + /// + public static CurrencyCodeIso4217? Find(string? currencyCodeOrNumber) + { + if (currencyCodeOrNumber.NotExists()) + { + return null; + } + +#if TESTINGONLY + if (currencyCodeOrNumber == Test.Code + || currencyCodeOrNumber == Test.Numeric) + { + return Test; + } +#endif + + var code = CurrencyCodesResolver.GetCurrenciesByCode(currencyCodeOrNumber) + .FirstOrDefault(cur => cur.Code.HasValue()); + if (code.Exists()) + { + return CurrencyCodeIso4217.Create(code.Name, code.Code, code.Num, code.Exponent.ToKind()); + } + + var numeric = CurrencyCodesResolver.GetCurrenciesByNumber(currencyCodeOrNumber) + .FirstOrDefault(cur => cur.Code.HasValue()); + if (numeric.Exists()) + { + return CurrencyCodeIso4217.Create(numeric.Name, numeric.Code, numeric.Num, numeric.Exponent.ToKind()); + } + + return null; + } + + /// + /// Returns the specified currency by its if it exists, + /// or returns + /// + public static CurrencyCodeIso4217 FindOrDefault(string? currencyCodeOrNumber) + { + var exists = Find(currencyCodeOrNumber); + return exists.Exists() + ? exists + : Default; + } + + /// + /// Converts the amount in cents to a currency + /// + public static decimal ToCurrency(string code, int amountInCents) + { + var currency = Find(code); + if (currency.NotExists()) + { + return amountInCents; + } + + return currency.Kind switch + { + CurrencyDecimalKind.ZeroDecimal => amountInCents, + CurrencyDecimalKind.TwoDecimal => (decimal)amountInCents / 100, + CurrencyDecimalKind.ThreeDecimal => (decimal)amountInCents / 1000, + CurrencyDecimalKind.FourDecimal => (decimal)amountInCents / 10000, + _ => amountInCents + }; + } +} + +/// +/// See: https://en.wikipedia.org/wiki/ISO_4217 for details +/// +public sealed class CurrencyCodeIso4217 : IEquatable +{ + private CurrencyCodeIso4217(string numeric, string shortName, string code) + { + numeric.ThrowIfInvalidParameter(num => + { + if (!int.TryParse(num, out var integer)) + { + return false; + } + + return integer is >= 1 and < 1000; + }, nameof(numeric), Resources.CurrencyIso4217_InvalidNumeric.Format(numeric)); + Numeric = numeric; + ShortName = shortName; + Code = code; + } + + public string Code { get; } + + public CurrencyDecimalKind Kind { get; private init; } + + public string Numeric { get; } + + public string ShortName { get; } + + public bool Equals(CurrencyCodeIso4217? other) + { + if (ReferenceEquals(null, other)) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return Numeric == other.Numeric; + } + + internal static CurrencyCodeIso4217 Create(string shortName, string code, string numeric, CurrencyDecimalKind kind) + { + shortName.ThrowIfNotValuedParameter(nameof(shortName)); + code.ThrowIfNotValuedParameter(nameof(code)); + + var instance = new CurrencyCodeIso4217(numeric, shortName, code) + { + Kind = kind + }; + + return instance; + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj.GetType() != GetType()) + { + return false; + } + + return Equals((CurrencyCodeIso4217)obj); + } + + public override int GetHashCode() + { + ArgumentException.ThrowIfNullOrEmpty(Numeric); + + return Numeric.GetHashCode(); + } + + public static bool operator ==(CurrencyCodeIso4217 left, CurrencyCodeIso4217 right) + { + return Equals(left, right); + } + + public static bool operator !=(CurrencyCodeIso4217 left, CurrencyCodeIso4217 right) + { + return !Equals(left, right); + } +} + +/// +/// The decimal kind of currency +/// +public enum CurrencyDecimalKind +{ + ZeroDecimal = 0, + TwoDecimal = 2, + ThreeDecimal = 3, + FourDecimal = 4, + Unknown = -1 +} + +internal static class ConversionExtensions +{ + public static CurrencyDecimalKind ToKind(this string exponent) + { + if (exponent.HasNoValue()) + { + return CurrencyDecimalKind.Unknown; + } + + var numeral = exponent.ToIntOrDefault(-1); + return numeral switch + { + -1 => CurrencyDecimalKind.Unknown, + 0 => CurrencyDecimalKind.ZeroDecimal, + 2 => CurrencyDecimalKind.TwoDecimal, + 3 => CurrencyDecimalKind.ThreeDecimal, + 4 => CurrencyDecimalKind.FourDecimal, + _ => CurrencyDecimalKind.Unknown + }; + } +} \ No newline at end of file diff --git a/src/Common/Extensions/DateTimeExtensions.cs b/src/Common/Extensions/DateTimeExtensions.cs index 23d84a90..de822a11 100644 --- a/src/Common/Extensions/DateTimeExtensions.cs +++ b/src/Common/Extensions/DateTimeExtensions.cs @@ -37,6 +37,24 @@ public static DateTime FromIso8601(this string? value) return default; } + /// + /// Converts the to a UTC date only, + /// but only if the is in the + /// ISO8601 format. + /// + public static DateOnly FromIso8601DateOnly(this string? value) + { + if (value.HasNoValue()) + { + return DateOnly.MinValue; + } + + var dateOnly = DateOnly.Parse(value); + return dateOnly.HasValue() + ? dateOnly + : DateOnly.MinValue; + } + /// /// Converts the to a UTC date, /// but only if the is in the UNIX Timestamp format. @@ -213,6 +231,24 @@ public static string ToIso8601(this DateTime? value) return value.Value.ToIso8601(); } + /// + /// Converts the to UTC and then to + /// ISO8601 + /// + public static string? ToIso8601(this DateOnly? date) + { + return date?.ToIso8601(); + } + + /// + /// Converts the to UTC and then to + /// ISO8601 + /// + public static string ToIso8601(this DateOnly date) + { + return date.ToString("O"); + } + /// /// Converts the to UTC and then to /// ISO8601 diff --git a/src/Common/Extensions/StringExtensions.cs b/src/Common/Extensions/StringExtensions.cs index 30e3c208..a3179467 100644 --- a/src/Common/Extensions/StringExtensions.cs +++ b/src/Common/Extensions/StringExtensions.cs @@ -283,7 +283,7 @@ public static double ToDouble(this string? value) } /// - /// Converts the to a integer value + /// Converts the to an integer value /// public static int ToInt(this string? value) { @@ -296,7 +296,7 @@ public static int ToInt(this string? value) } /// - /// Converts the to a integer value, + /// Converts the to an integer value, /// and in the case where the value cannot be converted, uses the /// public static int ToIntOrDefault(this string? value, int defaultValue) @@ -314,6 +314,38 @@ public static int ToIntOrDefault(this string? value, int defaultValue) return defaultValue; } + /// + /// Converts the to a decimal value + /// + public static decimal ToDecimal(this string? value) + { + if (value.HasNoValue()) + { + return -1; + } + + return decimal.Parse(value); + } + + /// + /// Converts the to a decimal value, + /// and in the case where the value cannot be converted, uses the + /// + public static decimal ToDecimalOrDefault(this string? value, decimal defaultValue) + { + if (value.HasNoValue()) + { + return defaultValue; + } + + if (decimal.TryParse(value, out var converted)) + { + return converted; + } + + return defaultValue; + } + /// /// Converts the to a long value /// diff --git a/src/Common/Resources.Designer.cs b/src/Common/Resources.Designer.cs index 0a44748e..58ddf85f 100644 --- a/src/Common/Resources.Designer.cs +++ b/src/Common/Resources.Designer.cs @@ -68,6 +68,15 @@ internal static string CountryCodeIso3166_InvalidNumeric { } } + /// + /// Looks up a localized string similar to The Numeric '{0}' must be a 3 decimal number between 1 and 1000. + /// + internal static string CurrencyIso4217_InvalidNumeric { + get { + return ResourceManager.GetString("CurrencyIso4217_InvalidNumeric", resourceCulture); + } + } + /// /// Looks up a localized string similar to The value of the optional variable is null. /// diff --git a/src/Common/Resources.resx b/src/Common/Resources.resx index 7b605b80..aef49258 100644 --- a/src/Common/Resources.resx +++ b/src/Common/Resources.resx @@ -54,5 +54,8 @@ The DaylightSavingsCode '{0}' must be a three or four letter code, or the offset in hrs + + The Numeric '{0}' must be a 3 decimal number between 1 and 1000 + \ No newline at end of file diff --git a/src/Domain.Events.Shared/Subscriptions/ProviderChanged.cs b/src/Domain.Events.Shared/Subscriptions/ProviderChanged.cs index 9cc57d9e..f5a4348f 100644 --- a/src/Domain.Events.Shared/Subscriptions/ProviderChanged.cs +++ b/src/Domain.Events.Shared/Subscriptions/ProviderChanged.cs @@ -19,9 +19,9 @@ public ProviderChanged() public required string BuyerReference { get; set; } - public required string Name { get; set; } + public required string ProviderName { get; set; } - public required Dictionary State { get; set; } + public required Dictionary ProviderState { get; set; } public required string SubscriptionReference { get; set; } } \ No newline at end of file diff --git a/src/Domain.Services.Shared/IBillingStateProxy.cs b/src/Domain.Services.Shared/IBillingStateProxy.cs index af62ce8f..a4fd6fd5 100644 --- a/src/Domain.Services.Shared/IBillingStateProxy.cs +++ b/src/Domain.Services.Shared/IBillingStateProxy.cs @@ -1,3 +1,6 @@ +using Common; +using Domain.Shared.Subscriptions; + namespace Domain.Services.Shared; public interface IBillingStateProxy @@ -5,32 +8,25 @@ public interface IBillingStateProxy /// /// Returns the name of this provider /// - string ProviderName { get; set; } + string ProviderName { get; } /// - /// Returns the subscription, given the + /// Returns the subscription, given the /// - BillingSubscription GetBillingSubscription(string providerName, BillingProviderState currentState); + Result GetBillingSubscription(BillingProvider current); /// /// Returns the providers reference for the buyer /// - string GetBuyerReference(BillingProviderState currentState); + Result GetBuyerReference(BillingProvider current); /// /// Returns the providers reference for the subscription /// - string GetSubscriptionReference(BillingProviderState currentState); + Result GetSubscriptionReference(BillingProvider current); /// - /// Translates the initial state of the provider + /// Translates the initial state of the newly subscribed provider /// - BillingProviderState TranslateInitialState(string providerName, BillingProviderState state); -} - -/// -/// Maintains the internal state for the billing provider -/// -public class BillingProviderState : Dictionary -{ + Result TranslateSubscribedProvider(BillingProvider subscribed); } \ No newline at end of file diff --git a/src/Domain.Shared.UnitTests/CurrencyCodeSpec.cs b/src/Domain.Shared.UnitTests/CurrencyCodeSpec.cs new file mode 100644 index 00000000..effd01b2 --- /dev/null +++ b/src/Domain.Shared.UnitTests/CurrencyCodeSpec.cs @@ -0,0 +1,37 @@ +using Common; +using FluentAssertions; +using UnitTesting.Common; +using Xunit; + +namespace Domain.Shared.UnitTests; + +[Trait("Category", "Unit")] +public class CurrencyCodeSpec +{ + [Fact] + public void WhenCreateWithUnknownCurrency_ThenReturnsDefault() + { + var result = CurrencyCode.Create("anunknowncurrency"); + + result.Should().BeSuccess(); + result.Value.Currency.Should().Be(CurrencyCodes.Default); + } + + [Fact] + public void WhenCreateWithKnownCurrency_ThenReturnsCurrency() + { + var result = CurrencyCode.Create(CurrencyCodes.NewZealandDollar.Code); + + result.Should().BeSuccess(); + result.Value.Currency.Should().Be(CurrencyCodes.NewZealandDollar); + } + + [Fact] + public void WhenCreateWithCurrencyCode_ThenReturnsCurrency() + { + var result = CurrencyCode.Create(CurrencyCodes.NewZealandDollar); + + result.Should().BeSuccess(); + result.Value.Currency.Should().Be(CurrencyCodes.NewZealandDollar); + } +} \ No newline at end of file diff --git a/src/Domain.Shared.UnitTests/Subscriptions/BillingInvoiceSpec.cs b/src/Domain.Shared.UnitTests/Subscriptions/BillingInvoiceSpec.cs new file mode 100644 index 00000000..f688da16 --- /dev/null +++ b/src/Domain.Shared.UnitTests/Subscriptions/BillingInvoiceSpec.cs @@ -0,0 +1,31 @@ +using Common; +using Domain.Shared.Subscriptions; +using FluentAssertions; +using UnitTesting.Common; +using Xunit; + +namespace Domain.Shared.UnitTests.Subscriptions; + +[Trait("Category", "Unit")] +public class BillingInvoiceSpec +{ + [Fact] + public void WhenCreateWithNegativeAmount_ThenReturnsError() + { + var result = BillingInvoice.Create(-1, CurrencyCode.Create(CurrencyCodes.NewZealandDollar).Value, null); + + result.Should().BeError(ErrorCode.Validation, Resources.BillingInvoice_InvalidAmount); + } + + [Fact] + public void WhenCreate_ThenCreatesInvoice() + { + var next = DateTime.UtcNow; + + var result = BillingInvoice.Create(1, CurrencyCode.Create(CurrencyCodes.NewZealandDollar).Value, next); + + result.Value.Amount.Should().Be(1); + result.Value.CurrencyCode.Currency.Should().Be(CurrencyCodes.NewZealandDollar); + result.Value.NextUtc.Should().Be(next); + } +} \ No newline at end of file diff --git a/src/Domain.Shared.UnitTests/Subscriptions/BillingPaymentMethodSpec.cs b/src/Domain.Shared.UnitTests/Subscriptions/BillingPaymentMethodSpec.cs new file mode 100644 index 00000000..024e9fd4 --- /dev/null +++ b/src/Domain.Shared.UnitTests/Subscriptions/BillingPaymentMethodSpec.cs @@ -0,0 +1,23 @@ +using Domain.Shared.Subscriptions; +using FluentAssertions; +using UnitTesting.Common; +using Xunit; + +namespace Domain.Shared.UnitTests.Subscriptions; + +[Trait("Category", "Unit")] +public class BillingPaymentMethodSpec +{ + [Fact] + public void WhenCreate_ThenReturnsPaymentMethod() + { + var expires = DateOnly.FromDateTime(DateTime.UtcNow); + var result = + BillingPaymentMethod.Create(BillingPaymentMethodType.Card, BillingPaymentMethodStatus.Valid, expires); + + result.Should().BeSuccess(); + result.Value.Type.Should().Be(BillingPaymentMethodType.Card); + result.Value.Status.Should().Be(BillingPaymentMethodStatus.Valid); + result.Value.ExpiresOn.Should().Be(expires); + } +} \ No newline at end of file diff --git a/src/Domain.Shared.UnitTests/Subscriptions/BillingPeriodSpec.cs b/src/Domain.Shared.UnitTests/Subscriptions/BillingPeriodSpec.cs new file mode 100644 index 00000000..961e0e6c --- /dev/null +++ b/src/Domain.Shared.UnitTests/Subscriptions/BillingPeriodSpec.cs @@ -0,0 +1,20 @@ +using Domain.Shared.Subscriptions; +using FluentAssertions; +using UnitTesting.Common; +using Xunit; + +namespace Domain.Shared.UnitTests.Subscriptions; + +[Trait("Category", "Unit")] +public class BillingPeriodSpec +{ + [Fact] + public void WhenCreate_ThenReturnsPeriod() + { + var result = BillingPeriod.Create(1, BillingFrequencyUnit.Day); + + result.Should().BeSuccess(); + result.Value.Frequency.Should().Be(1); + result.Value.Unit.Should().Be(BillingFrequencyUnit.Day); + } +} \ No newline at end of file diff --git a/src/Domain.Shared.UnitTests/Subscriptions/BillingPlanSpec.cs b/src/Domain.Shared.UnitTests/Subscriptions/BillingPlanSpec.cs new file mode 100644 index 00000000..f1a0a550 --- /dev/null +++ b/src/Domain.Shared.UnitTests/Subscriptions/BillingPlanSpec.cs @@ -0,0 +1,25 @@ +using Domain.Common.ValueObjects; +using Domain.Shared.Subscriptions; +using FluentAssertions; +using UnitTesting.Common; +using Xunit; + +namespace Domain.Shared.UnitTests.Subscriptions; + +[Trait("Category", "Unit")] +public class BillingPlanSpec +{ + [Fact] + public void WhenCreate_ThenReturnsPlan() + { + var trialEnd = DateTime.UtcNow; + + var result = BillingPlan.Create("anid".ToId(), true, trialEnd, BillingSubscriptionTier.Premium); + + result.Should().BeSuccess(); + result.Value.Id.Should().Be("anid".ToId()); + result.Value.IsTrial.Should().BeTrue(); + result.Value.TrialEndDateUtc.Should().Be(trialEnd); + result.Value.Tier.Should().Be(BillingSubscriptionTier.Premium); + } +} \ No newline at end of file diff --git a/src/SubscriptionsDomain.UnitTests/ProviderStateSpec.cs b/src/Domain.Shared.UnitTests/Subscriptions/BillingProviderSpec.cs similarity index 60% rename from src/SubscriptionsDomain.UnitTests/ProviderStateSpec.cs rename to src/Domain.Shared.UnitTests/Subscriptions/BillingProviderSpec.cs index 297df04d..5b6e50a9 100644 --- a/src/SubscriptionsDomain.UnitTests/ProviderStateSpec.cs +++ b/src/Domain.Shared.UnitTests/Subscriptions/BillingProviderSpec.cs @@ -1,20 +1,20 @@ using Common; -using Domain.Services.Shared; +using Domain.Shared.Subscriptions; using FluentAssertions; using UnitTesting.Common; using Xunit; -namespace SubscriptionsDomain.UnitTests; +namespace Domain.Shared.UnitTests.Subscriptions; [Trait("Category", "Unit")] -public class ProviderStateSpec +public class BillingProviderSpec { - private readonly ProviderState _providerState; + private readonly BillingProvider _provider; - public ProviderStateSpec() + public BillingProviderSpec() { - _providerState = ProviderState.Create("aprovidername", - new BillingProviderState + _provider = BillingProvider.Create("aprovidername", + new Dictionary { { "aname", "avalue" } }).Value; @@ -23,7 +23,10 @@ public ProviderStateSpec() [Fact] public void WhenEmpty_ThenUnInitialized() { - var result = ProviderState.Empty; + var result = BillingProvider.Create("aprovidername", new Dictionary + { + { "aname", "avalue" } + }).Value; result.State.Should().BeEmpty(); result.Name.Should().Be(string.Empty); @@ -31,9 +34,9 @@ public void WhenEmpty_ThenUnInitialized() } [Fact] - public void WhenCreateWithNullProviderName_TheReturnsError() + public void WhenCreateWithEmptyProviderName_TheReturnsError() { - var result = ProviderState.Create(null!, new BillingProviderState()); + var result = BillingProvider.Create(string.Empty, new Dictionary()); result.Should().BeError(ErrorCode.Validation, Resources.ProviderState_InvalidName); } @@ -41,25 +44,16 @@ public void WhenCreateWithNullProviderName_TheReturnsError() [Fact] public void WhenCreateWithInvalidProviderName_TheReturnsError() { - var result = ProviderState.Create("^^aninvalidname^^", - new BillingProviderState()); + var result = BillingProvider.Create("^^aninvalidname^^", + new Dictionary()); result.Should().BeError(ErrorCode.Validation, Resources.ProviderState_InvalidName); } [Fact] - public void WhenCreateWithNullState_TheReturnsError() - { - var result = ProviderState.Create("aprovidername", null!); - - result.Should().BeError(ErrorCode.Validation, Resources.ProviderState_InvalidState); - } - - [Fact] - public void WhenCreateWithEmptyProperties_TheReturnsError() + public void WhenCreateWithEmptyState_TheReturnsError() { - var result = ProviderState.Create("aprovidername", - new BillingProviderState()); + var result = BillingProvider.Create("aprovidername", new Dictionary()); result.Should().BeError(ErrorCode.Validation, Resources.ProviderState_InvalidState); } @@ -67,14 +61,14 @@ public void WhenCreateWithEmptyProperties_TheReturnsError() [Fact] public void WhenCreateWithProviderAndState_ThenInitialized() { - var state = new BillingProviderState + var state = new Dictionary { { "aname1", "avalue1" }, { "aname2", "avalue2" }, { "aname3", "avalue3" } }; - var result = ProviderState.Create("aprovidername", state).Value; + var result = BillingProvider.Create("aprovidername", state).Value; result.State.Count.Should().Be(3); result.State.Should().BeSameAs(state); @@ -85,7 +79,7 @@ public void WhenCreateWithProviderAndState_ThenInitialized() [Fact] public void WhenIsCurrentProviderAndDifferentProviderName_ThenReturnsFalse() { - var result = _providerState.IsCurrentProvider("anotherprovidername"); + var result = _provider.IsCurrentProvider("anotherprovidername"); result.Should().BeFalse(); } @@ -93,7 +87,7 @@ public void WhenIsCurrentProviderAndDifferentProviderName_ThenReturnsFalse() [Fact] public void WhenIsCurrentProviderAndSameProviderName_ThenReturnsTrue() { - var result = _providerState.IsCurrentProvider("aprovidername"); + var result = _provider.IsCurrentProvider("aprovidername"); result.Should().BeTrue(); } @@ -101,14 +95,14 @@ public void WhenIsCurrentProviderAndSameProviderName_ThenReturnsTrue() [Fact] public void WhenChangeState_ThenReturnsChangedState() { - var state = new BillingProviderState + var state = new Dictionary { { "aname1", "avalue1" }, { "aname2", "avalue2" }, { "aname3", "avalue3" } }; - var result = _providerState.ChangeState(state); + var result = _provider.ChangeState(state); result.State.Count.Should().Be(3); result.State.Should().Contain(x => x.Key == "aname1" && x.Value == "avalue1"); diff --git a/src/Domain.Shared.UnitTests/Subscriptions/BillingStatusSpec.cs b/src/Domain.Shared.UnitTests/Subscriptions/BillingStatusSpec.cs new file mode 100644 index 00000000..19ec7c05 --- /dev/null +++ b/src/Domain.Shared.UnitTests/Subscriptions/BillingStatusSpec.cs @@ -0,0 +1,34 @@ +using Domain.Shared.Subscriptions; +using FluentAssertions; +using UnitTesting.Common; +using Xunit; + +namespace Domain.Shared.UnitTests.Subscriptions; + +[Trait("Category", "Unit")] +public class BillingStatusSpec +{ + [Fact] + public void WhenCreate_ThenReturnsStatus() + { + var cancelled = DateTime.UtcNow; + + var result = BillingStatus.Create(BillingSubscriptionStatus.Unsubscribed, cancelled, true); + + result.Should().BeSuccess(); + result.Value.Subscription.Should().Be(BillingSubscriptionStatus.Unsubscribed); + result.Value.CancelledDateUtc.Should().Be(cancelled); + result.Value.CanBeCancelled.Should().Be(false); + result.Value.CanBeUnsubscribed.Should().Be(true); + } + + [Fact] + public void WhenActive_ThenCanBeCancelled() + { + var result = BillingStatus.Create(BillingSubscriptionStatus.Activated, null, true); + + result.Should().BeSuccess(); + result.Value.Subscription.Should().Be(BillingSubscriptionStatus.Activated); + result.Value.CanBeCancelled.Should().Be(true); + } +} \ No newline at end of file diff --git a/src/Domain.Shared.UnitTests/Subscriptions/BillingSubscriptionSpec.cs b/src/Domain.Shared.UnitTests/Subscriptions/BillingSubscriptionSpec.cs new file mode 100644 index 00000000..fa241e13 --- /dev/null +++ b/src/Domain.Shared.UnitTests/Subscriptions/BillingSubscriptionSpec.cs @@ -0,0 +1,42 @@ +using Domain.Common.ValueObjects; +using Domain.Shared.Subscriptions; +using FluentAssertions; +using Xunit; + +namespace Domain.Shared.UnitTests.Subscriptions; + +[Trait("Category", "Unit")] +public class BillingSubscriptionSpec +{ + [Fact] + public void WhenCreateWithStatus_ThenReturnsSubscription() + { + var status = BillingStatus.Create(BillingSubscriptionStatus.Activated, null, false).Value; + + var result = BillingSubscription.Create(status); + + result.Value.Status.Should().Be(status); + result.Value.Invoice.Should().Be(BillingInvoice.Empty); + result.Value.Period.Should().Be(BillingPeriod.Empty); + result.Value.Plan.Should().Be(BillingPlan.Empty); + result.Value.PaymentMethod.Should().Be(BillingPaymentMethod.Empty); + result.Value.SubscriptionId.Should().Be(Identifier.Empty()); + } + + [Fact] + public void WhenCreateWithStatusAndPaymentMethod_ThenReturnsSubscription() + { + var status = BillingStatus.Create(BillingSubscriptionStatus.Activated, null, false).Value; + var paymentMethod = BillingPaymentMethod + .Create(BillingPaymentMethodType.Card, BillingPaymentMethodStatus.Valid, null).Value; + + var result = BillingSubscription.Create(status, paymentMethod); + + result.Value.Status.Should().Be(status); + result.Value.Invoice.Should().Be(BillingInvoice.Empty); + result.Value.Period.Should().Be(BillingPeriod.Empty); + result.Value.Plan.Should().Be(BillingPlan.Empty); + result.Value.PaymentMethod.Should().Be(paymentMethod); + result.Value.SubscriptionId.Should().Be(Identifier.Empty()); + } +} \ No newline at end of file diff --git a/src/Domain.Shared/CurrencyCode.cs b/src/Domain.Shared/CurrencyCode.cs new file mode 100644 index 00000000..6846c807 --- /dev/null +++ b/src/Domain.Shared/CurrencyCode.cs @@ -0,0 +1,33 @@ +using Common; +using Domain.Common.ValueObjects; +using Domain.Interfaces; + +namespace Domain.Shared; + +public sealed class CurrencyCode : SingleValueObjectBase +{ + public static Result Create(CurrencyCodeIso4217 code) + { + return new CurrencyCode(code); + } + + public static Result Create(string code) + { + return new CurrencyCode(CurrencyCodes.FindOrDefault(code)); + } + + private CurrencyCode(CurrencyCodeIso4217 code) : this(code.Code) + { + } + + private CurrencyCode(string code) : base(code) + { + } + + public CurrencyCodeIso4217 Currency => CurrencyCodes.FindOrDefault(Value); + + public static ValueObjectFactory Rehydrate() + { + return (property, _) => new CurrencyCode(property); + } +} \ No newline at end of file diff --git a/src/Domain.Shared/Domain.Shared.csproj b/src/Domain.Shared/Domain.Shared.csproj index 98374b83..4f2eaf88 100644 --- a/src/Domain.Shared/Domain.Shared.csproj +++ b/src/Domain.Shared/Domain.Shared.csproj @@ -5,6 +5,7 @@ + diff --git a/src/Domain.Shared/Resources.Designer.cs b/src/Domain.Shared/Resources.Designer.cs index 286b5156..53f0cf28 100644 --- a/src/Domain.Shared/Resources.Designer.cs +++ b/src/Domain.Shared/Resources.Designer.cs @@ -68,6 +68,24 @@ internal static string Avatar_InvalidUrl { } } + /// + /// Looks up a localized string similar to The Amount is invalid. + /// + internal static string BillingInvoice_InvalidAmount { + get { + return ResourceManager.GetString("BillingInvoice_InvalidAmount", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The frequency must be positive. + /// + internal static string BillingPeriod_InvalidFrequency { + get { + return ResourceManager.GetString("BillingPeriod_InvalidFrequency", resourceCulture); + } + } + /// /// Looks up a localized string similar to Mr or Mrs. /// @@ -104,6 +122,24 @@ internal static string PhoneNumber_InvalidPhoneNumber { } } + /// + /// Looks up a localized string similar to Name of the provider is not invalid. + /// + internal static string ProviderState_InvalidName { + get { + return ResourceManager.GetString("ProviderState_InvalidName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to State of the provider is empty or invalid. + /// + internal static string ProviderState_InvalidState { + get { + return ResourceManager.GetString("ProviderState_InvalidState", resourceCulture); + } + } + /// /// Looks up a localized string similar to The Role value is invalid. /// diff --git a/src/Domain.Shared/Resources.resx b/src/Domain.Shared/Resources.resx index 8a54e9fd..dcf6c129 100644 --- a/src/Domain.Shared/Resources.resx +++ b/src/Domain.Shared/Resources.resx @@ -45,4 +45,17 @@ The URL is invalid + + The Amount is invalid + + + The frequency must be positive + + + Name of the provider is not invalid + + + State of the provider is empty or invalid + + \ No newline at end of file diff --git a/src/Domain.Shared/Subscriptions/BillingInvoice.cs b/src/Domain.Shared/Subscriptions/BillingInvoice.cs index 16467819..df01824b 100644 --- a/src/Domain.Shared/Subscriptions/BillingInvoice.cs +++ b/src/Domain.Shared/Subscriptions/BillingInvoice.cs @@ -1,21 +1,27 @@ +using Common; +using Common.Extensions; using Domain.Common.ValueObjects; +using Domain.Interfaces; namespace Domain.Shared.Subscriptions; public sealed class BillingInvoice : ValueObjectBase { - public BillingInvoice() + public static BillingInvoice Empty = new(0M, CurrencyCode.Create(CurrencyCodes.Default).Value, null); + + public static Result Create(decimal amount, CurrencyCode currency, DateTime? nextUtc) { - Amount = 0M; - CurrencyCode = new CurrencyCode(CurrencyCodes.Default); - NextUtc = null; + if (amount.IsInvalidParameter(num => num > 0, nameof(amount), Resources.BillingInvoice_InvalidAmount, + out var error1)) + { + return error1; + } + + return new BillingInvoice(amount, currency, nextUtc); } - public BillingInvoice(decimal amount, CurrencyCode currencyCode, DateTime? nextUtc) + private BillingInvoice(decimal amount, CurrencyCode currencyCode, DateTime? nextUtc) { - amount.GuardAgainstInvalid(val => val >= 0, nameof(amount), Resources.BillingPeriod_InvalidValue); - currencyCode.GuardAgainstNull(nameof(currencyCode)); - Amount = amount; CurrencyCode = currencyCode; NextUtc = nextUtc; @@ -32,14 +38,14 @@ public static ValueObjectFactory Rehydrate() return (property, _) => { var parts = RehydrateToList(property, false); - return new BillingInvoice(parts[0].ToDecimal(0), - CurrencyCode.Rehydrate()(parts[1], _), + return new BillingInvoice(parts[0].ToDecimalOrDefault(0), + CurrencyCode.Rehydrate()(parts[1]!, _), parts[2].FromIso8601()); }; } - protected override IEnumerable GetAtomicValues() + protected override IEnumerable GetAtomicValues() { - return new object[] { Amount, CurrencyCode, NextUtc }; + return new object?[] { Amount, CurrencyCode, NextUtc }; } } \ No newline at end of file diff --git a/src/Domain.Shared/Subscriptions/BillingPaymentMethod.cs b/src/Domain.Shared/Subscriptions/BillingPaymentMethod.cs index cf1c97df..e4e4522f 100644 --- a/src/Domain.Shared/Subscriptions/BillingPaymentMethod.cs +++ b/src/Domain.Shared/Subscriptions/BillingPaymentMethod.cs @@ -1,27 +1,35 @@ +using Common; +using Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Interfaces; + namespace Domain.Shared.Subscriptions; public sealed class BillingPaymentMethod : ValueObjectBase { - public BillingPaymentMethod() + public static BillingPaymentMethod Empty = + Create(BillingPaymentMethodType.None, BillingPaymentMethodStatus.Invalid, null).Value; + + public static Result Create(BillingPaymentMethodType type, + BillingPaymentMethodStatus status, + DateOnly? expiresOn) { - System.Type = BillingPaymentMethodType.None; - Status = BillingPaymentMethodStatus.Invalid; - ExpiresOn = null; + return new BillingPaymentMethod(type, status, expiresOn); } - public BillingPaymentMethod(BillingPaymentMethodType type, BillingPaymentMethodStatus status, + private BillingPaymentMethod(BillingPaymentMethodType type, BillingPaymentMethodStatus status, DateOnly? expiresOn) { - System.Type = type; + Type = type; Status = status; ExpiresOn = expiresOn; } - public BillingPaymentMethodType Type { get; } + public DateOnly? ExpiresOn { get; } public BillingPaymentMethodStatus Status { get; } - public DateOnly? ExpiresOn { get; } + public BillingPaymentMethodType Type { get; } public static ValueObjectFactory Rehydrate() { @@ -34,9 +42,9 @@ public static ValueObjectFactory Rehydrate() }; } - protected override IEnumerable GetAtomicValues() + protected override IEnumerable GetAtomicValues() { - return new object[] { System.Type, Status, ExpiresOn.ToIso8601() }; + return new object?[] { Type, Status, ExpiresOn.ToIso8601() }; } } diff --git a/src/Domain.Shared/Subscriptions/BillingPeriod.cs b/src/Domain.Shared/Subscriptions/BillingPeriod.cs index 8ae61575..e19008f6 100644 --- a/src/Domain.Shared/Subscriptions/BillingPeriod.cs +++ b/src/Domain.Shared/Subscriptions/BillingPeriod.cs @@ -1,16 +1,27 @@ +using Common; +using Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Interfaces; + namespace Domain.Shared.Subscriptions; public sealed class BillingPeriod : ValueObjectBase { - public BillingPeriod() + public static readonly BillingPeriod Empty = Create(0, BillingFrequencyUnit.Eternity).Value; + + public static Result Create(int frequency, BillingFrequencyUnit unit) { - Frequency = 0; - Unit = BillingFrequencyUnit.Eternity; + if (frequency.IsInvalidParameter(val => val >= 0, nameof(frequency), Resources.BillingPeriod_InvalidFrequency, + out var error)) + { + return error; + } + + return new BillingPeriod(frequency, unit); } - public BillingPeriod(int frequency, BillingFrequencyUnit unit) + private BillingPeriod(int frequency, BillingFrequencyUnit unit) { - frequency.GuardAgainstInvalid(val => val >= 0, nameof(frequency), Resources.BillingPeriod_InvalidValue); Frequency = frequency; Unit = unit; } @@ -24,7 +35,7 @@ public static ValueObjectFactory Rehydrate() return (property, _) => { var parts = RehydrateToList(property, false); - return new BillingPeriod(parts[0].ToInt(0), + return new BillingPeriod(parts[0].ToIntOrDefault(0), parts[1].ToEnumOrDefault(BillingFrequencyUnit.Eternity)); }; } diff --git a/src/Domain.Shared/Subscriptions/BillingPlan.cs b/src/Domain.Shared/Subscriptions/BillingPlan.cs index 9b748621..dbac6778 100644 --- a/src/Domain.Shared/Subscriptions/BillingPlan.cs +++ b/src/Domain.Shared/Subscriptions/BillingPlan.cs @@ -1,21 +1,23 @@ +using Common; +using Common.Extensions; using Domain.Common.ValueObjects; +using Domain.Interfaces; namespace Domain.Shared.Subscriptions; public sealed class BillingPlan : ValueObjectBase { - public BillingPlan() + public static readonly BillingPlan Empty = + Create(Identifier.Empty(), false, null, BillingSubscriptionTier.Unsubscribed).Value; + + public static Result Create(Identifier id, bool isTrial, DateTime? trialEndDateUtc, + BillingSubscriptionTier tier) { - Id = null; - IsTrial = false; - TrialEndDateUtc = null; - Tier = BillingSubscriptionTier.Unsubscribed; + return new BillingPlan(id, isTrial, trialEndDateUtc, tier); } - public BillingPlan(Identifier id, bool isTrial, DateTime? trialEndDateUtc, BillingSubscriptionTier tier) + private BillingPlan(Identifier id, bool isTrial, DateTime? trialEndDateUtc, BillingSubscriptionTier tier) { - id.GuardAgainstNull(nameof(id)); - Id = id; IsTrial = isTrial; TrialEndDateUtc = trialEndDateUtc; @@ -26,16 +28,16 @@ public BillingPlan(Identifier id, bool isTrial, DateTime? trialEndDateUtc, Billi public bool IsTrial { get; } - public DateTime? TrialEndDateUtc { get; } - public BillingSubscriptionTier Tier { get; } + public DateTime? TrialEndDateUtc { get; } + public static ValueObjectFactory Rehydrate() { return (property, _) => { var parts = RehydrateToList(property, false); - return new BillingPlan(parts[0].ToIdentifier(), parts[1].ToBool(), parts[2]?.FromIso8601(), + return new BillingPlan(parts[0].ToId(), parts[1].ToBool(), parts[2]?.FromIso8601(), parts[3].ToEnumOrDefault(BillingSubscriptionTier.Unsubscribed)); }; } diff --git a/src/Domain.Shared/Subscriptions/BillingProvider.cs b/src/Domain.Shared/Subscriptions/BillingProvider.cs new file mode 100644 index 00000000..3f634784 --- /dev/null +++ b/src/Domain.Shared/Subscriptions/BillingProvider.cs @@ -0,0 +1,73 @@ +using Common; +using Common.Extensions; +using Domain.Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Interfaces; +using Domain.Interfaces.ValueObjects; + +namespace Domain.Shared.Subscriptions; + +public sealed class BillingProvider : ValueObjectBase +{ + public static Result Create(string name, Dictionary state) + { + if (name.IsInvalidParameter(Validations.Subscriptions.Provider.Name, nameof(name), + Resources.ProviderState_InvalidName, + out var error1)) + { + return error1; + } + + if (state.IsInvalidParameter(Validations.Subscriptions.Provider.State, nameof(state), + Resources.ProviderState_InvalidState, out var error2)) + { + return error2; + } + + return new BillingProvider(name, state); + } + + private BillingProvider(string name, Dictionary state) + { + Name = name; + State = state; + } + + public bool IsInitialized => Name.HasValue(); + + public string Name { get; } + + public Dictionary State { get; } + + public static ValueObjectFactory Rehydrate() + { + return (property, _) => + { + var parts = RehydrateToList(property, false); + return new BillingProvider(parts[0]!, parts[1]!.FromJson>()!); + }; + } + + protected override IEnumerable GetAtomicValues() + { + return new object[] { Name, State.ToJson(casing: StringExtensions.JsonCasing.Pascal)! }; + } + + public BillingProvider ChangeState(Dictionary state) + { + return new BillingProvider(Name, state); + } + + [SkipImmutabilityCheck] + public bool IsCurrentProvider(string providerName) + { + return Name.EqualsIgnoreCase(providerName); + } + +#if TESTINGONLY + public BillingProvider TestingOnly_RevertProvider(string providerName, Dictionary state) + { + return new BillingProvider(providerName, state); + } +#endif +} \ No newline at end of file diff --git a/src/Domain.Shared/Subscriptions/BillingStatus.cs b/src/Domain.Shared/Subscriptions/BillingStatus.cs index 38e5b244..c81a3a82 100644 --- a/src/Domain.Shared/Subscriptions/BillingStatus.cs +++ b/src/Domain.Shared/Subscriptions/BillingStatus.cs @@ -1,15 +1,21 @@ +using Common; +using Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Interfaces; + namespace Domain.Shared.Subscriptions; public sealed class BillingStatus : ValueObjectBase { - public BillingStatus() + public static BillingStatus Empty = Create(BillingSubscriptionStatus.Unsubscribed, null, false).Value; + + public static Result Create(BillingSubscriptionStatus subscription, + DateTime? cancelledDateUtc, bool canBeUnsubscribed) { - Subscription = BillingSubscriptionStatus.Unsubscribed; - CancelledDateUtc = null; - CanBeUnsubscribed = false; + return new BillingStatus(subscription, cancelledDateUtc, canBeUnsubscribed); } - public BillingStatus(BillingSubscriptionStatus subscription, + private BillingStatus(BillingSubscriptionStatus subscription, DateTime? cancelledDateUtc, bool canBeUnsubscribed) { Subscription = subscription; @@ -17,13 +23,13 @@ public BillingStatus(BillingSubscriptionStatus subscription, CanBeUnsubscribed = canBeUnsubscribed; } - public BillingSubscriptionStatus Subscription { get; } - - public DateTime? CancelledDateUtc { get; } + public bool CanBeCancelled => Subscription == BillingSubscriptionStatus.Activated; public bool CanBeUnsubscribed { get; } - public bool CanBeCancelled => Subscription == BillingSubscriptionStatus.Activated; + public DateTime? CancelledDateUtc { get; } + + public BillingSubscriptionStatus Subscription { get; } public static ValueObjectFactory Rehydrate() { @@ -35,9 +41,9 @@ public static ValueObjectFactory Rehydrate() }; } - protected override IEnumerable GetAtomicValues() + protected override IEnumerable GetAtomicValues() { - return new object[] { Subscription, CancelledDateUtc, CancelledDateUtc }; + return new object?[] { Subscription, CancelledDateUtc, CancelledDateUtc }; } } diff --git a/src/Domain.Shared/Subscriptions/BillingSubscription.cs b/src/Domain.Shared/Subscriptions/BillingSubscription.cs index 7310e53b..29cbff43 100644 --- a/src/Domain.Shared/Subscriptions/BillingSubscription.cs +++ b/src/Domain.Shared/Subscriptions/BillingSubscription.cs @@ -1,65 +1,72 @@ +using Common; using Domain.Common.ValueObjects; +using Domain.Interfaces; -namespace Domain.Shared.Subscriptions +namespace Domain.Shared.Subscriptions; + +public sealed class BillingSubscription : ValueObjectBase { - public sealed class BillingSubscription : ValueObjectBase + public static readonly BillingSubscription Empty = Create(BillingStatus.Empty).Value; + + public static Result Create(BillingStatus status) + { - public BillingSubscription() : this(null, new BillingStatus(), new BillingPlan(), new BillingPeriod(), - new BillingInvoice(), new BillingPaymentMethod()) - { - } + return new BillingSubscription(Identifier.Empty(), status, BillingPlan.Empty, BillingPeriod.Empty, + BillingInvoice.Empty, BillingPaymentMethod.Empty); + } - public BillingSubscription(BillingStatus status) : this(null, status, new BillingPlan(), new BillingPeriod(), - new BillingInvoice(), new BillingPaymentMethod()) - { - } + public static Result Create(BillingStatus status, BillingPaymentMethod paymentMethod) + { + return new BillingSubscription(Identifier.Empty(), status, BillingPlan.Empty, BillingPeriod.Empty, + BillingInvoice.Empty, paymentMethod); + } - public BillingSubscription(BillingStatus status, BillingPaymentMethod paymentMethod) : this(null, status, - new BillingPlan(), new BillingPeriod(), - new BillingInvoice(), paymentMethod) - { - } + public static Result Create(Identifier subscriptionId, BillingStatus status, + BillingPlan plan, + BillingPeriod period, BillingInvoice invoice, BillingPaymentMethod paymentMethod) + { + return new BillingSubscription(subscriptionId, status, plan, period, invoice, paymentMethod); + } - public BillingSubscription(Identifier subscriptionId, BillingStatus status, BillingPlan plan, - BillingPeriod period, BillingInvoice invoice, BillingPaymentMethod paymentMethod) - { - SubscriptionId = subscriptionId; - Status = status; - Plan = plan; - Period = period; - Invoice = invoice; - PaymentMethod = paymentMethod; - } + private BillingSubscription(Identifier subscriptionId, BillingStatus status, BillingPlan plan, + BillingPeriod period, BillingInvoice invoice, BillingPaymentMethod paymentMethod) + { + SubscriptionId = subscriptionId; + Status = status; + Plan = plan; + Period = period; + Invoice = invoice; + PaymentMethod = paymentMethod; + } - public Identifier SubscriptionId { get; } + public BillingInvoice Invoice { get; } - public BillingStatus Status { get; } + public BillingPaymentMethod PaymentMethod { get; } - public BillingPlan Plan { get; } + public BillingPeriod Period { get; } - public BillingPeriod Period { get; } + public BillingPlan Plan { get; } - public BillingInvoice Invoice { get; } + public BillingStatus Status { get; } - public BillingPaymentMethod PaymentMethod { get; } + public Identifier SubscriptionId { get; } - public static ValueObjectFactory Rehydrate() + public static ValueObjectFactory Rehydrate() + { + return (property, container) => { - return (property, _) => - { - var parts = RehydrateToList(property, false); - return new BillingSubscription(parts[0].ToId(), - BillingStatus.Rehydrate()(parts[1], _), - BillingPlan.Rehydrate()(parts[2], _), - BillingPeriod.Rehydrate()(parts[3], _), - BillingInvoice.Rehydrate()(parts[4], _), - BillingPaymentMethod.Rehydrate()(parts[5], _)); - }; - } + var parts = RehydrateToList(property, false); + return new BillingSubscription(parts[0].ToId(), + BillingStatus.Rehydrate()(parts[1]!, container), + BillingPlan.Rehydrate()(parts[2]!, container), + BillingPeriod.Rehydrate()(parts[3]!, container), + BillingInvoice.Rehydrate()(parts[4]!, container), + BillingPaymentMethod.Rehydrate()(parts[5]!, container)); + }; + } - protected override IEnumerable GetAtomicValues() - { - return new object[] { SubscriptionId, Status, Plan, Period, Invoice, PaymentMethod }; - } + protected override IEnumerable GetAtomicValues() + { + return new object[] { SubscriptionId, Status, Plan, Period, Invoice, PaymentMethod }; } } \ No newline at end of file diff --git a/src/Domain.Shared/Validations.cs b/src/Domain.Shared/Validations.cs new file mode 100644 index 00000000..316db227 --- /dev/null +++ b/src/Domain.Shared/Validations.cs @@ -0,0 +1,19 @@ +using Common.Extensions; +using Domain.Interfaces.Validations; + +namespace Domain.Shared; + +public static class Validations +{ + public static class Subscriptions + { + public static class Provider + { + public static readonly Validation Name = CommonValidations.DescriptiveName(1, 50); + public static readonly Validation> State = new(state => + { + return state.Count > 0 && state.All(pair => pair.Value.Exists()); + }); + } + } +} \ No newline at end of file diff --git a/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs b/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs index 45b02488..1db9a4ec 100644 --- a/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs +++ b/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs @@ -8,6 +8,7 @@ using Domain.Interfaces; using Domain.Interfaces.Authorization; using Domain.Interfaces.Entities; +using Domain.Services.Shared; using Domain.Shared; using Domain.Shared.EndUsers; using EndUsersApplication.Persistence; diff --git a/src/EndUsersApplication.UnitTests/InvitationsApplication.DomainEventHandlersSpec.cs b/src/EndUsersApplication.UnitTests/InvitationsApplication.DomainEventHandlersSpec.cs index 2ab2a7dd..24ca47d7 100644 --- a/src/EndUsersApplication.UnitTests/InvitationsApplication.DomainEventHandlersSpec.cs +++ b/src/EndUsersApplication.UnitTests/InvitationsApplication.DomainEventHandlersSpec.cs @@ -7,6 +7,7 @@ using Domain.Common.ValueObjects; using Domain.Interfaces.Authorization; using Domain.Interfaces.Entities; +using Domain.Services.Shared; using Domain.Shared; using Domain.Shared.EndUsers; using EndUsersApplication.Persistence; diff --git a/src/EndUsersApplication.UnitTests/InvitationsApplicationSpec.cs b/src/EndUsersApplication.UnitTests/InvitationsApplicationSpec.cs index a1b6c953..62b26fa7 100644 --- a/src/EndUsersApplication.UnitTests/InvitationsApplicationSpec.cs +++ b/src/EndUsersApplication.UnitTests/InvitationsApplicationSpec.cs @@ -6,6 +6,7 @@ using Domain.Common.Identity; using Domain.Common.ValueObjects; using Domain.Interfaces.Entities; +using Domain.Services.Shared; using Domain.Shared; using Domain.Shared.EndUsers; using EndUsersApplication.Persistence; diff --git a/src/EndUsersApplication/InvitationsApplication.cs b/src/EndUsersApplication/InvitationsApplication.cs index 104b2e31..8521fd81 100644 --- a/src/EndUsersApplication/InvitationsApplication.cs +++ b/src/EndUsersApplication/InvitationsApplication.cs @@ -6,6 +6,7 @@ using Common.Extensions; using Domain.Common.Identity; using Domain.Common.ValueObjects; +using Domain.Services.Shared; using Domain.Shared; using Domain.Shared.EndUsers; using EndUsersApplication.Persistence; @@ -16,10 +17,10 @@ namespace EndUsersApplication; public partial class InvitationsApplication : IInvitationsApplication { private readonly IIdentifierFactory _idFactory; - private readonly IUserNotificationsService _userNotificationsService; private readonly IRecorder _recorder; private readonly IInvitationRepository _repository; private readonly ITokensService _tokensService; + private readonly IUserNotificationsService _userNotificationsService; private readonly IUserProfilesService _userProfilesService; public InvitationsApplication(IRecorder recorder, IIdentifierFactory idFactory, ITokensService tokensService, diff --git a/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs b/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs index de806564..e92bdd2c 100644 --- a/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs +++ b/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs @@ -6,6 +6,7 @@ using Domain.Interfaces; using Domain.Interfaces.Authorization; using Domain.Interfaces.Entities; +using Domain.Services.Shared; using Domain.Shared; using Domain.Shared.EndUsers; using Domain.Shared.Organizations; @@ -736,7 +737,7 @@ public void WhenAssignPlatformRolesAndHasRole_ThenDoesNothing() _user.Features.HasNone().Should().BeTrue(); } #endif - + #if TESTINGONLY [Fact] public void WhenAssignPlatformRoles_ThenAssigns() diff --git a/src/EndUsersDomain/EndUserRoot.cs b/src/EndUsersDomain/EndUserRoot.cs index cc238ac5..02c825c4 100644 --- a/src/EndUsersDomain/EndUserRoot.cs +++ b/src/EndUsersDomain/EndUserRoot.cs @@ -8,6 +8,7 @@ using Domain.Interfaces.Authorization; using Domain.Interfaces.Entities; using Domain.Interfaces.ValueObjects; +using Domain.Services.Shared; using Domain.Shared; using Domain.Shared.EndUsers; using Domain.Shared.Organizations; diff --git a/src/EndUsersInfrastructure/EndUsersModule.cs b/src/EndUsersInfrastructure/EndUsersModule.cs index 0c2d654f..03d3ceb9 100644 --- a/src/EndUsersInfrastructure/EndUsersModule.cs +++ b/src/EndUsersInfrastructure/EndUsersModule.cs @@ -5,6 +5,7 @@ using Common.Configuration; using Domain.Common.Identity; using Domain.Interfaces; +using Domain.Services.Shared; using EndUsersApplication; using EndUsersApplication.Persistence; using EndUsersDomain; diff --git a/src/IdentityApplication.UnitTests/APIKeysApplicationSpec.cs b/src/IdentityApplication.UnitTests/APIKeysApplicationSpec.cs index e6f3058c..567b129e 100644 --- a/src/IdentityApplication.UnitTests/APIKeysApplicationSpec.cs +++ b/src/IdentityApplication.UnitTests/APIKeysApplicationSpec.cs @@ -7,6 +7,7 @@ using Domain.Common.Identity; using Domain.Common.ValueObjects; using Domain.Interfaces.Entities; +using Domain.Services.Shared; using Domain.Shared.Identities; using FluentAssertions; using IdentityApplication.Persistence; diff --git a/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs b/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs index c5f54d7e..f18cf16b 100644 --- a/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs +++ b/src/IdentityApplication.UnitTests/PasswordCredentialsApplicationSpec.cs @@ -7,6 +7,7 @@ using Domain.Common.Identity; using Domain.Common.ValueObjects; using Domain.Interfaces.Entities; +using Domain.Services.Shared; using Domain.Shared; using FluentAssertions; using IdentityApplication.ApplicationServices; diff --git a/src/IdentityApplication.UnitTests/SSOProvidersServiceSpec.cs b/src/IdentityApplication.UnitTests/SSOProvidersServiceSpec.cs index 8cb53cd2..d48d3f48 100644 --- a/src/IdentityApplication.UnitTests/SSOProvidersServiceSpec.cs +++ b/src/IdentityApplication.UnitTests/SSOProvidersServiceSpec.cs @@ -5,6 +5,7 @@ using Domain.Common.Identity; using Domain.Common.ValueObjects; using Domain.Interfaces.Entities; +using Domain.Services.Shared; using Domain.Shared; using FluentAssertions; using IdentityApplication.ApplicationServices; diff --git a/src/IdentityApplication/APIKeysApplication.cs b/src/IdentityApplication/APIKeysApplication.cs index 0500a638..ccfd2747 100644 --- a/src/IdentityApplication/APIKeysApplication.cs +++ b/src/IdentityApplication/APIKeysApplication.cs @@ -6,6 +6,7 @@ using Common.Extensions; using Domain.Common.Identity; using Domain.Common.ValueObjects; +using Domain.Services.Shared; using IdentityApplication.Persistence; using IdentityDomain; using IdentityDomain.DomainServices; diff --git a/src/IdentityApplication/ApplicationServices/SSOProvidersService.cs b/src/IdentityApplication/ApplicationServices/SSOProvidersService.cs index 7c1aaed4..11696624 100644 --- a/src/IdentityApplication/ApplicationServices/SSOProvidersService.cs +++ b/src/IdentityApplication/ApplicationServices/SSOProvidersService.cs @@ -2,6 +2,7 @@ using Common.Extensions; using Domain.Common.Identity; using Domain.Common.ValueObjects; +using Domain.Services.Shared; using Domain.Shared; using IdentityApplication.Persistence; using IdentityDomain; diff --git a/src/IdentityApplication/PasswordCredentialsApplication.cs b/src/IdentityApplication/PasswordCredentialsApplication.cs index 5724159b..78a44c34 100644 --- a/src/IdentityApplication/PasswordCredentialsApplication.cs +++ b/src/IdentityApplication/PasswordCredentialsApplication.cs @@ -6,6 +6,7 @@ using Common.Configuration; using Domain.Common.Identity; using Domain.Common.ValueObjects; +using Domain.Services.Shared; using Domain.Shared; using IdentityApplication.ApplicationServices; using IdentityApplication.Persistence; @@ -379,7 +380,7 @@ public async Task> GetPersonRegist { return Error.EntityNotFound(); } - + var credential = retrieved.Value.Value; var token = credential.VerificationKeep.Token; diff --git a/src/IdentityDomain.UnitTests/PasswordCredentialRootSpec.cs b/src/IdentityDomain.UnitTests/PasswordCredentialRootSpec.cs index cf8e5df9..2e142efd 100644 --- a/src/IdentityDomain.UnitTests/PasswordCredentialRootSpec.cs +++ b/src/IdentityDomain.UnitTests/PasswordCredentialRootSpec.cs @@ -4,6 +4,7 @@ using Domain.Common.ValueObjects; using Domain.Events.Shared.Identities.PasswordCredentials; using Domain.Interfaces.Entities; +using Domain.Services.Shared; using Domain.Shared; using FluentAssertions; using IdentityDomain.DomainServices; diff --git a/src/IdentityDomain.UnitTests/SSOUserRootSpec.cs b/src/IdentityDomain.UnitTests/SSOUserRootSpec.cs index 8b56d05b..cbb86b20 100644 --- a/src/IdentityDomain.UnitTests/SSOUserRootSpec.cs +++ b/src/IdentityDomain.UnitTests/SSOUserRootSpec.cs @@ -4,6 +4,7 @@ using Domain.Common.ValueObjects; using Domain.Events.Shared.Identities.SSOUsers; using Domain.Interfaces.Entities; +using Domain.Services.Shared; using Domain.Shared; using FluentAssertions; using Moq; diff --git a/src/IdentityDomain/PasswordCredentialRoot.cs b/src/IdentityDomain/PasswordCredentialRoot.cs index b9bdbbeb..991962bd 100644 --- a/src/IdentityDomain/PasswordCredentialRoot.cs +++ b/src/IdentityDomain/PasswordCredentialRoot.cs @@ -8,6 +8,7 @@ using Domain.Interfaces; using Domain.Interfaces.Entities; using Domain.Interfaces.ValueObjects; +using Domain.Services.Shared; using Domain.Shared; using IdentityDomain.DomainServices; diff --git a/src/IdentityDomain/SSOUserRoot.cs b/src/IdentityDomain/SSOUserRoot.cs index d7bc9cf2..1f36175f 100644 --- a/src/IdentityDomain/SSOUserRoot.cs +++ b/src/IdentityDomain/SSOUserRoot.cs @@ -7,6 +7,7 @@ using Domain.Interfaces; using Domain.Interfaces.Entities; using Domain.Interfaces.ValueObjects; +using Domain.Services.Shared; using Domain.Shared; namespace IdentityDomain; diff --git a/src/IdentityInfrastructure.UnitTests/ApplicationServices/JWTTokensServiceSpec.cs b/src/IdentityInfrastructure.UnitTests/ApplicationServices/JWTTokensServiceSpec.cs index f434eb89..4baeabcf 100644 --- a/src/IdentityInfrastructure.UnitTests/ApplicationServices/JWTTokensServiceSpec.cs +++ b/src/IdentityInfrastructure.UnitTests/ApplicationServices/JWTTokensServiceSpec.cs @@ -2,6 +2,7 @@ using Application.Resources.Shared; using Common.Configuration; using Domain.Interfaces.Authorization; +using Domain.Services.Shared; using FluentAssertions; using IdentityInfrastructure.ApplicationServices; using Infrastructure.Common.Extensions; diff --git a/src/IdentityInfrastructure.UnitTests/DomainServices/EmailAddressServiceSpec.cs b/src/IdentityInfrastructure.UnitTests/DomainServices/EmailAddressServiceSpec.cs index 36b9dcd2..d679229c 100644 --- a/src/IdentityInfrastructure.UnitTests/DomainServices/EmailAddressServiceSpec.cs +++ b/src/IdentityInfrastructure.UnitTests/DomainServices/EmailAddressServiceSpec.cs @@ -1,6 +1,7 @@ using Common; using Common.Configuration; using Domain.Common.ValueObjects; +using Domain.Services.Shared; using Domain.Shared; using FluentAssertions; using IdentityApplication.Persistence; diff --git a/src/IdentityInfrastructure/ApplicationServices/JWTTokensService.cs b/src/IdentityInfrastructure/ApplicationServices/JWTTokensService.cs index ab7d7a4f..fb198b10 100644 --- a/src/IdentityInfrastructure/ApplicationServices/JWTTokensService.cs +++ b/src/IdentityInfrastructure/ApplicationServices/JWTTokensService.cs @@ -3,6 +3,7 @@ using Application.Resources.Shared; using Common; using Common.Configuration; +using Domain.Services.Shared; using IdentityApplication.ApplicationServices; using Infrastructure.Common.Extensions; using Infrastructure.Interfaces; diff --git a/src/IdentityInfrastructure/IdentityModule.cs b/src/IdentityInfrastructure/IdentityModule.cs index 7f243919..56469067 100644 --- a/src/IdentityInfrastructure/IdentityModule.cs +++ b/src/IdentityInfrastructure/IdentityModule.cs @@ -5,6 +5,7 @@ using Common.Configuration; using Domain.Common.Identity; using Domain.Interfaces; +using Domain.Services.Shared; using IdentityApplication; using IdentityApplication.ApplicationServices; using IdentityApplication.Persistence; diff --git a/src/Infrastructure.Common/DomainServices/AesEncryptionService.cs b/src/Infrastructure.Common/DomainServices/AesEncryptionService.cs index 6db2dd8e..05fa4e39 100644 --- a/src/Infrastructure.Common/DomainServices/AesEncryptionService.cs +++ b/src/Infrastructure.Common/DomainServices/AesEncryptionService.cs @@ -1,4 +1,5 @@ using System.Security.Cryptography; +using Domain.Services.Shared; namespace Infrastructure.Common.DomainServices; diff --git a/src/Infrastructure.Common/DomainServices/TenantSettingService.cs b/src/Infrastructure.Common/DomainServices/TenantSettingService.cs index df09e6f9..d508bda1 100644 --- a/src/Infrastructure.Common/DomainServices/TenantSettingService.cs +++ b/src/Infrastructure.Common/DomainServices/TenantSettingService.cs @@ -1,4 +1,5 @@ using Domain.Interfaces.Services; +using Domain.Services.Shared; namespace Infrastructure.Common.DomainServices; diff --git a/src/Infrastructure.Shared.UnitTests/ApplicationServices/BasicBillingStateProxySpec.cs b/src/Infrastructure.Shared.UnitTests/ApplicationServices/BasicBillingStateProxySpec.cs new file mode 100644 index 00000000..8519d7e3 --- /dev/null +++ b/src/Infrastructure.Shared.UnitTests/ApplicationServices/BasicBillingStateProxySpec.cs @@ -0,0 +1,176 @@ +using Common; +using Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Shared.Subscriptions; +using FluentAssertions; +using Infrastructure.Shared.ApplicationServices; +using UnitTesting.Common; +using Xunit; + +namespace Infrastructure.Shared.UnitTests.ApplicationServices; + +[Trait("Category", "Unit")] +public class BasicBillingStateProxySpec +{ + private readonly BasicBillingStateProxy _proxy; + + public BasicBillingStateProxySpec() + { + _proxy = new BasicBillingStateProxy(); + } + + [Fact] + public void WhenGetProviderName_ThenReturnsName() + { + var result = _proxy.ProviderName; + + result.Should().Be(BasicBillingStateProxy.Constants.ProviderName); + } + + [Fact] + public void WhenGetSubscriptionReferenceAndSubscriptionIdNotExist_ThenReturnsError() + { + var provider = BillingProvider.Create(BasicBillingStateProxy.Constants.ProviderName, + new Dictionary { { "aname", "avalue" } }) + .Value; + + var result = _proxy.GetSubscriptionReference(provider); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.BasicBillingStateProxy_PropertyNotFound.Format( + BasicBillingStateProxy.Constants.SubscriptionIdPropName, typeof(BasicBillingStateProxy).FullName!)); + } + + [Fact] + public void WhenGetSubscriptionReference_ThenReturnsSubscriptionId() + { + var provider = BillingProvider.Create(BasicBillingStateProxy.Constants.ProviderName, + new Dictionary + { + { BasicBillingStateProxy.Constants.SubscriptionIdPropName, "asubscriptionid" } + }).Value; + + var result = _proxy.GetSubscriptionReference(provider); + + result.Should().Be("asubscriptionid"); + } + + [Fact] + public void WhenGetBuyerReferenceAndBuyerIdNotExist_ThenReturnsError() + { + var provider = BillingProvider.Create(BasicBillingStateProxy.Constants.ProviderName, + new Dictionary { { "aname", "avalue" } }) + .Value; + + var result = _proxy.GetBuyerReference(provider); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.BasicBillingStateProxy_PropertyNotFound.Format(BasicBillingStateProxy.Constants.BuyerIdPropName, + typeof(BasicBillingStateProxy).FullName!)); + } + + [Fact] + public void WhenGetBuyerReference_ThenReturnsBuyerId() + { + var provider = BillingProvider.Create(BasicBillingStateProxy.Constants.ProviderName, + new Dictionary + { + { BasicBillingStateProxy.Constants.BuyerIdPropName, "abuyerid" } + }).Value; + + var result = _proxy.GetBuyerReference(provider); + + result.Should().Be("abuyerid"); + } + + [Fact] + public void WhenGetBillingSubscription_ThenAlwaysReturnsBasicPlan() + { + var provider = BillingProvider.Create(BasicBillingStateProxy.Constants.ProviderName, + new Dictionary + { + { BasicBillingStateProxy.Constants.SubscriptionIdPropName, "asubscriptionid" } + }).Value; + + var result = _proxy.GetBillingSubscription(provider); + + result.Value.SubscriptionId.Should().Be("asubscriptionid".ToId()); + result.Value.Status.Subscription.Should().Be(BillingSubscriptionStatus.Activated); + result.Value.Status.CancelledDateUtc.Should().BeNull(); + result.Value.Status.CanBeUnsubscribed.Should().BeTrue(); + result.Value.Plan.Id.Should().Be(BasicBillingStateProxy.Constants.BasicPlanId.ToId()); + result.Value.Plan.IsTrial.Should().BeFalse(); + result.Value.Plan.TrialEndDateUtc.Should().BeNull(); + result.Value.Plan.Tier.Should().Be(BillingSubscriptionTier.Basic); + result.Value.Period.Frequency.Should().Be(0); + result.Value.Period.Unit.Should().Be(BillingFrequencyUnit.Eternity); + result.Value.Invoice.CurrencyCode.Currency.Should().Be(CurrencyCodes.Default); + result.Value.Invoice.NextUtc.Should().BeNull(); + result.Value.Invoice.Amount.Should().Be(0); + result.Value.PaymentMethod.Status.Should().Be(BillingPaymentMethodStatus.Valid); + result.Value.PaymentMethod.Type.Should().Be(BillingPaymentMethodType.Other); + result.Value.PaymentMethod.ExpiresOn.Should().BeNull(); + } + + [Fact] + public void WhenTranslateSubscribedProviderAndNotForThisProvider_ThenReturnsError() + { + var provider = BillingProvider.Create("anotherprovider", new Dictionary + { + { BasicBillingStateProxy.Constants.BuyerIdPropName, "abuyerid" }, + { BasicBillingStateProxy.Constants.SubscriptionIdPropName, "asubscriptionid" } + }).Value; + + var result = _proxy.TranslateSubscribedProvider(provider); + + result.Should().BeError(ErrorCode.Validation, Resources.BasicBillingStateProxy_ProviderNameNotMatch); + } + + [Fact] + public void WhenTranslateSubscribedProviderAndMissingBuyerId_ThenReturnsError() + { + var provider = BillingProvider.Create(BasicBillingStateProxy.Constants.ProviderName, + new Dictionary + { + { BasicBillingStateProxy.Constants.SubscriptionIdPropName, "asubscriptionid" } + }).Value; + + var result = _proxy.TranslateSubscribedProvider(provider); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.BasicBillingStateProxy_PropertyNotFound.Format( + BasicBillingStateProxy.Constants.BuyerIdPropName, + typeof(BasicBillingStateProxy).FullName!)); + } + + [Fact] + public void WhenTranslateSubscribedProviderAndMissingSubscriptionId_ThenReturnsError() + { + var provider = BillingProvider.Create(BasicBillingStateProxy.Constants.ProviderName, + new Dictionary + { + { BasicBillingStateProxy.Constants.BuyerIdPropName, "abuyerid" } + }).Value; + + var result = _proxy.TranslateSubscribedProvider(provider); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.BasicBillingStateProxy_PropertyNotFound.Format( + BasicBillingStateProxy.Constants.SubscriptionIdPropName, + typeof(BasicBillingStateProxy).FullName!)); + } + + [Fact] + public void WhenTranslateSubscribedProvider_ThenReturnsSameProvider() + { + var provider = BillingProvider.Create(BasicBillingStateProxy.Constants.ProviderName, + new Dictionary + { + { BasicBillingStateProxy.Constants.BuyerIdPropName, "abuyerid" }, + { BasicBillingStateProxy.Constants.SubscriptionIdPropName, "asubscriptionid" } + }).Value; + + var result = _proxy.TranslateSubscribedProvider(provider); + + result.Should().BeSuccess(); + result.Value.Should().Be(provider); + } +} \ No newline at end of file diff --git a/src/Infrastructure.Shared.UnitTests/ApplicationServices/InProcessInMemBillingGatewayServiceSpec.cs b/src/Infrastructure.Shared.UnitTests/ApplicationServices/InProcessInMemBillingGatewayServiceSpec.cs new file mode 100644 index 00000000..d854061b --- /dev/null +++ b/src/Infrastructure.Shared.UnitTests/ApplicationServices/InProcessInMemBillingGatewayServiceSpec.cs @@ -0,0 +1,50 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Application.Services.Shared; +using Common; +using FluentAssertions; +using Infrastructure.Shared.ApplicationServices; +using Moq; +using UnitTesting.Common; +using Xunit; + +namespace Infrastructure.Shared.UnitTests.ApplicationServices; + +[Trait("Category", "Unit")] +public class InProcessInMemBillingGatewayServiceSpec +{ + private readonly Mock _caller; + private readonly InProcessInMemBillingGatewayService _service; + + public InProcessInMemBillingGatewayServiceSpec() + { + _caller = new Mock(); + _service = new InProcessInMemBillingGatewayService(); + } + + [Fact] + public async Task WhenSubscribeAsync_ThenReturns() + { + var buyer = new SubscriptionBuyer + { + Address = new ProfileAddress { CountryCode = CountryCodes.NewZealand.ToString() }, + EmailAddress = "auser@company.com", + Id = "abuyerid", + Name = new PersonName { FirstName = "afirstname" }, + Organization = new BuyerOrganization + { + Id = "anorganizationid", + Name = "anorganizationname" + }, + PhoneNumber = "aphonenumber" + }; + + var result = await _service.SubscribeAsync(_caller.Object, buyer, SubscribeOptions.Immediately, + CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Count.Should().Be(2); + result.Value[BasicBillingStateProxy.Constants.BuyerIdPropName].Should().Be("abuyerid"); + result.Value[BasicBillingStateProxy.Constants.SubscriptionIdPropName].Should().NotBeNull(); + } +} \ No newline at end of file diff --git a/src/Infrastructure.Shared/ApplicationServices/BasicBillingProvider.cs b/src/Infrastructure.Shared/ApplicationServices/BasicBillingProvider.cs new file mode 100644 index 00000000..5a4d5204 --- /dev/null +++ b/src/Infrastructure.Shared/ApplicationServices/BasicBillingProvider.cs @@ -0,0 +1,143 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Application.Services.Shared; +using Common; +using Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Services.Shared; +using Domain.Shared.Subscriptions; + +namespace Infrastructure.Shared.ApplicationServices; + +/// +/// Provides a default billing provider, that does very little +/// +public class BasicBillingProvider : IBillingProvider +{ + public BasicBillingProvider() + { + GatewayService = new InProcessInMemBillingGatewayService(); + StateProxy = new BasicBillingStateProxy(); + } + + public IBillingGatewayService GatewayService { get; } + + public string ProviderName => StateProxy.ProviderName; + + public IBillingStateProxy StateProxy { get; } +} + +/// +/// Provides a state proxy that only maintains the bare minimum +/// +public class BasicBillingStateProxy : IBillingStateProxy +{ + public string ProviderName { get; } = Constants.ProviderName; + + public Result GetBillingSubscription(BillingProvider current) + { + var status = BillingStatus.Create(BillingSubscriptionStatus.Activated, null, true); + if (status.IsFailure) + { + return status.Error; + } + + var plan = BillingPlan.Create(Constants.BasicPlanId.ToId(), false, null, BillingSubscriptionTier.Basic); + if (plan.IsFailure) + { + return plan.Error; + } + + var paymentMethod = + BillingPaymentMethod.Create(BillingPaymentMethodType.Other, BillingPaymentMethodStatus.Valid, null); + if (paymentMethod.IsFailure) + { + return paymentMethod.Error; + } + + var subscriptionId = current.State.GetValueOrDefault(Constants.SubscriptionIdPropName).ToId(); + + return BillingSubscription.Create(subscriptionId, status.Value, plan.Value, BillingPeriod.Empty, + BillingInvoice.Empty, paymentMethod.Value); + } + + public Result GetBuyerReference(BillingProvider current) + { + if (current.State.TryGetValue(Constants.BuyerIdPropName, out var reference)) + { + return reference; + } + + return Error.RuleViolation( + Resources.BasicBillingStateProxy_PropertyNotFound.Format(Constants.BuyerIdPropName, GetType().FullName!)); + } + + public Result GetSubscriptionReference(BillingProvider current) + { + if (current.State.TryGetValue(Constants.SubscriptionIdPropName, out var reference)) + { + return reference; + } + + return Error.RuleViolation( + Resources.BasicBillingStateProxy_PropertyNotFound.Format(Constants.SubscriptionIdPropName, + GetType().FullName!)); + } + + public Result TranslateSubscribedProvider(BillingProvider subscribed) + { + if (subscribed.Name.IsInvalidParameter(name => name.EqualsIgnoreCase(Constants.ProviderName), + nameof(subscribed.Name), Resources.BasicBillingStateProxy_ProviderNameNotMatch, out var error1)) + { + return error1; + } + + if (!subscribed.State.TryGetValue(Constants.SubscriptionIdPropName, out _)) + { + return Error.RuleViolation( + Resources.BasicBillingStateProxy_PropertyNotFound.Format(Constants.SubscriptionIdPropName, + GetType().FullName!)); + } + + if (!subscribed.State.TryGetValue(Constants.BuyerIdPropName, out _)) + { + return Error.RuleViolation( + Resources.BasicBillingStateProxy_PropertyNotFound.Format(Constants.BuyerIdPropName, + GetType().FullName!)); + } + + return subscribed; + } + + public static class Constants + { + public const string BasicPlanId = "_default_basic"; + public const string BuyerIdPropName = "BuyerId"; + public const string ProviderName = "default"; + public const string SubscriptionIdPropName = "SubscriptionId"; + } +} + +/// +/// Provides a in-memory gateway that has very basic behaviour +/// +public class InProcessInMemBillingGatewayService : IBillingGatewayService +{ + private const string SubscriptionIdPrefix = "default"; + + public Task> SubscribeAsync(ICallerContext caller, + SubscriptionBuyer buyer, SubscribeOptions options, + CancellationToken cancellationToken) + { + return Task.FromResult>(new BillingProviderState + { + { BasicBillingStateProxy.Constants.BuyerIdPropName, buyer.Id }, + { BasicBillingStateProxy.Constants.SubscriptionIdPropName, GenerateSubscriptionId() } + }); + } + + private static string GenerateSubscriptionId() + { + return $"{SubscriptionIdPrefix}_{Guid.NewGuid():N}"; + } +} \ No newline at end of file diff --git a/src/Infrastructure.Shared/DomainServices/TokensService.cs b/src/Infrastructure.Shared/DomainServices/TokensService.cs index 0cca30b6..f831af64 100644 --- a/src/Infrastructure.Shared/DomainServices/TokensService.cs +++ b/src/Infrastructure.Shared/DomainServices/TokensService.cs @@ -1,6 +1,7 @@ using System.Security.Cryptography; using Common; using Domain.Interfaces.Validations; +using Domain.Services.Shared; using Domain.Shared.Identities; namespace Infrastructure.Shared.DomainServices; diff --git a/src/Infrastructure.Shared/Resources.Designer.cs b/src/Infrastructure.Shared/Resources.Designer.cs index 99efcd42..f57b76cb 100644 --- a/src/Infrastructure.Shared/Resources.Designer.cs +++ b/src/Infrastructure.Shared/Resources.Designer.cs @@ -59,6 +59,24 @@ internal Resources() { } } + /// + /// Looks up a localized string similar to The {0} was not found for {1}. + /// + internal static string BasicBillingStateProxy_PropertyNotFound { + get { + return ResourceManager.GetString("BasicBillingStateProxy_PropertyNotFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The ProviderName does not match this provider. + /// + internal static string BasicBillingStateProxy_ProviderNameNotMatch { + get { + return ResourceManager.GetString("BasicBillingStateProxy_ProviderNameNotMatch", resourceCulture); + } + } + /// /// Looks up a localized string similar to Failed to notify consumer: {0}, with event: {1} ({2}). /// diff --git a/src/Infrastructure.Shared/Resources.resx b/src/Infrastructure.Shared/Resources.resx index 3acf5e7f..a8f008fb 100644 --- a/src/Infrastructure.Shared/Resources.resx +++ b/src/Infrastructure.Shared/Resources.resx @@ -30,4 +30,10 @@ Failed to notify consumer: {0}, with event: {1} ({2}) + + The {0} was not found for {1} + + + The ProviderName does not match this provider + \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.IntegrationTests/AuthNApiSpec.cs b/src/Infrastructure.Web.Api.IntegrationTests/AuthNApiSpec.cs index 93939be2..143c9082 100644 --- a/src/Infrastructure.Web.Api.IntegrationTests/AuthNApiSpec.cs +++ b/src/Infrastructure.Web.Api.IntegrationTests/AuthNApiSpec.cs @@ -4,6 +4,7 @@ using Common.Configuration; using Domain.Interfaces; using Domain.Interfaces.Authorization; +using Domain.Services.Shared; using FluentAssertions; using IdentityInfrastructure.ApplicationServices; using Infrastructure.Web.Api.Common.Extensions; diff --git a/src/Infrastructure.Web.Api.IntegrationTests/AuthZApiSpec.cs b/src/Infrastructure.Web.Api.IntegrationTests/AuthZApiSpec.cs index 2b52318d..e4040178 100644 --- a/src/Infrastructure.Web.Api.IntegrationTests/AuthZApiSpec.cs +++ b/src/Infrastructure.Web.Api.IntegrationTests/AuthZApiSpec.cs @@ -4,6 +4,7 @@ using Common.Configuration; using Domain.Interfaces; using Domain.Interfaces.Authorization; +using Domain.Services.Shared; using FluentAssertions; using IdentityInfrastructure.ApplicationServices; using Infrastructure.Web.Api.Common.Extensions; diff --git a/src/Infrastructure.Web.Api.Interfaces/ITenantedRequest.cs b/src/Infrastructure.Web.Api.Interfaces/ITenantedRequest.cs index 8b39febb..83276bf7 100644 --- a/src/Infrastructure.Web.Api.Interfaces/ITenantedRequest.cs +++ b/src/Infrastructure.Web.Api.Interfaces/ITenantedRequest.cs @@ -14,7 +14,7 @@ public interface ITenantedRequest /// /// Defines a request for a specific tenant, for Organization requests that are untenanted -/// Only to be used by the Organizations subdomain +/// Only to be used by the Organizations/Subscriptions subdomain /// public interface IUnTenantedOrganizationRequest { diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Subscriptions/GetSubscriptionRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Subscriptions/GetSubscriptionRequest.cs new file mode 100644 index 00000000..2855f636 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Subscriptions/GetSubscriptionRequest.cs @@ -0,0 +1,13 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Subscriptions; + +/// +/// Fetches the billing subscription for the organization +/// +[Route("/subscriptions/{Id}", OperationMethod.Get, AccessType.Token)] +[Authorize(Roles.Tenant_BillingAdmin, Features.Tenant_Basic)] +public class GetSubscriptionRequest : UnTenantedRequest, IUnTenantedOrganizationRequest +{ + public string? Id { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Subscriptions/GetSubscriptionResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/Subscriptions/GetSubscriptionResponse.cs new file mode 100644 index 00000000..5d22b623 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Subscriptions/GetSubscriptionResponse.cs @@ -0,0 +1,9 @@ +using Application.Resources.Shared; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Subscriptions; + +public class GetSubscriptionResponse : IWebResponse +{ + public Subscription? Subscription { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Hosting.Common/Pipeline/CSRFService.cs b/src/Infrastructure.Web.Hosting.Common/Pipeline/CSRFService.cs index 39957e2a..73ee75ca 100644 --- a/src/Infrastructure.Web.Hosting.Common/Pipeline/CSRFService.cs +++ b/src/Infrastructure.Web.Hosting.Common/Pipeline/CSRFService.cs @@ -1,5 +1,6 @@ using Application.Interfaces.Services; using Common; +using Domain.Services.Shared; namespace Infrastructure.Web.Hosting.Common.Pipeline; diff --git a/src/Infrastructure.Web.Hosting.Common/Pipeline/CSRFTokenPair.cs b/src/Infrastructure.Web.Hosting.Common/Pipeline/CSRFTokenPair.cs index 48a4b328..64483d61 100644 --- a/src/Infrastructure.Web.Hosting.Common/Pipeline/CSRFTokenPair.cs +++ b/src/Infrastructure.Web.Hosting.Common/Pipeline/CSRFTokenPair.cs @@ -1,6 +1,7 @@ using Common; using Common.Extensions; using Domain.Interfaces; +using Domain.Services.Shared; using Infrastructure.Web.Api.Common; namespace Infrastructure.Web.Hosting.Common.Pipeline; diff --git a/src/SaaStack.sln.DotSettings b/src/SaaStack.sln.DotSettings index ec415dfd..e752b94c 100644 --- a/src/SaaStack.sln.DotSettings +++ b/src/SaaStack.sln.DotSettings @@ -341,7 +341,7 @@ <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Methods"><ElementKinds><Kind Name="METHOD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="When" Suffix="" Style="AaBb_AaBb"><ExtraRule Prefix="" Suffix="" Style="AaBb" /><ExtraRule Prefix="Setup" Suffix="" Style="AaBb" /><ExtraRule Prefix="Configure" Suffix="" Style="AaBb" /></Policy></Policy> <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Enum members"><ElementKinds><Kind Name="ENUM_MEMBER" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="" Suffix="" Style="AaBb_AaBb" /></Policy></Policy> <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Types and namespaces"><ElementKinds><Kind Name="NAMESPACE" /><Kind Name="CLASS" /><Kind Name="STRUCT" /><Kind Name="ENUM" /><Kind Name="DELEGATE" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /></Policy> - <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Properties"><ElementKinds><Kind Name="PROPERTY" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="Configure" Suffix="" Style="AaBb" /></Policy></Policy> + <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Properties"><ElementKinds><Kind Name="PROPERTY" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="Configure" Suffix="" Style="AaBb" /><ExtraRule Prefix="Setup" Suffix="" Style="AaBb" /></Policy></Policy> True True True @@ -610,7 +610,7 @@ public sealed class $name$ : SingleValueObjectBase<$name$, $datatype$> True public async Task<ApiDeleteResult> $Action$$Resource$($Action$$Resource$Request request, CancellationToken cancellationToken) {$END$$SELECTION$ - var $resource$ = await _application.$Action$$Resource$Async(_contextFactory.Create(), request.Id, cancellationToken); + var $resource$ = await _application.$Action$$Resource$Async(_callerFactory.Create(), request.Id, cancellationToken); return () => $resource$.HandleApplicationResult(); } @@ -766,7 +766,7 @@ public class $Action$$Resource$RequestValidator : AbstractValidator<$Action$$ True public async Task<ApiPostResult<$Resource$, $Action$$Resource$Response>> $Action$$Resource$($Action$$Resource$Request request, CancellationToken cancellationToken) {$END$$SELECTION$ - var $resource$ = await _application.$Action$$Resource$Async(_contextFactory.Create(), request.Id, cancellationToken); + var $resource$ = await _application.$Action$$Resource$Async(_callerFactory.Create(), request.Id, cancellationToken); return () => $resource$.HandleApplicationResult<$Resource$, $Action$$Resource$Response>(x => new PostResult<$Action$$Resource$Response>(new $Action$$Resource$Response { $Resource$ = x })); } @@ -829,7 +829,7 @@ public sealed class $class$ : DomainEvent True public async Task<ApiSearchResult<$Resource$, $Action$$Resource$sResponse>> $Action$$Resource$s($Action$$Resource$sRequest request, CancellationToken cancellationToken) {$END$$SELECTION$ - var $resource$s = await _application.$Action$$Resource$sAsync(_contextFactory.Create(), request.ToSearchOptions(), request.ToGetOptions(), cancellationToken); + var $resource$s = await _application.$Action$$Resource$sAsync(_callerFactory.Create(), request.ToSearchOptions(), request.ToGetOptions(), cancellationToken); return () => $resource$s.HandleApplicationResult(x => new $Action$$Resource$sResponse { $Resource$s = x.Results, Metadata = x.Metadata }); } @@ -1224,7 +1224,7 @@ public class $Action$$Resource$Response : IWebResponse True public async Task<ApiGetResult<$Resource$, $Action$$Resource$Response>> $Action$$Resource$($Action$$Resource$Request request, CancellationToken cancellationToken) {$END$$SELECTION$ - var $resource$ = await _application.$Action$$Resource$Async(_contextFactory.Create(), request.Id, cancellationToken); + var $resource$ = await _application.$Action$$Resource$Async(_callerFactory.Create(), request.Id, cancellationToken); return () => $resource$.HandleApplicationResult<$Resource$, $Action$$Resource$Response>(x => new $Action$$Resource$Response { $Resource$ = x }); } @@ -1382,7 +1382,7 @@ public sealed class $name$Root : AggregateRootBase True public async Task<ApiPutPatchResult<$Resource$, $Action$$Resource$Response>> $Action$$Resource$($Action$$Resource$Request request, CancellationToken cancellationToken) {$END$$SELECTION$ - var $resource$ = await _application.$Action$$Resource$Async(_contextFactory.Create(), request.Id, cancellationToken); + var $resource$ = await _application.$Action$$Resource$Async(_callerFactory.Create(), request.Id, cancellationToken); return () => $resource$.HandleApplicationResult<$Resource$, $Action$$Resource$Response>(x => new $Action$$Resource$Response { $Resource$ = x }); } diff --git a/src/SubscriptionsApplication.UnitTests/SubscriptionsApplication.DomainEventHandlersSpec.cs b/src/SubscriptionsApplication.UnitTests/SubscriptionsApplication.DomainEventHandlersSpec.cs index daa27e6c..2772d8b3 100644 --- a/src/SubscriptionsApplication.UnitTests/SubscriptionsApplication.DomainEventHandlersSpec.cs +++ b/src/SubscriptionsApplication.UnitTests/SubscriptionsApplication.DomainEventHandlersSpec.cs @@ -1,17 +1,19 @@ using Application.Interfaces; +using Application.Resources.Shared; using Application.Services.Shared; using Common; using Domain.Common.Identity; using Domain.Common.ValueObjects; using Domain.Interfaces.Entities; -using Domain.Services.Shared; -using Domain.Shared.Organizations; +using Domain.Shared.Subscriptions; using Moq; using OrganizationsDomain; using SubscriptionsApplication.Persistence; using SubscriptionsDomain; +using UnitTesting.Common; using Xunit; using Events = OrganizationsDomain.Events; +using OrganizationOwnership = Domain.Shared.Organizations.OrganizationOwnership; namespace SubscriptionsApplication.UnitTests; @@ -33,6 +35,17 @@ public SubscriptionsApplicationDomainEventHandlersSpec() _caller = new Mock(); _userProfilesService = new Mock(); _billingProvider = new Mock(); + _billingProvider.Setup(bp => bp.ProviderName) + .Returns("aprovidername"); + _billingProvider.Setup(bp => bp.StateProxy.ProviderName) + .Returns("aprovidername"); + _billingProvider.Setup(bp => + bp.StateProxy.TranslateSubscribedProvider(It.IsAny())) + .Returns((BillingProvider provider) => provider); + _billingProvider.Setup(bp => bp.StateProxy.GetBuyerReference(It.IsAny())) + .Returns("abuyerreference"); + _billingProvider.Setup(bp => bp.StateProxy.GetSubscriptionReference(It.IsAny())) + .Returns("asubscriptionreference"); _repository = new Mock(); _application = new SubscriptionsApplication(recorder.Object, identifierFactory.Object, @@ -40,9 +53,69 @@ public SubscriptionsApplicationDomainEventHandlersSpec() } [Fact] - public async Task WhenHandleOrganizationCreatedAsync_ThenReturnsOk() + public async Task WhenHandleOrganizationCreatedByAMachineAsync_ThenReturnsError() { - //TODO: setup _billingProvider + _billingProvider.Setup(bp => + bp.StateProxy.GetBillingSubscription(It.IsAny())) + .Returns(BillingSubscription.Create(BillingStatus.Empty).Value); + _userProfilesService.Setup(ups => + ups.GetProfilePrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new UserProfile + { + DisplayName = "adisplayname", + Classification = UserProfileClassification.Machine, + Name = new PersonName + { + FirstName = "afirstname" + }, + UserId = "auserid", + Id = "aprofileid" + }); + + var domainEvent = Events.Created("anorganizationid".ToId(), OrganizationOwnership.Personal, "auserid".ToId(), + DisplayName.Create("aname").Value); + + var result = + await _application.HandleOrganizationCreatedAsync(_caller.Object, domainEvent, CancellationToken.None); + + result.Should().BeError(ErrorCode.EntityNotFound, Resources.SubscriptionsApplication_BuyerNotAPerson); + _repository.Verify(rep => rep.SaveAsync(It.IsAny(), It.IsAny()), + Times.Never); + _userProfilesService.Verify(ps => + ps.GetProfilePrivateAsync(_caller.Object, "auserid".ToId(), CancellationToken.None)); + _billingProvider.Verify(bp => bp.GatewayService.SubscribeAsync(It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _billingProvider.Verify(bp => bp.StateProxy.GetBuyerReference(It.IsAny()), Times.Never); + _billingProvider.Verify(bp => bp.StateProxy.GetSubscriptionReference(It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenHandleOrganizationCreatedByAPersonAsync_ThenReturnsOk() + { + _billingProvider.Setup(bp => + bp.StateProxy.GetBillingSubscription(It.IsAny())) + .Returns(BillingSubscription.Create(BillingStatus.Empty).Value); + _userProfilesService.Setup(ups => + ups.GetProfilePrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new UserProfile + { + DisplayName = "adisplayname", + Classification = UserProfileClassification.Person, + Name = new PersonName + { + FirstName = "afirstname" + }, + UserId = "auserid", + Id = "aprofileid" + }); + _billingProvider.Setup(bp => bp.GatewayService.SubscribeAsync(It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new BillingProviderState + { + { "aname", "avalue" } + }); var domainEvent = Events.Created("anorganizationid".ToId(), OrganizationOwnership.Personal, "auserid".ToId(), DisplayName.Create("aname").Value); @@ -54,13 +127,11 @@ public async Task WhenHandleOrganizationCreatedAsync_ThenReturnsOk() _repository.Verify(rep => rep.SaveAsync(It.Is(root => root.BuyerId == "auserid".ToId() && root.OwningEntityId == "anorganizationid".ToId() - && root.Provider == "aprovidername" + && root.Provider.Value.Name == "aprovidername" ), It.IsAny())); _userProfilesService.Verify(ps => ps.GetProfilePrivateAsync(_caller.Object, "auserid".ToId(), CancellationToken.None)); - _billingProvider.Verify(bp => bp.StateProxy.GetBuyerReference(It.IsAny())); - _billingProvider.Verify(bp => bp.StateProxy.GetSubscriptionReference(It.IsAny())); - _billingProvider.Verify(bp => - bp.StateProxy.GetBillingSubscription("aprovidername", It.IsAny())); + _billingProvider.Verify(bp => bp.StateProxy.GetBuyerReference(It.IsAny())); + _billingProvider.Verify(bp => bp.StateProxy.GetSubscriptionReference(It.IsAny())); } } \ No newline at end of file diff --git a/src/SubscriptionsApplication/ISubscriptionsApplication.cs b/src/SubscriptionsApplication/ISubscriptionsApplication.cs index 9843a390..bd940729 100644 --- a/src/SubscriptionsApplication/ISubscriptionsApplication.cs +++ b/src/SubscriptionsApplication/ISubscriptionsApplication.cs @@ -1,5 +1,11 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Common; + namespace SubscriptionsApplication; public partial interface ISubscriptionsApplication { + Task> GetSubscriptionAsync(ICallerContext caller, string owningEntityId, + CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/SubscriptionsApplication/Persistence/ISubscriptionRepository.cs b/src/SubscriptionsApplication/Persistence/ISubscriptionRepository.cs index 4a6c9bd6..a2bc745a 100644 --- a/src/SubscriptionsApplication/Persistence/ISubscriptionRepository.cs +++ b/src/SubscriptionsApplication/Persistence/ISubscriptionRepository.cs @@ -1,9 +1,13 @@ using Common; +using Domain.Common.ValueObjects; using SubscriptionsDomain; namespace SubscriptionsApplication.Persistence; public interface ISubscriptionRepository { + Task, Error>> FindByOwningEntityIdAsync(Identifier owningEntityId, + CancellationToken cancellationToken); + Task> SaveAsync(SubscriptionRoot subscription, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/SubscriptionsApplication/SubscriptionsApplication.cs b/src/SubscriptionsApplication/SubscriptionsApplication.cs index 25507a5c..9371dfa2 100644 --- a/src/SubscriptionsApplication/SubscriptionsApplication.cs +++ b/src/SubscriptionsApplication/SubscriptionsApplication.cs @@ -1,14 +1,19 @@ +using Application.Common.Extensions; +using Application.Interfaces; +using Application.Resources.Shared; using Application.Services.Shared; using Common; using Domain.Common.Identity; +using Domain.Common.ValueObjects; using SubscriptionsApplication.Persistence; +using SubscriptionsDomain; namespace SubscriptionsApplication; public partial class SubscriptionsApplication : ISubscriptionsApplication { - private readonly IIdentifierFactory _identifierFactory; private readonly IBillingProvider _billingProvider; + private readonly IIdentifierFactory _identifierFactory; private readonly IRecorder _recorder; private readonly ISubscriptionRepository _repository; private readonly IUserProfilesService _userProfilesService; @@ -23,4 +28,45 @@ public SubscriptionsApplication(IRecorder recorder, IIdentifierFactory identifie _billingProvider = billingProvider; _repository = repository; } + + public async Task> GetSubscriptionAsync(ICallerContext caller, string owningEntityId, + CancellationToken cancellationToken) + { + var retrievedSubscription = + await _repository.FindByOwningEntityIdAsync(owningEntityId.ToId(), cancellationToken); + if (retrievedSubscription.IsFailure) + { + return retrievedSubscription.Error; + } + + if (!retrievedSubscription.Value.HasValue) + { + return Error.EntityNotFound(); + } + + var subscription = retrievedSubscription.Value.Value; + + _recorder.TraceInformation(caller.ToCall(), "Retrieved subscription: {Id} for entity: {OwningEntity}", + subscription.Id, subscription.OwningEntityId); + return subscription.ToSubscription(); + } +} + +internal static class SubscriptionConversionExtensions +{ + public static Subscription ToSubscription(this SubscriptionRoot subscription) + { + return new Subscription + { + BuyerId = subscription.BuyerId, + OwningEntityId = subscription.OwningEntityId, + ProviderName = subscription.Provider.HasValue + ? subscription.Provider.Value.Name + : null, + ProviderState = subscription.Provider.HasValue + ? subscription.Provider.Value.State + : new Dictionary(), + Id = subscription.Id + }; + } } \ No newline at end of file diff --git a/src/SubscriptionsApplication/SubscriptionsApplication_DomainEventHandlers.cs b/src/SubscriptionsApplication/SubscriptionsApplication_DomainEventHandlers.cs index 6b94d89a..72eb0285 100644 --- a/src/SubscriptionsApplication/SubscriptionsApplication_DomainEventHandlers.cs +++ b/src/SubscriptionsApplication/SubscriptionsApplication_DomainEventHandlers.cs @@ -5,6 +5,7 @@ using Common; using Domain.Common.ValueObjects; using Domain.Events.Shared.Organizations; +using Domain.Shared.Subscriptions; using SubscriptionsDomain; namespace SubscriptionsApplication; @@ -39,8 +40,6 @@ private async Task> CreateSubscriptionInternalAsync(ICallerContext return created.Error; } - var subscription = created.Value; - var buyer = await CreateBuyerAsync(caller, buyerId, owningEntityId, owningEntityName, cancellationToken); if (buyer.IsFailure) { @@ -48,15 +47,20 @@ private async Task> CreateSubscriptionInternalAsync(ICallerContext } var subscribed = await _billingProvider.GatewayService.SubscribeAsync(caller, buyer.Value, - SubscribeOptions.Immediately); + SubscribeOptions.Immediately, cancellationToken); if (subscribed.IsFailure) { return subscribed.Error; } - var subscribedState = subscribed.Value; - var provided = subscription.SetProvider(_billingProvider.ProviderName, - subscribedState, buyerId, _billingProvider.StateProxy); + var provider = BillingProvider.Create(_billingProvider.ProviderName, subscribed.Value); + if (provider.IsFailure) + { + return provider.Error; + } + + var subscription = created.Value; + var provided = subscription.SetProvider(provider.Value, buyerId, _billingProvider.StateProxy); if (provided.IsFailure) { return provided.Error; diff --git a/src/SubscriptionsDomain.UnitTests/SubscriptionRootSpec.cs b/src/SubscriptionsDomain.UnitTests/SubscriptionRootSpec.cs index 2de518f4..31809927 100644 --- a/src/SubscriptionsDomain.UnitTests/SubscriptionRootSpec.cs +++ b/src/SubscriptionsDomain.UnitTests/SubscriptionRootSpec.cs @@ -4,7 +4,11 @@ using Domain.Events.Shared.Subscriptions; using Domain.Interfaces; using Domain.Interfaces.Entities; +using Domain.Services.Shared; +using Domain.Shared.Subscriptions; +using FluentAssertions; using Moq; +using UnitTesting.Common; using Xunit; namespace SubscriptionsDomain.UnitTests; @@ -22,6 +26,10 @@ public SubscriptionRootSpec() .Returns("anid".ToId()); var recorder = new Mock(); _proxy = new Mock(); + _proxy.Setup(p => p.ProviderName) + .Returns("aprovidername"); + _proxy.Setup(sp => sp.TranslateSubscribedProvider(It.IsAny())) + .Returns((BillingProvider provider) => provider); _subscription = SubscriptionRoot.Create(recorder.Object, identifierFactory.Object, "anowningentityid".ToId(), "abuyerid".ToId()).Value; @@ -62,7 +70,10 @@ public void WhenEnsureInvariantsAndOwningEntityIdIsEmpty_ThenReturnsErrors() [Fact] public void WhenSetProviderByAnotherUser_ThenReturnsError() { - var result = _subscription.SetProvider("aprovidername", new BillingProviderState(), "anotheruserid".ToId(), + var provider = BillingProvider.Create("aprovidername", new Dictionary { { "aname", "avalue" } }) + .Value; + + var result = _subscription.SetProvider(provider, "anotheruserid".ToId(), _proxy.Object); result.Should().BeError(ErrorCode.RoleViolation, Resources.SubscriptionRoot_NotBuyer); @@ -71,59 +82,53 @@ public void WhenSetProviderByAnotherUser_ThenReturnsError() [Fact] public void WhenSetProviderAndAlreadyInitialized_ThenReturnsError() { - SetupProvider(); + SetupInitialProvider(); + var provider = BillingProvider.Create("aprovidername", new Dictionary { { "aname", "avalue" } }) + .Value; - var result = _subscription.SetProvider("aprovidername", new BillingProviderState(), "abuyerid".ToId(), + var result = _subscription.SetProvider(provider, "abuyerid".ToId(), _proxy.Object); - result.Should().BeError(ErrorCode.RoleViolation, Resources.SubscriptionRoot_ProviderAlreadyInitialized); + result.Should().BeError(ErrorCode.RuleViolation, Resources.SubscriptionRoot_ProviderAlreadyInitialized); } [Fact] public void WhenSetProviderAndNotInstalledProvider_ThenReturnsError() { + var provider = BillingProvider.Create("aprovidername", new Dictionary + { + { "aname", "avalue" } + }).Value; _proxy.Setup(sp => sp.ProviderName) .Returns("anotherprovidername"); - var state = new BillingProviderState - { - { "aname1", "avalue1" }, - { "aname2", "avalue2" }, - { "aname3", "avalue3" } - }; - var result = _subscription.SetProvider("aprovidername", state, "abuyerid".ToId(), + var result = _subscription.SetProvider(provider, "abuyerid".ToId(), _proxy.Object); - result.Should().BeError(ErrorCode.RoleViolation, Resources.SubscriptionRoot_ProviderMismatch); + result.Should().BeError(ErrorCode.RuleViolation, Resources.SubscriptionRoot_ProviderMismatch); } [Fact] public void WhenSetProvider_ThenSets() { - _proxy.Setup(sp => - sp.TranslateInitialState(It.IsAny(), It.IsAny())) - .Returns((string _, BillingProviderState state) => state); - var state = new BillingProviderState - { - { "aname1", "avalue1" }, - { "aname2", "avalue2" }, - { "aname3", "avalue3" } - }; + var state = new Dictionary { { "aname", "avalue" } }; + var provider = BillingProvider.Create("aprovidername", state).Value; - _subscription.SetProvider("aprovidername", state, "abuyerid".ToId(), + _subscription.SetProvider(provider, "abuyerid".ToId(), _proxy.Object); - _subscription.Provider.Name.Should().Be("aprovidername"); - _subscription.Provider.State.Count.Should().Be(3); - _subscription.Provider.State.Should().BeEquivalentTo(state); + _subscription.Provider.Value.Name.Should().Be("aprovidername"); + _subscription.Provider.Value.State.Should().BeEquivalentTo(state); _subscription.Events.Last().Should().BeOfType(); - _proxy.Verify(sp => sp.TranslateInitialState("aprovidername", state)); + _proxy.Verify(sp => sp.TranslateSubscribedProvider(provider)); } [Fact] public void WhenChangeProviderByAnyUser_ThenReturnsError() { - var result = _subscription.SetProvider("aprovidername", new BillingProviderState(), "auserid".ToId(), + var provider = BillingProvider.Create("aprovidername", new Dictionary { { "aname", "avalue" } }) + .Value; + var result = _subscription.ChangeProvider(provider, "auserid".ToId(), _proxy.Object); result.Should().BeError(ErrorCode.RoleViolation, Resources.SubscriptionRoot_NotServiceAccount); @@ -132,66 +137,53 @@ public void WhenChangeProviderByAnyUser_ThenReturnsError() [Fact] public void WhenChangeProviderAndAlreadyInitialized_ThenReturnsError() { - SetupProvider(); + SetupInitialProvider(); + var provider = BillingProvider.Create("aprovidername", new Dictionary { { "aname", "avalue" } }) + .Value; - var result = _subscription.ChangeProvider("aprovidername", new BillingProviderState(), - CallerConstants.MaintenanceAccountUserId.ToId(), + var result = _subscription.ChangeProvider(provider, CallerConstants.MaintenanceAccountUserId.ToId(), _proxy.Object); - result.Should().BeError(ErrorCode.RoleViolation, Resources.SubscriptionRoot_ProviderAlreadyInitialized); + result.Should().BeError(ErrorCode.RuleViolation, Resources.SubscriptionRoot_ProviderAlreadyInitialized); } [Fact] public void WhenChangeProviderAndNotInstalledProvider_ThenReturnsError() { + var provider = BillingProvider.Create("aprovidername", new Dictionary { { "aname", "avalue" } }) + .Value; _proxy.Setup(sp => sp.ProviderName) .Returns("anotherprovidername"); - var state = new BillingProviderState - { - { "aname1", "avalue1" }, - { "aname2", "avalue2" }, - { "aname3", "avalue3" } - }; - var result = _subscription.ChangeProvider("aprovidername", state, - CallerConstants.MaintenanceAccountUserId.ToId(), + var result = _subscription.ChangeProvider(provider, CallerConstants.MaintenanceAccountUserId.ToId(), _proxy.Object); - result.Should().BeError(ErrorCode.RoleViolation, Resources.SubscriptionRoot_ProviderMismatch); + result.Should().BeError(ErrorCode.RuleViolation, Resources.SubscriptionRoot_ProviderMismatch); } [Fact] public void WhenChangeBillingProvider_ThenBillingInitialized() { - _proxy.Setup(sp => - sp.TranslateInitialState(It.IsAny(), It.IsAny())) - .Returns((string _, BillingProviderState state) => state); - var state = new BillingProviderState + var state = new Dictionary { - { "aname1", "avalue1" }, - { "aname2", "avalue2" }, - { "aname3", "avalue3" } + { "aname", "avalue" } }; - _subscription.ChangeProvider("aprovidername", state, CallerConstants.MaintenanceAccountUserId.ToId(), + var provider = BillingProvider.Create("aprovidername", state).Value; + + _subscription.ChangeProvider(provider, CallerConstants.MaintenanceAccountUserId.ToId(), _proxy.Object); - _subscription.Provider.Name.Should().Be("aprovidername"); - _subscription.Provider.State.Count.Should().Be(3); - _subscription.Provider.State.Should().BeEquivalentTo(state); + _subscription.Provider.Value.Name.Should().Be("aprovidername"); + _subscription.Provider.Value.State.Should().BeEquivalentTo(state); _subscription.Events.Last().Should().BeOfType(); - _proxy.Verify(sp => sp.TranslateInitialState("aprovidername", state)); + _proxy.Verify(sp => sp.TranslateSubscribedProvider(provider), Times.Never); } - private void SetupProvider() + private void SetupInitialProvider() { - _proxy.Setup(sp => - sp.TranslateInitialState(It.IsAny(), It.IsAny())) - .Returns((string _, BillingProviderState state) => state); - _subscription.ChangeProvider("aprovidername", - new BillingProviderState - { - { "aname1", "avalue1" } - }, CallerConstants.MaintenanceAccountUserId.ToId(), - _proxy.Object); + var provider = BillingProvider.Create("aprovidername", new Dictionary { { "aname", "avalue" } }) + .Value; + + _subscription.ChangeProvider(provider, CallerConstants.MaintenanceAccountUserId.ToId(), _proxy.Object); } } \ No newline at end of file diff --git a/src/SubscriptionsDomain/Events.cs b/src/SubscriptionsDomain/Events.cs index 524eb135..0c6ea4fd 100644 --- a/src/SubscriptionsDomain/Events.cs +++ b/src/SubscriptionsDomain/Events.cs @@ -1,5 +1,6 @@ using Domain.Common.ValueObjects; using Domain.Events.Shared.Subscriptions; +using Domain.Shared.Subscriptions; namespace SubscriptionsDomain; @@ -14,14 +15,14 @@ public static Created Created(Identifier id, Identifier owningEntityId, Identifi }; } - public static ProviderChanged ProviderChanged(Identifier id, Identifier owningEntityId, string providerName, - BillingProviderState state, string buyerReference, string subscriptionReference) + public static ProviderChanged ProviderChanged(Identifier id, Identifier owningEntityId, BillingProvider provider, + string buyerReference, string subscriptionReference) { return new ProviderChanged(id) { OwningEntityId = owningEntityId, - Name = providerName, - State = state, + ProviderName = provider.Name, + ProviderState = provider.State, BuyerReference = buyerReference, SubscriptionReference = subscriptionReference }; diff --git a/src/SubscriptionsDomain/ProviderState.cs b/src/SubscriptionsDomain/ProviderState.cs deleted file mode 100644 index 84435d13..00000000 --- a/src/SubscriptionsDomain/ProviderState.cs +++ /dev/null @@ -1,72 +0,0 @@ -using Common; -using Common.Extensions; -using Domain.Interfaces.ValueObjects; -using Domain.Services.Shared; - -namespace SubscriptionsDomain; - -public sealed class ProviderState : ValueObjectBase -{ - public static readonly ProviderState Empty = new(string.Empty, new BillingProviderState()); - - public static Result Create(string name, BillingProviderState state) - { - if (name.IsInvalidParameter(Validations.Provider.Name, nameof(name), Resources.ProviderState_InvalidName, - out var error1)) - { - return error1; - } - - if (state.IsInvalidParameter(Validations.Provider.State, nameof(state), - Resources.ProviderState_InvalidState, out var error2)) - { - return error2; - } - - return new ProviderState(name, state); - } - - private ProviderState(string name, BillingProviderState state) - { - Name = name; - State = state; - } - - public bool IsInitialized => Name.HasValue(); - - public string Name { get; } - - public BillingProviderState State { get; } - - public static ValueObjectFactory Rehydrate() - { - return (property, _) => - { - var parts = RehydrateToList(property, false); - return new ProviderState(parts[0]!, parts[1]!.FromJson()!); - }; - } - - protected override IEnumerable GetAtomicValues() - { - return new object[] { Name, State.ToJson(casing: StringExtensions.JsonCasing.Pascal)! }; - } - - public ProviderState ChangeState(BillingProviderState state) - { - return new ProviderState(Name, state); - } - - [SkipImmutabilityCheck] - public bool IsCurrentProvider(string providerName) - { - return Name.EqualsIgnoreCase(providerName); - } - -#if TESTINGONLY - public ProviderState TestingOnly_RevertProvider(string providerName, BillingProviderState state) - { - return new ProviderState(providerName, state); - } -#endif -} \ No newline at end of file diff --git a/src/SubscriptionsDomain/Resources.Designer.cs b/src/SubscriptionsDomain/Resources.Designer.cs index c8f8cc04..307441b7 100644 --- a/src/SubscriptionsDomain/Resources.Designer.cs +++ b/src/SubscriptionsDomain/Resources.Designer.cs @@ -59,24 +59,6 @@ internal Resources() { } } - /// - /// Looks up a localized string similar to Name of the provider is not invalid. - /// - internal static string ProviderState_InvalidName { - get { - return ResourceManager.GetString("ProviderState_InvalidName", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to State of the provider is empty or invalid. - /// - internal static string ProviderState_InvalidState { - get { - return ResourceManager.GetString("ProviderState_InvalidState", resourceCulture); - } - } - /// /// Looks up a localized string similar to Subscription must have a valid buyer ID. /// diff --git a/src/SubscriptionsDomain/Resources.resx b/src/SubscriptionsDomain/Resources.resx index 93fc22bd..acb6b2a7 100644 --- a/src/SubscriptionsDomain/Resources.resx +++ b/src/SubscriptionsDomain/Resources.resx @@ -39,12 +39,6 @@ Provider must be same as that of the currently registered provider - - Name of the provider is not invalid - - - State of the provider is empty or invalid - Only a service account change change the provider diff --git a/src/SubscriptionsDomain/SubscriptionRoot.cs b/src/SubscriptionsDomain/SubscriptionRoot.cs index 19173ccc..67991d1c 100644 --- a/src/SubscriptionsDomain/SubscriptionRoot.cs +++ b/src/SubscriptionsDomain/SubscriptionRoot.cs @@ -1,4 +1,5 @@ using Common; +using Common.Extensions; using Domain.Common.Entities; using Domain.Common.Identity; using Domain.Common.ValueObjects; @@ -6,6 +7,8 @@ using Domain.Interfaces; using Domain.Interfaces.Entities; using Domain.Interfaces.ValueObjects; +using Domain.Services.Shared; +using Domain.Shared.Subscriptions; namespace SubscriptionsDomain; @@ -39,7 +42,7 @@ private SubscriptionRoot(IRecorder recorder, IIdentifierFactory idFactory, /// public Identifier OwningEntityId { get; private set; } = Identifier.Empty(); - public ProviderState Provider { get; } = ProviderState.Empty; + public Optional Provider { get; private set; } public static AggregateRootFactory Rehydrate() { @@ -79,12 +82,25 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco return Result.Ok; } + case ProviderChanged changed: + { + var provider = BillingProvider.Create(changed.ProviderName, changed.ProviderState); + if (provider.IsFailure) + { + return provider.Error; + } + + Provider = provider.Value; + Recorder.TraceDebug(null, "Subscription {Id} changed provider to {Provider}", Id, Provider.Value.Name); + return Result.Ok; + } + default: return HandleUnKnownStateChangedEvent(@event); } } - public Result ChangeProvider(string name, BillingProviderState changedState, + public Result ChangeProvider(BillingProvider provider, Identifier modifierId, IBillingStateProxy proxy) { if (!IsServiceAccountOrWebhookAccount(modifierId)) @@ -92,25 +108,23 @@ public Result ChangeProvider(string name, BillingPro return Error.RoleViolation(Resources.SubscriptionRoot_NotServiceAccount); } - var changed = ChangeProviderInternal(name, changedState, proxy); + var changed = ChangeProviderInternal(false, provider, proxy); if (changed.IsFailure) { return changed.Error; } - return proxy.GetBillingSubscription(Provider.Name, - Provider.State); + return proxy.GetBillingSubscription(Provider); } - public Result SetProvider(string name, BillingProviderState subscribedState, Identifier buyerId, - IBillingStateProxy proxy) + public Result SetProvider(BillingProvider provider, Identifier buyerId, IBillingStateProxy proxy) { if (!IsBuyer(buyerId)) { return Error.RoleViolation(Resources.SubscriptionRoot_NotBuyer); } - return ChangeProviderInternal(name, subscribedState, proxy); + return ChangeProviderInternal(true, provider, proxy); } #if TESTINGONLY @@ -121,22 +135,42 @@ public void TestingOnly_SetDetails(Identifier buyerId, Identifier owningEntityId } #endif - private Result ChangeProviderInternal(string providerName, - BillingProviderState providerState, IBillingStateProxy proxy) + private Result ChangeProviderInternal(bool isRecentlySubscribed, BillingProvider provider, + IBillingStateProxy proxy) { - if (Provider.IsInitialized && Provider.IsCurrentProvider(providerName)) + if (Provider is { HasValue: true, Value.IsInitialized: true } + && Provider.Value.IsCurrentProvider(provider.Name)) { return Error.RuleViolation(Resources.SubscriptionRoot_ProviderAlreadyInitialized); } - if (proxy.ProviderName.NotEqualsIgnoreCase(providerName)) + if (proxy.ProviderName.NotEqualsIgnoreCase(provider.Name)) { return Error.RuleViolation(Resources.SubscriptionRoot_ProviderMismatch); } - var state = proxy.TranslateInitialState(providerName, providerState); - return RaiseChangeEvent(SubscriptionsDomain.Events.ProviderChanged(Id, OwningEntityId, providerName, state, - proxy.GetBuyerReference(state), proxy.GetSubscriptionReference(state))); + var translated = isRecentlySubscribed + ? proxy.TranslateSubscribedProvider(provider) + : new Result(provider); + if (translated.IsFailure) + { + return translated.Error; + } + + var buyerReference = proxy.GetSubscriptionReference(translated.Value); + if (buyerReference.IsFailure) + { + return buyerReference.Error; + } + + var subscriptionReference = proxy.GetSubscriptionReference(translated.Value); + if (subscriptionReference.IsFailure) + { + return subscriptionReference.Error; + } + + return RaiseChangeEvent(SubscriptionsDomain.Events.ProviderChanged(Id, OwningEntityId, translated.Value, + buyerReference.Value, subscriptionReference.Value)); } private bool IsBuyer(Identifier userId) diff --git a/src/SubscriptionsDomain/Validations.cs b/src/SubscriptionsDomain/Validations.cs index 1d36c908..ec9b9f90 100644 --- a/src/SubscriptionsDomain/Validations.cs +++ b/src/SubscriptionsDomain/Validations.cs @@ -6,12 +6,4 @@ namespace SubscriptionsDomain; public static class Validations { - public static class Provider - { - public static readonly Validation Name = CommonValidations.DescriptiveName(1, 50); - public static readonly Validation State = new(state => - { - return state.Count > 0 && state.All(pair => pair.Value.Exists()); - }); - } } \ No newline at end of file diff --git a/src/SubscriptionsInfrastructure.IntegrationTests/SubscriptionsApiSpec.cs b/src/SubscriptionsInfrastructure.IntegrationTests/SubscriptionsApiSpec.cs index 1b3884b6..e2c8abb8 100644 --- a/src/SubscriptionsInfrastructure.IntegrationTests/SubscriptionsApiSpec.cs +++ b/src/SubscriptionsInfrastructure.IntegrationTests/SubscriptionsApiSpec.cs @@ -1,4 +1,8 @@ using ApiHost1; +using FluentAssertions; +using Infrastructure.Shared.ApplicationServices; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Operations.Shared.Subscriptions; using IntegrationTesting.WebApi.Common; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -14,11 +18,24 @@ public SubscriptionsApiSpec(WebApiSetup setup) : base(setup, OverrideDe EmptyAllRepositories(); } - private static void OverrideDependencies(IServiceCollection services) + [Fact] + public async Task WhenGetSubscription_ThenReturns() { - //TODO: remove this is method if you are not overriding any dependencies with any stubs - throw new NotImplementedException(); + var login = await LoginUserAsync(); + + var result = await Api.GetAsync(new GetSubscriptionRequest + { + Id = login.DefaultOrganizationId! + }, req => req.SetJWTBearerToken(login.AccessToken)); + + result.Content.Value.Subscription!.BuyerId.Should().Be(login.Profile!.UserId); + result.Content.Value.Subscription.ProviderName.Should().Be(BasicBillingStateProxy.Constants.ProviderName); + result.Content.Value.Subscription.OwningEntityId.Should().Be(login.DefaultOrganizationId); + result.Content.Value.Subscription.ProviderState.Should().NotBeEmpty(); } - //TIP: type testm or testma to create a new test method + private static void OverrideDependencies(IServiceCollection services) + { + // do nothing + } } \ No newline at end of file diff --git a/src/SubscriptionsInfrastructure/Api/Subscriptions/SubscriptionsApi.cs b/src/SubscriptionsInfrastructure/Api/Subscriptions/SubscriptionsApi.cs index 60a2d2ec..7081b1e6 100644 --- a/src/SubscriptionsInfrastructure/Api/Subscriptions/SubscriptionsApi.cs +++ b/src/SubscriptionsInfrastructure/Api/Subscriptions/SubscriptionsApi.cs @@ -1,5 +1,8 @@ +using Application.Resources.Shared; using Infrastructure.Interfaces; +using Infrastructure.Web.Api.Common.Extensions; using Infrastructure.Web.Api.Interfaces; +using Infrastructure.Web.Api.Operations.Shared.Subscriptions; using SubscriptionsApplication; namespace SubscriptionsInfrastructure.Api.Subscriptions; @@ -15,6 +18,15 @@ public SubscriptionsApi(ICallerContextFactory callerFactory, ISubscriptionsAppli _subscriptionsApplication = subscriptionsApplication; } - //TODO: Add your service operation methods here - //Tip: try: postapi and getapi + public async Task> GetSubscription( + GetSubscriptionRequest request, CancellationToken cancellationToken) + { + var subscription = await _subscriptionsApplication.GetSubscriptionAsync(_callerFactory.Create(), + request.Id!, cancellationToken); + + return () => + subscription.HandleApplicationResult(sub => + new GetSubscriptionResponse + { Subscription = sub }); + } } \ No newline at end of file diff --git a/src/SubscriptionsInfrastructure/Persistence/SubscriptionRepository.cs b/src/SubscriptionsInfrastructure/Persistence/SubscriptionRepository.cs index 44263240..97fecde3 100644 --- a/src/SubscriptionsInfrastructure/Persistence/SubscriptionRepository.cs +++ b/src/SubscriptionsInfrastructure/Persistence/SubscriptionRepository.cs @@ -5,6 +5,7 @@ using Domain.Interfaces; using Infrastructure.Persistence.Common; using Infrastructure.Persistence.Interfaces; +using QueryAny; using SubscriptionsApplication.Persistence; using SubscriptionsApplication.Persistence.ReadModels; using SubscriptionsDomain; @@ -23,6 +24,14 @@ public SubscriptionRepository(IRecorder recorder, IDomainFactory domainFactory, _subscriptions = subscriptionsStore; } + public async Task, Error>> FindByOwningEntityIdAsync(Identifier owningEntityId, + CancellationToken cancellationToken) + { + var query = Query.From() + .Where(at => at.OwningEntityId, ConditionOperator.EqualTo, owningEntityId); + return await FindFirstByQueryAsync(query, cancellationToken); + } + public async Task> SaveAsync(SubscriptionRoot subscription, CancellationToken cancellationToken) { @@ -54,4 +63,28 @@ public async Task> LoadAsync(Identifier id, Canc return subscription; } + + private async Task, Error>> FindFirstByQueryAsync(QueryClause query, + CancellationToken cancellationToken) + { + var queried = await _subscriptionQueries.QueryAsync(query, false, cancellationToken); + if (queried.IsFailure) + { + return queried.Error; + } + + var matching = queried.Value.Results.FirstOrDefault(); + if (matching.NotExists()) + { + return Optional.None; + } + + var subscriptions = await _subscriptions.LoadAsync(matching.Id.Value.ToId(), cancellationToken); + if (subscriptions.IsFailure) + { + return subscriptions.Error; + } + + return subscriptions.Value.ToOptional(); + } } \ No newline at end of file diff --git a/src/SubscriptionsInfrastructure/SubscriptionsModule.cs b/src/SubscriptionsInfrastructure/SubscriptionsModule.cs index c43e6b8f..004cbf58 100644 --- a/src/SubscriptionsInfrastructure/SubscriptionsModule.cs +++ b/src/SubscriptionsInfrastructure/SubscriptionsModule.cs @@ -6,6 +6,7 @@ using Infrastructure.Hosting.Common.Extensions; using Infrastructure.Interfaces; using Infrastructure.Persistence.Interfaces; +using Infrastructure.Shared.ApplicationServices; using Infrastructure.Web.Hosting.Common; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; @@ -43,6 +44,8 @@ public Action RegisterServices { return (_, services) => { + services.AddSingleton(); + services .AddPerHttpRequest(); services.AddPerHttpRequest(); diff --git a/src/WebsiteHost/BackEndForFrontEndModule.cs b/src/WebsiteHost/BackEndForFrontEndModule.cs index 8d894cac..c0950925 100644 --- a/src/WebsiteHost/BackEndForFrontEndModule.cs +++ b/src/WebsiteHost/BackEndForFrontEndModule.cs @@ -1,6 +1,7 @@ using System.Reflection; using System.Text.Json; using Application.Interfaces.Services; +using Domain.Services.Shared; using Infrastructure.Common.DomainServices; using Infrastructure.Web.Common.Clients; using Infrastructure.Web.Hosting.Common;