From 131db355ae2b393dd2140c8be1decbd6485026f2 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Tue, 25 Oct 2022 17:14:38 +0200 Subject: [PATCH] Referral program (#933) * Referral program. * Fixes. * Fix noop manager. --- backend/i18n/frontend_en.json | 2 + backend/i18n/frontend_it.json | 2 + backend/i18n/frontend_nl.json | 2 + backend/i18n/frontend_zh.json | 2 + backend/i18n/source/frontend_en.json | 2 + .../Assets/AssetUsageTracker.cs | 8 +-- .../Assets/AssetUsageTracker_EventHandling.cs | 8 +-- .../Billing/IBillingManager.cs | 6 ++ .../{IAppUsageGate.cs => IUsageGate.cs} | 2 +- .../Billing/NoopBillingManager.cs | 12 ++++ .../Billing/UsageGate.cs | 2 +- .../Squidex.Web/Pipeline/ApiCostsFilter.cs | 8 +-- .../Squidex.Web/Pipeline/UsageMiddleware.cs | 14 ++--- .../Apps/AppContributorsController.cs | 14 ++--- .../Controllers/Assets/AssetsController.cs | 8 +-- .../Controllers/Plans/AppPlansController.cs | 59 +++++++++++-------- .../Api/Controllers/Plans/Models/PlansDto.cs | 31 ++++++++-- .../Controllers/Plans/TeamPlansController.cs | 43 +++++++------- .../Statistics/UsagesController.cs | 25 ++++---- .../Config/Domain/SubscriptionServices.cs | 2 +- .../Assets/AssetUsageTrackerTests.cs | 6 +- .../Billing/NoopBillingManagerTests.cs | 16 +++++ .../Pipeline/ApiCostsFilterTests.cs | 12 ++-- .../Pipeline/UsageMiddlewareTests.cs | 24 ++++---- .../pages/plans/plans-page.component.html | 9 +++ .../pages/plans/plans-page.component.html | 9 +++ .../features/teams/state/team-plans.state.ts | 14 +++++ .../app/shared/services/plans.service.spec.ts | 4 ++ frontend/src/app/shared/services/shared.ts | 6 ++ frontend/src/app/shared/state/plans.state.ts | 14 +++++ 30 files changed, 249 insertions(+), 117 deletions(-) rename backend/src/Squidex.Domain.Apps.Entities/Billing/{IAppUsageGate.cs => IUsageGate.cs} (97%) diff --git a/backend/i18n/frontend_en.json b/backend/i18n/frontend_en.json index 9e5f7ef4b..925c2687f 100644 --- a/backend/i18n/frontend_en.json +++ b/backend/i18n/frontend_en.json @@ -629,6 +629,8 @@ "plans.perMonth": "Per Month", "plans.perYear": "Per Year", "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.reloaded": "Plans reloaded.", "plans.selected": "Selected", diff --git a/backend/i18n/frontend_it.json b/backend/i18n/frontend_it.json index 6b770b2e7..949396318 100644 --- a/backend/i18n/frontend_it.json +++ b/backend/i18n/frontend_it.json @@ -629,6 +629,8 @@ "plans.perMonth": "Al mese", "plans.perYear": "all'anno", "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.reloaded": "Piano aggiornati.", "plans.selected": "Selezionato", diff --git a/backend/i18n/frontend_nl.json b/backend/i18n/frontend_nl.json index 535398f3c..dfdf1d585 100644 --- a/backend/i18n/frontend_nl.json +++ b/backend/i18n/frontend_nl.json @@ -629,6 +629,8 @@ "plans.perMonth": "Per maand", "plans.perYear": "Per jaar", "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.reloaded": "Plans reloaded.", "plans.selected": "Geselecteerd", diff --git a/backend/i18n/frontend_zh.json b/backend/i18n/frontend_zh.json index 393d10e8d..ab45cf9eb 100644 --- a/backend/i18n/frontend_zh.json +++ b/backend/i18n/frontend_zh.json @@ -629,6 +629,8 @@ "plans.perMonth": "每月", "plans.perYear": "每年", "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.reloaded": "计划重新加载。", "plans.selected": "已选择", diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json index 9e5f7ef4b..925c2687f 100644 --- a/backend/i18n/source/frontend_en.json +++ b/backend/i18n/source/frontend_en.json @@ -629,6 +629,8 @@ "plans.perMonth": "Per Month", "plans.perYear": "Per Year", "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.reloaded": "Plans reloaded.", "plans.selected": "Selected", diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs index 4d63519b7..dda14f7c2 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs +++ b/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 ISnapshotStore store; private readonly ITagService tagService; - private readonly IAppUsageGate appUsageGate; + private readonly IUsageGate usageGate; [CollectionName("Index_TagHistory")] public sealed class State @@ -27,10 +27,10 @@ namespace Squidex.Domain.Apps.Entities.Assets public HashSet? Tags { get; set; } } - public AssetUsageTracker(IAppUsageGate appUsageGate, IAssetLoader assetLoader, ITagService tagService, + public AssetUsageTracker(IUsageGate usageGate, IAssetLoader assetLoader, ITagService tagService, ISnapshotStore store) { - this.appUsageGate = appUsageGate; + this.usageGate = usageGate; this.assetLoader = assetLoader; this.tagService = tagService; this.store = store; @@ -41,7 +41,7 @@ namespace Squidex.Domain.Apps.Entities.Assets Task IDeleter.DeleteAppAsync(IAppEntity app, CancellationToken ct) { - return appUsageGate.DeleteAssetUsageAsync(app.Id, ct); + return usageGate.DeleteAssetUsageAsync(app.Id, ct); } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs index 10e130449..fa9c487d0 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs @@ -57,7 +57,7 @@ namespace Squidex.Domain.Apps.Entities.Assets await store.ClearAsync(); - await appUsageGate.DeleteAssetsUsageAsync(); + await usageGate.DeleteAssetsUsageAsync(); } public async Task On(IEnumerable> events) @@ -185,13 +185,13 @@ namespace Squidex.Domain.Apps.Entities.Assets switch (@event.Payload) { 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: - 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: - 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; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Billing/IBillingManager.cs b/backend/src/Squidex.Domain.Apps.Entities/Billing/IBillingManager.cs index 3f4a81140..6c693e4ec 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Billing/IBillingManager.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Billing/IBillingManager.cs @@ -18,6 +18,12 @@ namespace Squidex.Domain.Apps.Entities.Billing Task GetPortalLinkAsync(string userId, ITeamEntity team, 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 MustRedirectToPortalAsync(string userId, IAppEntity app, string? planId, CancellationToken ct = default); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Billing/IAppUsageGate.cs b/backend/src/Squidex.Domain.Apps.Entities/Billing/IUsageGate.cs similarity index 97% rename from backend/src/Squidex.Domain.Apps.Entities/Billing/IAppUsageGate.cs rename to backend/src/Squidex.Domain.Apps.Entities/Billing/IUsageGate.cs index 4a481d741..02cbec79d 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Billing/IAppUsageGate.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Billing/IUsageGate.cs @@ -11,7 +11,7 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Billing { - public interface IAppUsageGate + public interface IUsageGate { Task IsBlockedAsync(IAppEntity app, string? clientId, DateTime date, CancellationToken ct = default); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Billing/NoopBillingManager.cs b/backend/src/Squidex.Domain.Apps.Entities/Billing/NoopBillingManager.cs index b45e5b307..df441e4cb 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Billing/NoopBillingManager.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Billing/NoopBillingManager.cs @@ -24,6 +24,18 @@ namespace Squidex.Domain.Apps.Entities.Billing return Task.FromResult(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 MustRedirectToPortalAsync(string userId, IAppEntity app, string? planId, CancellationToken ct = default) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.cs b/backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.cs index f1973fe73..97cc8de7c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.cs @@ -17,7 +17,7 @@ using Squidex.Messaging; 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 CounterTotalSize = "TotalSize"; diff --git a/backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs b/backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs index fd8f62436..0480b4e60 100644 --- a/backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs +++ b/backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs @@ -15,11 +15,11 @@ namespace Squidex.Web.Pipeline { 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; } @@ -50,7 +50,7 @@ namespace Squidex.Web.Pipeline { 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) { diff --git a/backend/src/Squidex.Web/Pipeline/UsageMiddleware.cs b/backend/src/Squidex.Web/Pipeline/UsageMiddleware.cs index b2d55c56c..15a94f91a 100644 --- a/backend/src/Squidex.Web/Pipeline/UsageMiddleware.cs +++ b/backend/src/Squidex.Web/Pipeline/UsageMiddleware.cs @@ -17,15 +17,15 @@ namespace Squidex.Web.Pipeline { public sealed class UsageMiddleware : IMiddleware { - private readonly IAppLogStore appUsageLog; - private readonly IAppUsageGate appUsageGate; + private readonly IAppLogStore usageLog; + private readonly IUsageGate usageGate; public IClock Clock { get; set; } = SystemClock.Instance; - public UsageMiddleware(IAppLogStore appUsageLog, IAppUsageGate appUsageGate) + public UsageMiddleware(IAppLogStore usageLog, IUsageGate usageGate) { - this.appUsageLog = appUsageLog; - this.appUsageGate = appUsageGate; + this.usageLog = usageLog; + this.usageGate = usageGate; } public async Task InvokeAsync(HttpContext context, RequestDelegate next) @@ -69,13 +69,13 @@ namespace Squidex.Web.Pipeline request.UserClientId = clientId; // 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) { var date = request.Timestamp.ToDateTimeUtc().Date; - await appUsageGate.TrackRequestAsync(app, request.UserClientId, date, + await usageGate.TrackRequestAsync(app, request.UserClientId, date, request.Costs, request.ElapsedMs, request.Bytes, diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs index 24483ed83..11dc28d83 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs @@ -25,14 +25,14 @@ namespace Squidex.Areas.Api.Controllers.Apps [ApiExplorerSettings(GroupName = nameof(Apps))] public sealed class AppContributorsController : ApiController { - private readonly IAppUsageGate usageTracker; - private readonly IUserResolver usageGate; + private readonly IUsageGate usageGate; + private readonly IUserResolver userResolver; - public AppContributorsController(ICommandBus commandBus, IAppUsageGate usageGate, IUserResolver userResolver) + public AppContributorsController(ICommandBus commandBus, IUsageGate usageGate, IUserResolver userResolver) : base(commandBus) { - this.usageTracker = usageGate; - this.usageGate = userResolver; + this.usageGate = usageGate; + this.userResolver = userResolver; } /// @@ -145,9 +145,9 @@ namespace Squidex.Areas.Api.Controllers.Apps private async Task 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); } } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs index bfc904bd8..263c50694 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs @@ -32,7 +32,7 @@ namespace Squidex.Areas.Api.Controllers.Assets [ApiExplorerSettings(GroupName = nameof(Assets))] public sealed class AssetsController : ApiController { - private readonly IAppUsageGate appUsageGate; + private readonly IUsageGate usageGate; private readonly IAssetQueryService assetQuery; private readonly IAssetUsageTracker assetUsageTracker; private readonly ITagService tagService; @@ -40,14 +40,14 @@ namespace Squidex.Areas.Api.Controllers.Assets public AssetsController( ICommandBus commandBus, - IAppUsageGate appUsageGate, + IUsageGate usageGate, IAssetQueryService assetQuery, IAssetUsageTracker assetUsageTracker, ITagService tagService, AssetTusRunner assetTusRunner) : base(commandBus) { - this.appUsageGate = appUsageGate; + this.usageGate = usageGate; this.assetQuery = assetQuery; this.assetUsageTracker = assetUsageTracker; this.assetTusRunner = assetTusRunner; @@ -469,7 +469,7 @@ namespace Squidex.Areas.Api.Controllers.Assets 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); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs b/backend/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs index 795831bb8..89915dafe 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs +++ b/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.Infrastructure.Commands; using Squidex.Infrastructure.Reflection; +using Squidex.Infrastructure.Tasks; using Squidex.Shared; using Squidex.Web; @@ -25,17 +26,17 @@ namespace Squidex.Areas.Api.Controllers.Plans { private readonly IBillingPlans billingPlans; private readonly IBillingManager billingManager; - private readonly IAppUsageGate appUsageGate; + private readonly IUsageGate usageGate; public AppPlansController(ICommandBus commandBus, - IAppUsageGate appUsageGate, + IUsageGate usageGate, IBillingPlans billingPlans, IBillingManager billingManager) : base(commandBus) { this.billingPlans = billingPlans; this.billingManager = billingManager; - this.appUsageGate = appUsageGate; + this.usageGate = usageGate; } /// @@ -55,34 +56,42 @@ namespace Squidex.Areas.Api.Controllers.Plans { 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 isOwner = string.Equals(owner, UserId, StringComparison.Ordinal); - var isLocked = PlansLockedReason.None; - var linkUrl = (Uri?)null; + var (plan, link, referral) = + await AsyncHelper.WhenAll( + usageGate.GetPlanForAppAsync(App, HttpContext.RequestAborted), + billingManager.GetPortalLinkAsync(UserId, App, HttpContext.RequestAborted), + billingManager.GetReferralCodeAsync(UserId, App, HttpContext.RequestAborted)); - if (teamId != null) - { - isLocked = PlansLockedReason.ManagedByTeam; - } - else if (!Resources.CanChangePlan) - { - isLocked = PlansLockedReason.NoPermission; - } - else if (owner != null && !isOwner) - { - isLocked = PlansLockedReason.NotOwner; - } + var planOwner = App.Plan?.Owner.Identifier; - 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(), owner, planId, linkUrl, isLocked); + return PlansDto.FromDomain( + plans.ToArray(), + planOwner, + plan.PlanId, + referral.Code, + referral.AmountEarned, + link, + GetLocked()); }); Response.Headers[HeaderNames.ETag] = App.ToEtag(); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Plans/Models/PlansDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Plans/Models/PlansDto.cs index 5f969f1fc..397310d02 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Plans/Models/PlansDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Plans/Models/PlansDto.cs @@ -31,24 +31,47 @@ namespace Squidex.Areas.Api.Controllers.Plans.Models /// /// The link to the management portal. /// - public string? PortalLink { get; set; } + public Uri? PortalLink { get; set; } + + /// + /// The code for referral managemenent. + /// + public string? ReferralCode { get; set; } + + /// + /// The amount earned. + /// + public string? ReferralEarned { get; set; } /// /// The reason why the plan cannot be changed. /// 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 { Locked = locked, CurrentPlanId = planId, Plans = plans.Select(PlanDto.FromDomain).ToArray(), - PlanOwner = owner, - PortalLink = portalLink?.ToString() + PlanOwner = planOwner, + ReferralCode = referralCode, + ReferralEarned = $"{referralAmount ?? 0:0.00} EUR" }; + if (locked == PlansLockedReason.None) + { + result.PortalLink = portalLink; + } + return result; } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Plans/TeamPlansController.cs b/backend/src/Squidex/Areas/Api/Controllers/Plans/TeamPlansController.cs index 09325c48b..e8dc6d850 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Plans/TeamPlansController.cs +++ b/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.Infrastructure.Commands; using Squidex.Infrastructure.Reflection; +using Squidex.Infrastructure.Tasks; using Squidex.Shared; using Squidex.Web; @@ -23,12 +24,12 @@ namespace Squidex.Areas.Api.Controllers.Plans [ApiExplorerSettings(GroupName = nameof(Plans))] public sealed class TeamPlansController : ApiController { - private readonly IAppUsageGate appUsageGate; + private readonly IUsageGate appUsageGate; private readonly IBillingPlans billingPlans; private readonly IBillingManager billingManager; public TeamPlansController(ICommandBus commandBus, - IAppUsageGate appUsageGate, + IUsageGate appUsageGate, IBillingPlans billingPlans, IBillingManager billingManager) : base(commandBus) @@ -55,31 +56,31 @@ namespace Squidex.Areas.Api.Controllers.Plans { var response = Deferred.AsyncResponse(async () => { - var owner = Team.Plan?.Owner.Identifier; - - var (_, planId) = await appUsageGate.GetPlanForTeamAsync(Team, HttpContext.RequestAborted); + var plans = billingPlans.GetAvailablePlans(); - 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; - } - else if (owner != null && !string.Equals(owner, UserId, StringComparison.OrdinalIgnoreCase)) - { - lockedReason = PlansLockedReason.NotOwner; - } + if (!Resources.CanChangeTeamPlan) + { + return PlansLockedReason.NoPermission; + } - var linkUrl = (Uri?)null; - - if (lockedReason == PlansLockedReason.None) - { - linkUrl = await billingManager.GetPortalLinkAsync(UserId, Team, HttpContext.RequestAborted); + return PlansLockedReason.None; } - var plans = billingPlans.GetAvailablePlans(); - - return PlansDto.FromDomain(plans.ToArray(), owner, planId, linkUrl, lockedReason); + return PlansDto.FromDomain( + plans.ToArray(), null, + plan.PlanId, + referral.Code, + referral.AmountEarned, + link, + GetLocked()); }); Response.Headers[HeaderNames.ETag] = Team.ToEtag(); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs b/backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs index 410961fe4..411118f62 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs @@ -27,8 +27,8 @@ namespace Squidex.Areas.Api.Controllers.Statistics public sealed class UsagesController : ApiController { private readonly IApiUsageTracker usageTracker; - private readonly IAppLogStore appLogStore; - private readonly IAppUsageGate appUsageGate; + private readonly IAppLogStore usageLog; + private readonly IUsageGate usageGate; private readonly IAssetUsageTracker assetStatsRepository; private readonly IDataProtector dataProtector; private readonly IUrlGenerator urlGenerator; @@ -37,18 +37,17 @@ namespace Squidex.Areas.Api.Controllers.Statistics ICommandBus commandBus, IDataProtectionProvider dataProtection, IApiUsageTracker usageTracker, - IAppLogStore appLogStore, - IAppUsageGate appUsageGate, + IAppLogStore usageLog, + IUsageGate usageGate, IAssetUsageTracker assetStatsRepository, IUrlGenerator urlGenerator) : base(commandBus) { - this.usageTracker = usageTracker; - - this.appLogStore = appLogStore; - this.appUsageGate = appUsageGate; + this.usageLog = usageLog; this.assetStatsRepository = assetStatsRepository; this.urlGenerator = urlGenerator; + this.usageGate = usageGate; + this.usageTracker = usageTracker; dataProtector = dataProtection.CreateProtector("LogToken"); } @@ -91,7 +90,7 @@ namespace Squidex.Areas.Api.Controllers.Statistics 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) @@ -127,7 +126,7 @@ namespace Squidex.Areas.Api.Controllers.Statistics 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. - var (plan, _, _) = await appUsageGate.GetPlanForAppAsync(App, HttpContext.RequestAborted); + var (plan, _, _) = await usageGate.GetPlanForAppAsync(App, HttpContext.RequestAborted); 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); // 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); @@ -186,7 +185,7 @@ namespace Squidex.Areas.Api.Controllers.Statistics var size = await assetStatsRepository.GetTotalSizeByAppAsync(AppId, HttpContext.RequestAborted); // 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 }; @@ -211,7 +210,7 @@ namespace Squidex.Areas.Api.Controllers.Statistics var size = await assetStatsRepository.GetTotalSizeByTeamAsync(TeamId, HttpContext.RequestAborted); // 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 }; diff --git a/backend/src/Squidex/Config/Domain/SubscriptionServices.cs b/backend/src/Squidex/Config/Domain/SubscriptionServices.cs index d65bb4167..8a1590ca9 100644 --- a/backend/src/Squidex/Config/Domain/SubscriptionServices.cs +++ b/backend/src/Squidex/Config/Domain/SubscriptionServices.cs @@ -26,7 +26,7 @@ namespace Squidex.Config.Domain .AsOptional(); services.AddSingletonAs() - .AsOptional().As(); + .AsOptional().As(); } } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetUsageTrackerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetUsageTrackerTests.cs index 222c183b7..966981627 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetUsageTrackerTests.cs +++ b/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(); private readonly ISnapshotStore store = A.Fake>(); private readonly ITagService tagService = A.Fake(); - private readonly IAppUsageGate appUsageGate = A.Fake(); + private readonly IUsageGate usageGate = A.Fake(); private readonly NamedId appId = NamedId.Of(DomainId.NewGuid(), "my-app"); private readonly DomainId assetId = DomainId.NewGuid(); private readonly DomainId assetKey; @@ -33,7 +33,7 @@ namespace Squidex.Domain.Apps.Entities.Assets { assetKey = DomainId.Combine(appId, assetId); - sut = new AssetUsageTracker(appUsageGate, assetLoader, tagService, store); + sut = new AssetUsageTracker(usageGate, assetLoader, tagService, store); } [Fact] @@ -92,7 +92,7 @@ namespace Squidex.Domain.Apps.Entities.Assets 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(); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/NoopBillingManagerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/NoopBillingManagerTests.cs index 8a839d650..8fa69300b 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/NoopBillingManagerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/NoopBillingManagerTests.cs @@ -55,6 +55,22 @@ namespace Squidex.Domain.Apps.Entities.Billing 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] public async Task Should_do_nothing_if_checking_for_redirect_for_app() { diff --git a/backend/tests/Squidex.Web.Tests/Pipeline/ApiCostsFilterTests.cs b/backend/tests/Squidex.Web.Tests/Pipeline/ApiCostsFilterTests.cs index e3ac1f2c4..c63530be3 100644 --- a/backend/tests/Squidex.Web.Tests/Pipeline/ApiCostsFilterTests.cs +++ b/backend/tests/Squidex.Web.Tests/Pipeline/ApiCostsFilterTests.cs @@ -21,7 +21,7 @@ namespace Squidex.Web.Pipeline public class ApiCostsFilterTests { private readonly IAppEntity appEntity = A.Fake(); - private readonly IAppUsageGate appUsageGate = A.Fake(); + private readonly IUsageGate usageGate = A.Fake(); private readonly ActionExecutingContext actionContext; private readonly ActionExecutionDelegate next; private readonly HttpContext httpContext = new DefaultHttpContext(); @@ -43,7 +43,7 @@ namespace Squidex.Web.Pipeline return Task.FromResult(null!); }; - sut = new ApiCostsFilter(appUsageGate); + sut = new ApiCostsFilter(usageGate); } [Fact] @@ -53,7 +53,7 @@ namespace Squidex.Web.Pipeline SetupApp(); - A.CallTo(() => appUsageGate.IsBlockedAsync(appEntity, A._, DateTime.Today, default)) + A.CallTo(() => usageGate.IsBlockedAsync(appEntity, A._, DateTime.Today, default)) .Returns(true); await sut.OnActionExecutionAsync(actionContext, next); @@ -69,7 +69,7 @@ namespace Squidex.Web.Pipeline SetupApp(); - A.CallTo(() => appUsageGate.IsBlockedAsync(appEntity, A._, DateTime.Today, default)) + A.CallTo(() => usageGate.IsBlockedAsync(appEntity, A._, DateTime.Today, default)) .Returns(false); await sut.OnActionExecutionAsync(actionContext, next); @@ -88,7 +88,7 @@ namespace Squidex.Web.Pipeline Assert.True(isNextCalled); - A.CallTo(() => appUsageGate.IsBlockedAsync(appEntity, A._, DateTime.Today, default)) + A.CallTo(() => usageGate.IsBlockedAsync(appEntity, A._, DateTime.Today, default)) .MustNotHaveHappened(); } @@ -101,7 +101,7 @@ namespace Squidex.Web.Pipeline Assert.True(isNextCalled); - A.CallTo(() => appUsageGate.IsBlockedAsync(appEntity, A._, DateTime.Today, default)) + A.CallTo(() => usageGate.IsBlockedAsync(appEntity, A._, DateTime.Today, default)) .MustNotHaveHappened(); } diff --git a/backend/tests/Squidex.Web.Tests/Pipeline/UsageMiddlewareTests.cs b/backend/tests/Squidex.Web.Tests/Pipeline/UsageMiddlewareTests.cs index f258ac734..bafeaf0e4 100644 --- a/backend/tests/Squidex.Web.Tests/Pipeline/UsageMiddlewareTests.cs +++ b/backend/tests/Squidex.Web.Tests/Pipeline/UsageMiddlewareTests.cs @@ -19,8 +19,8 @@ namespace Squidex.Web.Pipeline { public class UsageMiddlewareTests { - private readonly IAppLogStore appLogStore = A.Fake(); - private readonly IAppUsageGate appUsageGate = A.Fake(); + private readonly IAppLogStore usageLog = A.Fake(); + private readonly IUsageGate usageGate = A.Fake(); private readonly IClock clock = A.Fake(); private readonly Instant instant = SystemClock.Instance.GetCurrentInstant(); private readonly HttpContext httpContext = new DefaultHttpContext(); @@ -41,7 +41,7 @@ namespace Squidex.Web.Pipeline return Task.CompletedTask; }; - sut = new UsageMiddleware(appLogStore, appUsageGate) + sut = new UsageMiddleware(usageLog, usageGate) { Clock = clock }; @@ -56,7 +56,7 @@ namespace Squidex.Web.Pipeline var date = instant.ToDateTimeUtc().Date; - A.CallTo(() => appUsageGate.TrackRequestAsync(A._, A._, A._, A._, A._, A._, default)) + A.CallTo(() => usageGate.TrackRequestAsync(A._, A._, A._, A._, A._, A._, default)) .MustNotHaveHappened(); } @@ -76,7 +76,7 @@ namespace Squidex.Web.Pipeline var date = instant.ToDateTimeUtc().Date; - A.CallTo(() => appUsageGate.TrackRequestAsync(app, A._, date, A._, A._, A._, default)) + A.CallTo(() => usageGate.TrackRequestAsync(app, A._, date, A._, A._, A._, default)) .MustNotHaveHappened(); } @@ -94,7 +94,7 @@ namespace Squidex.Web.Pipeline var date = instant.ToDateTimeUtc().Date; - A.CallTo(() => appUsageGate.TrackRequestAsync(app, A._, date, 13, A._, A._, default)) + A.CallTo(() => usageGate.TrackRequestAsync(app, A._, date, 13, A._, A._, default)) .MustHaveHappened(); } @@ -113,7 +113,7 @@ namespace Squidex.Web.Pipeline var date = instant.ToDateTimeUtc().Date; - A.CallTo(() => appUsageGate.TrackRequestAsync(app, A._, date, 13, A._, 1024, default)) + A.CallTo(() => usageGate.TrackRequestAsync(app, A._, date, 13, A._, 1024, default)) .MustHaveHappened(); } @@ -136,7 +136,7 @@ namespace Squidex.Web.Pipeline var date = instant.ToDateTimeUtc().Date; - A.CallTo(() => appUsageGate.TrackRequestAsync(app, A._, date, 13, A._, 11, default)) + A.CallTo(() => usageGate.TrackRequestAsync(app, A._, date, 13, A._, 11, default)) .MustHaveHappened(); } @@ -159,7 +159,7 @@ namespace Squidex.Web.Pipeline var date = instant.ToDateTimeUtc().Date; - A.CallTo(() => appUsageGate.TrackRequestAsync(app, A._, date, 13, A._, 11, default)) + A.CallTo(() => usageGate.TrackRequestAsync(app, A._, date, 13, A._, 11, default)) .MustHaveHappened(); } @@ -192,7 +192,7 @@ namespace Squidex.Web.Pipeline var date = instant.ToDateTimeUtc().Date; - A.CallTo(() => appUsageGate.TrackRequestAsync(app, A._, date, 13, A._, 11, default)) + A.CallTo(() => usageGate.TrackRequestAsync(app, A._, date, 13, A._, 11, default)) .MustHaveHappened(); } @@ -210,7 +210,7 @@ namespace Squidex.Web.Pipeline var date = instant.ToDateTimeUtc().Date; - A.CallTo(() => appUsageGate.TrackRequestAsync(app, A._, date, A._, A._, A._, default)) + A.CallTo(() => usageGate.TrackRequestAsync(app, A._, date, A._, A._, A._, default)) .MustNotHaveHappened(); } @@ -227,7 +227,7 @@ namespace Squidex.Web.Pipeline await sut.InvokeAsync(httpContext, next); - A.CallTo(() => appLogStore.LogAsync(appId.Id, + A.CallTo(() => usageLog.LogAsync(appId.Id, A.That.Matches(x => x.Timestamp == instant && x.RequestMethod == "GET" && diff --git a/frontend/src/app/features/settings/pages/plans/plans-page.component.html b/frontend/src/app/features/settings/pages/plans/plans-page.component.html index b82c46d48..8ffe4a8b9 100644 --- a/frontend/src/app/features/settings/pages/plans/plans-page.component.html +++ b/frontend/src/app/features/settings/pages/plans/plans-page.component.html @@ -20,6 +20,15 @@ {{ 'plans.managedByTeam' | sqxTranslate }} +
+
Squidex Referal Program
+ + +
+
+
+
+
{{ 'plans.noPlanConfigured' | sqxTranslate }} diff --git a/frontend/src/app/features/teams/pages/plans/plans-page.component.html b/frontend/src/app/features/teams/pages/plans/plans-page.component.html index 1bd5a3ab8..b418cbc7c 100644 --- a/frontend/src/app/features/teams/pages/plans/plans-page.component.html +++ b/frontend/src/app/features/teams/pages/plans/plans-page.component.html @@ -16,6 +16,15 @@ {{ 'plans.allApps' | sqxTranslate }}
+
+
Squidex Referal Program
+ + +
+
+
+
+
{{ 'plans.noPlanConfigured' | sqxTranslate }} diff --git a/frontend/src/app/features/teams/state/team-plans.state.ts b/frontend/src/app/features/teams/state/team-plans.state.ts index 6a9d80ea2..c9e6e0501 100644 --- a/frontend/src/app/features/teams/state/team-plans.state.ts +++ b/frontend/src/app/features/teams/state/team-plans.state.ts @@ -32,6 +32,12 @@ interface Snapshot extends LoadingState { // The portal link if available. portalLink?: string; + // The referral code. + referralCode?: string; + + // The amount earned with referrals. + referralEarned?: string; + // The reason why the plan cannot be changed. locked?: PlanLockedReason; @@ -56,6 +62,12 @@ export class TeamPlansState extends State { public locked = this.project(x => x.locked); + public referralCode = + this.project(x => x.referralCode); + + public referralEarned = + this.project(x => x.referralEarned); + public portalLink = this.project(x => x.portalLink); @@ -100,6 +112,8 @@ export class TeamPlansState extends State { planOwner: payload.planOwner, plans, portalLink: payload.portalLink, + referralCode: payload.referralCode, + referralEarned: payload.referralEarned, version, }, 'Loading Success'); }), diff --git a/frontend/src/app/shared/services/plans.service.spec.ts b/frontend/src/app/shared/services/plans.service.spec.ts index 6e7a22f2c..64f6ceb9b 100644 --- a/frontend/src/app/shared/services/plans.service.spec.ts +++ b/frontend/src/app/shared/services/plans.service.spec.ts @@ -44,6 +44,8 @@ describe('PlansService', () => { req.flush({ currentPlanId: '123', portalLink: 'link/to/portal', + referralCode: 'CODE', + referralEarned: '100.00 EUR', planOwner: '456', plans: [ { @@ -84,6 +86,8 @@ describe('PlansService', () => { payload: { currentPlanId: '123', portalLink: 'link/to/portal', + referralCode: 'CODE', + referralEarned: '100.00 EUR', planOwner: '456', plans: [ new PlanDto( diff --git a/frontend/src/app/shared/services/shared.ts b/frontend/src/app/shared/services/shared.ts index 838e8b777..7aace4b55 100644 --- a/frontend/src/app/shared/services/shared.ts +++ b/frontend/src/app/shared/services/shared.ts @@ -91,6 +91,12 @@ export type PlansPayload = Readonly<{ // The portal link if available. portalLink?: string; + // The referral code. + referralCode?: string; + + // The amount earned with referrals. + referralEarned?: string; + // The reason why the plan cannot be changed. locked: PlanLockedReason; }>; diff --git a/frontend/src/app/shared/state/plans.state.ts b/frontend/src/app/shared/state/plans.state.ts index a8915f889..6e8d82e65 100644 --- a/frontend/src/app/shared/state/plans.state.ts +++ b/frontend/src/app/shared/state/plans.state.ts @@ -33,6 +33,12 @@ interface Snapshot extends LoadingState { // The portal link if available. portalLink?: string; + // The referral code. + referralCode?: string; + + // The amount earned with referrals. + referralEarned?: string; + // The reason why the plan cannot be changed. locked?: PlanLockedReason; @@ -57,6 +63,12 @@ export class PlansState extends State { public locked = this.project(x => x.locked); + public referralCode = + this.project(x => x.referralCode); + + public referralEarned = + this.project(x => x.referralEarned); + public portalLink = this.project(x => x.portalLink); @@ -105,6 +117,8 @@ export class PlansState extends State { planOwner: payload.planOwner, plans, portalLink: payload.portalLink, + referralCode: payload.referralCode, + referralEarned: payload.referralEarned, version, }, 'Loading Success'); }),