diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Services/IAppLimitsPlan.cs b/src/Squidex.Domain.Apps.Entities/Apps/Services/IAppLimitsPlan.cs index 21bbae9bf..59d0feed4 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Services/IAppLimitsPlan.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Services/IAppLimitsPlan.cs @@ -15,6 +15,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.Services string Costs { get; } + string YearlyCosts { get; } + + string YearlyId { get; } + long MaxApiCalls { get; } long MaxAssetSize { get; } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/ConfigAppLimitsPlan.cs b/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/ConfigAppLimitsPlan.cs index 5f4892e4b..3d568c928 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/ConfigAppLimitsPlan.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/ConfigAppLimitsPlan.cs @@ -15,6 +15,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.Services.Implementations public string Costs { get; set; } + public string YearlyCosts { get; set; } + + public string YearlyId { get; set; } + public long MaxApiCalls { get; set; } public long MaxAssetSize { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/ConfigAppPlansProvider.cs b/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/ConfigAppPlansProvider.cs index 813914f09..83bd9d196 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/ConfigAppPlansProvider.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/ConfigAppPlansProvider.cs @@ -23,15 +23,23 @@ namespace Squidex.Domain.Apps.Entities.Apps.Services.Implementations MaxContributors = -1 }; - private readonly Dictionary plansById; - private readonly List plansList; + private readonly Dictionary plansById = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly List plansList = new List(); public ConfigAppPlansProvider(IEnumerable config) { Guard.NotNull(config, nameof(config)); - plansList = config.Select(c => c.Clone()).OrderBy(x => x.MaxApiCalls).ToList(); - plansById = plansList.ToDictionary(c => c.Id, StringComparer.OrdinalIgnoreCase); + foreach (var plan in config.OrderBy(x => x.MaxApiCalls).Select(x => x.Clone())) + { + plansList.Add(plan); + plansById[plan.Id] = plan; + + if (!string.IsNullOrWhiteSpace(plan.YearlyId) && !string.IsNullOrWhiteSpace(plan.YearlyCosts)) + { + plansById[plan.YearlyId] = plan; + } + } } public IEnumerable GetAvailablePlans() diff --git a/src/Squidex.Infrastructure/EventSourcing/Grains/OrleansEventNotifier.cs b/src/Squidex.Infrastructure/EventSourcing/Grains/OrleansEventNotifier.cs index 0e97746e4..17900a901 100644 --- a/src/Squidex.Infrastructure/EventSourcing/Grains/OrleansEventNotifier.cs +++ b/src/Squidex.Infrastructure/EventSourcing/Grains/OrleansEventNotifier.cs @@ -10,26 +10,24 @@ using Orleans; namespace Squidex.Infrastructure.EventSourcing.Grains { - public sealed class OrleansEventNotifier : IEventNotifier, IInitializable + public sealed class OrleansEventNotifier : IEventNotifier { private readonly IGrainFactory factory; - private IEventConsumerManagerGrain eventConsumerManagerGrain; + private readonly Lazy eventConsumerManagerGrain; public OrleansEventNotifier(IGrainFactory factory) { Guard.NotNull(factory, nameof(factory)); - this.factory = factory; - } - - public void Initialize() - { - eventConsumerManagerGrain = factory.GetGrain("Default"); + eventConsumerManagerGrain = new Lazy(() => + { + return factory.GetGrain("Default"); + }); } public void NotifyEventsStored(string streamName) { - eventConsumerManagerGrain?.ActivateAsync(streamName); + eventConsumerManagerGrain.Value.ActivateAsync(streamName); } public IDisposable Subscribe(Action handler) diff --git a/src/Squidex.Infrastructure/Orleans/Bootstrap.cs b/src/Squidex.Infrastructure/Orleans/Bootstrap.cs index 79abd18b9..cf1f03145 100644 --- a/src/Squidex.Infrastructure/Orleans/Bootstrap.cs +++ b/src/Squidex.Infrastructure/Orleans/Bootstrap.cs @@ -14,6 +14,7 @@ namespace Squidex.Infrastructure.Orleans { public sealed class Bootstrap : IStartupTask where T : IBackgroundGrain { + private const int NumTries = 10; private readonly IGrainFactory grainFactory; public Bootstrap(IGrainFactory grainFactory) @@ -23,11 +24,26 @@ namespace Squidex.Infrastructure.Orleans this.grainFactory = grainFactory; } - public Task Execute(CancellationToken cancellationToken) + public async Task Execute(CancellationToken cancellationToken) { - var grain = grainFactory.GetGrain("Default"); + for (var i = 1; i <= NumTries; i++) + { + try + { + var grain = grainFactory.GetGrain("Default"); - return grain.ActivateAsync(); + await grain.ActivateAsync(); + + return; + } + catch (OrleansMessageRejectionException) + { + if (i == NumTries) + { + throw; + } + } + } } } } diff --git a/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs b/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs index b0d406c9b..0b93c9043 100644 --- a/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs +++ b/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs @@ -55,11 +55,12 @@ namespace Squidex.Areas.Api.Controllers.Plans public IActionResult GetPlans(string app) { var planId = appPlansProvider.GetPlanForApp(App).Id; + var plans = appPlansProvider.GetAvailablePlans().Select(x => SimpleMapper.Map(x, new PlanDto())).ToList(); var response = new AppPlansDto { CurrentPlanId = planId, - Plans = appPlansProvider.GetAvailablePlans().Select(x => SimpleMapper.Map(x, new PlanDto())).ToList(), + Plans = plans, PlanOwner = App.Plan?.Owner.Identifier, HasPortal = appPlansBillingManager.HasPortal }; diff --git a/src/Squidex/Areas/Api/Controllers/Plans/Models/PlanDto.cs b/src/Squidex/Areas/Api/Controllers/Plans/Models/PlanDto.cs index 59adb8595..7c330ad23 100644 --- a/src/Squidex/Areas/Api/Controllers/Plans/Models/PlanDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Plans/Models/PlanDto.cs @@ -5,6 +5,8 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.ComponentModel.DataAnnotations; + namespace Squidex.Areas.Api.Controllers.Plans.Models { public sealed class PlanDto @@ -12,18 +14,31 @@ namespace Squidex.Areas.Api.Controllers.Plans.Models /// /// The id of the plan. /// + [Required] public string Id { get; set; } /// /// The name of the plan. /// + [Required] public string Name { get; set; } /// /// The monthly costs of the plan. /// + [Required] public string Costs { get; set; } + /// + /// The yearly costs of the plan. + /// + public string YearlyCosts { get; set; } + + /// + /// The yearly id of the plan. + /// + public string YearlyId { get; set; } + /// /// The maximum number of API calls. /// diff --git a/src/Squidex/Config/Domain/EventStoreServices.cs b/src/Squidex/Config/Domain/EventStoreServices.cs index baa744514..8f3c76ff3 100644 --- a/src/Squidex/Config/Domain/EventStoreServices.cs +++ b/src/Squidex/Config/Domain/EventStoreServices.cs @@ -53,8 +53,7 @@ namespace Squidex.Config.Domain }); services.AddSingletonAs() - .As() - .As(); + .As(); services.AddSingletonAs() .As(); diff --git a/src/Squidex/app/features/settings/pages/plans/plans-page.component.html b/src/Squidex/app/features/settings/pages/plans/plans-page.component.html index 0d29550a5..a376cece1 100644 --- a/src/Squidex/app/features/settings/pages/plans/plans-page.component.html +++ b/src/Squidex/app/features/settings/pages/plans/plans-page.component.html @@ -32,24 +32,25 @@
-
+

{{plan.name}}

{{plan.costs}}
- + Per Month
-
- {{plan.maxApiCalls | sqxKNumber}} API Calls -
-
- {{plan.maxAssetSize | sqxFileSize}} Storage +
+
+ {{plan.maxApiCalls | sqxKNumber}} API Calls +
+
+ {{plan.maxAssetSize | sqxFileSize}} Storage +
+
+ {{plan.maxContributors}} Contributors +
-
- {{plan.maxContributors}} Contributors -
-
-
+ @@ -58,6 +59,21 @@ Change
+
diff --git a/src/Squidex/app/features/settings/pages/plans/plans-page.component.scss b/src/Squidex/app/features/settings/pages/plans/plans-page.component.scss index 9c75fef9a..6079c4b72 100644 --- a/src/Squidex/app/features/settings/pages/plans/plans-page.component.scss +++ b/src/Squidex/app/features/settings/pages/plans/plans-page.component.scss @@ -8,12 +8,10 @@ margin: .5rem; } - &-header { - border-bottom: 1px solid $color-border; - } - &-price { color: $color-theme-blue; + margin-top: 0; + margin-bottom: 0; } &-selected { @@ -21,10 +19,20 @@ } &-fact { - line-height: 2rem; + line-height: 1.8rem; + } + + .btn { + margin-top: 1rem; } } +.card-footer, +.card-header, +.card-body { + padding: 1rem; +} + .empty { margin: 1.25rem; margin-top: 6.25rem; diff --git a/src/Squidex/app/shared/services/plans.service.spec.ts b/src/Squidex/app/shared/services/plans.service.spec.ts index 1d9d8e4ed..4202b4112 100644 --- a/src/Squidex/app/shared/services/plans.service.spec.ts +++ b/src/Squidex/app/shared/services/plans.service.spec.ts @@ -62,6 +62,8 @@ describe('PlansService', () => { id: 'free', name: 'Free', costs: '14 €', + yearlyId: 'free_yearly', + yearlyCosts: '12 €', maxApiCalls: 1000, maxAssetSize: 1500, maxContributors: 2500 @@ -70,6 +72,8 @@ describe('PlansService', () => { id: 'prof', name: 'Prof', costs: '18 €', + yearlyId: 'prof_yearly', + yearlyCosts: '16 €', maxApiCalls: 4000, maxAssetSize: 5500, maxContributors: 6500 @@ -87,8 +91,8 @@ describe('PlansService', () => { '456', true, [ - new PlanDto('free', 'Free', '14 €', 1000, 1500, 2500), - new PlanDto('prof', 'Prof', '18 €', 4000, 5500, 6500) + new PlanDto('free', 'Free', '14 €', 'free_yearly', '12 €', 1000, 1500, 2500), + new PlanDto('prof', 'Prof', '18 €', 'prof_yearly', '16 €', 4000, 5500, 6500) ], new Version('2') )); diff --git a/src/Squidex/app/shared/services/plans.service.ts b/src/Squidex/app/shared/services/plans.service.ts index 287e56831..7063eebf2 100644 --- a/src/Squidex/app/shared/services/plans.service.ts +++ b/src/Squidex/app/shared/services/plans.service.ts @@ -44,6 +44,8 @@ export class PlanDto { public readonly id: string, public readonly name: string, public readonly costs: string, + public readonly yearlyId: string, + public readonly yearlyCosts: string, public readonly maxApiCalls: number, public readonly maxAssetSize: number, public readonly maxContributors: number @@ -92,6 +94,8 @@ export class PlansService { item.id, item.name, item.costs, + item.yearlyId, + item.yearlyCosts, item.maxApiCalls, item.maxAssetSize, item.maxContributors); diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Billing/ConfigAppLimitsProviderTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Billing/ConfigAppLimitsProviderTests.cs index 99dd68dab..059164011 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Billing/ConfigAppLimitsProviderTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Billing/ConfigAppLimitsProviderTests.cs @@ -41,7 +41,9 @@ namespace Squidex.Domain.Apps.Entities.Apps.Billing Name = "Basic", MaxApiCalls = 150000, MaxAssetSize = 1024 * 1024 * 2, - MaxContributors = 5 + MaxContributors = 5, + YearlyCosts = "100€", + YearlyId = "basic_yearly" }; private static readonly ConfigAppLimitsPlan[] Plans = { BasicPlan, FreePlan }; @@ -76,6 +78,16 @@ namespace Squidex.Domain.Apps.Entities.Apps.Billing plan.ShouldBeEquivalentTo(BasicPlan); } + [Fact] + public void Should_return_fitting_yearly_app_plan() + { + var sut = new ConfigAppPlansProvider(Plans); + + var plan = sut.GetPlanForApp(CreateApp("basic_yearly")); + + plan.ShouldBeEquivalentTo(BasicPlan); + } + [Fact] public void Should_smallest_plan_if_none_fits() {