1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

Estimate tax for trial initiation flow when trial length is 0 (#14674)

This commit is contained in:
Alex Morask
2025-05-13 09:28:25 -04:00
committed by GitHub
parent 7a51161d50
commit 4cf6e19b30
8 changed files with 175 additions and 5 deletions

View File

@@ -51,8 +51,38 @@
<h2 class="tw-mb-3 tw-text-base tw-font-semibold">{{ "paymentType" | i18n }}</h2> <h2 class="tw-mb-3 tw-text-base tw-font-semibold">{{ "paymentType" | i18n }}</h2>
<app-payment [showAccountCredit]="false"></app-payment> <app-payment [showAccountCredit]="false"></app-payment>
<app-manage-tax-information <app-manage-tax-information
(taxInformationChanged)="changedCountry()" (taxInformationChanged)="onTaxInformationChanged()"
></app-manage-tax-information> ></app-manage-tax-information>
@if (trialLength === 0) {
@let priceLabel =
subscriptionProduct === SubscriptionProduct.PasswordManager
? "passwordManagerPlanPrice"
: "secretsManagerPlanPrice";
<div id="price" class="tw-my-4">
<div class="tw-text-muted tw-text-base">
{{ priceLabel | i18n }}: {{ getPriceFor(formGroup.value.cadence) | currency: "USD $" }}
<div>
{{ "estimatedTax" | i18n }}:
@if (fetchingTaxAmount) {
<ng-container *ngTemplateOutlet="loadingSpinner" />
} @else {
{{ taxAmount | currency: "USD $" }}
}
</div>
</div>
<hr class="tw-my-1 tw-grid tw-grid-cols-3 tw-ml-0" />
<p class="tw-text-lg">
<strong>{{ "total" | i18n }}: </strong>
@if (fetchingTaxAmount) {
<ng-container *ngTemplateOutlet="loadingSpinner" />
} @else {
{{ total | currency: "USD $" }}/{{ interval | i18n }}
}
</p>
</div>
}
</div> </div>
<div class="tw-flex tw-space-x-2"> <div class="tw-flex tw-space-x-2">
<button type="submit" buttonType="primary" bitButton [loading]="form.loading"> <button type="submit" buttonType="primary" bitButton [loading]="form.loading">
@@ -62,3 +92,12 @@
</div> </div>
</div> </div>
</form> </form>
<ng-template #loadingSpinner>
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-template>

View File

@@ -1,7 +1,16 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from "@angular/core"; import {
Component,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
ViewChild,
} from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms"; import { FormBuilder, Validators } from "@angular/forms";
import { from, Subject, switchMap, takeUntil } from "rxjs";
import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components"; import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
@@ -12,7 +21,14 @@ import {
PaymentInformation, PaymentInformation,
PlanInformation, PlanInformation,
} from "@bitwarden/common/billing/abstractions/organization-billing.service"; } from "@bitwarden/common/billing/abstractions/organization-billing.service";
import { PaymentMethodType, PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
import {
PaymentMethodType,
PlanType,
ProductTierType,
ProductType,
} from "@bitwarden/common/billing/enums";
import { PreviewTaxAmountForOrganizationTrialRequest } from "@bitwarden/common/billing/models/request/tax";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
@@ -50,7 +66,7 @@ export enum SubscriptionProduct {
imports: [BillingSharedModule], imports: [BillingSharedModule],
standalone: true, standalone: true,
}) })
export class TrialBillingStepComponent implements OnInit { export class TrialBillingStepComponent implements OnInit, OnDestroy {
@ViewChild(PaymentComponent) paymentComponent: PaymentComponent; @ViewChild(PaymentComponent) paymentComponent: PaymentComponent;
@ViewChild(ManageTaxInformationComponent) taxInfoComponent: ManageTaxInformationComponent; @ViewChild(ManageTaxInformationComponent) taxInfoComponent: ManageTaxInformationComponent;
@Input() organizationInfo: OrganizationInfo; @Input() organizationInfo: OrganizationInfo;
@@ -60,6 +76,7 @@ export class TrialBillingStepComponent implements OnInit {
@Output() organizationCreated = new EventEmitter<OrganizationCreatedEvent>(); @Output() organizationCreated = new EventEmitter<OrganizationCreatedEvent>();
loading = true; loading = true;
fetchingTaxAmount = false;
annualCadence = SubscriptionCadence.Annual; annualCadence = SubscriptionCadence.Annual;
monthlyCadence = SubscriptionCadence.Monthly; monthlyCadence = SubscriptionCadence.Monthly;
@@ -73,6 +90,12 @@ export class TrialBillingStepComponent implements OnInit {
annualPlan?: PlanResponse; annualPlan?: PlanResponse;
monthlyPlan?: PlanResponse; monthlyPlan?: PlanResponse;
taxAmount = 0;
private destroy$ = new Subject<void>();
protected readonly SubscriptionProduct = SubscriptionProduct;
constructor( constructor(
private apiService: ApiService, private apiService: ApiService,
private i18nService: I18nService, private i18nService: I18nService,
@@ -80,6 +103,7 @@ export class TrialBillingStepComponent implements OnInit {
private messagingService: MessagingService, private messagingService: MessagingService,
private organizationBillingService: OrganizationBillingService, private organizationBillingService: OrganizationBillingService,
private toastService: ToastService, private toastService: ToastService,
private taxService: TaxServiceAbstraction,
) {} ) {}
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
@@ -87,9 +111,26 @@ export class TrialBillingStepComponent implements OnInit {
this.applicablePlans = plans.data.filter(this.isApplicable); this.applicablePlans = plans.data.filter(this.isApplicable);
this.annualPlan = this.findPlanFor(SubscriptionCadence.Annual); this.annualPlan = this.findPlanFor(SubscriptionCadence.Annual);
this.monthlyPlan = this.findPlanFor(SubscriptionCadence.Monthly); this.monthlyPlan = this.findPlanFor(SubscriptionCadence.Monthly);
if (this.trialLength === 0) {
this.formGroup.controls.cadence.valueChanges
.pipe(
switchMap((cadence) => from(this.previewTaxAmount(cadence))),
takeUntil(this.destroy$),
)
.subscribe((taxAmount) => {
this.taxAmount = taxAmount;
});
}
this.loading = false; this.loading = false;
} }
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
async submit(): Promise<void> { async submit(): Promise<void> {
if (!this.taxInfoComponent.validate()) { if (!this.taxInfoComponent.validate()) {
return; return;
@@ -115,7 +156,11 @@ export class TrialBillingStepComponent implements OnInit {
this.messagingService.send("organizationCreated", { organizationId }); this.messagingService.send("organizationCreated", { organizationId });
} }
protected changedCountry() { async onTaxInformationChanged() {
if (this.trialLength === 0) {
this.taxAmount = await this.previewTaxAmount(this.formGroup.value.cadence);
}
this.paymentComponent.showBankAccount = this.paymentComponent.showBankAccount =
this.taxInfoComponent.getTaxInformation().country === "US"; this.taxInfoComponent.getTaxInformation().country === "US";
if ( if (
@@ -250,4 +295,45 @@ export class TrialBillingStepComponent implements OnInit {
const notDisabledOrLegacy = !plan.disabled && !plan.legacyYear; const notDisabledOrLegacy = !plan.disabled && !plan.legacyYear;
return hasCorrectProductType && notDisabledOrLegacy; return hasCorrectProductType && notDisabledOrLegacy;
} }
private previewTaxAmount = async (cadence: SubscriptionCadence): Promise<number> => {
this.fetchingTaxAmount = true;
if (!this.taxInfoComponent.validate()) {
return 0;
}
const plan = this.findPlanFor(cadence);
const productType =
this.subscriptionProduct === SubscriptionProduct.PasswordManager
? ProductType.PasswordManager
: ProductType.SecretsManager;
const taxInformation = this.taxInfoComponent.getTaxInformation();
const request: PreviewTaxAmountForOrganizationTrialRequest = {
planType: plan.type,
productType,
taxInformation: {
...taxInformation,
},
};
const response = await this.taxService.previewTaxAmountForOrganizationTrial(request);
this.fetchingTaxAmount = false;
return response.taxAmount;
};
get price() {
return this.getPriceFor(this.formGroup.value.cadence);
}
get total() {
return this.price + this.taxAmount;
}
get interval() {
return this.formGroup.value.cadence === SubscriptionCadence.Annual ? "year" : "month";
}
} }

View File

@@ -1,7 +1,9 @@
import { CountryListItem } from "../models/domain"; import { CountryListItem } from "../models/domain";
import { PreviewIndividualInvoiceRequest } from "../models/request/preview-individual-invoice.request"; import { PreviewIndividualInvoiceRequest } from "../models/request/preview-individual-invoice.request";
import { PreviewOrganizationInvoiceRequest } from "../models/request/preview-organization-invoice.request"; import { PreviewOrganizationInvoiceRequest } from "../models/request/preview-organization-invoice.request";
import { PreviewTaxAmountForOrganizationTrialRequest } from "../models/request/tax";
import { PreviewInvoiceResponse } from "../models/response/preview-invoice.response"; import { PreviewInvoiceResponse } from "../models/response/preview-invoice.response";
import { PreviewTaxAmountResponse } from "../models/response/tax";
export abstract class TaxServiceAbstraction { export abstract class TaxServiceAbstraction {
abstract getCountries(): CountryListItem[]; abstract getCountries(): CountryListItem[];
@@ -15,4 +17,8 @@ export abstract class TaxServiceAbstraction {
abstract previewOrganizationInvoice( abstract previewOrganizationInvoice(
request: PreviewOrganizationInvoiceRequest, request: PreviewOrganizationInvoiceRequest,
): Promise<PreviewInvoiceResponse>; ): Promise<PreviewInvoiceResponse>;
abstract previewTaxAmountForOrganizationTrial: (
request: PreviewTaxAmountForOrganizationTrialRequest,
) => Promise<PreviewTaxAmountResponse>;
} }

View File

@@ -0,0 +1 @@
export * from "./preview-tax-amount-for-organization-trial.request";

View File

@@ -0,0 +1,11 @@
import { PlanType, ProductType } from "../../../enums";
export type PreviewTaxAmountForOrganizationTrialRequest = {
planType: PlanType;
productType: ProductType;
taxInformation: {
country: string;
postalCode: string;
taxId?: string;
};
};

View File

@@ -0,0 +1 @@
export * from "./preview-tax-amount.response";

View File

@@ -0,0 +1,11 @@
import { BaseResponse } from "../../../../models/response/base.response";
export class PreviewTaxAmountResponse extends BaseResponse {
taxAmount: number;
constructor(response: any) {
super(response);
this.taxAmount = this.getResponseProperty("TaxAmount");
}
}

View File

@@ -1,3 +1,6 @@
import { PreviewTaxAmountForOrganizationTrialRequest } from "@bitwarden/common/billing/models/request/tax";
import { PreviewTaxAmountResponse } from "@bitwarden/common/billing/models/response/tax";
import { ApiService } from "../../abstractions/api.service"; import { ApiService } from "../../abstractions/api.service";
import { TaxServiceAbstraction } from "../abstractions/tax.service.abstraction"; import { TaxServiceAbstraction } from "../abstractions/tax.service.abstraction";
import { CountryListItem } from "../models/domain"; import { CountryListItem } from "../models/domain";
@@ -300,4 +303,16 @@ export class TaxService implements TaxServiceAbstraction {
); );
return new PreviewInvoiceResponse(response); return new PreviewInvoiceResponse(response);
} }
async previewTaxAmountForOrganizationTrial(
request: PreviewTaxAmountForOrganizationTrialRequest,
): Promise<PreviewTaxAmountResponse> {
return await this.apiService.send(
"POST",
"/tax/preview-amount/organization-trial",
request,
true,
true,
);
}
} }