Combined subscription and payment method pages in provider portal (#9828)

This commit is contained in:
Alex Morask 2024-06-26 09:08:25 -04:00 committed by GitHub
parent 93a57e6724
commit 679c25b082
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 160 additions and 140 deletions

View File

@ -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

View File

@ -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,

View File

@ -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],
})

View File

@ -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>

View File

@ -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();
}

View File

@ -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 }} &#160;<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()
}}) {{ "&times;" }}{{ 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 }} &#160;<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()
}}) {{ "&times;" }}{{ 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>

View File

@ -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;
}

View File

@ -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);
}
}
}

View File

@ -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");
}
}