Combined subscription and payment method pages in provider portal (#9828)
This commit is contained in:
parent
93a57e6724
commit
679c25b082
|
@ -31,7 +31,6 @@
|
|||
*ngIf="canAccessBilling$ | async"
|
||||
>
|
||||
<bit-nav-item [text]="'subscription' | i18n" route="billing/subscription"></bit-nav-item>
|
||||
<bit-nav-item [text]="'paymentMethod' | i18n" route="billing/payment-method"></bit-nav-item>
|
||||
<bit-nav-item [text]="'billingHistory' | i18n" route="billing/history"></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
<bit-nav-item
|
||||
|
|
|
@ -12,7 +12,6 @@ import {
|
|||
ManageClientsComponent,
|
||||
ProviderSubscriptionComponent,
|
||||
hasConsolidatedBilling,
|
||||
ProviderPaymentMethodComponent,
|
||||
ProviderBillingHistoryComponent,
|
||||
} from "../../billing/providers";
|
||||
|
||||
|
@ -134,14 +133,6 @@ const routes: Routes = [
|
|||
titleId: "subscription",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "payment-method",
|
||||
component: ProviderPaymentMethodComponent,
|
||||
canActivate: [ProviderPermissionsGuard],
|
||||
data: {
|
||||
titleId: "paymentMethod",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "history",
|
||||
component: ProviderBillingHistoryComponent,
|
||||
|
|
|
@ -20,7 +20,7 @@ import {
|
|||
ProviderSelectPaymentMethodDialogComponent,
|
||||
ProviderSubscriptionComponent,
|
||||
} from "../../billing/providers";
|
||||
import { SubscriptionStatusComponent } from "../../billing/providers/subscription/subscription-status.component";
|
||||
import { ProviderSubscriptionStatusComponent } from "../../billing/providers/subscription/provider-subscription-status.component";
|
||||
|
||||
import { AddOrganizationComponent } from "./clients/add-organization.component";
|
||||
import { ClientsComponent } from "./clients/clients.component";
|
||||
|
@ -75,7 +75,7 @@ import { SetupComponent } from "./setup/setup.component";
|
|||
ProviderSubscriptionComponent,
|
||||
ProviderSelectPaymentMethodDialogComponent,
|
||||
ProviderPaymentMethodComponent,
|
||||
SubscriptionStatusComponent,
|
||||
ProviderSubscriptionStatusComponent,
|
||||
],
|
||||
providers: [WebProviderService, ProviderPermissionsGuard],
|
||||
})
|
||||
|
|
|
@ -1,15 +1,6 @@
|
|||
<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>
|
||||
|
@ -18,7 +9,7 @@
|
|||
<dt>{{ data.status.label }}</dt>
|
||||
<dd>
|
||||
<span class="tw-capitalize">
|
||||
{{ displayedStatus }}
|
||||
{{ data.status.value }}
|
||||
</span>
|
||||
</dd>
|
||||
<dt>
|
|
@ -1,5 +1,5 @@
|
|||
import { DatePipe } from "@angular/common";
|
||||
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
||||
import { Component, Input } from "@angular/core";
|
||||
|
||||
import { ProviderSubscriptionResponse } from "@bitwarden/common/billing/models/response/provider-subscription-response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
@ -17,47 +17,29 @@ type ComponentData = {
|
|||
severity: "danger" | "warning";
|
||||
header: string;
|
||||
body: string;
|
||||
showReinstatementButton: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: "app-subscription-status",
|
||||
templateUrl: "subscription-status.component.html",
|
||||
selector: "app-provider-subscription-status",
|
||||
templateUrl: "provider-subscription-status.component.html",
|
||||
})
|
||||
export class SubscriptionStatusComponent {
|
||||
@Input({ required: true }) providerSubscriptionResponse: ProviderSubscriptionResponse;
|
||||
@Output() reinstatementRequested = new EventEmitter<void>();
|
||||
export class ProviderSubscriptionStatusComponent {
|
||||
@Input({ required: true }) subscription: ProviderSubscriptionResponse;
|
||||
|
||||
constructor(
|
||||
private datePipe: DatePipe,
|
||||
private i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
get displayedStatus(): string {
|
||||
return this.data.status.value;
|
||||
}
|
||||
|
||||
get planName() {
|
||||
return this.providerSubscriptionResponse.plans[0];
|
||||
}
|
||||
|
||||
get status(): string {
|
||||
if (this.subscription.cancelAt && this.subscription.status === "active") {
|
||||
this.subscription.status = "pending_cancellation";
|
||||
return "pending_cancellation";
|
||||
}
|
||||
|
||||
return this.subscription.status;
|
||||
}
|
||||
|
||||
get isExpired() {
|
||||
return this.subscription.status !== "active";
|
||||
}
|
||||
|
||||
get subscription() {
|
||||
return this.providerSubscriptionResponse;
|
||||
}
|
||||
|
||||
get data(): ComponentData {
|
||||
const defaultStatusLabel = this.i18nService.t("status");
|
||||
|
||||
|
@ -66,21 +48,6 @@ export class SubscriptionStatusComponent {
|
|||
const cancellationDateLabel = this.i18nService.t("cancellationDate");
|
||||
|
||||
switch (this.status) {
|
||||
case "free": {
|
||||
return {};
|
||||
}
|
||||
case "trialing": {
|
||||
return {
|
||||
status: {
|
||||
label: defaultStatusLabel,
|
||||
value: this.i18nService.t("trial"),
|
||||
},
|
||||
date: {
|
||||
label: nextChargeDateLabel,
|
||||
value: this.subscription.currentPeriodEndDate.toDateString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
case "active": {
|
||||
return {
|
||||
status: {
|
||||
|
@ -89,26 +56,26 @@ export class SubscriptionStatusComponent {
|
|||
},
|
||||
date: {
|
||||
label: nextChargeDateLabel,
|
||||
value: this.subscription.currentPeriodEndDate.toDateString(),
|
||||
value: this.subscription.currentPeriodEndDate,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "past_due": {
|
||||
const pastDueText = this.i18nService.t("pastDue");
|
||||
const suspensionDate = this.datePipe.transform(
|
||||
this.subscription.suspensionDate,
|
||||
this.subscription.suspension.suspensionDate,
|
||||
"mediumDate",
|
||||
);
|
||||
const calloutBody =
|
||||
this.subscription.collectionMethod === "charge_automatically"
|
||||
? this.i18nService.t(
|
||||
"pastDueWarningForChargeAutomatically",
|
||||
this.subscription.gracePeriod,
|
||||
this.subscription.suspension.gracePeriod,
|
||||
suspensionDate,
|
||||
)
|
||||
: this.i18nService.t(
|
||||
"pastDueWarningForSendInvoice",
|
||||
this.subscription.gracePeriod,
|
||||
this.subscription.suspension.gracePeriod,
|
||||
suspensionDate,
|
||||
);
|
||||
return {
|
||||
|
@ -118,13 +85,12 @@ export class SubscriptionStatusComponent {
|
|||
},
|
||||
date: {
|
||||
label: subscriptionExpiredDateLabel,
|
||||
value: this.subscription.unpaidPeriodEndDate,
|
||||
value: this.subscription.suspension.unpaidPeriodEndDate,
|
||||
},
|
||||
callout: {
|
||||
severity: "warning",
|
||||
header: pastDueText,
|
||||
body: calloutBody,
|
||||
showReinstatementButton: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -136,13 +102,12 @@ export class SubscriptionStatusComponent {
|
|||
},
|
||||
date: {
|
||||
label: subscriptionExpiredDateLabel,
|
||||
value: this.subscription.currentPeriodEndDate.toDateString(),
|
||||
value: this.subscription.suspension.unpaidPeriodEndDate,
|
||||
},
|
||||
callout: {
|
||||
severity: "danger",
|
||||
header: this.i18nService.t("unpaidInvoice"),
|
||||
body: this.i18nService.t("toReactivateYourSubscription"),
|
||||
showReinstatementButton: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -163,7 +128,6 @@ export class SubscriptionStatusComponent {
|
|||
body:
|
||||
this.i18nService.t("subscriptionPendingCanceled") +
|
||||
this.i18nService.t("providerReinstate"),
|
||||
showReinstatementButton: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -177,18 +141,15 @@ export class SubscriptionStatusComponent {
|
|||
},
|
||||
date: {
|
||||
label: cancellationDateLabel,
|
||||
value: this.subscription.currentPeriodEndDate.toDateString(),
|
||||
value: this.subscription.currentPeriodEndDate,
|
||||
},
|
||||
callout: {
|
||||
severity: "danger",
|
||||
header: canceledText,
|
||||
body: this.i18nService.t("subscriptionCanceled"),
|
||||
showReinstatementButton: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
requestReinstatement = () => this.reinstatementRequested.emit();
|
||||
}
|
|
@ -4,58 +4,85 @@
|
|||
<i class="bwi bwi-spinner bwi-spin text-muted" title="{{ 'loading' | i18n }}"></i>
|
||||
<span class="sr-only">{{ "loading" | i18n }}</span>
|
||||
</ng-container>
|
||||
<app-subscription-status [providerSubscriptionResponse]="subscription"> </app-subscription-status>
|
||||
<ng-container>
|
||||
<div class="tw-flex-col">
|
||||
<strong class="tw-block tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300 pb-2"
|
||||
>{{ "details" | i18n }}  <span
|
||||
bitBadge
|
||||
variant="success"
|
||||
*ngIf="subscription.discountPercentage"
|
||||
>{{ "providerDiscount" | i18n: subscription.discountPercentage }}</span
|
||||
>
|
||||
</strong>
|
||||
<bit-table>
|
||||
<ng-template body>
|
||||
<ng-container *ngIf="subscription">
|
||||
<tr bitRow *ngFor="let i of subscription.plans">
|
||||
<td bitCell class="tw-pl-0 tw-py-3">
|
||||
{{ getFormattedPlanName(i.planName) }} {{ "orgSeats" | i18n }} ({{
|
||||
i.cadence.toLowerCase()
|
||||
}}) {{ "×" }}{{ getFormattedSeatCount(i.seatMinimum, i.purchasedSeats) }}
|
||||
@
|
||||
{{
|
||||
getFormattedCost(
|
||||
i.cost,
|
||||
i.seatMinimum,
|
||||
i.purchasedSeats,
|
||||
subscription.discountPercentage
|
||||
) | currency: "$"
|
||||
}}
|
||||
</td>
|
||||
<td bitCell class="tw-text-right tw-py-3">
|
||||
{{ ((100 - subscription.discountPercentage) / 100) * i.cost | currency: "$" }} /{{
|
||||
"month" | i18n
|
||||
}}
|
||||
<div>
|
||||
<bit-hint class="tw-text-sm tw-line-through">
|
||||
{{ i.cost | currency: "$" }} /{{ "month" | i18n }}
|
||||
</bit-hint>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<ng-container *ngIf="firstLoaded && !loading">
|
||||
<app-provider-subscription-status
|
||||
[subscription]="subscription"
|
||||
></app-provider-subscription-status>
|
||||
<ng-container>
|
||||
<div class="tw-flex-col">
|
||||
<strong
|
||||
class="tw-block tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300 pb-2"
|
||||
>{{ "details" | i18n }}  <span
|
||||
bitBadge
|
||||
variant="success"
|
||||
*ngIf="subscription.discountPercentage"
|
||||
>{{ "providerDiscount" | i18n: subscription.discountPercentage }}</span
|
||||
>
|
||||
</strong>
|
||||
<bit-table>
|
||||
<ng-template body>
|
||||
<ng-container *ngIf="subscription">
|
||||
<tr bitRow *ngFor="let i of subscription.plans">
|
||||
<td bitCell class="tw-pl-0 tw-py-3">
|
||||
{{ getFormattedPlanName(i.planName) }} {{ "orgSeats" | i18n }} ({{
|
||||
i.cadence.toLowerCase()
|
||||
}}) {{ "×" }}{{ getFormattedSeatCount(i.seatMinimum, i.purchasedSeats) }}
|
||||
@
|
||||
{{
|
||||
getFormattedCost(
|
||||
i.cost,
|
||||
i.seatMinimum,
|
||||
i.purchasedSeats,
|
||||
subscription.discountPercentage
|
||||
) | currency: "$"
|
||||
}}
|
||||
</td>
|
||||
<td bitCell class="tw-text-right tw-py-3">
|
||||
{{ ((100 - subscription.discountPercentage) / 100) * i.cost | currency: "$" }} /{{
|
||||
"month" | i18n
|
||||
}}
|
||||
<div>
|
||||
<bit-hint class="tw-text-sm tw-line-through">
|
||||
{{ i.cost | currency: "$" }} /{{ "month" | i18n }}
|
||||
</bit-hint>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr bitRow>
|
||||
<td bitCell class="tw-pl-0 tw-py-3"></td>
|
||||
<td bitCell class="tw-text-right">
|
||||
<span class="tw-font-bold">Total:</span> {{ totalCost | currency: "$" }} /{{
|
||||
"month" | i18n
|
||||
}}
|
||||
</td>
|
||||
</tr>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</bit-table>
|
||||
</div>
|
||||
<tr bitRow>
|
||||
<td bitCell class="tw-pl-0 tw-py-3"></td>
|
||||
<td bitCell class="tw-text-right">
|
||||
<span class="tw-font-bold">Total:</span> {{ totalCost | currency: "$" }} /{{
|
||||
"month" | i18n
|
||||
}}
|
||||
</td>
|
||||
</tr>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</bit-table>
|
||||
</div>
|
||||
</ng-container>
|
||||
<!-- Account Credit -->
|
||||
<ng-container>
|
||||
<h2 bitTypography="h2">
|
||||
{{ "accountCredit" | i18n }}
|
||||
</h2>
|
||||
<p class="tw-text-lg tw-font-bold">{{ subscription.accountCredit | currency: "$" }}</p>
|
||||
<p bitTypography="body1">{{ "creditAppliedDesc" | i18n }}</p>
|
||||
<button type="button" bitButton buttonType="secondary" [bitAction]="addAccountCredit">
|
||||
{{ "addCredit" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
<!-- Tax Information -->
|
||||
<ng-container>
|
||||
<h2 bitTypography="h2" class="tw-mt-16">{{ "taxInformation" | i18n }}</h2>
|
||||
<p>{{ "taxInformationDesc" | i18n }}</p>
|
||||
<app-manage-tax-information
|
||||
*ngIf="subscription.taxInformation"
|
||||
[startWith]="TaxInformation.from(subscription.taxInformation)"
|
||||
[onSubmit]="updateTaxInformation"
|
||||
(taxInformationUpdated)="load()"
|
||||
/>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</bit-container>
|
||||
|
|
|
@ -2,19 +2,25 @@ import { Component } from "@angular/core";
|
|||
import { ActivatedRoute } from "@angular/router";
|
||||
import { Subject, concatMap, takeUntil } from "rxjs";
|
||||
|
||||
import { openAddAccountCreditDialog } from "@bitwarden/angular/billing/components";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction";
|
||||
import { TaxInformation } from "@bitwarden/common/billing/models/domain";
|
||||
import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request";
|
||||
import {
|
||||
ProviderPlanResponse,
|
||||
ProviderSubscriptionResponse,
|
||||
} from "@bitwarden/common/billing/models/response/provider-subscription-response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
@Component({
|
||||
selector: "app-provider-subscription",
|
||||
templateUrl: "./provider-subscription.component.html",
|
||||
})
|
||||
export class ProviderSubscriptionComponent {
|
||||
subscription: ProviderSubscriptionResponse;
|
||||
providerId: string;
|
||||
subscription: ProviderSubscriptionResponse;
|
||||
|
||||
firstLoaded = false;
|
||||
loading: boolean;
|
||||
private destroy$ = new Subject<void>();
|
||||
|
@ -23,7 +29,10 @@ export class ProviderSubscriptionComponent {
|
|||
|
||||
constructor(
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
private dialogService: DialogService,
|
||||
private i18nService: I18nService,
|
||||
private route: ActivatedRoute,
|
||||
private toastService: ToastService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
|
@ -54,6 +63,23 @@ export class ProviderSubscriptionComponent {
|
|||
this.loading = false;
|
||||
}
|
||||
|
||||
addAccountCredit = () =>
|
||||
openAddAccountCreditDialog(this.dialogService, {
|
||||
data: {
|
||||
providerId: this.providerId,
|
||||
},
|
||||
});
|
||||
|
||||
updateTaxInformation = async (taxInformation: TaxInformation) => {
|
||||
const request = ExpandedTaxInfoUpdateRequest.From(taxInformation);
|
||||
await this.billingApiService.updateProviderTaxInformation(this.providerId, request);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("updatedTaxInformation"),
|
||||
});
|
||||
};
|
||||
|
||||
getFormattedCost(
|
||||
cost: number,
|
||||
seatMinimum: number,
|
||||
|
@ -61,8 +87,7 @@ export class ProviderSubscriptionComponent {
|
|||
discountPercentage: number,
|
||||
): number {
|
||||
const costPerSeat = cost / (seatMinimum + purchasedSeats);
|
||||
const discountedCost = costPerSeat - (costPerSeat * discountPercentage) / 100;
|
||||
return discountedCost;
|
||||
return costPerSeat - (costPerSeat * discountPercentage) / 100;
|
||||
}
|
||||
|
||||
getFormattedPlanName(planName: string): string {
|
||||
|
@ -83,4 +108,6 @@ export class ProviderSubscriptionComponent {
|
|||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
protected readonly TaxInformation = TaxInformation;
|
||||
}
|
||||
|
|
|
@ -1,29 +1,38 @@
|
|||
import { SubscriptionSuspensionResponse } from "@bitwarden/common/billing/models/response/subscription-suspension.response";
|
||||
import { TaxInfoResponse } from "@bitwarden/common/billing/models/response/tax-info.response";
|
||||
|
||||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
|
||||
export class ProviderSubscriptionResponse extends BaseResponse {
|
||||
status: string;
|
||||
currentPeriodEndDate: Date;
|
||||
currentPeriodEndDate: string;
|
||||
discountPercentage?: number | null;
|
||||
plans: ProviderPlanResponse[] = [];
|
||||
collectionMethod: string;
|
||||
unpaidPeriodEndDate?: string;
|
||||
gracePeriod?: number | null;
|
||||
suspensionDate?: string;
|
||||
plans: ProviderPlanResponse[] = [];
|
||||
accountCredit: number;
|
||||
taxInformation?: TaxInfoResponse;
|
||||
cancelAt?: string;
|
||||
suspension?: SubscriptionSuspensionResponse;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.status = this.getResponseProperty("status");
|
||||
this.currentPeriodEndDate = new Date(this.getResponseProperty("currentPeriodEndDate"));
|
||||
this.currentPeriodEndDate = this.getResponseProperty("currentPeriodEndDate");
|
||||
this.discountPercentage = this.getResponseProperty("discountPercentage");
|
||||
this.collectionMethod = this.getResponseProperty("collectionMethod");
|
||||
this.unpaidPeriodEndDate = this.getResponseProperty("unpaidPeriodEndDate");
|
||||
this.gracePeriod = this.getResponseProperty("gracePeriod");
|
||||
this.suspensionDate = this.getResponseProperty("suspensionDate");
|
||||
this.cancelAt = this.getResponseProperty("cancelAt");
|
||||
const plans = this.getResponseProperty("plans");
|
||||
if (plans != null) {
|
||||
this.plans = plans.map((i: any) => new ProviderPlanResponse(i));
|
||||
this.plans = plans.map((plan: any) => new ProviderPlanResponse(plan));
|
||||
}
|
||||
this.accountCredit = this.getResponseProperty("accountCredit");
|
||||
const taxInformation = this.getResponseProperty("taxInformation");
|
||||
if (taxInformation != null) {
|
||||
this.taxInformation = new TaxInfoResponse(taxInformation);
|
||||
}
|
||||
this.cancelAt = this.getResponseProperty("cancelAt");
|
||||
const suspension = this.getResponseProperty("suspension");
|
||||
if (suspension != null) {
|
||||
this.suspension = new SubscriptionSuspensionResponse(suspension);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
|
||||
export class SubscriptionSuspensionResponse extends BaseResponse {
|
||||
suspensionDate: string;
|
||||
unpaidPeriodEndDate: string;
|
||||
gracePeriod: number;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.suspensionDate = this.getResponseProperty("suspensionDate");
|
||||
this.unpaidPeriodEndDate = this.getResponseProperty("unpaidPeriodEndDate");
|
||||
this.gracePeriod = this.getResponseProperty("gracePeriod");
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue