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" *ngIf="canAccessBilling$ | async"
> >
<bit-nav-item [text]="'subscription' | i18n" route="billing/subscription"></bit-nav-item> <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-item [text]="'billingHistory' | i18n" route="billing/history"></bit-nav-item>
</bit-nav-group> </bit-nav-group>
<bit-nav-item <bit-nav-item

View File

@ -12,7 +12,6 @@ import {
ManageClientsComponent, ManageClientsComponent,
ProviderSubscriptionComponent, ProviderSubscriptionComponent,
hasConsolidatedBilling, hasConsolidatedBilling,
ProviderPaymentMethodComponent,
ProviderBillingHistoryComponent, ProviderBillingHistoryComponent,
} from "../../billing/providers"; } from "../../billing/providers";
@ -134,14 +133,6 @@ const routes: Routes = [
titleId: "subscription", titleId: "subscription",
}, },
}, },
{
path: "payment-method",
component: ProviderPaymentMethodComponent,
canActivate: [ProviderPermissionsGuard],
data: {
titleId: "paymentMethod",
},
},
{ {
path: "history", path: "history",
component: ProviderBillingHistoryComponent, component: ProviderBillingHistoryComponent,

View File

@ -20,7 +20,7 @@ import {
ProviderSelectPaymentMethodDialogComponent, ProviderSelectPaymentMethodDialogComponent,
ProviderSubscriptionComponent, ProviderSubscriptionComponent,
} from "../../billing/providers"; } 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 { AddOrganizationComponent } from "./clients/add-organization.component";
import { ClientsComponent } from "./clients/clients.component"; import { ClientsComponent } from "./clients/clients.component";
@ -75,7 +75,7 @@ import { SetupComponent } from "./setup/setup.component";
ProviderSubscriptionComponent, ProviderSubscriptionComponent,
ProviderSelectPaymentMethodDialogComponent, ProviderSelectPaymentMethodDialogComponent,
ProviderPaymentMethodComponent, ProviderPaymentMethodComponent,
SubscriptionStatusComponent, ProviderSubscriptionStatusComponent,
], ],
providers: [WebProviderService, ProviderPermissionsGuard], providers: [WebProviderService, ProviderPermissionsGuard],
}) })

View File

@ -1,15 +1,6 @@
<ng-container> <ng-container>
<bit-callout *ngIf="data.callout" [type]="data.callout.severity" [title]="data.callout.header"> <bit-callout *ngIf="data.callout" [type]="data.callout.severity" [title]="data.callout.header">
<p>{{ data.callout.body }}</p> <p>{{ data.callout.body }}</p>
<button
*ngIf="data.callout.showReinstatementButton"
bitButton
buttonType="secondary"
[bitAction]="requestReinstatement"
type="button"
>
{{ "reinstateSubscription" | i18n }}
</button>
</bit-callout> </bit-callout>
<dl class="tw-grid tw-grid-flow-col tw-grid-rows-2"> <dl class="tw-grid tw-grid-flow-col tw-grid-rows-2">
<dt>{{ "billingPlan" | i18n }}</dt> <dt>{{ "billingPlan" | i18n }}</dt>
@ -18,7 +9,7 @@
<dt>{{ data.status.label }}</dt> <dt>{{ data.status.label }}</dt>
<dd> <dd>
<span class="tw-capitalize"> <span class="tw-capitalize">
{{ displayedStatus }} {{ data.status.value }}
</span> </span>
</dd> </dd>
<dt> <dt>

View File

