Browse Source

Merge pull request #268 from Squidex/feature-yearly-plans

Yearly plans.
pull/272/head
Sebastian Stehle 8 years ago
committed by GitHub
parent
commit
dcececd7b4
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      src/Squidex.Domain.Apps.Entities/Apps/Services/IAppLimitsPlan.cs
  2. 4
      src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/ConfigAppLimitsPlan.cs
  3. 16
      src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/ConfigAppPlansProvider.cs
  4. 14
      src/Squidex.Infrastructure/EventSourcing/Grains/OrleansEventNotifier.cs
  5. 20
      src/Squidex.Infrastructure/Orleans/Bootstrap.cs
  6. 3
      src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs
  7. 15
      src/Squidex/Areas/Api/Controllers/Plans/Models/PlanDto.cs
  8. 3
      src/Squidex/Config/Domain/EventStoreServices.cs
  9. 28
      src/Squidex/app/features/settings/pages/plans/plans-page.component.html
  10. 18
      src/Squidex/app/features/settings/pages/plans/plans-page.component.scss
  11. 8
      src/Squidex/app/shared/services/plans.service.spec.ts
  12. 4
      src/Squidex/app/shared/services/plans.service.ts
  13. 14
      tests/Squidex.Domain.Apps.Entities.Tests/Apps/Billing/ConfigAppLimitsProviderTests.cs
  14. 1
      tests/Squidex.Infrastructure.Tests/EventSourcing/Grains/OrleansEventNotifierTests.cs
  15. 39
      tests/Squidex.Infrastructure.Tests/Orleans/BootstrapTests.cs

4
src/Squidex.Domain.Apps.Entities/Apps/Services/IAppLimitsPlan.cs

@ -15,6 +15,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.Services
string Costs { get; } string Costs { get; }
string YearlyCosts { get; }
string YearlyId { get; }
long MaxApiCalls { get; } long MaxApiCalls { get; }
long MaxAssetSize { get; } long MaxAssetSize { get; }

4
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 Costs { get; set; }
public string YearlyCosts { get; set; }
public string YearlyId { get; set; }
public long MaxApiCalls { get; set; } public long MaxApiCalls { get; set; }
public long MaxAssetSize { get; set; } public long MaxAssetSize { get; set; }

16
src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/ConfigAppPlansProvider.cs

@ -23,15 +23,23 @@ namespace Squidex.Domain.Apps.Entities.Apps.Services.Implementations
MaxContributors = -1 MaxContributors = -1
}; };
private readonly Dictionary<string, ConfigAppLimitsPlan> plansById; private readonly Dictionary<string, ConfigAppLimitsPlan> plansById = new Dictionary<string, ConfigAppLimitsPlan>(StringComparer.OrdinalIgnoreCase);
private readonly List<ConfigAppLimitsPlan> plansList; private readonly List<ConfigAppLimitsPlan> plansList = new List<ConfigAppLimitsPlan>();
public ConfigAppPlansProvider(IEnumerable<ConfigAppLimitsPlan> config) public ConfigAppPlansProvider(IEnumerable<ConfigAppLimitsPlan> config)
{ {
Guard.NotNull(config, nameof(config)); Guard.NotNull(config, nameof(config));
plansList = config.Select(c => c.Clone()).OrderBy(x => x.MaxApiCalls).ToList(); foreach (var plan in config.OrderBy(x => x.MaxApiCalls).Select(x => x.Clone()))
plansById = plansList.ToDictionary(c => c.Id, StringComparer.OrdinalIgnoreCase); {
plansList.Add(plan);
plansById[plan.Id] = plan;
if (!string.IsNullOrWhiteSpace(plan.YearlyId) && !string.IsNullOrWhiteSpace(plan.YearlyCosts))
{
plansById[plan.YearlyId] = plan;
}
}
} }
public IEnumerable<IAppLimitsPlan> GetAvailablePlans() public IEnumerable<IAppLimitsPlan> GetAvailablePlans()

14
src/Squidex.Infrastructure/EventSourcing/Grains/OrleansEventNotifier.cs

