[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
This commit is contained in:
Stephon Brown 2026-04-10 11:23:27 -04:00 committed by GitHub
parent 3aa627c724
commit 542eeeafc8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 492 additions and 173 deletions

View File

@ -20,6 +20,7 @@
[subscriber]="subscriber()"
[paymentMethod]="paymentMethod()"
[externalFormGroup]="formGroup.controls.paymentMethodForm"
[showBankAccountOption]="false"
>
</app-display-payment-method-inline>
<h5 bitTypography="h5" class="tw-pt-4 tw-pb-2">{{ "billingAddress" | i18n }}</h5>

View File

@ -60,6 +60,7 @@ class MockDisplayPaymentMethodInlineComponent {
readonly subscriber = input.required<any>();
readonly paymentMethod = input<any>();
readonly externalFormGroup = input<any>();
readonly showBankAccountOption = input<boolean>(false);
readonly updated = output<any>();
readonly changePaymentMethodClicked = output<void>();
@ -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",

View File

@ -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<PremiumOrgUpgradePaymentResult> {
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<typeof getBillingAddressFromForm>,
): Promise<PremiumOrgUpgradePaymentResult> {
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<typeof getBillingAddressFromForm>,
): Promise<void> {
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.

View File

@ -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<AccountBillingClient>;
let previewInvoiceClient: jest.Mocked<PreviewInvoiceClient>;
let subscriberBillingClient: jest.Mocked<SubscriberBillingClient>;
let syncService: jest.Mocked<SyncService>;
let keyService: jest.Mocked<KeyService>;
let encryptService: jest.Mocked<EncryptService>;
@ -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);
});
});
});

View File

@ -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 {

View File

@ -397,6 +397,7 @@
<app-enter-payment-method
#enterPaymentMethodComponent
[group]="billingFormGroup.controls.paymentMethod"
[showBankAccount]="!upgradingFromPremium"
></app-enter-payment-method>
}
<app-enter-billing-address

View File

@ -502,6 +502,12 @@ describe("OrganizationPlansComponent", () => {
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<SubscriptionDiscount[]>();
@ -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();

View File

@ -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,

View File

@ -93,7 +93,7 @@ import { EnterPaymentMethodComponent } from "./enter-payment-method.component";
#enterPaymentMethodComponent
[includeBillingAddress]="false"
[group]="formGroup"
[showBankAccount]="true"
[showBankAccount]="showBankAccountOption()"
[showAccountCredit]="false"
>
</app-enter-payment-method>
@ -123,6 +123,7 @@ export class DisplayPaymentMethodInlineComponent {
readonly subscriber = input.required<BitwardenSubscriber>();
readonly paymentMethod = input.required<MaskedPaymentMethod | null>();
readonly externalFormGroup = input<FormGroup | null>(null);
readonly showBankAccountOption = input<boolean>(false);
readonly updated = output<MaskedPaymentMethod>();

View File

@ -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."
}
}