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

feat(billing): Add Upgrade Payment Service

This commit is contained in:
Stephon Brown
2025-09-24 15:49:16 -04:00
parent 803cd52360
commit f127f2db5c
2 changed files with 584 additions and 0 deletions

View File

@@ -0,0 +1,350 @@
import { TestBed } from "@angular/core/testing";
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 { Account } from "@bitwarden/common/auth/abstractions/account.service";
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 { SubscriberBillingClient } from "../../../../clients";
import { TokenizedPaymentMethod } from "../../../../payment/types";
import { BitwardenSubscriber } from "../../../../types";
import { PersonalSubscriptionPricingTierIds } from "../../../../types/subscription-pricing-tier";
import { UpgradePaymentService, PlanDetails } from "./upgrade-payment.service";
describe("UpgradePaymentService", () => {
const mockOrganizationBillingService = mock<OrganizationBillingServiceAbstraction>();
const mockSubscriberBillingClient = mock<SubscriberBillingClient>();
const mockTaxService = mock<TaxServiceAbstraction>();
const mockLogService = mock<LogService>();
const mockApiService = mock<ApiService>();
const mockSyncService = mock<SyncService>();
mockApiService.refreshIdentityToken.mockResolvedValue({});
mockSyncService.fullSync.mockResolvedValue(true);
let sut: UpgradePaymentService;
const mockSubscriber: BitwardenSubscriber = {
type: "account",
data: {
id: "user-id" as UserId,
email: "test@example.com",
} as Account,
};
const mockTokenizedPaymentMethod: TokenizedPaymentMethod = {
token: "test-token",
type: "card",
};
const mockBillingAddress = {
country: "US",
postalCode: "12345",
};
const mockPremiumPlanDetails: PlanDetails = {
tier: PersonalSubscriptionPricingTierIds.Premium,
details: {
id: PersonalSubscriptionPricingTierIds.Premium,
name: "Premium",
description: "Premium plan",
availableCadences: ["annually"],
passwordManager: {
type: "standalone",
annualPrice: 10,
annualPricePerAdditionalStorageGB: 4,
features: [
{ key: "feature1", value: "Feature 1" },
{ key: "feature2", value: "Feature 2" },
],
},
},
};
const mockFamiliesPlanDetails: PlanDetails = {
tier: PersonalSubscriptionPricingTierIds.Families,
details: {
id: PersonalSubscriptionPricingTierIds.Families,
name: "Families",
description: "Families plan",
availableCadences: ["annually"],
passwordManager: {
type: "packaged",
annualPrice: 40,
annualPricePerAdditionalStorageGB: 4,
features: [
{ key: "feature1", value: "Feature 1" },
{ key: "feature2", value: "Feature 2" },
],
users: 6,
},
},
};
beforeEach(() => {
mockReset(mockOrganizationBillingService);
mockReset(mockSubscriberBillingClient);
mockReset(mockTaxService);
mockReset(mockLogService);
TestBed.configureTestingModule({
providers: [
UpgradePaymentService,
{
provide: OrganizationBillingServiceAbstraction,
useValue: mockOrganizationBillingService,
},
{ provide: SubscriberBillingClient, useValue: mockSubscriberBillingClient },
{ provide: TaxServiceAbstraction, useValue: mockTaxService },
{ provide: LogService, useValue: mockLogService },
{ provide: ApiService, useValue: mockApiService },
{ provide: SyncService, useValue: mockSyncService },
],
});
sut = TestBed.inject(UpgradePaymentService);
});
describe("calculateEstimatedTax", () => {
it("should calculate tax for premium plan", async () => {
// Arrange
const mockResponse = mock<PreviewInvoiceResponse>();
mockResponse.taxAmount = 2.5;
mockTaxService.previewIndividualInvoice.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",
},
});
});
it("should calculate tax for families plan", async () => {
// Arrange
const mockResponse = mock<PreviewInvoiceResponse>();
mockResponse.taxAmount = 5.0;
mockTaxService.previewOrganizationInvoice.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,
},
taxInformation: {
postalCode: "12345",
country: "US",
taxId: null,
},
});
});
it("should throw and log error if personal tax calculation fails", async () => {
// Arrange
const error = new Error("Tax service error");
mockTaxService.previewIndividualInvoice.mockRejectedValue(error);
// Act & Assert
await expect(
sut.calculateEstimatedTax(mockPremiumPlanDetails, mockBillingAddress),
).rejects.toThrow();
expect(mockLogService.error).toHaveBeenCalledWith("Tax calculation failed:", error);
});
it("should throw and log error if organization tax calculation fails", async () => {
// Arrange
const error = new Error("Tax service error");
mockTaxService.previewOrganizationInvoice.mockRejectedValue(error);
// Act & Assert
await expect(
sut.calculateEstimatedTax(mockFamiliesPlanDetails, mockBillingAddress),
).rejects.toThrow();
expect(mockLogService.error).toHaveBeenCalledWith("Tax calculation failed:", error);
});
});
describe("upgradeToPremium", () => {
it("should call subscriberBillingClient to purchase premium subscription and refresh data", async () => {
// Arrange
mockSubscriberBillingClient.purchasePremiumSubscription.mockResolvedValue();
// Act
await sut.upgradeToPremium(mockSubscriber, mockTokenizedPaymentMethod, mockBillingAddress);
// Assert
expect(mockSubscriberBillingClient.purchasePremiumSubscription).toHaveBeenCalledWith(
mockSubscriber,
mockTokenizedPaymentMethod,
mockBillingAddress,
);
expect(mockApiService.refreshIdentityToken).toHaveBeenCalled();
expect(mockSyncService.fullSync).toHaveBeenCalledWith(true);
});
it("should throw error if payment method is incomplete", async () => {
// Arrange
const incompletePaymentMethod = { type: "card" } as TokenizedPaymentMethod;
// Act & Assert
await expect(
sut.upgradeToPremium(mockSubscriber, incompletePaymentMethod, mockBillingAddress),
).rejects.toThrow("Payment method type or token is missing");
});
it("should throw error if billing address is incomplete", async () => {
// Arrange
const incompleteBillingAddress = { country: "US", postalCode: null } as any;
// Act & Assert
await expect(
sut.upgradeToPremium(mockSubscriber, mockTokenizedPaymentMethod, incompleteBillingAddress),
).rejects.toThrow("Billing address information is incomplete");
});
});
describe("upgradeToFamilies", () => {
it("should call organizationBillingService to purchase subscription and refresh data", async () => {
// Arrange
mockOrganizationBillingService.purchaseSubscription.mockResolvedValue({
id: "org-id",
name: "Test Organization",
billingEmail: "test@example.com",
} as OrganizationResponse);
// Act
await sut.upgradeToFamilies(
mockSubscriber,
mockFamiliesPlanDetails,
mockTokenizedPaymentMethod,
{
organizationName: "Test Organization",
billingAddress: mockBillingAddress,
},
);
// Assert
expect(mockOrganizationBillingService.purchaseSubscription).toHaveBeenCalledWith(
expect.objectContaining({
organization: {
name: "Test Organization",
billingEmail: "test@example.com",
},
plan: {
type: PlanType.FamiliesAnnually,
passwordManagerSeats: 6,
},
payment: {
paymentMethod: ["test-token", PaymentMethodType.Card],
billing: {
country: "US",
postalCode: "12345",
},
},
}),
"user-id",
);
expect(mockApiService.refreshIdentityToken).toHaveBeenCalled();
expect(mockSyncService.fullSync).toHaveBeenCalledWith(true);
});
it("should throw error if password manager seats are 0", async () => {
// Arrange
const invalidPlanDetails: PlanDetails = {
tier: PersonalSubscriptionPricingTierIds.Families,
details: {
passwordManager: {
type: "packaged",
users: 0,
annualPrice: 0,
features: [],
annualPricePerAdditionalStorageGB: 0,
},
id: "families",
name: "",
description: "",
availableCadences: ["annually"],
},
};
mockOrganizationBillingService.purchaseSubscription.mockRejectedValue(
new Error("Seats must be greater than 0 for families plan"),
);
// Act & Assert
await expect(
sut.upgradeToFamilies(mockSubscriber, invalidPlanDetails, mockTokenizedPaymentMethod, {
organizationName: "Test Organization",
billingAddress: mockBillingAddress,
}),
).rejects.toThrow("Seats must be greater than 0 for families plan");
expect(mockOrganizationBillingService.purchaseSubscription).toHaveBeenCalledTimes(1);
});
it("should throw error if subscriber is not an account", async () => {
const invalidSubscriber = { type: "organization" } as BitwardenSubscriber;
await expect(
sut.upgradeToFamilies(
invalidSubscriber,
mockFamiliesPlanDetails,
mockTokenizedPaymentMethod,
{
organizationName: "Test Organization",
billingAddress: mockBillingAddress,
},
),
).rejects.toThrow("Subscriber must be an account for families upgrade");
});
it("should throw error if payment method is incomplete", async () => {
const incompletePaymentMethod = { type: "card" } as TokenizedPaymentMethod;
await expect(
sut.upgradeToFamilies(mockSubscriber, mockFamiliesPlanDetails, incompletePaymentMethod, {
organizationName: "Test Organization",
billingAddress: mockBillingAddress,
}),
).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

@@ -0,0 +1,234 @@
import { Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
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 { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { LogService } from "@bitwarden/logging";
import { SubscriberBillingClient } from "../../../../clients";
import {
BillingAddress,
TokenizablePaymentMethod,
TokenizedPaymentMethod,
} from "../../../../payment/types";
import { BitwardenSubscriber } from "../../../../types";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierId,
PersonalSubscriptionPricingTierIds,
} from "../../../../types/subscription-pricing-tier";
type TaxInformation = {
postalCode: string;
country: string;
taxId: string | null;
};
export type PlanDetails = {
tier: PersonalSubscriptionPricingTierId;
details: PersonalSubscriptionPricingTier;
};
export type PaymentFormValues = {
organizationName?: string | null;
billingAddress: {
country: string;
postalCode: string;
};
};
/**
* Service for handling payment submission and sales tax calculation for upgrade payment component
*/
@Injectable()
export class UpgradePaymentService {
constructor(
private organizationBillingService: OrganizationBillingServiceAbstraction,
private subscriberBillingClient: SubscriberBillingClient,
private taxService: TaxServiceAbstraction,
private logService: LogService,
private apiService: ApiService,
private syncService: SyncService,
) {}
/**
* Calculate estimated tax for the selected plan
*/
async calculateEstimatedTax(
planDetails: PlanDetails,
billingAddress: Pick<BillingAddress, "country" | "postalCode">,
): 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;
if (isOrganizationPlan) {
const seats = this.getPasswordManagerSeats(planDetails);
if (seats === 0) {
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,
};
taxServiceCall = this.taxService.previewOrganizationInvoice(request);
}
if (isPremiumPlan) {
const request: PreviewIndividualInvoiceRequest = {
passwordManager: { additionalStorage: 0 },
taxInformation: {
postalCode: billingAddress.postalCode,
country: billingAddress.country,
},
};
taxServiceCall = this.taxService.previewIndividualInvoice(request);
}
if (taxServiceCall === null) {
throw new Error("Tax service call is not defined");
}
const invoice = await taxServiceCall;
return invoice.taxAmount;
} catch (error: unknown) {
this.logService.error("Tax calculation failed:", error);
throw error;
}
}
/**
* Process premium upgrade
*/
async upgradeToPremium(
subscriber: BitwardenSubscriber,
paymentMethod: TokenizedPaymentMethod,
billingAddress: Pick<BillingAddress, "country" | "postalCode">,
): Promise<void> {
this.validatePaymentAndBillingInfo(paymentMethod, billingAddress);
await this.subscriberBillingClient.purchasePremiumSubscription(
subscriber,
paymentMethod,
billingAddress,
);
await this.refreshAndSync();
}
/**
* Process families upgrade
*/
async upgradeToFamilies(
subscriber: BitwardenSubscriber,
planDetails: PlanDetails,
paymentMethod: TokenizedPaymentMethod,
formValues: PaymentFormValues,
): Promise<OrganizationResponse> {
if (subscriber.type !== "account") {
throw new Error("Subscriber must be an account for families upgrade");
}
const user = subscriber.data as Account;
const billingAddress = formValues.billingAddress;
if (!formValues.organizationName) {
throw new Error("Organization name is required for families upgrade");
}
this.validatePaymentAndBillingInfo(paymentMethod, billingAddress);
const passwordManagerSeats = this.getPasswordManagerSeats(planDetails);
const subscriptionInformation: SubscriptionInformation = {
organization: {
name: formValues.organizationName,
billingEmail: user.email, // Use account email as billing email
},
plan: {
type: PlanType.FamiliesAnnually,
passwordManagerSeats: passwordManagerSeats,
},
payment: {
paymentMethod: [
paymentMethod.token,
this.tokenizablePaymentMethodToLegacyEnum(paymentMethod.type),
],
billing: {
country: billingAddress.country,
postalCode: billingAddress.postalCode,
},
},
};
const result = await this.organizationBillingService.purchaseSubscription(
subscriptionInformation,
user.id,
);
await this.refreshAndSync();
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
: 0;
}
private validatePaymentAndBillingInfo(
paymentMethod: TokenizedPaymentMethod,
billingAddress: { country: string; postalCode: string },
): void {
if (!paymentMethod?.token || !paymentMethod?.type) {
throw new Error("Payment method type or token is missing");
}
if (!billingAddress?.country || !billingAddress?.postalCode) {
throw new Error("Billing address information is incomplete");
}
}
private async refreshAndSync(): Promise<void> {
await this.apiService.refreshIdentityToken();
await this.syncService.fullSync(true);
}
}