From 542eeeafc8b7b00db11f7efc2e966b670465eb58 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Fri, 10 Apr 2026 11:23:27 -0400 Subject: [PATCH] [PM-33301] Prevent Unverified Bank Account from Upgrade to Premium (#19745) * feat(billing): add showBankAccountOption input to payment method components * feat(billing): introduce bank account not supported error for upgrades * feat(billing): block bank account payment methods for premium org upgrades * refactor(billing): reorganize premium org upgrade payment submission logic * feat(billing): display toast for bank account not supported upgrade error * test(billing): add comprehensive tests for premium org upgrade payment * refactor(billing): Rename bank account unsupported message to unverified * feat(billing): Implement `isUnverifiedBankAccount` logic * test(billing): Add unit tests for `isUnverifiedBankAccount` helper * test(billing): Update service upgrade tests for unverified bank accounts * fix(billing): Prevent UI submission with unverified bank accounts * test(billing): Update UI component tests for unverified bank accounts * fix(billing): simplify logic * test: add payment method validation in upgrade flow * feat: implement payment method tokenization before upgrade * test(billing/organization-plans): add mock payment method setup to premium upgrade tests --- ...premium-org-upgrade-payment.component.html | 1 + ...mium-org-upgrade-payment.component.spec.ts | 334 ++++++++++-------- .../premium-org-upgrade-payment.component.ts | 86 +++-- .../premium-org-upgrade.service.spec.ts | 137 +++++++ .../services/premium-org-upgrade.service.ts | 36 +- .../organization-plans.component.html | 1 + .../organization-plans.component.spec.ts | 42 +++ .../organization-plans.component.ts | 22 ++ ...display-payment-method-inline.component.ts | 3 +- apps/web/src/locales/en/messages.json | 3 + 10 files changed, 492 insertions(+), 173 deletions(-) diff --git a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.html b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.html index d61ea14b3b2..955dc37c896 100644 --- a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.html +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.html @@ -20,6 +20,7 @@ [subscriber]="subscriber()" [paymentMethod]="paymentMethod()" [externalFormGroup]="formGroup.controls.paymentMethodForm" + [showBankAccountOption]="false" >
{{ "billingAddress" | i18n }}
diff --git a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.spec.ts index 2a175e34864..a04c3663d52 100644 --- a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.spec.ts @@ -60,6 +60,7 @@ class MockDisplayPaymentMethodInlineComponent { readonly subscriber = input.required(); readonly paymentMethod = input(); readonly externalFormGroup = input(); + readonly showBankAccountOption = input(false); readonly updated = output(); readonly changePaymentMethodClicked = output(); @@ -385,6 +386,68 @@ describe("PremiumOrgUpgradePaymentComponent", () => { }); }); + it("should show an error toast and not upgrade when existing payment method is an unverified bank account", async () => { + component["paymentMethod"].set({ + type: "bankAccount", + bankName: "Chase", + last4: "1234", + hostedVerificationUrl: "https://stripe.com/verify", + } as any); + mockPremiumOrgUpgradeService.isUnverifiedBankAccount.mockReturnValue(true); + + component["formGroup"].patchValue({ organizationName: "My New Org" }); + + await component["submit"](); + + expect(mockPremiumOrgUpgradeService.isUnverifiedBankAccount).toHaveBeenCalled(); + expect(mockPremiumOrgUpgradeService.upgradeToOrganization).not.toHaveBeenCalled(); + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + message: "unverifiedBankAccountNotSupportedForUpgrade", + }); + }); + + it("should not show toast and proceed with upgrade when existing payment method is a verified bank account", async () => { + component["paymentMethod"].set({ + type: "bankAccount", + bankName: "Chase", + last4: "1234", + } as any); + mockPremiumOrgUpgradeService.isUnverifiedBankAccount.mockReturnValue(false); + + component["formGroup"].setValue({ + organizationName: "My New Org", + paymentMethodForm: { + type: "card", + bankAccount: { + routingNumber: "", + accountNumber: "", + accountHolderName: "", + accountHolderType: "", + }, + billingAddress: { country: "", postalCode: "" }, + }, + billingAddress: { + country: "US", + postalCode: "90210", + line1: "123 Main St", + line2: "", + city: "Beverly Hills", + state: "CA", + taxId: "", + }, + }); + + await component["submit"](); + + expect(mockPremiumOrgUpgradeService.isUnverifiedBankAccount).toHaveBeenCalled(); + expect(mockPremiumOrgUpgradeService.upgradeToOrganization).toHaveBeenCalled(); + expect(mockToastService.showToast).not.toHaveBeenCalledWith({ + variant: "error", + message: "unverifiedBankAccountNotSupportedForUpgrade", + }); + }); + it("should not submit if the form is invalid", async () => { const markAllAsTouchedSpy = jest.spyOn(component["formGroup"], "markAllAsTouched"); component["formGroup"].get("organizationName")?.setValue(""); @@ -395,6 +458,108 @@ describe("PremiumOrgUpgradePaymentComponent", () => { expect(markAllAsTouchedSpy).toHaveBeenCalled(); expect(mockPremiumOrgUpgradeService.upgradeToOrganization).not.toHaveBeenCalled(); }); + + it("should throw when billing address is incomplete", async () => { + component["formGroup"].setValue({ + organizationName: "My New Org", + paymentMethodForm: { + type: "card", + bankAccount: { + routingNumber: "", + accountNumber: "", + accountHolderName: "", + accountHolderType: "", + }, + billingAddress: { country: "", postalCode: "" }, + }, + billingAddress: { + country: "", + postalCode: "", + line1: "", + line2: "", + city: "", + state: "", + taxId: "", + }, + }); + + await expect(component["submit"]()).rejects.toThrow("Billing address is incomplete"); + }); + + it("should call updatePaymentMethod and refresh payment method when isChangingPayment returns true", async () => { + const mockPaymentMethodComponent = { + isChangingPayment: jest.fn().mockReturnValue(true), + isFormValid: jest.fn().mockReturnValue(true), + getTokenizedPaymentMethod: jest.fn().mockResolvedValue({ token: "new-token-123" }), + }; + jest + .spyOn(component, "paymentMethodComponent") + .mockReturnValue(mockPaymentMethodComponent as any); + + const mockSubscriber = { id: "subscriber-123" }; + component["subscriber"].set(mockSubscriber as any); + + component["formGroup"].setValue({ + organizationName: "My New Org", + paymentMethodForm: { + type: "card", + bankAccount: { + routingNumber: "", + accountNumber: "", + accountHolderName: "", + accountHolderType: "", + }, + billingAddress: { country: "", postalCode: "" }, + }, + billingAddress: { + country: "US", + postalCode: "90210", + line1: "123 Main St", + line2: "", + city: "Beverly Hills", + state: "CA", + taxId: "", + }, + }); + + await component["submit"](); + + expect(mockSubscriberBillingClient.updatePaymentMethod).toHaveBeenCalledWith( + mockSubscriber, + { token: "new-token-123" }, + expect.objectContaining({ country: "US", postalCode: "90210" }), + ); + expect(mockPremiumOrgUpgradeService.upgradeToOrganization).toHaveBeenCalled(); + }); + + it("should throw when payment method is null and not changing payment", async () => { + component["paymentMethod"].set(null); + + component["formGroup"].setValue({ + organizationName: "My New Org", + paymentMethodForm: { + type: "card", + bankAccount: { + routingNumber: "", + accountNumber: "", + accountHolderName: "", + accountHolderType: "", + }, + billingAddress: { country: "", postalCode: "" }, + }, + billingAddress: { + country: "US", + postalCode: "90210", + line1: "123 Main St", + line2: "", + city: "Beverly Hills", + state: "CA", + taxId: "", + }, + }); + + await expect(component["submit"]()).rejects.toThrow("Payment method is required"); + }); }); it("should map plan id to correct upgrade status", () => { @@ -493,167 +658,46 @@ describe("PremiumOrgUpgradePaymentComponent", () => { }); }); - describe("processUpgrade", () => { + describe("isFormValid", () => { beforeEach(() => { - // Set up mocks specific to processUpgrade tests - mockPremiumOrgUpgradeService.upgradeToOrganization.mockResolvedValue("org-id-123"); + component["formGroup"].patchValue({ + organizationName: "Test Org", + billingAddress: { country: "US", postalCode: "12345" }, + }); + }); + + it("should return true when existing payment method is a card", () => { component["paymentMethod"].set({ type: "card", brand: "visa", last4: "4242", expiration: "12/2025", }); + + expect(component["isFormValid"]()).toBe(true); }); - it("should throw error when billing address is incomplete", async () => { - component["formGroup"].patchValue({ - organizationName: "Test Org", - billingAddress: { - country: "", - postalCode: "", - }, - }); + it("should return true when existing payment method is a bank account (blocked in submit)", () => { + component["paymentMethod"].set({ + type: "bankAccount", + bankName: "Chase", + last4: "1234", + } as any); - await expect(component["processUpgrade"]()).rejects.toThrow("Billing address is incomplete"); + expect(component["isFormValid"]()).toBe(true); }); - it("should throw error when organization name is missing", async () => { - component["formGroup"].patchValue({ - organizationName: "", - billingAddress: { - country: "US", - postalCode: "12345", - }, - }); - - await expect(component["processUpgrade"]()).rejects.toThrow("Organization name is required"); - }); - - it("should update payment method when isChangingPayment returns true", async () => { + it("should defer to payment method component form validity when changing payment", () => { const mockPaymentMethodComponent = { isChangingPayment: jest.fn().mockReturnValue(true), - getTokenizedPaymentMethod: jest.fn().mockResolvedValue({ token: "new-token-123" }), + isFormValid: jest.fn().mockReturnValue(true), }; jest .spyOn(component, "paymentMethodComponent") .mockReturnValue(mockPaymentMethodComponent as any); - const mockSubscriber = { id: "subscriber-123" }; - component["subscriber"].set(mockSubscriber as any); - component["selectedPlan"].set({ - tier: "teams" as BusinessSubscriptionPricingTierId, - details: mockTeamsPlan, - cost: 48, - }); - - component["formGroup"].patchValue({ - organizationName: "Test Organization", - billingAddress: { - country: "US", - postalCode: "12345", - }, - }); - - const result = await component["processUpgrade"](); - - expect(mockPaymentMethodComponent.isChangingPayment).toHaveBeenCalled(); - expect(mockPaymentMethodComponent.getTokenizedPaymentMethod).toHaveBeenCalled(); - expect(mockSubscriberBillingClient.updatePaymentMethod).toHaveBeenCalledWith( - mockSubscriber, - { token: "new-token-123" }, - expect.objectContaining({ - country: "US", - postalCode: "12345", - }), - ); - expect(mockPremiumOrgUpgradeService.upgradeToOrganization).toHaveBeenCalledWith( - mockAccount, - "Test Organization", - "teams", - expect.objectContaining({ - country: "US", - postalCode: "12345", - }), - ); - expect(result.organizationId).toBe("org-id-123"); - }); - - it("should not update payment method when isChangingPayment returns false", async () => { - const mockPaymentMethodComponent = { - isChangingPayment: jest.fn().mockReturnValue(false), - getTokenizedPaymentMethod: jest.fn(), - }; - jest - .spyOn(component, "paymentMethodComponent") - .mockReturnValue(mockPaymentMethodComponent as any); - component["selectedPlan"].set({ - tier: "teams" as BusinessSubscriptionPricingTierId, - details: mockTeamsPlan, - cost: 48, - }); - - component["formGroup"].patchValue({ - organizationName: "Test Organization", - billingAddress: { - country: "US", - postalCode: "12345", - }, - }); - - await component["processUpgrade"](); - - expect(mockPaymentMethodComponent.isChangingPayment).toHaveBeenCalled(); - expect(mockPaymentMethodComponent.getTokenizedPaymentMethod).not.toHaveBeenCalled(); - expect(mockSubscriberBillingClient.updatePaymentMethod).not.toHaveBeenCalled(); - expect(mockPremiumOrgUpgradeService.upgradeToOrganization).toHaveBeenCalled(); - }); - - it("should handle null paymentMethodComponent gracefully", async () => { - jest.spyOn(component, "paymentMethodComponent").mockReturnValue(null as any); - component["selectedPlan"].set({ - tier: "teams" as BusinessSubscriptionPricingTierId, - details: mockTeamsPlan, - cost: 48, - }); - - component["formGroup"].patchValue({ - organizationName: "Test Organization", - billingAddress: { - country: "US", - postalCode: "12345", - }, - }); - - await component["processUpgrade"](); - - expect(mockSubscriberBillingClient.updatePaymentMethod).not.toHaveBeenCalled(); - expect(mockPremiumOrgUpgradeService.upgradeToOrganization).toHaveBeenCalled(); - }); - - it("should throw error when payment method is null and user is not changing payment", async () => { - const mockPaymentMethodComponent = { - isChangingPayment: jest.fn().mockReturnValue(false), - getTokenizedPaymentMethod: jest.fn(), - }; - jest - .spyOn(component, "paymentMethodComponent") - .mockReturnValue(mockPaymentMethodComponent as any); - component["paymentMethod"].set(null); - component["selectedPlan"].set({ - tier: "teams" as BusinessSubscriptionPricingTierId, - details: mockTeamsPlan, - cost: 48, - }); - - component["formGroup"].patchValue({ - organizationName: "Test Organization", - billingAddress: { - country: "US", - postalCode: "12345", - }, - }); - - await expect(component["processUpgrade"]()).rejects.toThrow("Payment method is required"); + expect(component["isFormValid"]()).toBe(true); + expect(mockPaymentMethodComponent.isFormValid).toHaveBeenCalled(); }); }); @@ -695,7 +739,9 @@ describe("PremiumOrgUpgradePaymentComponent", () => { describe("Error Handling", () => { it("should log error and continue when submit fails", async () => { - jest.spyOn(component as any, "processUpgrade").mockRejectedValue(new Error("Network error")); + const networkError = new Error("Network error"); + jest.spyOn(component as any, "processUpgrade").mockRejectedValue(networkError); + mockPremiumOrgUpgradeService.isBankAccountNotSupportedError.mockReturnValue(false); component["formGroup"].setValue({ organizationName: "My New Org", @@ -725,7 +771,7 @@ describe("PremiumOrgUpgradePaymentComponent", () => { await component["submit"](); - expect(mockLogService.error).toHaveBeenCalledWith("Upgrade failed:", expect.any(Error)); + expect(mockLogService.error).toHaveBeenCalledWith("Upgrade failed:", networkError); expect(mockToastService.showToast).toHaveBeenCalledWith({ variant: "error", message: "upgradeErrorMessage", diff --git a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.ts b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.ts index 881bc7e4889..54f232b8215 100644 --- a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/premium-org-upgrade-payment.component.ts @@ -273,12 +273,45 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit return; } + const paymentMethodComponent = this.paymentMethodComponent(); + const isChangingPayment = paymentMethodComponent?.isChangingPayment(); + const paymentMethod = this.paymentMethod(); + + if ( + !isChangingPayment && + this.premiumOrgUpgradeService.isUnverifiedBankAccount(paymentMethod) + ) { + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("unverifiedBankAccountNotSupportedForUpgrade"), + }); + return; + } + if (!this.selectedPlan()) { throw new Error("No plan selected"); } + const organizationName = this.formGroup.value?.organizationName; + if (!organizationName) { + throw new Error("Organization name is required"); + } + + const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress); + if (!billingAddress.country || !billingAddress.postalCode) { + throw new Error("Billing address is incomplete"); + } + + if (isChangingPayment) { + await this.updatePaymentMethod(billingAddress); + } else { + if (!paymentMethod) { + throw new Error("Payment method is required"); + } + } + try { - const result = await this.processUpgrade(); + const result = await this.processUpgrade(organizationName, billingAddress); this.toastService.showToast({ variant: "success", message: this.i18nService.t("plansUpdated", this.selectedPlan()?.details.name), @@ -286,6 +319,13 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit this.complete.emit(result); } catch (error: unknown) { this.logService.error("Upgrade failed:", error); + if (this.premiumOrgUpgradeService.isBankAccountNotSupportedError(error)) { + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("unverifiedBankAccountNotSupportedForUpgrade"), + }); + return; + } this.toastService.showToast({ variant: "error", message: this.i18nService.t("upgradeErrorMessage"), @@ -293,31 +333,10 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit } }; - private async processUpgrade(): Promise { - const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress); - const organizationName = this.formGroup.value?.organizationName; - if (!billingAddress.country || !billingAddress.postalCode) { - throw new Error("Billing address is incomplete"); - } - - const paymentMethodComponent = this.paymentMethodComponent(); - // If the user is changing their payment method, process that first - if (paymentMethodComponent && paymentMethodComponent.isChangingPayment()) { - const newPaymentMethod = await paymentMethodComponent.getTokenizedPaymentMethod(); - await this.subscriberBillingClient.updatePaymentMethod( - this.subscriber()!, - newPaymentMethod, - billingAddress, - ); - } else if (!this.paymentMethod()) { - // If user is not changing payment method but has no payment method on file - throw new Error("Payment method is required"); - } - - if (!organizationName) { - throw new Error("Organization name is required"); - } - + private async processUpgrade( + organizationName: string, + billingAddress: ReturnType, + ): Promise { const organizationId = await this.premiumOrgUpgradeService.upgradeToOrganization( this.account()!, organizationName, @@ -335,6 +354,21 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit return this.UPGRADE_STATUS_MAP[planId] ?? PremiumOrgUpgradePaymentStatus.Closed; } + /** + * Updates the payment method with the new tokenized payment from the payment method component. + */ + private async updatePaymentMethod( + billingAddress: ReturnType, + ): Promise { + const paymentMethodComponent = this.paymentMethodComponent(); + const newPaymentMethod = await paymentMethodComponent.getTokenizedPaymentMethod(); + await this.subscriberBillingClient.updatePaymentMethod( + this.subscriber()!, + newPaymentMethod, + billingAddress, + ); + } + /** * Gets the appropriate translation key for the membership display. * Returns a prorated message if the plan has prorated months, otherwise returns the standard plan message. diff --git a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/services/premium-org-upgrade.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/services/premium-org-upgrade.service.spec.ts index 5cabcc04901..175c80e68ff 100644 --- a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/services/premium-org-upgrade.service.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/services/premium-org-upgrade.service.spec.ts @@ -15,9 +15,11 @@ import { KeyService } from "@bitwarden/key-management"; import { AccountBillingClient } from "../../../../clients/account-billing.client"; import { PreviewInvoiceClient } from "../../../../clients/preview-invoice.client"; +import { SubscriberBillingClient } from "../../../../clients/subscriber-billing.client"; import { BillingAddress } from "../../../../payment/types"; import { + UNVERIFIED_BANK_ACCOUNT_MESSAGE, PremiumOrgUpgradePlanDetails, PremiumOrgUpgradeService, } from "./premium-org-upgrade.service"; @@ -26,6 +28,7 @@ describe("PremiumOrgUpgradeService", () => { let service: PremiumOrgUpgradeService; let accountBillingClient: jest.Mocked; let previewInvoiceClient: jest.Mocked; + let subscriberBillingClient: jest.Mocked; let syncService: jest.Mocked; let keyService: jest.Mocked; let encryptService: jest.Mocked; @@ -62,6 +65,14 @@ describe("PremiumOrgUpgradeService", () => { .fn() .mockResolvedValue({ tax: 5, total: 55, credit: 0 }), } as any; + subscriberBillingClient = { + getPaymentMethod: jest.fn().mockResolvedValue({ + type: "card", + brand: "visa", + last4: "4242", + expiration: "12/2025", + }), + } as any; syncService = { fullSync: jest.fn().mockResolvedValue(undefined), } as any; @@ -85,6 +96,7 @@ describe("PremiumOrgUpgradeService", () => { PremiumOrgUpgradeService, { provide: AccountBillingClient, useValue: accountBillingClient }, { provide: PreviewInvoiceClient, useValue: previewInvoiceClient }, + { provide: SubscriberBillingClient, useValue: subscriberBillingClient }, { provide: SyncService, useValue: syncService }, { provide: AccountService, useValue: { activeAccount$: of(mockAccount) } }, { provide: KeyService, useValue: keyService }, @@ -125,6 +137,58 @@ describe("PremiumOrgUpgradeService", () => { expect(result).toBe("new-org-id"); }); + it("should throw an error when payment method is an unverified bank account", async () => { + subscriberBillingClient.getPaymentMethod.mockResolvedValue({ + type: "bankAccount", + bankName: "Chase", + last4: "1234", + hostedVerificationUrl: "https://stripe.com/verify", + } as any); + + await expect( + service.upgradeToOrganization( + mockAccount, + "Test Organization", + mockPlanDetails.tier, + mockBillingAddress, + ), + ).rejects.toThrow(UNVERIFIED_BANK_ACCOUNT_MESSAGE); + + expect(accountBillingClient.upgradePremiumToOrganization).not.toHaveBeenCalled(); + }); + + it("should proceed when payment method is a verified bank account", async () => { + subscriberBillingClient.getPaymentMethod.mockResolvedValue({ + type: "bankAccount", + bankName: "Chase", + last4: "1234", + } as any); + + const result = await service.upgradeToOrganization( + mockAccount, + "Test Organization", + mockPlanDetails.tier, + mockBillingAddress, + ); + + expect(result).toBe("new-org-id"); + expect(accountBillingClient.upgradePremiumToOrganization).toHaveBeenCalled(); + }); + + it("should proceed when payment method is null", async () => { + subscriberBillingClient.getPaymentMethod.mockResolvedValue(null); + + const result = await service.upgradeToOrganization( + mockAccount, + "Test Organization", + mockPlanDetails.tier, + mockBillingAddress, + ); + + expect(result).toBe("new-org-id"); + expect(accountBillingClient.upgradePremiumToOrganization).toHaveBeenCalled(); + }); + it("should throw an error if organization name is missing", async () => { await expect( service.upgradeToOrganization(mockAccount, "", mockPlanDetails.tier, mockBillingAddress), @@ -344,4 +408,77 @@ describe("PremiumOrgUpgradeService", () => { ).rejects.toThrow("Invoice API error"); }); }); + + describe("isBankAccountNotSupportedError", () => { + it("should return true when error is an unverified bank account not supported error", () => { + const error = new Error(UNVERIFIED_BANK_ACCOUNT_MESSAGE); + + expect(service.isBankAccountNotSupportedError(error)).toBe(true); + }); + + it("should return false when error is an Error but with different message", () => { + const error = new Error("Some other error message"); + + expect(service.isBankAccountNotSupportedError(error)).toBe(false); + }); + + it("should return false when error is not an Error instance", () => { + const error = "string error"; + + expect(service.isBankAccountNotSupportedError(error)).toBe(false); + }); + + it("should return false when error is null", () => { + expect(service.isBankAccountNotSupportedError(null)).toBe(false); + }); + }); + + describe("isUnverifiedBankAccount", () => { + it("should return true when payment method is a bank account with hostedVerificationUrl", () => { + const paymentMethod = { + type: "bankAccount", + bankName: "Chase", + last4: "1234", + hostedVerificationUrl: "https://stripe.com/verify", + } as any; + + expect(service.isUnverifiedBankAccount(paymentMethod)).toBe(true); + }); + + it("should return false when payment method is a bank account without hostedVerificationUrl", () => { + const paymentMethod = { + type: "bankAccount", + bankName: "Chase", + last4: "1234", + } as any; + + expect(service.isUnverifiedBankAccount(paymentMethod)).toBe(false); + }); + + it("should return false when payment method is a bank account with empty hostedVerificationUrl", () => { + const paymentMethod = { + type: "bankAccount", + bankName: "Chase", + last4: "1234", + hostedVerificationUrl: "", + } as any; + + expect(service.isUnverifiedBankAccount(paymentMethod)).toBe(false); + }); + + it("should return false when payment method is not a bank account", () => { + const paymentMethod = { + type: "card", + brand: "visa", + last4: "4242", + expiration: "12/2025", + } as any; + + expect(service.isUnverifiedBankAccount(paymentMethod)).toBe(false); + }); + + it("should return false when payment method is null", () => { + expect(service.isUnverifiedBankAccount(null)).toBe(false); + }); + }); }); diff --git a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/services/premium-org-upgrade.service.ts b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/services/premium-org-upgrade.service.ts index f57db2aee0a..f3e583d1b93 100644 --- a/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/services/premium-org-upgrade.service.ts +++ b/apps/web/src/app/billing/individual/upgrade/premium-org-upgrade-payment/services/premium-org-upgrade.service.ts @@ -20,8 +20,19 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv import { KeyService } from "@bitwarden/key-management"; import { UserId } from "@bitwarden/user-core"; -import { AccountBillingClient, PreviewInvoiceClient } from "../../../../clients"; -import { BillingAddress } from "../../../../payment/types"; +import { + AccountBillingClient, + PreviewInvoiceClient, + SubscriberBillingClient, +} from "../../../../clients"; +import { + BillingAddress, + MaskedPaymentMethod, + TokenizablePaymentMethods, +} from "../../../../payment/types"; + +export const UNVERIFIED_BANK_ACCOUNT_MESSAGE = + "Unverified bank account payment method is not supported for this upgrade"; export type PremiumOrgUpgradePlanDetails = { tier: PersonalSubscriptionPricingTierId | BusinessSubscriptionPricingTierId; @@ -51,6 +62,7 @@ export class PremiumOrgUpgradeService { constructor( private accountBillingClient: AccountBillingClient, private previewInvoiceClient: PreviewInvoiceClient, + private subscriberBillingClient: SubscriberBillingClient, private keyService: KeyService, private i18nService: I18nService, private encryptService: EncryptService, @@ -89,6 +101,15 @@ export class PremiumOrgUpgradeService { throw new Error("Billing address information is incomplete"); } + const paymentMethod = await this.subscriberBillingClient.getPaymentMethod({ + type: "account", + data: account, + }); + + if (this.isUnverifiedBankAccount(paymentMethod)) { + throw new Error(UNVERIFIED_BANK_ACCOUNT_MESSAGE); + } + const productTier: ProductTierType = this.ProductTierTypeFromSubscriptionTierId(tier); const encryptionData = await this.generateOrganizationEncryptionData(account.id); @@ -108,6 +129,17 @@ export class PremiumOrgUpgradeService { return orgId; } + isUnverifiedBankAccount(paymentMethod: MaskedPaymentMethod | null): boolean { + return ( + paymentMethod?.type === TokenizablePaymentMethods.bankAccount && + !!paymentMethod.hostedVerificationUrl + ); + } + + isBankAccountNotSupportedError(error: unknown): boolean { + return error instanceof Error && error.message === UNVERIFIED_BANK_ACCOUNT_MESSAGE; + } + private ProductTierTypeFromSubscriptionTierId( tierId: PersonalSubscriptionPricingTierId | BusinessSubscriptionPricingTierId, ): ProductTierType { diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.html b/apps/web/src/app/billing/organizations/organization-plans.component.html index 1cb9e1c9dad..9452715cbdf 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.html +++ b/apps/web/src/app/billing/organizations/organization-plans.component.html @@ -397,6 +397,7 @@ } { postalCode: "12345", }), updatePaymentMethod: jest.fn().mockResolvedValue(undefined), + getPaymentMethod: jest.fn().mockResolvedValue({ + type: "card", + brand: "visa", + last4: "4242", + expiration: "12/2025", + }), } as any; mockPreviewInvoiceClient = { @@ -552,6 +558,7 @@ describe("OrganizationPlansComponent", () => { } throw new Error(`Unsupported product tier: ${productTier}`); }), + isBankAccountNotSupportedError: jest.fn().mockReturnValue(false), } as any; mockDiscountSubject = new Subject(); @@ -2316,10 +2323,16 @@ describe("OrganizationPlansComponent", () => { }); it("should call premiumOrgUpgradeService.upgradeToOrganization() instead of create()", async () => { + setupMockPaymentMethodComponent(component, "mock_token", "card"); organizationsSubject.next([{ id: newOrgId, name: newOrgName, isOwner: true } as any]); await component.submit(); + expect(mockSubscriberBillingClient.updatePaymentMethod).toHaveBeenCalledWith( + expect.objectContaining({ type: "account" }), + { token: "mock_token", type: "card" }, + expect.objectContaining({ country: "US", postalCode: "12345" }), + ); expect(mockPremiumOrgUpgradeService.upgradeToOrganization).toHaveBeenCalledWith( expect.objectContaining({ id: "user-id" }), newOrgName, @@ -2334,7 +2347,36 @@ describe("OrganizationPlansComponent", () => { }); }); + it("should not call upgradeToOrganization when tokenization fails", async () => { + setupMockPaymentMethodComponent(component); // Simulates tokenization failure (returns null) + + await component.submit(); + + expect(mockPremiumOrgUpgradeService.upgradeToOrganization).not.toHaveBeenCalled(); + }); + + it("should show an error toast when the service rejects an unverified bank account payment method", async () => { + setupMockPaymentMethodComponent(component, "mock_token", "card"); + organizationsSubject.next([{ id: newOrgId, name: newOrgName, isOwner: true } as any]); + const bankAccountError = new Error( + "Unverified bank account payment method is not supported for this upgrade", + ); + mockPremiumOrgUpgradeService.upgradeToOrganization.mockRejectedValue(bankAccountError); + mockPremiumOrgUpgradeService.isBankAccountNotSupportedError.mockReturnValue(true); + + await component.submit(); + + expect(mockPremiumOrgUpgradeService.isBankAccountNotSupportedError).toHaveBeenCalledWith( + bankAccountError, + ); + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + message: "unverifiedBankAccountNotSupportedForUpgrade", + }); + }); + it("should navigate to the new org and show success toast after premium upgrade", async () => { + setupMockPaymentMethodComponent(component, "mock_token", "card"); organizationsSubject.next([{ id: newOrgId, name: newOrgName, isOwner: true } as any]); await component.submit(); diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.ts b/apps/web/src/app/billing/organizations/organization-plans.component.ts index 536be3038ea..7171a448494 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.ts +++ b/apps/web/src/app/billing/organizations/organization-plans.component.ts @@ -837,6 +837,13 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { if (error instanceof Error && error.message === "Payment method validation failed") { return; } + if (this.premiumOrgUpgradeService.isBankAccountNotSupportedError(error)) { + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("unverifiedBankAccountNotSupportedForUpgrade"), + }); + return; + } if (this.subscriptionDiscountService.isDiscountExpiredError(error)) { this.subscriptionDiscountService.refresh(); this.toastService.showToast({ @@ -1316,6 +1323,21 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { const tier = this.premiumOrgUpgradeService.SubscriptionTierIdFromProductTier( this.formGroup.controls.productTier.value!, ); + + const paymentMethod = await this.enterPaymentMethodComponent()?.tokenize(); + if (!paymentMethod) { + throw new Error("Payment method validation failed"); + } + + await this.subscriberBillingClient.updatePaymentMethod( + { type: "account", data: account }, + paymentMethod, + { + country: this.billingFormGroup.value.billingAddress?.country ?? "", + postalCode: this.billingFormGroup.value.billingAddress?.postalCode ?? "", + }, + ); + return await this.premiumOrgUpgradeService.upgradeToOrganization( account!, organizationName, diff --git a/apps/web/src/app/billing/payment/components/display-payment-method-inline.component.ts b/apps/web/src/app/billing/payment/components/display-payment-method-inline.component.ts index 11e0bdaef34..0d5f5d52715 100644 --- a/apps/web/src/app/billing/payment/components/display-payment-method-inline.component.ts +++ b/apps/web/src/app/billing/payment/components/display-payment-method-inline.component.ts @@ -93,7 +93,7 @@ import { EnterPaymentMethodComponent } from "./enter-payment-method.component"; #enterPaymentMethodComponent [includeBillingAddress]="false" [group]="formGroup" - [showBankAccount]="true" + [showBankAccount]="showBankAccountOption()" [showAccountCredit]="false" > @@ -123,6 +123,7 @@ export class DisplayPaymentMethodInlineComponent { readonly subscriber = input.required(); readonly paymentMethod = input.required(); readonly externalFormGroup = input(null); + readonly showBankAccountOption = input(false); readonly updated = output(); diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index a912c3c90b7..39d3651ef27 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -13256,5 +13256,8 @@ "example": "4" } } + }, + "unverifiedBankAccountNotSupportedForUpgrade": { + "message": "Your bank account needs to be verified before it can be used for this upgrade. Please verify your bank account or choose a different payment method." } } \ No newline at end of file