Browse Source

Referral program (#933)

* Referral program.

* Fixes.

* Fix noop manager.
pull/934/head
Sebastian Stehle 4 years ago
committed by GitHub
parent
commit
131db355ae
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      backend/i18n/frontend_en.json
  2. 2
      backend/i18n/frontend_it.json
  3. 2
      backend/i18n/frontend_nl.json
  4. 2
      backend/i18n/frontend_zh.json
  5. 2
      backend/i18n/source/frontend_en.json
  6. 8
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs
  7. 8
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs
  8. 6
      backend/src/Squidex.Domain.Apps.Entities/Billing/IBillingManager.cs
  9. 2
      backend/src/Squidex.Domain.Apps.Entities/Billing/IUsageGate.cs
  10. 12
      backend/src/Squidex.Domain.Apps.Entities/Billing/NoopBillingManager.cs
  11. 2
      backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.cs
  12. 8
      backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs
  13. 14
      backend/src/Squidex.Web/Pipeline/UsageMiddleware.cs
  14. 14
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs
  15. 8
      backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs
  16. 59
      backend/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs
  17. 31
      backend/src/Squidex/Areas/Api/Controllers/Plans/Models/PlansDto.cs
  18. 43
      backend/src/Squidex/Areas/Api/Controllers/Plans/TeamPlansController.cs
  19. 25
      backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs
  20. 2
      backend/src/Squidex/Config/Domain/SubscriptionServices.cs
  21. 6
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetUsageTrackerTests.cs
  22. 16
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/NoopBillingManagerTests.cs
  23. 12
      backend/tests/Squidex.Web.Tests/Pipeline/ApiCostsFilterTests.cs
  24. 24
      backend/tests/Squidex.Web.Tests/Pipeline/UsageMiddlewareTests.cs
  25. 9
      frontend/src/app/features/settings/pages/plans/plans-page.component.html
  26. 9
      frontend/src/app/features/teams/pages/plans/plans-page.component.html
  27. 14
      frontend/src/app/features/teams/state/team-plans.state.ts
  28. 4
      frontend/src/app/shared/services/plans.service.spec.ts
  29. 6
      frontend/src/app/shared/services/shared.ts
  30. 14
      frontend/src/app/shared/state/plans.state.ts

2
backend/i18n/frontend_en.json

@ -629,6 +629,8 @@
"plans.perMonth": "Per Month", "plans.perMonth": "Per Month",
"plans.perYear": "Per Year", "plans.perYear": "Per Year",
"plans.planOwner": "Owner", "plans.planOwner": "Owner",
"plans.referralEarned": "Amount earned so far: **{amount}**",
"plans.referralHint": "Earn money and just share the following discount code to your friend and family: **{code}**.\n\nFor the first 12 months they will get a discount of 5% and you get a credit for 10% of the total invoice amount.",
"plans.refreshTooltip": "Refresh Plans", "plans.refreshTooltip": "Refresh Plans",
"plans.reloaded": "Plans reloaded.", "plans.reloaded": "Plans reloaded.",
"plans.selected": "Selected", "plans.selected": "Selected",

2
backend/i18n/frontend_it.json

@ -629,6 +629,8 @@
"plans.perMonth": "Al mese", "plans.perMonth": "Al mese",
"plans.perYear": "all'anno", "plans.perYear": "all'anno",
"plans.planOwner": "Owner", "plans.planOwner": "Owner",
"plans.referralEarned": "Amount earned so far: **{amount}**",
"plans.referralHint": "Earn money and just share the following discount code to your friend and family: **{code}**.\n\nFor the first 12 months they will get a discount of 5% and you get a credit for 10% of the total invoice amount.",
"plans.refreshTooltip": "Aggiorna i piani", "plans.refreshTooltip": "Aggiorna i piani",
"plans.reloaded": "Piano aggiornati.", "plans.reloaded": "Piano aggiornati.",
"plans.selected": "Selezionato", "plans.selected": "Selezionato",

2
backend/i18n/frontend_nl.json

@ -629,6 +629,8 @@
"plans.perMonth": "Per maand", "plans.perMonth": "Per maand",
"plans.perYear": "Per jaar", "plans.perYear": "Per jaar",
"plans.planOwner": "Owner", "plans.planOwner": "Owner",
"plans.referralEarned": "Amount earned so far: **{amount}**",
"plans.referralHint": "Earn money and just share the following discount code to your friend and family: **{code}**.\n\nFor the first 12 months they will get a discount of 5% and you get a credit for 10% of the total invoice amount.",
"plans.refreshTooltip": "Plannen vernieuwen", "plans.refreshTooltip": "Plannen vernieuwen",
"plans.reloaded": "Plans reloaded.", "plans.reloaded": "Plans reloaded.",
"plans.selected": "Geselecteerd", "plans.selected": "Geselecteerd",

2
backend/i18n/frontend_zh.json

@ -629,6 +629,8 @@
"plans.perMonth": "每月", "plans.perMonth": "每月",
"plans.perYear": "每年", "plans.perYear": "每年",
"plans.planOwner": "Owner", "plans.planOwner": "Owner",
"plans.referralEarned": "Amount earned so far: **{amount}**",
"plans.referralHint": "Earn money and just share the following discount code to your friend and family: **{code}**.\n\nFor the first 12 months they will get a discount of 5% and you get a credit for 10% of the total invoice amount.",
"plans.refreshTooltip": "刷新计划", "plans.refreshTooltip": "刷新计划",
"plans.reloaded": "计划重新加载。", "plans.reloaded": "计划重新加载。",
"plans.selected": "已选择", "plans.selected": "已选择",

2
backend/i18n/source/frontend_en.json

@ -629,6 +629,8 @@
"plans.perMonth": "Per Month", "plans.perMonth": "Per Month",
"plans.perYear": "Per Year", "plans.perYear": "Per Year",
"plans.planOwner": "Owner", "plans.planOwner": "Owner",
"plans.referralEarned": "Amount earned so far: **{amount}**",
"plans.referralHint": "Earn money and just share the following discount code to your friend and family: **{code}**.\n\nFor the first 12 months they will get a discount of 5% and you get a credit for 10% of the total invoice amount.",
"plans.refreshTooltip": "Refresh Plans", "plans.refreshTooltip": "Refresh Plans",
"plans.reloaded": "Plans reloaded.", "plans.reloaded": "Plans reloaded.",
"plans.selected": "Selected", "plans.selected": "Selected",

8
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs

@ -19,7 +19,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
private readonly IAssetLoader assetLoader; private readonly IAssetLoader assetLoader;
private readonly ISnapshotStore<State> store; private readonly ISnapshotStore<State> store;
private readonly ITagService tagService; private readonly ITagService tagService;
private readonly IAppUsageGate appUsageGate; private readonly IUsageGate usageGate;
[CollectionName("Index_TagHistory")] [CollectionName("Index_TagHistory")]
public sealed class State public sealed class State
@ -27,10 +27,10 @@ namespace Squidex.Domain.Apps.Entities.Assets
public HashSet<string>? Tags { get; set; } public HashSet<string>? Tags { get; set; }
} }
public AssetUsageTracker(IAppUsageGate appUsageGate, IAssetLoader assetLoader, ITagService tagService, public AssetUsageTracker(IUsageGate usageGate, IAssetLoader assetLoader, ITagService tagService,
ISnapshotStore<State> store) ISnapshotStore<State> store)
{ {
this.appUsageGate = appUsageGate; this.usageGate = usageGate;
this.assetLoader = assetLoader; this.assetLoader = assetLoader;
this.tagService = tagService; this.tagService = tagService;
this.store = store; this.store = store;
@ -41,7 +41,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
Task IDeleter.DeleteAppAsync(IAppEntity app, Task IDeleter.DeleteAppAsync(IAppEntity app,
CancellationToken ct) CancellationToken ct)
{ {
return appUsageGate.DeleteAssetUsageAsync(app.Id, ct); return usageGate.DeleteAssetUsageAsync(app.Id, ct);
} }
} }
} }

