1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-04 02:33:33 +00:00

fix(billing): Update component and service to use new tax service

This commit is contained in:
Stephon Brown
2025-10-01 19:29:10 -04:00
parent 6352fc3e98
commit dc2bf3614f
3 changed files with 64 additions and 110 deletions

View File

@@ -4,15 +4,13 @@ import { mock, mockReset } from "jest-mock-extended";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response";
import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
import { PreviewInvoiceResponse } from "@bitwarden/common/billing/models/response/preview-invoice.response";
import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid";
import { LogService } from "@bitwarden/logging";
import { AccountBillingClient } from "../../../../clients";
import { TokenizedPaymentMethod } from "../../../../payment/types";
import { AccountBillingClient, TaxAmounts, TaxClient } from "../../../../clients";
import { BillingAddress, TokenizedPaymentMethod } from "../../../../payment/types";
import { PersonalSubscriptionPricingTierIds } from "../../../../types/subscription-pricing-tier";
import { UpgradePaymentService, PlanDetails } from "./upgrade-payment.service";
@@ -20,7 +18,7 @@ import { UpgradePaymentService, PlanDetails } from "./upgrade-payment.service";
describe("UpgradePaymentService", () => {
const mockOrganizationBillingService = mock<OrganizationBillingServiceAbstraction>();
const mockAccountBillingClient = mock<AccountBillingClient>();
const mockTaxService = mock<TaxServiceAbstraction>();
const mockTaxClient = mock<TaxClient>();
const mockLogService = mock<LogService>();
const mockApiService = mock<ApiService>();
const mockSyncService = mock<SyncService>();
@@ -42,9 +40,14 @@ describe("UpgradePaymentService", () => {
type: "card",
};
const mockBillingAddress = {
const mockBillingAddress: BillingAddress = {
line1: "123 Test St",
line2: null,
city: "Test City",
state: "TS",
country: "US",
postalCode: "12345",
taxId: null,
};
const mockPremiumPlanDetails: PlanDetails = {
@@ -89,7 +92,7 @@ describe("UpgradePaymentService", () => {
beforeEach(() => {
mockReset(mockOrganizationBillingService);
mockReset(mockAccountBillingClient);
mockReset(mockTaxService);
mockReset(mockTaxClient);
mockReset(mockLogService);
TestBed.configureTestingModule({
@@ -101,7 +104,7 @@ describe("UpgradePaymentService", () => {
useValue: mockOrganizationBillingService,
},
{ provide: AccountBillingClient, useValue: mockAccountBillingClient },
{ provide: TaxServiceAbstraction, useValue: mockTaxService },
{ provide: TaxClient, useValue: mockTaxClient },
{ provide: LogService, useValue: mockLogService },
{ provide: ApiService, useValue: mockApiService },
{ provide: SyncService, useValue: mockSyncService },
@@ -114,55 +117,52 @@ describe("UpgradePaymentService", () => {
describe("calculateEstimatedTax", () => {
it("should calculate tax for premium plan", async () => {
// Arrange
const mockResponse = mock<PreviewInvoiceResponse>();
mockResponse.taxAmount = 2.5;
const mockResponse = mock<TaxAmounts>();
mockResponse.tax = 2.5;
mockTaxService.previewIndividualInvoice.mockResolvedValue(mockResponse);
mockTaxClient.previewTaxForPremiumSubscriptionPurchase.mockResolvedValue(mockResponse);
// Act
const result = await sut.calculateEstimatedTax(mockPremiumPlanDetails, mockBillingAddress);
// Assert
expect(result).toEqual(2.5);
expect(mockTaxService.previewIndividualInvoice).toHaveBeenCalledWith({
passwordManager: { additionalStorage: 0 },
taxInformation: {
postalCode: "12345",
country: "US",
},
});
expect(mockTaxClient.previewTaxForPremiumSubscriptionPurchase).toHaveBeenCalledWith(
0,
mockBillingAddress,
);
});
it("should calculate tax for families plan", async () => {
// Arrange
const mockResponse = mock<PreviewInvoiceResponse>();
mockResponse.taxAmount = 5.0;
const mockResponse = mock<TaxAmounts>();
mockResponse.tax = 5.0;
mockTaxService.previewOrganizationInvoice.mockResolvedValue(mockResponse);
mockTaxClient.previewTaxForOrganizationSubscriptionPurchase.mockResolvedValue(mockResponse);
// Act
const result = await sut.calculateEstimatedTax(mockFamiliesPlanDetails, mockBillingAddress);
// Assert
expect(result).toEqual(5.0);
expect(mockTaxService.previewOrganizationInvoice).toHaveBeenCalledWith({
passwordManager: {
additionalStorage: 0,
plan: PlanType.FamiliesAnnually,
seats: 6,
expect(mockTaxClient.previewTaxForOrganizationSubscriptionPurchase).toHaveBeenCalledWith(
{
cadence: "annually",
tier: "families",
passwordManager: {
additionalStorage: 0,
seats: 6,
sponsored: false,
},
},
taxInformation: {
postalCode: "12345",
country: "US",
taxId: null,
},
});
mockBillingAddress,
);
});
it("should throw and log error if personal tax calculation fails", async () => {
// Arrange
const error = new Error("Tax service error");
mockTaxService.previewIndividualInvoice.mockRejectedValue(error);
mockTaxClient.previewTaxForPremiumSubscriptionPurchase.mockRejectedValue(error);
// Act & Assert
await expect(
@@ -174,7 +174,7 @@ describe("UpgradePaymentService", () => {
it("should throw and log error if organization tax calculation fails", async () => {
// Arrange
const error = new Error("Tax service error");
mockTaxService.previewOrganizationInvoice.mockRejectedValue(error);
mockTaxClient.previewTaxForOrganizationSubscriptionPurchase.mockRejectedValue(error);
// Act & Assert
await expect(
sut.calculateEstimatedTax(mockFamiliesPlanDetails, mockBillingAddress),
@@ -309,22 +309,5 @@ describe("UpgradePaymentService", () => {
}),
).rejects.toThrow("Payment method type or token is missing");
});
describe("tokenizablePaymentMethodToLegacyEnum", () => {
it("should convert 'card' to PaymentMethodType.Card", () => {
const result = sut.tokenizablePaymentMethodToLegacyEnum("card");
expect(result).toBe(PaymentMethodType.Card);
});
it("should convert 'bankAccount' to PaymentMethodType.BankAccount", () => {
const result = sut.tokenizablePaymentMethodToLegacyEnum("bankAccount");
expect(result).toBe(PaymentMethodType.BankAccount);
});
it("should convert 'payPal' to PaymentMethodType.PayPal", () => {
const result = sut.tokenizablePaymentMethodToLegacyEnum("payPal");
expect(result).toBe(PaymentMethodType.PayPal);
});
});
});
});

View File

@@ -7,17 +7,19 @@ import {
OrganizationBillingServiceAbstraction,
SubscriptionInformation,
} from "@bitwarden/common/billing/abstractions";
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
import { PreviewIndividualInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-individual-invoice.request";
import { PreviewOrganizationInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-organization-invoice.request";
import { PlanType } from "@bitwarden/common/billing/enums";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { LogService } from "@bitwarden/logging";
import { AccountBillingClient } from "../../../../clients";
import {
AccountBillingClient,
OrganizationSubscriptionPurchase,
TaxAmounts,
TaxClient,
} from "../../../../clients";
import {
BillingAddress,
TokenizablePaymentMethod,
tokenizablePaymentMethodToLegacyEnum,
TokenizedPaymentMethod,
} from "../../../../payment/types";
import {
@@ -26,12 +28,6 @@ import {
PersonalSubscriptionPricingTierIds,
} from "../../../../types/subscription-pricing-tier";
type TaxInformation = {
postalCode: string;
country: string;
taxId: string | null;
};
export type PlanDetails = {
tier: PersonalSubscriptionPricingTierId;
details: PersonalSubscriptionPricingTier;
@@ -53,7 +49,7 @@ export class UpgradePaymentService {
constructor(
private organizationBillingService: OrganizationBillingServiceAbstraction,
private accountBillingClient: AccountBillingClient,
private taxService: TaxServiceAbstraction,
private taxClient: TaxClient,
private logService: LogService,
private apiService: ApiService,
private syncService: SyncService,
@@ -64,20 +60,13 @@ export class UpgradePaymentService {
*/
async calculateEstimatedTax(
planDetails: PlanDetails,
billingAddress: Pick<BillingAddress, "country" | "postalCode">,
billingAddress: BillingAddress,
): Promise<number> {
try {
const taxInformation: TaxInformation = {
postalCode: billingAddress.postalCode,
country: billingAddress.country,
// This is null for now since we only process Families and Premium plans
taxId: null,
};
const isOrganizationPlan = planDetails.tier === PersonalSubscriptionPricingTierIds.Families;
const isPremiumPlan = planDetails.tier === PersonalSubscriptionPricingTierIds.Premium;
let taxServiceCall: Promise<{ taxAmount: number }> | null = null;
let taxClientCall: Promise<TaxAmounts> | null = null;
if (isOrganizationPlan) {
const seats = this.getPasswordManagerSeats(planDetails);
@@ -85,36 +74,28 @@ export class UpgradePaymentService {
throw new Error("Seats must be greater than 0 for organization plan");
}
// Currently, only Families plan is supported for organization plans
const request: PreviewOrganizationInvoiceRequest = {
passwordManager: {
additionalStorage: 0,
plan: PlanType.FamiliesAnnually,
seats: seats,
},
taxInformation,
const request: OrganizationSubscriptionPurchase = {
tier: "families",
cadence: "annually",
passwordManager: { seats, additionalStorage: 0, sponsored: false },
};
taxServiceCall = this.taxService.previewOrganizationInvoice(request);
taxClientCall = this.taxClient.previewTaxForOrganizationSubscriptionPurchase(
request,
billingAddress,
);
}
if (isPremiumPlan) {
const request: PreviewIndividualInvoiceRequest = {
passwordManager: { additionalStorage: 0 },
taxInformation: {
postalCode: billingAddress.postalCode,
country: billingAddress.country,
},
};
taxServiceCall = this.taxService.previewIndividualInvoice(request);
taxClientCall = this.taxClient.previewTaxForPremiumSubscriptionPurchase(0, billingAddress);
}
if (taxServiceCall === null) {
throw new Error("Tax service call is not defined");
if (taxClientCall === null) {
throw new Error("Tax client call is not defined");
}
const invoice = await taxServiceCall;
return invoice.taxAmount;
const preview = await taxClientCall;
return preview.tax;
} catch (error: unknown) {
this.logService.error("Tax calculation failed:", error);
throw error;
@@ -166,7 +147,7 @@ export class UpgradePaymentService {
payment: {
paymentMethod: [
paymentMethod.token,
this.tokenizablePaymentMethodToLegacyEnum(paymentMethod.type),
tokenizablePaymentMethodToLegacyEnum(paymentMethod.type),
],
billing: {
country: billingAddress.country,
@@ -183,21 +164,6 @@ export class UpgradePaymentService {
return result;
}
/**
* Convert tokenizable payment method to legacy enum
* note: this will be removed once another PR is merged
*/
tokenizablePaymentMethodToLegacyEnum(paymentMethod: TokenizablePaymentMethod): PaymentMethodType {
switch (paymentMethod) {
case "bankAccount":
return PaymentMethodType.BankAccount;
case "card":
return PaymentMethodType.Card;
case "payPal":
return PaymentMethodType.PayPal;
}
}
private getPasswordManagerSeats(planDetails: PlanDetails): number {
return "users" in planDetails.details.passwordManager
? planDetails.details.passwordManager.users

View File

@@ -249,8 +249,13 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
this.upgradePaymentService
.calculateEstimatedTax(this.selectedPlan, {
line1: null,
line2: null,
city: null,
state: null,
country: billingAddress.country,
postalCode: billingAddress.postalCode,
taxId: null,
})
.then((tax) => {
this.estimatedTax = tax;