mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-21 11:35:34 +01:00
[AC-1759] Update subscription status section (#8578)
* Resolve subscription status confusion * Add feature flag
This commit is contained in:
parent
f79d159277
commit
7df3304a25
@ -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 {}
|
||||
|
@ -12,51 +12,58 @@
|
||||
></app-org-subscription-hidden>
|
||||
|
||||
<ng-container *ngIf="sub && firstLoaded">
|
||||
<bit-callout
|
||||
type="warning"
|
||||
title="{{ 'canceled' | i18n }}"
|
||||
*ngIf="subscription && subscription.cancelled"
|
||||
>
|
||||
{{ "subscriptionCanceled" | i18n }}</bit-callout
|
||||
>
|
||||
<bit-callout
|
||||
type="warning"
|
||||
title="{{ 'pendingCancellation' | i18n }}"
|
||||
*ngIf="subscriptionMarkedForCancel"
|
||||
>
|
||||
<p>{{ "subscriptionPendingCanceled" | i18n }}</p>
|
||||
<button
|
||||
*ngIf="userOrg.canEditSubscription"
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
[bitAction]="reinstate"
|
||||
type="button"
|
||||
<ng-container *ngIf="!(showUpdatedSubscriptionStatusSection$ | async)">
|
||||
<bit-callout
|
||||
type="warning"
|
||||
title="{{ 'canceled' | i18n }}"
|
||||
*ngIf="subscription && subscription.cancelled"
|
||||
>
|
||||
{{ "reinstateSubscription" | i18n }}
|
||||
</button>
|
||||
</bit-callout>
|
||||
{{ "subscriptionCanceled" | i18n }}</bit-callout
|
||||
>
|
||||
<bit-callout
|
||||
type="warning"
|
||||
title="{{ 'pendingCancellation' | i18n }}"
|
||||
*ngIf="subscriptionMarkedForCancel"
|
||||
>
|
||||
<p>{{ "subscriptionPendingCanceled" | i18n }}</p>
|
||||
<button
|
||||
*ngIf="userOrg.canEditSubscription"
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
[bitAction]="reinstate"
|
||||
type="button"
|
||||
>
|
||||
{{ "reinstateSubscription" | i18n }}
|
||||
</button>
|
||||
</bit-callout>
|
||||
|
||||
<dl class="tw-grid tw-grid-flow-col tw-grid-rows-2">
|
||||
<dt>{{ "billingPlan" | i18n }}</dt>
|
||||
<dd>{{ sub.plan.name }}</dd>
|
||||
<ng-container *ngIf="subscription">
|
||||
<dt>{{ "status" | i18n }}</dt>
|
||||
<dd>
|
||||
<span class="tw-capitalize">{{
|
||||
isSponsoredSubscription ? "sponsored" : subscription.status || "-"
|
||||
}}</span>
|
||||
<span bitBadge variant="warning" *ngIf="subscriptionMarkedForCancel">{{
|
||||
"pendingCancellation" | i18n
|
||||
}}</span>
|
||||
</dd>
|
||||
<dt [ngClass]="{ 'tw-text-danger': isExpired }">
|
||||
{{ "subscriptionExpiration" | i18n }}
|
||||
</dt>
|
||||
<dd [ngClass]="{ 'tw-text-danger': isExpired }">
|
||||
{{ nextInvoice ? (nextInvoice.date | date: "mediumDate") : "-" }}
|
||||
</dd>
|
||||
</ng-container>
|
||||
</dl>
|
||||
<dl class="tw-grid tw-grid-flow-col tw-grid-rows-2">
|
||||
<dt>{{ "billingPlan" | i18n }}</dt>
|
||||
<dd>{{ sub.plan.name }}</dd>
|
||||
<ng-container *ngIf="subscription">
|
||||
<dt>{{ "status" | i18n }}</dt>
|
||||
<dd>
|
||||
<span class="tw-capitalize">{{
|
||||
isSponsoredSubscription ? "sponsored" : subscription.status || "-"
|
||||
}}</span>
|
||||
<span bitBadge variant="warning" *ngIf="subscriptionMarkedForCancel">{{
|
||||
"pendingCancellation" | i18n
|
||||
}}</span>
|
||||
</dd>
|
||||
<dt [ngClass]="{ 'tw-text-danger': isExpired }">
|
||||
{{ "subscriptionExpiration" | i18n }}
|
||||
</dt>
|
||||
<dd [ngClass]="{ 'tw-text-danger': isExpired }">
|
||||
{{ nextInvoice ? (nextInvoice.date | date: "mediumDate") : "-" }}
|
||||
</dd>
|
||||
</ng-container>
|
||||
</dl>
|
||||
</ng-container>
|
||||
<app-subscription-status
|
||||
*ngIf="showUpdatedSubscriptionStatusSection$ | async"
|
||||
[organizationSubscriptionResponse]="sub"
|
||||
(reinstatementRequested)="reinstate()"
|
||||
></app-subscription-status>
|
||||
<ng-container *ngIf="userOrg.canEditSubscription">
|
||||
<div class="tw-flex-col">
|
||||
<strong class="tw-block tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300">{{
|
||||
|
@ -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<boolean>;
|
||||
|
||||
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);
|
||||
|
@ -0,0 +1,32 @@
|
||||
<ng-container>
|
||||
<bit-callout *ngIf="data.callout" [type]="data.callout.severity" [title]="data.callout.header">
|
||||
<p>{{ data.callout.body }}</p>
|
||||
<button
|
||||
*ngIf="data.callout.showReinstatementButton"
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
[bitAction]="requestReinstatement"
|
||||
type="button"
|
||||
>
|
||||
{{ "reinstateSubscription" | i18n }}
|
||||
</button>
|
||||
</bit-callout>
|
||||
<dl class="tw-grid tw-grid-flow-col tw-grid-rows-2">
|
||||
<dt>{{ "billingPlan" | i18n }}</dt>
|
||||
<dd>{{ planName }}</dd>
|
||||
<ng-container>
|
||||
<dt>{{ data.status.label }}</dt>
|
||||
<dd>
|
||||
<span class="tw-capitalize">
|
||||
{{ displayedStatus }}
|
||||
</span>
|
||||
</dd>
|
||||
<dt>
|
||||
{{ data.date.label | titlecase }}
|
||||
</dt>
|
||||
<dd>
|
||||
{{ data.date.value | date: "mediumDate" }}
|
||||
</dd>
|
||||
</ng-container>
|
||||
</dl>
|
||||
</ng-container>
|
@ -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<void>();
|
||||
|
||||
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();
|
||||
}
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user