From f127f2db5cdf3567764a8a030e8f7dfc107fdb2a Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 24 Sep 2025 15:49:16 -0400 Subject: [PATCH] feat(billing): Add Upgrade Payment Service --- .../services/upgrade-payment.service.spec.ts | 350 ++++++++++++++++++ .../services/upgrade-payment.service.ts | 234 ++++++++++++ 2 files changed, 584 insertions(+) create mode 100644 apps/web/src/app/billing/individual/upgrade/upgrade-payment-dialog/services/upgrade-payment.service.spec.ts create mode 100644 apps/web/src/app/billing/individual/upgrade/upgrade-payment-dialog/services/upgrade-payment.service.ts diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment-dialog/services/upgrade-payment.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment-dialog/services/upgrade-payment.service.spec.ts new file mode 100644 index 00000000000..8ed49a88235 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment-dialog/services/upgrade-payment.service.spec.ts @@ -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(); + const mockSubscriberBillingClient = mock(); + const mockTaxService = mock(); + const mockLogService = mock(); + const mockApiService = mock(); + const mockSyncService = mock(); + + 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(); + 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(); + 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); + }); + }); + }); +}); diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment-dialog/services/upgrade-payment.service.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment-dialog/services/upgrade-payment.service.ts new file mode 100644 index 00000000000..3f28fa3e04f --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment-dialog/services/upgrade-payment.service.ts @@ -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, + ): Promise { + 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, + ): Promise { + 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 { + 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 { + await this.apiService.refreshIdentityToken(); + await this.syncService.fullSync(true); + } +}