diff --git a/apps/web/src/app/billing/organizations/organization-billing.module.ts b/apps/web/src/app/billing/organizations/organization-billing.module.ts index 141233aef3..490ebafbff 100644 --- a/apps/web/src/app/billing/organizations/organization-billing.module.ts +++ b/apps/web/src/app/billing/organizations/organization-billing.module.ts @@ -17,6 +17,7 @@ import { OrganizationSubscriptionSelfhostComponent } from "./organization-subscr import { SecretsManagerAdjustSubscriptionComponent } from "./sm-adjust-subscription.component"; import { SecretsManagerSubscribeStandaloneComponent } from "./sm-subscribe-standalone.component"; import { SubscriptionHiddenComponent } from "./subscription-hidden.component"; +import { SubscriptionStatusComponent } from "./subscription-status.component"; @NgModule({ imports: [ @@ -38,6 +39,7 @@ import { SubscriptionHiddenComponent } from "./subscription-hidden.component"; SecretsManagerAdjustSubscriptionComponent, SecretsManagerSubscribeStandaloneComponent, SubscriptionHiddenComponent, + SubscriptionStatusComponent, ], }) export class OrganizationBillingModule {} diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html index 5f767d85c4..b4fac65854 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html @@ -12,51 +12,58 @@ > - - {{ "subscriptionCanceled" | i18n }} - - {{ "subscriptionPendingCanceled" | i18n }} - + - {{ "reinstateSubscription" | i18n }} - - + {{ "subscriptionCanceled" | i18n }} + + {{ "subscriptionPendingCanceled" | i18n }} + + {{ "reinstateSubscription" | i18n }} + + - - {{ "billingPlan" | i18n }} - {{ sub.plan.name }} - - {{ "status" | i18n }} - - {{ - isSponsoredSubscription ? "sponsored" : subscription.status || "-" - }} - {{ - "pendingCancellation" | i18n - }} - - - {{ "subscriptionExpiration" | i18n }} - - - {{ nextInvoice ? (nextInvoice.date | date: "mediumDate") : "-" }} - - - + + {{ "billingPlan" | i18n }} + {{ sub.plan.name }} + + {{ "status" | i18n }} + + {{ + isSponsoredSubscription ? "sponsored" : subscription.status || "-" + }} + {{ + "pendingCancellation" | i18n + }} + + + {{ "subscriptionExpiration" | i18n }} + + + {{ nextInvoice ? (nextInvoice.date | date: "mediumDate") : "-" }} + + + + + {{ diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts index 2256a92756..0810f79b8e 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts @@ -1,6 +1,6 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; -import { concatMap, firstValueFrom, lastValueFrom, Subject, takeUntil } from "rxjs"; +import { concatMap, firstValueFrom, lastValueFrom, Observable, Subject, takeUntil } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; @@ -11,6 +11,8 @@ import { PlanType } from "@bitwarden/common/billing/enums"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { BillingSubscriptionItemResponse } from "@bitwarden/common/billing/models/response/subscription.response"; import { ProductType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -41,6 +43,8 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy showSecretsManagerSubscribe = false; firstLoaded = false; loading: boolean; + locale: string; + showUpdatedSubscriptionStatusSection$: Observable; protected readonly teamsStarter = ProductType.TeamsStarter; @@ -55,6 +59,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy private organizationApiService: OrganizationApiServiceAbstraction, private route: ActivatedRoute, private dialogService: DialogService, + private configService: ConfigService, ) {} async ngOnInit() { @@ -74,6 +79,11 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy takeUntil(this.destroy$), ) .subscribe(); + + this.showUpdatedSubscriptionStatusSection$ = this.configService.getFeatureFlag$( + FeatureFlag.AC1795_UpdatedSubscriptionStatusSection, + false, + ); } ngOnDestroy() { @@ -86,6 +96,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy return; } this.loading = true; + this.locale = await firstValueFrom(this.i18nService.locale$); this.userOrg = await this.organizationService.get(this.organizationId); if (this.userOrg.canViewSubscription) { this.sub = await this.organizationApiService.getSubscription(this.organizationId); diff --git a/apps/web/src/app/billing/organizations/subscription-status.component.html b/apps/web/src/app/billing/organizations/subscription-status.component.html new file mode 100644 index 0000000000..4bb2c91b85 --- /dev/null +++ b/apps/web/src/app/billing/organizations/subscription-status.component.html @@ -0,0 +1,32 @@ + + + {{ data.callout.body }} + + {{ "reinstateSubscription" | i18n }} + + + + {{ "billingPlan" | i18n }} + {{ planName }} + + {{ data.status.label }} + + + {{ displayedStatus }} + + + + {{ data.date.label | titlecase }} + + + {{ data.date.value | date: "mediumDate" }} + + + + diff --git a/apps/web/src/app/billing/organizations/subscription-status.component.ts b/apps/web/src/app/billing/organizations/subscription-status.component.ts new file mode 100644 index 0000000000..54af940be5 --- /dev/null +++ b/apps/web/src/app/billing/organizations/subscription-status.component.ts @@ -0,0 +1,184 @@ +import { DatePipe } from "@angular/common"; +import { Component, EventEmitter, Input, Output } from "@angular/core"; + +import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +type ComponentData = { + status: { + label: string; + value: string; + }; + date: { + label: string; + value: string; + }; + callout?: { + severity: "danger" | "warning"; + header: string; + body: string; + showReinstatementButton: boolean; + }; +}; + +@Component({ + selector: "app-subscription-status", + templateUrl: "subscription-status.component.html", +}) +export class SubscriptionStatusComponent { + @Input({ required: true }) organizationSubscriptionResponse: OrganizationSubscriptionResponse; + @Output() reinstatementRequested = new EventEmitter(); + + constructor( + private datePipe: DatePipe, + private i18nService: I18nService, + ) {} + + get displayedStatus(): string { + const sponsored = this.subscription.items.some((item) => item.sponsoredSubscriptionItem); + return sponsored ? this.i18nService.t("sponsored") : this.data.status.value; + } + + get planName() { + return this.organizationSubscriptionResponse.plan.name; + } + + get status(): string { + return this.subscription.status != "canceled" && this.subscription.cancelAtEndDate + ? "pending_cancellation" + : this.subscription.status; + } + + get subscription() { + return this.organizationSubscriptionResponse.subscription; + } + + get data(): ComponentData { + const defaultStatusLabel = this.i18nService.t("status"); + + const nextChargeDateLabel = this.i18nService.t("nextCharge"); + const subscriptionExpiredDateLabel = this.i18nService.t("subscriptionExpired"); + const cancellationDateLabel = this.i18nService.t("cancellationDate"); + + switch (this.status) { + case "trialing": { + return { + status: { + label: defaultStatusLabel, + value: this.i18nService.t("trial"), + }, + date: { + label: nextChargeDateLabel, + value: this.subscription.periodEndDate, + }, + }; + } + case "active": { + return { + status: { + label: defaultStatusLabel, + value: this.i18nService.t("active"), + }, + date: { + label: nextChargeDateLabel, + value: this.subscription.periodEndDate, + }, + }; + } + case "past_due": { + const pastDueText = this.i18nService.t("pastDue"); + const suspensionDate = this.datePipe.transform( + this.subscription.suspensionDate, + "mediumDate", + ); + const calloutBody = + this.subscription.collectionMethod === "charge_automatically" + ? this.i18nService.t( + "pastDueWarningForChargeAutomatically", + this.subscription.gracePeriod, + suspensionDate, + ) + : this.i18nService.t( + "pastDueWarningForSendInvoice", + this.subscription.gracePeriod, + suspensionDate, + ); + return { + status: { + label: defaultStatusLabel, + value: pastDueText, + }, + date: { + label: subscriptionExpiredDateLabel, + value: this.subscription.unpaidPeriodEndDate, + }, + callout: { + severity: "warning", + header: pastDueText, + body: calloutBody, + showReinstatementButton: false, + }, + }; + } + case "unpaid": { + return { + status: { + label: defaultStatusLabel, + value: this.i18nService.t("unpaid"), + }, + date: { + label: subscriptionExpiredDateLabel, + value: this.subscription.unpaidPeriodEndDate, + }, + callout: { + severity: "danger", + header: this.i18nService.t("unpaidInvoice"), + body: this.i18nService.t("toReactivateYourSubscription"), + showReinstatementButton: false, + }, + }; + } + case "pending_cancellation": { + const pendingCancellationText = this.i18nService.t("pendingCancellation"); + return { + status: { + label: defaultStatusLabel, + value: pendingCancellationText, + }, + date: { + label: cancellationDateLabel, + value: this.subscription.periodEndDate, + }, + callout: { + severity: "warning", + header: pendingCancellationText, + body: this.i18nService.t("subscriptionPendingCanceled"), + showReinstatementButton: true, + }, + }; + } + case "incomplete_expired": + case "canceled": { + const canceledText = this.i18nService.t("canceled"); + return { + status: { + label: defaultStatusLabel, + value: canceledText, + }, + date: { + label: cancellationDateLabel, + value: this.subscription.periodEndDate, + }, + callout: { + severity: "danger", + header: canceledText, + body: this.i18nService.t("subscriptionCanceled"), + showReinstatementButton: false, + }, + }; + } + } + } + + requestReinstatement = () => this.reinstatementRequested.emit(); +} diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index b8e5a5ff4d..05697461a9 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -7678,5 +7678,57 @@ }, "subscriptionUpdateFailed": { "message": "Subscription update failed" + }, + "trial": { + "message": "Trial", + "description": "A subscription status label." + }, + "pastDue": { + "message": "Past due", + "description": "A subscription status label" + }, + "subscriptionExpired": { + "message": "Subscription expired", + "description": "The date header used when a subscription is past due." + }, + "pastDueWarningForChargeAutomatically": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date to maintain your subscription. Please resolve the past due invoices by $SUSPENSION_DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "11" + }, + "suspension_date": { + "content": "$2", + "example": "01/10/2024" + } + }, + "description": "A warning shown to the user when their subscription is past due and they are charged automatically." + }, + "pastDueWarningForSendInvoice": { + "message": "You have a grace period of $DAYS$ days from the date your first unpaid invoice is due to maintain your subscription. Please resolve the past due invoices by $SUSPENSION_DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "11" + }, + "suspension_date": { + "content": "$2", + "example": "01/10/2024" + } + }, + "description": "A warning shown to the user when their subscription is past due and they pay via invoice." + }, + "unpaidInvoice": { + "message": "Unpaid invoice", + "description": "The header of a warning box shown to a user whose subscription is unpaid." + }, + "toReactivateYourSubscription": { + "message": "To reactivate your subscription, please resolve the past due invoices.", + "description": "The body of a warning box shown to a user whose subscription is unpaid." + }, + "cancellationDate": { + "message": "Cancellation date", + "description": "The date header used when a subscription is cancelled." } } diff --git a/libs/common/src/billing/models/response/subscription.response.ts b/libs/common/src/billing/models/response/subscription.response.ts index 0a2cb2290e..a05a40624d 100644 --- a/libs/common/src/billing/models/response/subscription.response.ts +++ b/libs/common/src/billing/models/response/subscription.response.ts @@ -36,6 +36,10 @@ export class BillingSubscriptionResponse extends BaseResponse { status: string; cancelled: boolean; items: BillingSubscriptionItemResponse[] = []; + collectionMethod: string; + suspensionDate?: string; + unpaidPeriodEndDate?: string; + gracePeriod?: number; constructor(response: any) { super(response); @@ -51,6 +55,10 @@ export class BillingSubscriptionResponse extends BaseResponse { if (items != null) { this.items = items.map((i: any) => new BillingSubscriptionItemResponse(i)); } + this.collectionMethod = this.getResponseProperty("CollectionMethod"); + this.suspensionDate = this.getResponseProperty("SuspensionDate"); + this.unpaidPeriodEndDate = this.getResponseProperty("unpaidPeriodEndDate"); + this.gracePeriod = this.getResponseProperty("GracePeriod"); } } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 9470db9447..9d427034bd 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -8,6 +8,7 @@ export enum FeatureFlag { FlexibleCollectionsMigration = "flexible-collections-migration", ShowPaymentMethodWarningBanners = "show-payment-method-warning-banners", EnableConsolidatedBilling = "enable-consolidated-billing", + AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section", } // Replace this with a type safe lookup of the feature flag values in PM-2282
{{ "subscriptionPendingCanceled" | i18n }}
{{ data.callout.body }}