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