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:
@@ -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>
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
1
libs/common/src/billing/models/request/tax/index.ts
Normal file
1
libs/common/src/billing/models/request/tax/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./preview-tax-amount-for-organization-trial.request";
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { PlanType, ProductType } from "../../../enums";
|
||||||
|
|
||||||
|
export type PreviewTaxAmountForOrganizationTrialRequest = {
|
||||||
|
planType: PlanType;
|
||||||
|
productType: ProductType;
|
||||||
|
taxInformation: {
|
||||||
|
country: string;
|
||||||
|
postalCode: string;
|
||||||
|
taxId?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
1
libs/common/src/billing/models/response/tax/index.ts
Normal file
1
libs/common/src/billing/models/response/tax/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./preview-tax-amount.response";
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user