@ -1,5 +1,5 @@
import { DatePipe } from "@angular/common"; 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 { ProviderSubscriptionResponse } from "@bitwarden/common/billing/models/response/provider-subscription-response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@ -17,47 +17,29 @@ type ComponentData = {
severity: "danger" | "warning"; severity: "danger" | "warning";
header: string; header: string;
body: string; body: string;
showReinstatementButton: boolean;
}; };
}; };
@Component({ @Component({
selector: "app-subscription-status", selector: "app-provider-subscription-status",
templateUrl: "subscription-status.component.html", templateUrl: "provider-subscription-status.component.html",
}) })
export class SubscriptionStatusComponent { export class ProviderSubscriptionStatusComponent {
@Input({ required: true }) providerSubscriptionResponse: ProviderSubscriptionResponse; @Input({ required: true }) subscription: ProviderSubscriptionResponse;
@Output() reinstatementRequested = new EventEmitter<void>();
constructor( constructor(
private datePipe: DatePipe, private datePipe: DatePipe,
private i18nService: I18nService, private i18nService: I18nService,
) {} ) {}
get displayedStatus(): string {
return this.data.status.value;
}
get planName() {
return this.providerSubscriptionResponse.plans[0];
}
get status(): string { get status(): string {
if (this.subscription.cancelAt && this.subscription.status === "active") { if (this.subscription.cancelAt && this.subscription.status === "active") {
this.subscription.status = "pending_cancellation"; return "pending_cancellation";
} }
return this.subscription.status; return this.subscription.status;
} }
get isExpired() {
return this.subscription.status !== "active";
}
get subscription() {
return this.providerSubscriptionResponse;
}
get data(): ComponentData { get data(): ComponentData {
const defaultStatusLabel = this.i18nService.t("status"); const defaultStatusLabel = this.i18nService.t("status");
@ -66,21 +48,6 @@ export class SubscriptionStatusComponent {
const cancellationDateLabel = this.i18nService.t("cancellationDate"); const cancellationDateLabel = this.i18nService.t("cancellationDate");
switch (this.status) { 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": { case "active": {
return { return {
status: { status: {
@ -89,26 +56,26 @@ export class SubscriptionStatusComponent {
}, },
date: { date: {
label: nextChargeDateLabel, label: nextChargeDateLabel,
value: this.subscription.currentPeriodEndDate.toDateString(), value: this.subscription.currentPeriodEndDate,
}, },
}; };
} }
case "past_due": { case "past_due": {
const pastDueText = this.i18nService.t("pastDue"); const pastDueText = this.i18nService.t("pastDue");
const suspensionDate = this.datePipe.transform( const suspensionDate = this.datePipe.transform(
this.subscription.suspensionDate, this.subscription.suspension.suspensionDate,
"mediumDate", "mediumDate",
); );
const calloutBody = const calloutBody =
this.subscription.collectionMethod === "charge_automatically" this.subscription.collectionMethod === "charge_automatically"
? this.i18nService.t( ? this.i18nService.t(
"pastDueWarningForChargeAutomatically", "pastDueWarningForChargeAutomatically",
this.subscription.gracePeriod, this.subscription.suspension.gracePeriod,
suspensionDate, suspensionDate,
) )
: this.i18nService.t( : this.i18nService.t(
"pastDueWarningForSendInvoice", "pastDueWarningForSendInvoice",
this.subscription.gracePeriod, this.subscription.suspension.gracePeriod,
suspensionDate, suspensionDate,
); );
return { return {
@ -118,13 +85,12 @@ export class SubscriptionStatusComponent {
}, },
date: { date: {
label: subscriptionExpiredDateLabel, label: subscriptionExpiredDateLabel,
value: this.subscription.unpaidPeriodEndDate, value: this.subscription.suspension.unpaidPeriodEndDate,
}, },
callout: { callout: {
severity: "warning", severity: "warning",
header: pastDueText, header: pastDueText,
body: calloutBody, body: calloutBody,
showReinstatementButton: false,
}, },
}; };
} }
@ -136,13 +102,12 @@ export class SubscriptionStatusComponent {
}, },
date: { date: {
label: subscriptionExpiredDateLabel, label: subscriptionExpiredDateLabel,
value: this.subscription.currentPeriodEndDate.toDateString(), value: this.subscription.suspension.unpaidPeriodEndDate,
}, },
callout: { callout: {
severity: "danger", severity: "danger",
header: this.i18nService.t("unpaidInvoice"), header: this.i18nService.t("unpaidInvoice"),
body: this.i18nService.t("toReactivateYourSubscription"), body: this.i18nService.t("toReactivateYourSubscription"),
showReinstatementButton: false,
}, },
}; };
} }
@ -163,7 +128,6 @@ export class SubscriptionStatusComponent {
body: body:
this.i18nService.t("subscriptionPendingCanceled") + this.i18nService.t("subscriptionPendingCanceled") +
this.i18nService.t("providerReinstate"), this.i18nService.t("providerReinstate"),
showReinstatementButton: false,
}, },
}; };
} }
@ -177,18 +141,15 @@ export class SubscriptionStatusComponent {
}, },
date: { date: {
label: cancellationDateLabel, label: cancellationDateLabel,
value: this.subscription.currentPeriodEndDate.toDateString(), value: this.subscription.currentPeriodEndDate,
}, },
callout: { callout: {
severity: "danger", severity: "danger",
header: canceledText, header: canceledText,
body: this.i18nService.t("subscriptionCanceled"), 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> <i class="bwi bwi-spinner bwi-spin text-muted" title="{{ 'loading' | i18n }}"></i>
<span class="sr-only">{{ "loading" | i18n }}</span> <span class="sr-only">{{ "loading" | i18n }}</span>
</ng-container> </ng-container>
<app-subscription-status [providerSubscriptionResponse]="subscription"> </app-subscription-status> <ng-container *ngIf="firstLoaded && !loading">
<ng-container> <app-provider-subscription-status
<div class="tw-flex-col"> [subscription]="subscription"
<strong class="tw-block tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300 pb-2" ></app-provider-subscription-status>
>{{ "details" | i18n }} &#160;<span <ng-container>
bitBadge <div class="tw-flex-col">
variant="success" <strong
*ngIf="subscription.discountPercentage" class="tw-block tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300 pb-2"
>{{ "providerDiscount" | i18n: subscription.discountPercentage }}</span >{{ "details" | i18n }} &#160;<span
> bitBadge
</strong> variant="success"
<bit-table> *ngIf="subscription.discountPercentage"
<ng-template body> >{{ "providerDiscount" | i18n: subscription.discountPercentage }}</span
<ng-container *ngIf="subscription"> >
<tr bitRow *ngFor="let i of subscription.plans"> </strong>
<td bitCell class="tw-pl-0 tw-py-3"> <bit-table>
{{ getFormattedPlanName(i.planName) }} {{ "orgSeats" | i18n }} ({{ <ng-template body>
i.cadence.toLowerCase() <ng-container *ngIf="subscription">
}}) {{ "&times;" }}{{ getFormattedSeatCount(i.seatMinimum, i.purchasedSeats) }} <tr bitRow *ngFor="let i of subscription.plans">
@ <td bitCell class="tw-pl-0 tw-py-3">
{{ {{ getFormattedPlanName(i.planName) }} {{ "orgSeats" | i18n }} ({{
getFormattedCost( i.cadence.toLowerCase()
i.cost, }}) {{ "&times;" }}{{ getFormattedSeatCount(i.seatMinimum, i.purchasedSeats) }}
i.seatMinimum, @
i.purchasedSeats, {{
subscription.discountPercentage getFormattedCost(
) | currency: "$" i.cost,
}} i.seatMinimum,
</td> i.purchasedSeats,
<td bitCell class="tw-text-right tw-py-3"> subscription.discountPercentage
{{ ((100 - subscription.discountPercentage) / 100) * i.cost | currency: "$" }} /{{ ) | currency: "$"
"month" | i18n }}
}} </td>
<div> <td bitCell class="tw-text-right tw-py-3">
<bit-hint class="tw-text-sm tw-line-through"> {{ ((100 - subscription.discountPercentage) / 100) * i.cost | currency: "$" }} /{{
{{ i.cost | currency: "$" }} /{{ "month" | i18n }} "month" | i18n
</bit-hint> }}
</div> <div>
</td> <bit-hint class="tw-text-sm tw-line-through">
</tr> {{ i.cost | currency: "$" }} /{{ "month" | i18n }}
</bit-hint>
</div>
</td>
</tr>
<tr bitRow> <tr bitRow>
<td bitCell class="tw-pl-0 tw-py-3"></td> <td bitCell class="tw-pl-0 tw-py-3"></td>
<td bitCell class="tw-text-right"> <td bitCell class="tw-text-right">
<span class="tw-font-bold">Total:</span> {{ totalCost | currency: "$" }} /{{ <span class="tw-font-bold">Total:</span> {{ totalCost | currency: "$" }} /{{
"month" | i18n "month" | i18n
}} }}
</td> </td>
</tr> </tr>
</ng-container> </ng-container>
</ng-template> </ng-template>
</bit-table> </bit-table>
</div> </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> </ng-container>
</bit-container> </bit-container>

View File

@ -2,19 +2,25 @@ import { Component } from "@angular/core";
import { ActivatedRoute } from "@angular/router"; import { ActivatedRoute } from "@angular/router";
import { Subject, concatMap, takeUntil } from "rxjs"; 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 { 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 { import {
ProviderPlanResponse, ProviderPlanResponse,
ProviderSubscriptionResponse, ProviderSubscriptionResponse,
} from "@bitwarden/common/billing/models/response/provider-subscription-response"; } 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({ @Component({
selector: "app-provider-subscription", selector: "app-provider-subscription",
templateUrl: "./provider-subscription.component.html", templateUrl: "./provider-subscription.component.html",
}) })
export class ProviderSubscriptionComponent { export class ProviderSubscriptionComponent {
subscription: ProviderSubscriptionResponse;
providerId: string; providerId: string;
subscription: ProviderSubscriptionResponse;
firstLoaded = false; firstLoaded = false;
loading: boolean; loading: boolean;
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
@ -23,7 +29,10 @@ export class ProviderSubscriptionComponent {
constructor( constructor(
private billingApiService: BillingApiServiceAbstraction, private billingApiService: BillingApiServiceAbstraction,
private dialogService: DialogService,
private i18nService: I18nService,
private route: ActivatedRoute, private route: ActivatedRoute,
private toastService: ToastService,
) {} ) {}
async ngOnInit() { async ngOnInit() {
@ -54,6 +63,23 @@ export class ProviderSubscriptionComponent {
this.loading = false; 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( getFormattedCost(
cost: number, cost: number,
seatMinimum: number, seatMinimum: number,
@ -61,8 +87,7 @@ export class ProviderSubscriptionComponent {
discountPercentage: number, discountPercentage: number,
): number { ): number {
const costPerSeat = cost / (seatMinimum + purchasedSeats); const costPerSeat = cost / (seatMinimum + purchasedSeats);
const discountedCost = costPerSeat - (costPerSeat * discountPercentage) / 100; return costPerSeat - (costPerSeat * discountPercentage) / 100;
return discountedCost;
} }
getFormattedPlanName(planName: string): string { getFormattedPlanName(planName: string): string {
@ -83,4 +108,6 @@ export class ProviderSubscriptionComponent {
this.destroy$.next(); this.destroy$.next();
this.destroy$.complete(); 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"; import { BaseResponse } from "../../../models/response/base.response";
export class ProviderSubscriptionResponse extends BaseResponse { export class ProviderSubscriptionResponse extends BaseResponse {
status: string; status: string;
currentPeriodEndDate: Date; currentPeriodEndDate: string;
discountPercentage?: number | null; discountPercentage?: number | null;
plans: ProviderPlanResponse[] = [];
collectionMethod: string; collectionMethod: string;
unpaidPeriodEndDate?: string; plans: ProviderPlanResponse[] = [];
gracePeriod?: number | null; accountCredit: number;
suspensionDate?: string; taxInformation?: TaxInfoResponse;
cancelAt?: string; cancelAt?: string;
suspension?: SubscriptionSuspensionResponse;
constructor(response: any) { constructor(response: any) {
super(response); super(response);
this.status = this.getResponseProperty("status"); this.status = this.getResponseProperty("status");
this.currentPeriodEndDate = new Date(this.getResponseProperty("currentPeriodEndDate")); this.currentPeriodEndDate = this.getResponseProperty("currentPeriodEndDate");
this.discountPercentage = this.getResponseProperty("discountPercentage"); this.discountPercentage = this.getResponseProperty("discountPercentage");
this.collectionMethod = this.getResponseProperty("collectionMethod"); 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"); const plans = this.getResponseProperty("plans");
if (plans != null) { 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");
}
}