@ -10,26 +10,24 @@ using Orleans;
namespace Squidex.Infrastructure.EventSourcing.Grains namespace Squidex.Infrastructure.EventSourcing.Grains
{ {
public sealed class OrleansEventNotifier : IEventNotifier, IInitializable public sealed class OrleansEventNotifier : IEventNotifier
{ {
private readonly IGrainFactory factory; private readonly IGrainFactory factory;
private IEventConsumerManagerGrain eventConsumerManagerGrain; private readonly Lazy<IEventConsumerManagerGrain> eventConsumerManagerGrain;
public OrleansEventNotifier(IGrainFactory factory) public OrleansEventNotifier(IGrainFactory factory)
{ {
Guard.NotNull(factory, nameof(factory)); Guard.NotNull(factory, nameof(factory));
this.factory = factory; eventConsumerManagerGrain = new Lazy<IEventConsumerManagerGrain>(() =>
}
public void Initialize()
{ {
eventConsumerManagerGrain = factory.GetGrain<IEventConsumerManagerGrain>("Default"); return factory.GetGrain<IEventConsumerManagerGrain>("Default");
});
} }
public void NotifyEventsStored(string streamName) public void NotifyEventsStored(string streamName)
{ {
eventConsumerManagerGrain?.ActivateAsync(streamName); eventConsumerManagerGrain.Value.ActivateAsync(streamName);
} }
public IDisposable Subscribe(Action<string> handler) public IDisposable Subscribe(Action<string> handler)

20
src/Squidex.Infrastructure/Orleans/Bootstrap.cs

@ -14,6 +14,7 @@ namespace Squidex.Infrastructure.Orleans
{ {
public sealed class Bootstrap<T> : IStartupTask where T : IBackgroundGrain public sealed class Bootstrap<T> : IStartupTask where T : IBackgroundGrain
{ {
private const int NumTries = 10;
private readonly IGrainFactory grainFactory; private readonly IGrainFactory grainFactory;
public Bootstrap(IGrainFactory grainFactory) public Bootstrap(IGrainFactory grainFactory)
@ -23,11 +24,26 @@ namespace Squidex.Infrastructure.Orleans
this.grainFactory = grainFactory; this.grainFactory = grainFactory;
} }
public Task Execute(CancellationToken cancellationToken) public async Task Execute(CancellationToken cancellationToken)
{
for (var i = 1; i <= NumTries; i++)
{
try
{ {
var grain = grainFactory.GetGrain<T>("Default"); var grain = grainFactory.GetGrain<T>("Default");
return grain.ActivateAsync(); await grain.ActivateAsync();
return;
}
catch (OrleansException)
{
if (i == NumTries)
{
throw;
}
}
}
} }
} }
} }

3
src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs

@ -55,11 +55,12 @@ namespace Squidex.Areas.Api.Controllers.Plans
public IActionResult GetPlans(string app) public IActionResult GetPlans(string app)
{ {
var planId = appPlansProvider.GetPlanForApp(App).Id; var planId = appPlansProvider.GetPlanForApp(App).Id;
var plans = appPlansProvider.GetAvailablePlans().Select(x => SimpleMapper.Map(x, new PlanDto())).ToList();
var response = new AppPlansDto var response = new AppPlansDto
{ {
CurrentPlanId = planId, CurrentPlanId = planId,
Plans = appPlansProvider.GetAvailablePlans().Select(x => SimpleMapper.Map(x, new PlanDto())).ToList(), Plans = plans,
PlanOwner = App.Plan?.Owner.Identifier, PlanOwner = App.Plan?.Owner.Identifier,
HasPortal = appPlansBillingManager.HasPortal HasPortal = appPlansBillingManager.HasPortal
}; };

15
src/Squidex/Areas/Api/Controllers/Plans/Models/PlanDto.cs

@ -5,6 +5,8 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.ComponentModel.DataAnnotations;
namespace Squidex.Areas.Api.Controllers.Plans.Models namespace Squidex.Areas.Api.Controllers.Plans.Models
{ {
public sealed class PlanDto public sealed class PlanDto
@ -12,18 +14,31 @@ namespace Squidex.Areas.Api.Controllers.Plans.Models
/// <summary> /// <summary>
/// The id of the plan. /// The id of the plan.
/// </summary> /// </summary>
[Required]
public string Id { get; set; } public string Id { get; set; }
/// <summary> /// <summary>
/// The name of the plan. /// The name of the plan.
/// </summary> /// </summary>
[Required]
public string Name { get; set; } public string Name { get; set; }
/// <summary> /// <summary>
/// The monthly costs of the plan. /// The monthly costs of the plan.
/// </summary> /// </summary>
[Required]
public string Costs { get; set; } public string Costs { get; set; }
/// <summary>
/// The yearly costs of the plan.
/// </summary>
public string YearlyCosts { get; set; }
/// <summary>
/// The yearly id of the plan.
/// </summary>
public string YearlyId { get; set; }
/// <summary> /// <summary>
/// The maximum number of API calls. /// The maximum number of API calls.
/// </summary> /// </summary>

3
src/Squidex/Config/Domain/EventStoreServices.cs

@ -53,8 +53,7 @@ namespace Squidex.Config.Domain
}); });
services.AddSingletonAs<OrleansEventNotifier>() services.AddSingletonAs<OrleansEventNotifier>()
.As<IEventNotifier>() .As<IEventNotifier>();
.As<IInitializable>();
services.AddSingletonAs<DefaultStreamNameResolver>() services.AddSingletonAs<DefaultStreamNameResolver>()
.As<IStreamNameResolver>(); .As<IStreamNameResolver>();

28
src/Squidex/app/features/settings/pages/plans/plans-page.component.html

@ -32,24 +32,25 @@
<div class="clearfix"> <div class="clearfix">
<div class="card plan float-left" *ngFor="let plan of plans.plans"> <div class="card plan float-left" *ngFor="let plan of plans.plans">
<div class="card-body plan-header text-center"> <div class="card-header text-center">
<h4 class="card-title">{{plan.name}}</h4> <h4 class="card-title">{{plan.name}}</h4>
<h5 class="plan-price">{{plan.costs}}</h5> <h5 class="plan-price">{{plan.costs}}</h5>
<small class="text-muted">Per Month</small> <small class="text-muted">Per Month</small>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="plan-fact"> <div class="plan-fact text-center">
{{plan.maxApiCalls | sqxKNumber}} API Calls <div>
<strong>{{plan.maxApiCalls | sqxKNumber}}</strong> API Calls
</div> </div>
<div class="plan-fact"> <div>
{{plan.maxAssetSize | sqxFileSize}} Storage {{plan.maxAssetSize | sqxFileSize}} Storage
</div> </div>
<div class="plan-fact"> <div>
{{plan.maxContributors}} Contributors {{plan.maxContributors}} Contributors
</div> </div>
</div> </div>
<div class="card-body">
<button *ngIf="plan.id === plans.currentPlanId" class="btn btn-block btn-link btn-success plan-selected"> <button *ngIf="plan.id === plans.currentPlanId" class="btn btn-block btn-link btn-success plan-selected">
&#10003; Selected &#10003; Selected
</button> </button>
@ -58,6 +59,21 @@
Change Change
</button> </button>
</div> </div>
<div class="card-footer" *ngIf="plan.yearlyId">
<div class="text-center">
<h5 class="plan-price">{{plan.yearlyCosts}}</h5>
<small class="text-muted">Per Year</small>
</div>
<button *ngIf="plan.yearlyId === plans.currentPlanId" class="btn btn-block btn-link btn-success plan-selected">
&#10003; Selected
</button>
<button *ngIf="plan.yearlyId !== plans.currentPlanId" class="btn btn-block btn-success" [disabled]="isDisabled || !planOwned" (click)="changePlan(plan.yearlyId)">
Change
</button>
</div>
</div> </div>
</div> </div>

18
src/Squidex/app/features/settings/pages/plans/plans-page.component.scss

@ -8,12 +8,10 @@
margin: .5rem; margin: .5rem;
} }
&-header {
border-bottom: 1px solid $color-border;
}
&-price { &-price {
color: $color-theme-blue; color: $color-theme-blue;
margin-top: 0;
margin-bottom: 0;
} }
&-selected { &-selected {
@ -21,10 +19,20 @@
} }
&-fact { &-fact {
line-height: 2rem; line-height: 1.8rem;
}
.btn {
margin-top: 1rem;
} }
} }
.card-footer,
.card-header,
.card-body {
padding: 1rem;
}
.empty { .empty {
margin: 1.25rem; margin: 1.25rem;
margin-top: 6.25rem; margin-top: 6.25rem;

8
src/Squidex/app/shared/services/plans.service.spec.ts

@ -62,6 +62,8 @@ describe('PlansService', () => {
id: 'free', id: 'free',
name: 'Free', name: 'Free',
costs: '14 €', costs: '14 €',
yearlyId: 'free_yearly',
yearlyCosts: '12 €',
maxApiCalls: 1000, maxApiCalls: 1000,
maxAssetSize: 1500, maxAssetSize: 1500,
maxContributors: 2500 maxContributors: 2500
@ -70,6 +72,8 @@ describe('PlansService', () => {
id: 'prof', id: 'prof',
name: 'Prof', name: 'Prof',
costs: '18 €', costs: '18 €',
yearlyId: 'prof_yearly',
yearlyCosts: '16 €',
maxApiCalls: 4000, maxApiCalls: 4000,
maxAssetSize: 5500, maxAssetSize: 5500,
maxContributors: 6500 maxContributors: 6500
@ -87,8 +91,8 @@ describe('PlansService', () => {
'456', '456',
true, true,
[ [
new PlanDto('free', 'Free', '14 €', 1000, 1500, 2500), new PlanDto('free', 'Free', '14 €', 'free_yearly', '12 €', 1000, 1500, 2500),
new PlanDto('prof', 'Prof', '18 €', 4000, 5500, 6500) new PlanDto('prof', 'Prof', '18 €', 'prof_yearly', '16 €', 4000, 5500, 6500)
], ],
new Version('2') new Version('2')
)); ));

4
src/Squidex/app/shared/services/plans.service.ts

@ -44,6 +44,8 @@ export class PlanDto {
public readonly id: string, public readonly id: string,
public readonly name: string, public readonly name: string,
public readonly costs: string, public readonly costs: string,
public readonly yearlyId: string,
public readonly yearlyCosts: string,
public readonly maxApiCalls: number, public readonly maxApiCalls: number,
public readonly maxAssetSize: number, public readonly maxAssetSize: number,
public readonly maxContributors: number public readonly maxContributors: number
@ -92,6 +94,8 @@ export class PlansService {
item.id, item.id,
item.name, item.name,
item.costs, item.costs,
item.yearlyId,
item.yearlyCosts,
item.maxApiCalls, item.maxApiCalls,
item.maxAssetSize, item.maxAssetSize,
item.maxContributors); item.maxContributors);

14
tests/Squidex.Domain.Apps.Entities.Tests/Apps/Billing/ConfigAppLimitsProviderTests.cs

@ -41,7 +41,9 @@ namespace Squidex.Domain.Apps.Entities.Apps.Billing
Name = "Basic", Name = "Basic",
MaxApiCalls = 150000, MaxApiCalls = 150000,
MaxAssetSize = 1024 * 1024 * 2, MaxAssetSize = 1024 * 1024 * 2,
MaxContributors = 5 MaxContributors = 5,
YearlyCosts = "100€",
YearlyId = "basic_yearly"
}; };
private static readonly ConfigAppLimitsPlan[] Plans = { BasicPlan, FreePlan }; private static readonly ConfigAppLimitsPlan[] Plans = { BasicPlan, FreePlan };
@ -76,6 +78,16 @@ namespace Squidex.Domain.Apps.Entities.Apps.Billing
plan.ShouldBeEquivalentTo(BasicPlan); 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] [Fact]
public void Should_smallest_plan_if_none_fits() public void Should_smallest_plan_if_none_fits()
{ {

1
tests/Squidex.Infrastructure.Tests/EventSourcing/Grains/OrleansEventNotifierTests.cs

@ -29,7 +29,6 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
[Fact] [Fact]
public void Should_wakeup_manager_with_stream_name() public void Should_wakeup_manager_with_stream_name()
{ {
sut.Initialize();
sut.NotifyEventsStored("my-stream"); sut.NotifyEventsStored("my-stream");
A.CallTo(() => manager.ActivateAsync("my-stream")) A.CallTo(() => manager.ActivateAsync("my-stream"))

39
tests/Squidex.Infrastructure.Tests/Orleans/BootstrapTests.cs

@ -6,10 +6,13 @@
// All rights reserved. // All rights reserved.
// ========================================================================== // ==========================================================================
using System;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
using Orleans; using Orleans;
using Orleans.Runtime;
using Squidex.Infrastructure.Tasks;
using Xunit; using Xunit;
namespace Squidex.Infrastructure.Orleans namespace Squidex.Infrastructure.Orleans
@ -37,5 +40,41 @@ namespace Squidex.Infrastructure.Orleans
A.CallTo(() => grain.ActivateAsync()) A.CallTo(() => grain.ActivateAsync())
.MustHaveHappened(); .MustHaveHappened();
} }
[Fact]
public async Task Should_fail_on_non_rejection_exception()
{
A.CallTo(() => grain.ActivateAsync())
.Throws(new InvalidOperationException());
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.Execute(CancellationToken.None));
}
[Fact]
public async Task Should_retry_after_rejection_exception()
{
A.CallTo(() => grain.ActivateAsync())
.Returns(TaskHelper.Done);
A.CallTo(() => grain.ActivateAsync())
.Throws(new OrleansException()).Once();
await sut.Execute(CancellationToken.None);
A.CallTo(() => grain.ActivateAsync())
.MustHaveHappened(Repeated.Exactly.Twice);
}
[Fact]
public async Task Should_fail_after_10_rejection_exception()
{
A.CallTo(() => grain.ActivateAsync())
.Throws(new OrleansException());
await Assert.ThrowsAsync<OrleansException>(() => sut.Execute(CancellationToken.None));
A.CallTo(() => grain.ActivateAsync())
.MustHaveHappened(Repeated.Exactly.Times(10));
}
} }
} }

Loading…
Cancel
Save