mirror of
https://github.com/bitwarden/clients.git
synced 2026-07-07 21:10:55 +08:00
[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:
parent
3aa627c724
commit
542eeeafc8
@ -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>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -397,6 +397,7 @@
|
||||
<app-enter-payment-method
|
||||
#enterPaymentMethodComponent
|
||||
[group]="billingFormGroup.controls.paymentMethod"
|
||||
[showBankAccount]="!upgradingFromPremium"
|
||||
></app-enter-payment-method>
|
||||
}
|
||||
<app-enter-billing-address
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>();
|
||||
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user