8
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs

@ -57,7 +57,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
await store.ClearAsync(); await store.ClearAsync();
await appUsageGate.DeleteAssetsUsageAsync(); await usageGate.DeleteAssetsUsageAsync();
} }
public async Task On(IEnumerable<Envelope<IEvent>> events) public async Task On(IEnumerable<Envelope<IEvent>> events)
@ -185,13 +185,13 @@ namespace Squidex.Domain.Apps.Entities.Assets
switch (@event.Payload) switch (@event.Payload)
{ {
case AssetCreated assetCreated: case AssetCreated assetCreated:
return appUsageGate.TrackAssetAsync(assetCreated.AppId.Id, GetDate(@event), assetCreated.FileSize, 1); return usageGate.TrackAssetAsync(assetCreated.AppId.Id, GetDate(@event), assetCreated.FileSize, 1);
case AssetUpdated assetUpdated: case AssetUpdated assetUpdated:
return appUsageGate.TrackAssetAsync(assetUpdated.AppId.Id, GetDate(@event), assetUpdated.FileSize, 0); return usageGate.TrackAssetAsync(assetUpdated.AppId.Id, GetDate(@event), assetUpdated.FileSize, 0);
case AssetDeleted assetDeleted: case AssetDeleted assetDeleted:
return appUsageGate.TrackAssetAsync(assetDeleted.AppId.Id, GetDate(@event), -assetDeleted.DeletedSize, -1); return usageGate.TrackAssetAsync(assetDeleted.AppId.Id, GetDate(@event), -assetDeleted.DeletedSize, -1);
} }
return Task.CompletedTask; return Task.CompletedTask;

6
backend/src/Squidex.Domain.Apps.Entities/Billing/IBillingManager.cs

@ -18,6 +18,12 @@ namespace Squidex.Domain.Apps.Entities.Billing
Task<Uri?> GetPortalLinkAsync(string userId, ITeamEntity team, Task<Uri?> GetPortalLinkAsync(string userId, ITeamEntity team,
CancellationToken ct = default); CancellationToken ct = default);
Task<(string? Code, double AmountEarned)> GetReferralCodeAsync(string userId, IAppEntity app,
CancellationToken ct = default);
Task<(string? Code, double AmountEarned)> GetReferralCodeAsync(string userId, ITeamEntity team,
CancellationToken ct = default);
Task<Uri?> MustRedirectToPortalAsync(string userId, IAppEntity app, string? planId, Task<Uri?> MustRedirectToPortalAsync(string userId, IAppEntity app, string? planId,
CancellationToken ct = default); CancellationToken ct = default);

2
backend/src/Squidex.Domain.Apps.Entities/Billing/IAppUsageGate.cs → backend/src/Squidex.Domain.Apps.Entities/Billing/IUsageGate.cs

@ -11,7 +11,7 @@ using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Billing namespace Squidex.Domain.Apps.Entities.Billing
{ {
public interface IAppUsageGate public interface IUsageGate
{ {
Task<bool> IsBlockedAsync(IAppEntity app, string? clientId, DateTime date, Task<bool> IsBlockedAsync(IAppEntity app, string? clientId, DateTime date,
CancellationToken ct = default); CancellationToken ct = default);

12
backend/src/Squidex.Domain.Apps.Entities/Billing/NoopBillingManager.cs

@ -24,6 +24,18 @@ namespace Squidex.Domain.Apps.Entities.Billing
return Task.FromResult<Uri?>(null); return Task.FromResult<Uri?>(null);
} }
public Task<(string? Code, double AmountEarned)> GetReferralCodeAsync(string userId, IAppEntity app,
CancellationToken ct = default)
{
return Task.FromResult<(string? Code, double AmountEarned)>((null, 0));
}
public Task<(string? Code, double AmountEarned)> GetReferralCodeAsync(string userId, ITeamEntity team,
CancellationToken ct = default)
{
return Task.FromResult<(string? Code, double AmountEarned)>((null, 0));
}
public Task<Uri?> MustRedirectToPortalAsync(string userId, IAppEntity app, string? planId, public Task<Uri?> MustRedirectToPortalAsync(string userId, IAppEntity app, string? planId,
CancellationToken ct = default) CancellationToken ct = default)
{ {

2
backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.cs

@ -17,7 +17,7 @@ using Squidex.Messaging;
namespace Squidex.Domain.Apps.Entities.Billing namespace Squidex.Domain.Apps.Entities.Billing
{ {
public sealed class UsageGate : IAppUsageGate, IAssetUsageTracker public sealed class UsageGate : IUsageGate, IAssetUsageTracker
{ {
private const string CounterTotalCount = "TotalAssets"; private const string CounterTotalCount = "TotalAssets";
private const string CounterTotalSize = "TotalSize"; private const string CounterTotalSize = "TotalSize";

8
backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs

@ -15,11 +15,11 @@ namespace Squidex.Web.Pipeline
{ {
public sealed class ApiCostsFilter : IAsyncActionFilter, IFilterContainer public sealed class ApiCostsFilter : IAsyncActionFilter, IFilterContainer
{ {
private readonly IAppUsageGate appUsageGate; private readonly IUsageGate usageGate;
public ApiCostsFilter(IAppUsageGate appUsageGate) public ApiCostsFilter(IUsageGate usageGate)
{ {
this.appUsageGate = appUsageGate; this.usageGate = usageGate;
} }
IFilterMetadata IFilterContainer.FilterDefinition { get; set; } IFilterMetadata IFilterContainer.FilterDefinition { get; set; }
@ -50,7 +50,7 @@ namespace Squidex.Web.Pipeline
{ {
var (_, clientId) = context.HttpContext.User.GetClient(); var (_, clientId) = context.HttpContext.User.GetClient();
var isBlocked = await appUsageGate.IsBlockedAsync(app, clientId, DateTime.Today, context.HttpContext.RequestAborted); var isBlocked = await usageGate.IsBlockedAsync(app, clientId, DateTime.Today, context.HttpContext.RequestAborted);
if (isBlocked) if (isBlocked)
{ {

14
backend/src/Squidex.Web/Pipeline/UsageMiddleware.cs

@ -17,15 +17,15 @@ namespace Squidex.Web.Pipeline
{ {
public sealed class UsageMiddleware : IMiddleware public sealed class UsageMiddleware : IMiddleware
{ {
private readonly IAppLogStore appUsageLog; private readonly IAppLogStore usageLog;
private readonly IAppUsageGate appUsageGate; private readonly IUsageGate usageGate;
public IClock Clock { get; set; } = SystemClock.Instance; public IClock Clock { get; set; } = SystemClock.Instance;
public UsageMiddleware(IAppLogStore appUsageLog, IAppUsageGate appUsageGate) public UsageMiddleware(IAppLogStore usageLog, IUsageGate usageGate)
{ {
this.appUsageLog = appUsageLog; this.usageLog = usageLog;
this.appUsageGate = appUsageGate; this.usageGate = usageGate;
} }
public async Task InvokeAsync(HttpContext context, RequestDelegate next) public async Task InvokeAsync(HttpContext context, RequestDelegate next)
@ -69,13 +69,13 @@ namespace Squidex.Web.Pipeline
request.UserClientId = clientId; request.UserClientId = clientId;
// Do not flow cancellation token because it is too important. // Do not flow cancellation token because it is too important.
await appUsageLog.LogAsync(app.Id, request, default); await usageLog.LogAsync(app.Id, request, default);
if (request.Costs > 0) if (request.Costs > 0)
{ {
var date = request.Timestamp.ToDateTimeUtc().Date; var date = request.Timestamp.ToDateTimeUtc().Date;
await appUsageGate.TrackRequestAsync(app, request.UserClientId, date, await usageGate.TrackRequestAsync(app, request.UserClientId, date,
request.Costs, request.Costs,
request.ElapsedMs, request.ElapsedMs,
request.Bytes, request.Bytes,

14
backend/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs

@ -25,14 +25,14 @@ namespace Squidex.Areas.Api.Controllers.Apps
[ApiExplorerSettings(GroupName = nameof(Apps))] [ApiExplorerSettings(GroupName = nameof(Apps))]
public sealed class AppContributorsController : ApiController public sealed class AppContributorsController : ApiController
{ {
private readonly IAppUsageGate usageTracker; private readonly IUsageGate usageGate;
private readonly IUserResolver usageGate; private readonly IUserResolver userResolver;
public AppContributorsController(ICommandBus commandBus, IAppUsageGate usageGate, IUserResolver userResolver) public AppContributorsController(ICommandBus commandBus, IUsageGate usageGate, IUserResolver userResolver)
: base(commandBus) : base(commandBus)
{ {
this.usageTracker = usageGate; this.usageGate = usageGate;
this.usageGate = userResolver; this.userResolver = userResolver;
} }
/// <summary> /// <summary>
@ -145,9 +145,9 @@ namespace Squidex.Areas.Api.Controllers.Apps
private async Task<ContributorsDto> GetResponseAsync(IAppEntity app, bool invited) private async Task<ContributorsDto> GetResponseAsync(IAppEntity app, bool invited)
{ {
var (plan, _, _) = await usageTracker.GetPlanForAppAsync(app, HttpContext.RequestAborted); var (plan, _, _) = await usageGate.GetPlanForAppAsync(app, HttpContext.RequestAborted);
return await ContributorsDto.FromDomainAsync(app, Resources, usageGate, plan, invited); return await ContributorsDto.FromDomainAsync(app, Resources, userResolver, plan, invited);
} }
} }
} }

8
backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs

@ -32,7 +32,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
[ApiExplorerSettings(GroupName = nameof(Assets))] [ApiExplorerSettings(GroupName = nameof(Assets))]
public sealed class AssetsController : ApiController public sealed class AssetsController : ApiController
{ {
private readonly IAppUsageGate appUsageGate; private readonly IUsageGate usageGate;
private readonly IAssetQueryService assetQuery; private readonly IAssetQueryService assetQuery;
private readonly IAssetUsageTracker assetUsageTracker; private readonly IAssetUsageTracker assetUsageTracker;
private readonly ITagService tagService; private readonly ITagService tagService;
@ -40,14 +40,14 @@ namespace Squidex.Areas.Api.Controllers.Assets
public AssetsController( public AssetsController(
ICommandBus commandBus, ICommandBus commandBus,
IAppUsageGate appUsageGate, IUsageGate usageGate,
IAssetQueryService assetQuery, IAssetQueryService assetQuery,
IAssetUsageTracker assetUsageTracker, IAssetUsageTracker assetUsageTracker,
ITagService tagService, ITagService tagService,
AssetTusRunner assetTusRunner) AssetTusRunner assetTusRunner)
: base(commandBus) : base(commandBus)
{ {
this.appUsageGate = appUsageGate; this.usageGate = usageGate;
this.assetQuery = assetQuery; this.assetQuery = assetQuery;
this.assetUsageTracker = assetUsageTracker; this.assetUsageTracker = assetUsageTracker;
this.assetTusRunner = assetTusRunner; this.assetTusRunner = assetTusRunner;
@ -469,7 +469,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
throw new ValidationException(error); throw new ValidationException(error);
} }
var (plan, _, _) = await appUsageGate.GetPlanForAppAsync(App, HttpContext.RequestAborted); var (plan, _, _) = await usageGate.GetPlanForAppAsync(App, HttpContext.RequestAborted);
var currentSize = await assetUsageTracker.GetTotalSizeByAppAsync(AppId, HttpContext.RequestAborted); var currentSize = await assetUsageTracker.GetTotalSizeByAppAsync(AppId, HttpContext.RequestAborted);

59
backend/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs

@ -12,6 +12,7 @@ using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Domain.Apps.Entities.Billing; using Squidex.Domain.Apps.Entities.Billing;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Tasks;
using Squidex.Shared; using Squidex.Shared;
using Squidex.Web; using Squidex.Web;
@ -25,17 +26,17 @@ namespace Squidex.Areas.Api.Controllers.Plans
{ {
private readonly IBillingPlans billingPlans; private readonly IBillingPlans billingPlans;
private readonly IBillingManager billingManager; private readonly IBillingManager billingManager;
private readonly IAppUsageGate appUsageGate; private readonly IUsageGate usageGate;
public AppPlansController(ICommandBus commandBus, public AppPlansController(ICommandBus commandBus,
IAppUsageGate appUsageGate, IUsageGate usageGate,
IBillingPlans billingPlans, IBillingPlans billingPlans,
IBillingManager billingManager) IBillingManager billingManager)
: base(commandBus) : base(commandBus)
{ {
this.billingPlans = billingPlans; this.billingPlans = billingPlans;
this.billingManager = billingManager; this.billingManager = billingManager;
this.appUsageGate = appUsageGate; this.usageGate = usageGate;
} }
/// <summary> /// <summary>
@ -55,34 +56,42 @@ namespace Squidex.Areas.Api.Controllers.Plans
{ {
var response = Deferred.AsyncResponse(async () => var response = Deferred.AsyncResponse(async () =>
{ {
var (_, planId, teamId) = await appUsageGate.GetPlanForAppAsync(App, HttpContext.RequestAborted); var plans = billingPlans.GetAvailablePlans();
var owner = App.Plan?.Owner.Identifier; var (plan, link, referral) =
var isOwner = string.Equals(owner, UserId, StringComparison.Ordinal); await AsyncHelper.WhenAll(
var isLocked = PlansLockedReason.None; usageGate.GetPlanForAppAsync(App, HttpContext.RequestAborted),
var linkUrl = (Uri?)null; billingManager.GetPortalLinkAsync(UserId, App, HttpContext.RequestAborted),
billingManager.GetReferralCodeAsync(UserId, App, HttpContext.RequestAborted));
if (teamId != null) var planOwner = App.Plan?.Owner.Identifier;
{
isLocked = PlansLockedReason.ManagedByTeam;
}
else if (!Resources.CanChangePlan)
{
isLocked = PlansLockedReason.NoPermission;
}
else if (owner != null && !isOwner)
{
isLocked = PlansLockedReason.NotOwner;
}
if (isLocked == PlansLockedReason.None || isOwner) PlansLockedReason GetLocked()
{ {
linkUrl = await billingManager.GetPortalLinkAsync(UserId, App, HttpContext.RequestAborted); if (plan.TeamId != null)
{
return PlansLockedReason.ManagedByTeam;
}
else if (!Resources.CanChangePlan)
{
return PlansLockedReason.NoPermission;
}
else if (planOwner != null && !string.Equals(planOwner, UserId, StringComparison.Ordinal))
{
return PlansLockedReason.NotOwner;
}
return PlansLockedReason.None;
} }
var plans = billingPlans.GetAvailablePlans(); return PlansDto.FromDomain(
plans.ToArray(),
return PlansDto.FromDomain(plans.ToArray(), owner, planId, linkUrl, isLocked); planOwner,
plan.PlanId,
referral.Code,
referral.AmountEarned,
link,
GetLocked());
}); });
Response.Headers[HeaderNames.ETag] = App.ToEtag(); Response.Headers[HeaderNames.ETag] = App.ToEtag();

31
backend/src/Squidex/Areas/Api/Controllers/Plans/Models/PlansDto.cs

@ -31,24 +31,47 @@ namespace Squidex.Areas.Api.Controllers.Plans.Models
/// <summary> /// <summary>
/// The link to the management portal. /// The link to the management portal.
/// </summary> /// </summary>
public string? PortalLink { get; set; } public Uri? PortalLink { get; set; }
/// <summary>
/// The code for referral managemenent.
/// </summary>
public string? ReferralCode { get; set; }
/// <summary>
/// The amount earned.
/// </summary>
public string? ReferralEarned { get; set; }
/// <summary> /// <summary>
/// The reason why the plan cannot be changed. /// The reason why the plan cannot be changed.
/// </summary> /// </summary>
public PlansLockedReason Locked { get; set; } public PlansLockedReason Locked { get; set; }
public static PlansDto FromDomain(Plan[] plans, string? owner, string planId, Uri? portalLink, PlansLockedReason locked) public static PlansDto FromDomain(
Plan[] plans,
string? planOwner,
string? planId,
string? referralCode,
double? referralAmount,
Uri? portalLink,
PlansLockedReason locked)
{ {
var result = new PlansDto var result = new PlansDto
{ {
Locked = locked, Locked = locked,
CurrentPlanId = planId, CurrentPlanId = planId,
Plans = plans.Select(PlanDto.FromDomain).ToArray(), Plans = plans.Select(PlanDto.FromDomain).ToArray(),
PlanOwner = owner, PlanOwner = planOwner,
PortalLink = portalLink?.ToString() ReferralCode = referralCode,
ReferralEarned = $"{referralAmount ?? 0:0.00} EUR"
}; };
if (locked == PlansLockedReason.None)
{
result.PortalLink = portalLink;
}
return result; return result;
} }
} }

43
backend/src/Squidex/Areas/Api/Controllers/Plans/TeamPlansController.cs

@ -12,6 +12,7 @@ using Squidex.Domain.Apps.Entities.Billing;
using Squidex.Domain.Apps.Entities.Teams.Commands; using Squidex.Domain.Apps.Entities.Teams.Commands;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Tasks;
using Squidex.Shared; using Squidex.Shared;
using Squidex.Web; using Squidex.Web;
@ -23,12 +24,12 @@ namespace Squidex.Areas.Api.Controllers.Plans
[ApiExplorerSettings(GroupName = nameof(Plans))] [ApiExplorerSettings(GroupName = nameof(Plans))]
public sealed class TeamPlansController : ApiController public sealed class TeamPlansController : ApiController
{ {
private readonly IAppUsageGate appUsageGate; private readonly IUsageGate appUsageGate;
private readonly IBillingPlans billingPlans; private readonly IBillingPlans billingPlans;
private readonly IBillingManager billingManager; private readonly IBillingManager billingManager;
public TeamPlansController(ICommandBus commandBus, public TeamPlansController(ICommandBus commandBus,
IAppUsageGate appUsageGate, IUsageGate appUsageGate,
IBillingPlans billingPlans, IBillingPlans billingPlans,
IBillingManager billingManager) IBillingManager billingManager)
: base(commandBus) : base(commandBus)
@ -55,31 +56,31 @@ namespace Squidex.Areas.Api.Controllers.Plans
{ {
var response = Deferred.AsyncResponse(async () => var response = Deferred.AsyncResponse(async () =>
{ {
var owner = Team.Plan?.Owner.Identifier; var plans = billingPlans.GetAvailablePlans();
var (_, planId) = await appUsageGate.GetPlanForTeamAsync(Team, HttpContext.RequestAborted);
var lockedReason = PlansLockedReason.None; var (plan, link, referral) =
await AsyncHelper.WhenAll(
appUsageGate.GetPlanForTeamAsync(Team, HttpContext.RequestAborted),
billingManager.GetPortalLinkAsync(UserId, Team, HttpContext.RequestAborted),
billingManager.GetReferralCodeAsync(UserId, Team, HttpContext.RequestAborted));
if (!Resources.CanChangeTeamPlan) PlansLockedReason GetLocked()
{ {
lockedReason = PlansLockedReason.NoPermission; if (!Resources.CanChangeTeamPlan)
} {
else if (owner != null && !string.Equals(owner, UserId, StringComparison.OrdinalIgnoreCase)) return PlansLockedReason.NoPermission;
{ }
lockedReason = PlansLockedReason.NotOwner;
}
var linkUrl = (Uri?)null; return PlansLockedReason.None;
if (lockedReason == PlansLockedReason.None)
{
linkUrl = await billingManager.GetPortalLinkAsync(UserId, Team, HttpContext.RequestAborted);
} }
var plans = billingPlans.GetAvailablePlans(); return PlansDto.FromDomain(
plans.ToArray(), null,
return PlansDto.FromDomain(plans.ToArray(), owner, planId, linkUrl, lockedReason); plan.PlanId,
referral.Code,
referral.AmountEarned,
link,
GetLocked());
}); });
Response.Headers[HeaderNames.ETag] = Team.ToEtag(); Response.Headers[HeaderNames.ETag] = Team.ToEtag();

25
backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs

@ -27,8 +27,8 @@ namespace Squidex.Areas.Api.Controllers.Statistics
public sealed class UsagesController : ApiController public sealed class UsagesController : ApiController
{ {
private readonly IApiUsageTracker usageTracker; private readonly IApiUsageTracker usageTracker;
private readonly IAppLogStore appLogStore; private readonly IAppLogStore usageLog;
private readonly IAppUsageGate appUsageGate; private readonly IUsageGate usageGate;
private readonly IAssetUsageTracker assetStatsRepository; private readonly IAssetUsageTracker assetStatsRepository;
private readonly IDataProtector dataProtector; private readonly IDataProtector dataProtector;
private readonly IUrlGenerator urlGenerator; private readonly IUrlGenerator urlGenerator;
@ -37,18 +37,17 @@ namespace Squidex.Areas.Api.Controllers.Statistics
ICommandBus commandBus, ICommandBus commandBus,
IDataProtectionProvider dataProtection, IDataProtectionProvider dataProtection,
IApiUsageTracker usageTracker, IApiUsageTracker usageTracker,
IAppLogStore appLogStore, IAppLogStore usageLog,
IAppUsageGate appUsageGate, IUsageGate usageGate,
IAssetUsageTracker assetStatsRepository, IAssetUsageTracker assetStatsRepository,
IUrlGenerator urlGenerator) IUrlGenerator urlGenerator)
: base(commandBus) : base(commandBus)
{ {
this.usageTracker = usageTracker; this.usageLog = usageLog;
this.appLogStore = appLogStore;
this.appUsageGate = appUsageGate;
this.assetStatsRepository = assetStatsRepository; this.assetStatsRepository = assetStatsRepository;
this.urlGenerator = urlGenerator; this.urlGenerator = urlGenerator;
this.usageGate = usageGate;
this.usageTracker = usageTracker;
dataProtector = dataProtection.CreateProtector("LogToken"); dataProtector = dataProtection.CreateProtector("LogToken");
} }
@ -91,7 +90,7 @@ namespace Squidex.Areas.Api.Controllers.Statistics
var callback = new FileCallback((body, range, ct) => var callback = new FileCallback((body, range, ct) =>
{ {
return appLogStore.ReadLogAsync(appId, fileDate.AddDays(-30), fileDate, body, ct); return usageLog.ReadLogAsync(appId, fileDate.AddDays(-30), fileDate, body, ct);
}); });
return new FileCallbackResult("text/csv", callback) return new FileCallbackResult("text/csv", callback)
@ -127,7 +126,7 @@ namespace Squidex.Areas.Api.Controllers.Statistics
var (summary, details) = await usageTracker.QueryAsync(AppId.ToString(), fromDate.Date, toDate.Date, HttpContext.RequestAborted); var (summary, details) = await usageTracker.QueryAsync(AppId.ToString(), fromDate.Date, toDate.Date, HttpContext.RequestAborted);
// Use the current app plan to show the limits to the user. // Use the current app plan to show the limits to the user.
var (plan, _, _) = await appUsageGate.GetPlanForAppAsync(App, HttpContext.RequestAborted); var (plan, _, _) = await usageGate.GetPlanForAppAsync(App, HttpContext.RequestAborted);
var response = CallsUsageDtoDto.FromDomain(plan, summary, details); var response = CallsUsageDtoDto.FromDomain(plan, summary, details);
@ -161,7 +160,7 @@ namespace Squidex.Areas.Api.Controllers.Statistics
var (summary, details) = await usageTracker.QueryAsync(TeamId.ToString(), fromDate.Date, toDate.Date, HttpContext.RequestAborted); var (summary, details) = await usageTracker.QueryAsync(TeamId.ToString(), fromDate.Date, toDate.Date, HttpContext.RequestAborted);
// Use the current team plan to show the limits to the user. // Use the current team plan to show the limits to the user.
var (plan, _) = await appUsageGate.GetPlanForTeamAsync(Team, HttpContext.RequestAborted); var (plan, _) = await usageGate.GetPlanForTeamAsync(Team, HttpContext.RequestAborted);
var response = CallsUsageDtoDto.FromDomain(plan, summary, details); var response = CallsUsageDtoDto.FromDomain(plan, summary, details);
@ -186,7 +185,7 @@ namespace Squidex.Areas.Api.Controllers.Statistics
var size = await assetStatsRepository.GetTotalSizeByAppAsync(AppId, HttpContext.RequestAborted); var size = await assetStatsRepository.GetTotalSizeByAppAsync(AppId, HttpContext.RequestAborted);
// Use the current app plan to show the limits to the user. // Use the current app plan to show the limits to the user.
var (plan, _, _) = await appUsageGate.GetPlanForAppAsync(App, HttpContext.RequestAborted); var (plan, _, _) = await usageGate.GetPlanForAppAsync(App, HttpContext.RequestAborted);
var response = new CurrentStorageDto { Size = size, MaxAllowed = plan.MaxAssetSize }; var response = new CurrentStorageDto { Size = size, MaxAllowed = plan.MaxAssetSize };
@ -211,7 +210,7 @@ namespace Squidex.Areas.Api.Controllers.Statistics
var size = await assetStatsRepository.GetTotalSizeByTeamAsync(TeamId, HttpContext.RequestAborted); var size = await assetStatsRepository.GetTotalSizeByTeamAsync(TeamId, HttpContext.RequestAborted);
// Use the current team plan to show the limits to the user. // Use the current team plan to show the limits to the user.
var (plan, _) = await appUsageGate.GetPlanForTeamAsync(Team, HttpContext.RequestAborted); var (plan, _) = await usageGate.GetPlanForTeamAsync(Team, HttpContext.RequestAborted);
var response = new CurrentStorageDto { Size = size, MaxAllowed = plan.MaxAssetSize }; var response = new CurrentStorageDto { Size = size, MaxAllowed = plan.MaxAssetSize };

2
backend/src/Squidex/Config/Domain/SubscriptionServices.cs

@ -26,7 +26,7 @@ namespace Squidex.Config.Domain
.AsOptional<IBillingManager>(); .AsOptional<IBillingManager>();
services.AddSingletonAs<UsageGate>() services.AddSingletonAs<UsageGate>()
.AsOptional<IAppUsageGate>().As<IAssetUsageTracker>(); .AsOptional<IUsageGate>().As<IAssetUsageTracker>();
} }
} }
} }

6
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetUsageTrackerTests.cs

@ -23,7 +23,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
private readonly IAssetLoader assetLoader = A.Fake<IAssetLoader>(); private readonly IAssetLoader assetLoader = A.Fake<IAssetLoader>();
private readonly ISnapshotStore<AssetUsageTracker.State> store = A.Fake<ISnapshotStore<AssetUsageTracker.State>>(); private readonly ISnapshotStore<AssetUsageTracker.State> store = A.Fake<ISnapshotStore<AssetUsageTracker.State>>();
private readonly ITagService tagService = A.Fake<ITagService>(); private readonly ITagService tagService = A.Fake<ITagService>();
private readonly IAppUsageGate appUsageGate = A.Fake<IAppUsageGate>(); private readonly IUsageGate usageGate = A.Fake<IUsageGate>();
private readonly NamedId<DomainId> appId = NamedId.Of(DomainId.NewGuid(), "my-app"); private readonly NamedId<DomainId> appId = NamedId.Of(DomainId.NewGuid(), "my-app");
private readonly DomainId assetId = DomainId.NewGuid(); private readonly DomainId assetId = DomainId.NewGuid();
private readonly DomainId assetKey; private readonly DomainId assetKey;
@ -33,7 +33,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
{ {
assetKey = DomainId.Combine(appId, assetId); assetKey = DomainId.Combine(appId, assetId);
sut = new AssetUsageTracker(appUsageGate, assetLoader, tagService, store); sut = new AssetUsageTracker(usageGate, assetLoader, tagService, store);
} }
[Fact] [Fact]
@ -92,7 +92,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
await sut.On(new[] { envelope }); await sut.On(new[] { envelope });
A.CallTo(() => appUsageGate.TrackAssetAsync(appId.Id, date, sizeDiff, countDiff, default)) A.CallTo(() => usageGate.TrackAssetAsync(appId.Id, date, sizeDiff, countDiff, default))
.MustHaveHappened(); .MustHaveHappened();
} }

16
backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/NoopBillingManagerTests.cs

@ -55,6 +55,22 @@ namespace Squidex.Domain.Apps.Entities.Billing
Assert.Null(actual); Assert.Null(actual);
} }
[Fact]
public async Task Should_not_return_referral_code_for_app()
{
var actual = await sut.GetReferralCodeAsync(null!, (IAppEntity)null!);
Assert.Equal((null, 00), actual);
}
[Fact]
public async Task Should_not_return_referral_code_for_team()
{
var actual = await sut.GetReferralCodeAsync(null!, (ITeamEntity)null!);
Assert.Equal((null, 0), actual);
}
[Fact] [Fact]
public async Task Should_do_nothing_if_checking_for_redirect_for_app() public async Task Should_do_nothing_if_checking_for_redirect_for_app()
{ {

12
backend/tests/Squidex.Web.Tests/Pipeline/ApiCostsFilterTests.cs

@ -21,7 +21,7 @@ namespace Squidex.Web.Pipeline
public class ApiCostsFilterTests public class ApiCostsFilterTests
{ {
private readonly IAppEntity appEntity = A.Fake<IAppEntity>(); private readonly IAppEntity appEntity = A.Fake<IAppEntity>();
private readonly IAppUsageGate appUsageGate = A.Fake<IAppUsageGate>(); private readonly IUsageGate usageGate = A.Fake<IUsageGate>();
private readonly ActionExecutingContext actionContext; private readonly ActionExecutingContext actionContext;
private readonly ActionExecutionDelegate next; private readonly ActionExecutionDelegate next;
private readonly HttpContext httpContext = new DefaultHttpContext(); private readonly HttpContext httpContext = new DefaultHttpContext();
@ -43,7 +43,7 @@ namespace Squidex.Web.Pipeline
return Task.FromResult<ActionExecutedContext>(null!); return Task.FromResult<ActionExecutedContext>(null!);
}; };
sut = new ApiCostsFilter(appUsageGate); sut = new ApiCostsFilter(usageGate);
} }
[Fact] [Fact]
@ -53,7 +53,7 @@ namespace Squidex.Web.Pipeline
SetupApp(); SetupApp();
A.CallTo(() => appUsageGate.IsBlockedAsync(appEntity, A<string>._, DateTime.Today, default)) A.CallTo(() => usageGate.IsBlockedAsync(appEntity, A<string>._, DateTime.Today, default))
.Returns(true); .Returns(true);
await sut.OnActionExecutionAsync(actionContext, next); await sut.OnActionExecutionAsync(actionContext, next);
@ -69,7 +69,7 @@ namespace Squidex.Web.Pipeline
SetupApp(); SetupApp();
A.CallTo(() => appUsageGate.IsBlockedAsync(appEntity, A<string>._, DateTime.Today, default)) A.CallTo(() => usageGate.IsBlockedAsync(appEntity, A<string>._, DateTime.Today, default))
.Returns(false); .Returns(false);
await sut.OnActionExecutionAsync(actionContext, next); await sut.OnActionExecutionAsync(actionContext, next);
@ -88,7 +88,7 @@ namespace Squidex.Web.Pipeline
Assert.True(isNextCalled); Assert.True(isNextCalled);
A.CallTo(() => appUsageGate.IsBlockedAsync(appEntity, A<string>._, DateTime.Today, default)) A.CallTo(() => usageGate.IsBlockedAsync(appEntity, A<string>._, DateTime.Today, default))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -101,7 +101,7 @@ namespace Squidex.Web.Pipeline
Assert.True(isNextCalled); Assert.True(isNextCalled);
A.CallTo(() => appUsageGate.IsBlockedAsync(appEntity, A<string>._, DateTime.Today, default)) A.CallTo(() => usageGate.IsBlockedAsync(appEntity, A<string>._, DateTime.Today, default))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }

24
backend/tests/Squidex.Web.Tests/Pipeline/UsageMiddlewareTests.cs

@ -19,8 +19,8 @@ namespace Squidex.Web.Pipeline
{ {
public class UsageMiddlewareTests public class UsageMiddlewareTests
{ {
private readonly IAppLogStore appLogStore = A.Fake<IAppLogStore>(); private readonly IAppLogStore usageLog = A.Fake<IAppLogStore>();
private readonly IAppUsageGate appUsageGate = A.Fake<IAppUsageGate>(); private readonly IUsageGate usageGate = A.Fake<IUsageGate>();
private readonly IClock clock = A.Fake<IClock>(); private readonly IClock clock = A.Fake<IClock>();
private readonly Instant instant = SystemClock.Instance.GetCurrentInstant(); private readonly Instant instant = SystemClock.Instance.GetCurrentInstant();
private readonly HttpContext httpContext = new DefaultHttpContext(); private readonly HttpContext httpContext = new DefaultHttpContext();
@ -41,7 +41,7 @@ namespace Squidex.Web.Pipeline
return Task.CompletedTask; return Task.CompletedTask;
}; };
sut = new UsageMiddleware(appLogStore, appUsageGate) sut = new UsageMiddleware(usageLog, usageGate)
{ {
Clock = clock Clock = clock
}; };
@ -56,7 +56,7 @@ namespace Squidex.Web.Pipeline
var date = instant.ToDateTimeUtc().Date; var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => appUsageGate.TrackRequestAsync(A<IAppEntity>._, A<string>._, A<DateTime>._, A<double>._, A<long>._, A<long>._, default)) A.CallTo(() => usageGate.TrackRequestAsync(A<IAppEntity>._, A<string>._, A<DateTime>._, A<double>._, A<long>._, A<long>._, default))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -76,7 +76,7 @@ namespace Squidex.Web.Pipeline
var date = instant.ToDateTimeUtc().Date; var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => appUsageGate.TrackRequestAsync(app, A<string>._, date, A<double>._, A<long>._, A<long>._, default)) A.CallTo(() => usageGate.TrackRequestAsync(app, A<string>._, date, A<double>._, A<long>._, A<long>._, default))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -94,7 +94,7 @@ namespace Squidex.Web.Pipeline
var date = instant.ToDateTimeUtc().Date; var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => appUsageGate.TrackRequestAsync(app, A<string>._, date, 13, A<long>._, A<long>._, default)) A.CallTo(() => usageGate.TrackRequestAsync(app, A<string>._, date, 13, A<long>._, A<long>._, default))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -113,7 +113,7 @@ namespace Squidex.Web.Pipeline
var date = instant.ToDateTimeUtc().Date; var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => appUsageGate.TrackRequestAsync(app, A<string>._, date, 13, A<long>._, 1024, default)) A.CallTo(() => usageGate.TrackRequestAsync(app, A<string>._, date, 13, A<long>._, 1024, default))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -136,7 +136,7 @@ namespace Squidex.Web.Pipeline
var date = instant.ToDateTimeUtc().Date; var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => appUsageGate.TrackRequestAsync(app, A<string>._, date, 13, A<long>._, 11, default)) A.CallTo(() => usageGate.TrackRequestAsync(app, A<string>._, date, 13, A<long>._, 11, default))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -159,7 +159,7 @@ namespace Squidex.Web.Pipeline
var date = instant.ToDateTimeUtc().Date; var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => appUsageGate.TrackRequestAsync(app, A<string>._, date, 13, A<long>._, 11, default)) A.CallTo(() => usageGate.TrackRequestAsync(app, A<string>._, date, 13, A<long>._, 11, default))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -192,7 +192,7 @@ namespace Squidex.Web.Pipeline
var date = instant.ToDateTimeUtc().Date; var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => appUsageGate.TrackRequestAsync(app, A<string>._, date, 13, A<long>._, 11, default)) A.CallTo(() => usageGate.TrackRequestAsync(app, A<string>._, date, 13, A<long>._, 11, default))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -210,7 +210,7 @@ namespace Squidex.Web.Pipeline
var date = instant.ToDateTimeUtc().Date; var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => appUsageGate.TrackRequestAsync(app, A<string>._, date, A<double>._, A<long>._, A<long>._, default)) A.CallTo(() => usageGate.TrackRequestAsync(app, A<string>._, date, A<double>._, A<long>._, A<long>._, default))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -227,7 +227,7 @@ namespace Squidex.Web.Pipeline
await sut.InvokeAsync(httpContext, next); await sut.InvokeAsync(httpContext, next);
A.CallTo(() => appLogStore.LogAsync(appId.Id, A.CallTo(() => usageLog.LogAsync(appId.Id,
A<RequestLog>.That.Matches(x => A<RequestLog>.That.Matches(x =>
x.Timestamp == instant && x.Timestamp == instant &&
x.RequestMethod == "GET" && x.RequestMethod == "GET" &&

9
frontend/src/app/features/settings/pages/plans/plans-page.component.html

@ -20,6 +20,15 @@
{{ 'plans.managedByTeam' | sqxTranslate }} {{ 'plans.managedByTeam' | sqxTranslate }}
</div> </div>
<div class="card card-body mb-4" *ngIf="plansState.referralCode | async">
<h5 class="card-title">Squidex Referal Program</h5>
<sqx-form-hint>
<div [innerHTML]="'plans.referralHint' | sqxTranslate: { code: plansState.snapshot.referralCode } | sqxMarkdown | sqxSafeHtml"></div>
<div [innerHTML]="'plans.referralEarned' | sqxTranslate: { amount: plansState.snapshot.referralEarned } | sqxMarkdown | sqxSafeHtml"></div>
</sqx-form-hint>
</div>
<div> <div>
<div class="text-muted text-center empty" *ngIf="plans.length === 0"> <div class="text-muted text-center empty" *ngIf="plans.length === 0">
{{ 'plans.noPlanConfigured' | sqxTranslate }} {{ 'plans.noPlanConfigured' | sqxTranslate }}

9
frontend/src/app/features/teams/pages/plans/plans-page.component.html

@ -16,6 +16,15 @@
{{ 'plans.allApps' | sqxTranslate }} {{ 'plans.allApps' | sqxTranslate }}
</div> </div>
<div class="card card-body mb-4" *ngIf="plansState.referralCode | async">
<h5 class="card-title">Squidex Referal Program</h5>
<sqx-form-hint>
<div [innerHTML]="'plans.referralHint' | sqxTranslate: { code: plansState.snapshot.referralCode } | sqxMarkdown | sqxSafeHtml"></div>
<div [innerHTML]="'plans.referralEarned' | sqxTranslate: { amount: plansState.snapshot.referralEarned } | sqxMarkdown | sqxSafeHtml"></div>
</sqx-form-hint>
</div>
<div> <div>
<div class="text-muted text-center empty" *ngIf="plans.length === 0"> <div class="text-muted text-center empty" *ngIf="plans.length === 0">
{{ 'plans.noPlanConfigured' | sqxTranslate }} {{ 'plans.noPlanConfigured' | sqxTranslate }}

14
frontend/src/app/features/teams/state/team-plans.state.ts

@ -32,6 +32,12 @@ interface Snapshot extends LoadingState {
// The portal link if available. // The portal link if available.
portalLink?: string; portalLink?: string;
// The referral code.
referralCode?: string;
// The amount earned with referrals.
referralEarned?: string;
// The reason why the plan cannot be changed. // The reason why the plan cannot be changed.
locked?: PlanLockedReason; locked?: PlanLockedReason;
@ -56,6 +62,12 @@ export class TeamPlansState extends State<Snapshot> {
public locked = public locked =
this.project(x => x.locked); this.project(x => x.locked);
public referralCode =
this.project(x => x.referralCode);
public referralEarned =
this.project(x => x.referralEarned);
public portalLink = public portalLink =
this.project(x => x.portalLink); this.project(x => x.portalLink);
@ -100,6 +112,8 @@ export class TeamPlansState extends State<Snapshot> {
planOwner: payload.planOwner, planOwner: payload.planOwner,
plans, plans,
portalLink: payload.portalLink, portalLink: payload.portalLink,
referralCode: payload.referralCode,
referralEarned: payload.referralEarned,
version, version,
}, 'Loading Success'); }, 'Loading Success');
}), }),

4
frontend/src/app/shared/services/plans.service.spec.ts

@ -44,6 +44,8 @@ describe('PlansService', () => {
req.flush({ req.flush({
currentPlanId: '123', currentPlanId: '123',
portalLink: 'link/to/portal', portalLink: 'link/to/portal',
referralCode: 'CODE',
referralEarned: '100.00 EUR',
planOwner: '456', planOwner: '456',
plans: [ plans: [
{ {
@ -84,6 +86,8 @@ describe('PlansService', () => {
payload: { payload: {
currentPlanId: '123', currentPlanId: '123',
portalLink: 'link/to/portal', portalLink: 'link/to/portal',
referralCode: 'CODE',
referralEarned: '100.00 EUR',
planOwner: '456', planOwner: '456',
plans: [ plans: [
new PlanDto( new PlanDto(

6
frontend/src/app/shared/services/shared.ts

@ -91,6 +91,12 @@ export type PlansPayload = Readonly<{
// The portal link if available. // The portal link if available.
portalLink?: string; portalLink?: string;
// The referral code.
referralCode?: string;
// The amount earned with referrals.
referralEarned?: string;
// The reason why the plan cannot be changed. // The reason why the plan cannot be changed.
locked: PlanLockedReason; locked: PlanLockedReason;
}>; }>;

14
frontend/src/app/shared/state/plans.state.ts

@ -33,6 +33,12 @@ interface Snapshot extends LoadingState {
// The portal link if available. // The portal link if available.
portalLink?: string; portalLink?: string;
// The referral code.
referralCode?: string;
// The amount earned with referrals.
referralEarned?: string;
// The reason why the plan cannot be changed. // The reason why the plan cannot be changed.
locked?: PlanLockedReason; locked?: PlanLockedReason;
@ -57,6 +63,12 @@ export class PlansState extends State<Snapshot> {
public locked = public locked =
this.project(x => x.locked); this.project(x => x.locked);
public referralCode =
this.project(x => x.referralCode);
public referralEarned =
this.project(x => x.referralEarned);
public portalLink = public portalLink =
this.project(x => x.portalLink); this.project(x => x.portalLink);
@ -105,6 +117,8 @@ export class PlansState extends State<Snapshot> {
planOwner: payload.planOwner, planOwner: payload.planOwner,
plans, plans,
portalLink: payload.portalLink, portalLink: payload.portalLink,
referralCode: payload.referralCode,
referralEarned: payload.referralEarned,
version, version,
}, 'Loading Success'); }, 'Loading Success');
}), }),

Loading…
Cancel
Save