mirror of
https://github.com/bitwarden/browser
synced 2026-01-29 15:53:45 +00:00
fix(billing): update tests and logic
update update fix
This commit is contained in:
@@ -32,9 +32,6 @@
|
||||
|
||||
<section>
|
||||
<billing-cart-summary #cartSummaryComponent [cart]="cart()"></billing-cart-summary>
|
||||
<p bitTypography="helper" class="tw-italic tw-text-muted !tw-mb-0">
|
||||
{{ "paymentChargedWithTrial" | i18n }}
|
||||
</p>
|
||||
</section>
|
||||
</ng-container>
|
||||
|
||||
|
||||
@@ -146,6 +146,7 @@ describe("PremiumOrgUpgradePaymentComponent", () => {
|
||||
const mockAccountBillingClient = mock<AccountBillingClient>();
|
||||
const mockPreviewInvoiceClient = mock<PreviewInvoiceClient>();
|
||||
const mockLogService = mock<LogService>();
|
||||
const mockI18nService = { t: jest.fn((key: string, ...params: any[]) => key) };
|
||||
|
||||
const mockAccount = { id: "user-id", email: "test@bitwarden.com" } as Account;
|
||||
const mockTeamsPlan: BusinessSubscriptionPricingTier = {
|
||||
@@ -181,6 +182,12 @@ describe("PremiumOrgUpgradePaymentComponent", () => {
|
||||
jest.clearAllMocks();
|
||||
mockAccountBillingClient.upgradePremiumToOrganization.mockResolvedValue(undefined);
|
||||
mockPremiumOrgUpgradeService.upgradeToOrganization.mockResolvedValue(undefined);
|
||||
mockPremiumOrgUpgradeService.previewProratedInvoice.mockResolvedValue({
|
||||
tax: 5.0,
|
||||
total: 53.0,
|
||||
credit: 10.0,
|
||||
proratedAmountOfMonths: 1,
|
||||
});
|
||||
|
||||
mockSubscriptionPricingService.getBusinessSubscriptionPricingTiers$.mockReturnValue(
|
||||
of([mockTeamsPlan]),
|
||||
@@ -199,7 +206,7 @@ describe("PremiumOrgUpgradePaymentComponent", () => {
|
||||
},
|
||||
{ provide: ToastService, useValue: mockToastService },
|
||||
{ provide: LogService, useValue: mockLogService },
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: AccountBillingClient, useValue: mockAccountBillingClient },
|
||||
{ provide: PreviewInvoiceClient, useValue: mockPreviewInvoiceClient },
|
||||
{
|
||||
@@ -251,7 +258,7 @@ describe("PremiumOrgUpgradePaymentComponent", () => {
|
||||
it("should initialize with the correct plan details", () => {
|
||||
expect(component["selectedPlan"]()).not.toBeNull();
|
||||
expect(component["selectedPlan"]()?.details.id).toBe("teams");
|
||||
expect(component["upgradeToMessage"]()).toContain("startFreeTrial");
|
||||
expect(component["upgradeToMessage"]()).toContain("upgradeToTeams");
|
||||
});
|
||||
|
||||
it("should handle invalid plan id that doesn't exist in pricing tiers", async () => {
|
||||
@@ -406,4 +413,213 @@ describe("PremiumOrgUpgradePaymentComponent", () => {
|
||||
PremiumOrgUpgradePaymentStatus.Closed,
|
||||
);
|
||||
});
|
||||
|
||||
describe("Invoice Preview", () => {
|
||||
it("should return zero values when billing address is incomplete", fakeAsync(() => {
|
||||
component["formGroup"].patchValue({
|
||||
organizationName: "Test Org",
|
||||
billingAddress: {
|
||||
country: "US",
|
||||
postalCode: "", // Missing postal code
|
||||
},
|
||||
});
|
||||
|
||||
tick(1000);
|
||||
fixture.detectChanges();
|
||||
|
||||
const estimatedInvoice = component["estimatedInvoice"]();
|
||||
expect(estimatedInvoice.tax).toBe(0);
|
||||
expect(estimatedInvoice.total).toBe(0);
|
||||
}));
|
||||
});
|
||||
|
||||
describe("Form Validation", () => {
|
||||
it("should validate organization name is required", () => {
|
||||
component["formGroup"].patchValue({ organizationName: "" });
|
||||
expect(component["formGroup"].get("organizationName")?.invalid).toBe(true);
|
||||
});
|
||||
|
||||
it("should validate organization name when provided", () => {
|
||||
component["formGroup"].patchValue({ organizationName: "My Organization" });
|
||||
expect(component["formGroup"].get("organizationName")?.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when payment component validation fails", () => {
|
||||
component["formGroup"].patchValue({
|
||||
organizationName: "Test Org",
|
||||
billingAddress: {
|
||||
country: "US",
|
||||
postalCode: "12345",
|
||||
},
|
||||
});
|
||||
|
||||
const mockPaymentComponent = {
|
||||
validate: jest.fn().mockReturnValue(false),
|
||||
} as any;
|
||||
jest.spyOn(component, "paymentComponent").mockReturnValue(mockPaymentComponent);
|
||||
|
||||
expect(component["isFormValid"]()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cart Calculation", () => {
|
||||
it("should calculate cart with correct values for selected plan", () => {
|
||||
const cart = component["cart"]();
|
||||
expect(cart.passwordManager.seats.cost).toBe(48); // Teams annual price per user
|
||||
expect(cart.passwordManager.seats.quantity).toBe(1);
|
||||
expect(cart.cadence).toBe("annually");
|
||||
});
|
||||
|
||||
it("should return default cart when no plan is selected", () => {
|
||||
component["selectedPlan"].set(null);
|
||||
const cart = component["cart"]();
|
||||
|
||||
expect(cart.passwordManager.seats.cost).toBe(0);
|
||||
expect(cart.passwordManager.seats.quantity).toBe(0);
|
||||
expect(cart.estimatedTax).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ngAfterViewInit", () => {
|
||||
it("should collapse cart summary after view init", () => {
|
||||
const mockCartSummary = {
|
||||
isExpanded: signal(true),
|
||||
} as any;
|
||||
jest.spyOn(component, "cartSummaryComponent").mockReturnValue(mockCartSummary);
|
||||
|
||||
component.ngAfterViewInit();
|
||||
|
||||
expect(mockCartSummary.isExpanded()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Plan Price Calculation", () => {
|
||||
it("should calculate price for personal plan with annualPrice", () => {
|
||||
const price = component["getPlanPrice"](mockFamiliesPlan);
|
||||
expect(price).toBe(40);
|
||||
});
|
||||
|
||||
it("should calculate price for business plan with annualPricePerUser", () => {
|
||||
const price = component["getPlanPrice"](mockTeamsPlan);
|
||||
expect(price).toBe(48);
|
||||
});
|
||||
|
||||
it("should return 0 when passwordManager is missing", () => {
|
||||
const invalidPlan = { ...mockTeamsPlan, passwordManager: undefined } as any;
|
||||
const price = component["getPlanPrice"](invalidPlan);
|
||||
expect(price).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("processUpgrade", () => {
|
||||
it("should throw error when billing address is incomplete", async () => {
|
||||
component["formGroup"].patchValue({
|
||||
organizationName: "Test Org",
|
||||
billingAddress: {
|
||||
country: "",
|
||||
postalCode: "",
|
||||
},
|
||||
});
|
||||
|
||||
const mockPaymentComponent = {
|
||||
tokenize: jest.fn().mockResolvedValue({ type: "card", token: "mock-token" }),
|
||||
} as any;
|
||||
jest.spyOn(component, "paymentComponent").mockReturnValue(mockPaymentComponent);
|
||||
|
||||
await expect(component["processUpgrade"]()).rejects.toThrow("Billing address is incomplete");
|
||||
});
|
||||
|
||||
it("should throw error when organization name is missing", async () => {
|
||||
component["formGroup"].patchValue({
|
||||
organizationName: "",
|
||||
billingAddress: {
|
||||
country: "US",
|
||||
postalCode: "12345",
|
||||
},
|
||||
});
|
||||
|
||||
const mockPaymentComponent = {
|
||||
tokenize: jest.fn().mockResolvedValue({ type: "card", token: "mock-token" }),
|
||||
} as any;
|
||||
jest.spyOn(component, "paymentComponent").mockReturnValue(mockPaymentComponent);
|
||||
|
||||
await expect(component["processUpgrade"]()).rejects.toThrow("Organization name is required");
|
||||
});
|
||||
|
||||
it("should throw error when payment method tokenization fails", async () => {
|
||||
component["formGroup"].patchValue({
|
||||
organizationName: "Test Org",
|
||||
billingAddress: {
|
||||
country: "US",
|
||||
postalCode: "12345",
|
||||
},
|
||||
});
|
||||
|
||||
const mockPaymentComponent = {
|
||||
tokenize: jest.fn().mockResolvedValue(null),
|
||||
} as any;
|
||||
jest.spyOn(component, "paymentComponent").mockReturnValue(mockPaymentComponent);
|
||||
|
||||
await expect(component["processUpgrade"]()).rejects.toThrow("Payment method is required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Plan Membership Messages", () => {
|
||||
it("should return correct membership message for families plan", async () => {
|
||||
const newFixture = TestBed.createComponent(PremiumOrgUpgradePaymentComponent);
|
||||
const newComponent = newFixture.componentInstance;
|
||||
|
||||
newFixture.componentRef.setInput(
|
||||
"selectedPlanId",
|
||||
"families" as PersonalSubscriptionPricingTierId,
|
||||
);
|
||||
newFixture.componentRef.setInput("account", mockAccount);
|
||||
newFixture.detectChanges();
|
||||
await newFixture.whenStable();
|
||||
|
||||
expect(newComponent["planMembershipMessage"]()).toBe("familiesMembership");
|
||||
});
|
||||
|
||||
it("should return correct membership message for teams plan", () => {
|
||||
expect(component["planMembershipMessage"]()).toBe("teamsMembership");
|
||||
});
|
||||
|
||||
it("should return correct membership message for enterprise plan", async () => {
|
||||
const newFixture = TestBed.createComponent(PremiumOrgUpgradePaymentComponent);
|
||||
const newComponent = newFixture.componentInstance;
|
||||
|
||||
newFixture.componentRef.setInput(
|
||||
"selectedPlanId",
|
||||
"enterprise" as BusinessSubscriptionPricingTierId,
|
||||
);
|
||||
newFixture.componentRef.setInput("account", mockAccount);
|
||||
newFixture.detectChanges();
|
||||
await newFixture.whenStable();
|
||||
|
||||
expect(newComponent["planMembershipMessage"]()).toBe("enterpriseMembership");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should log error and continue when submit fails", async () => {
|
||||
jest.spyOn(component as any, "isFormValid").mockReturnValue(true);
|
||||
jest.spyOn(component as any, "processUpgrade").mockRejectedValue(new Error("Network error"));
|
||||
|
||||
await component["submit"]();
|
||||
|
||||
expect(mockLogService.error).toHaveBeenCalledWith("Upgrade failed:", expect.any(Error));
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
message: "upgradeErrorMessage",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("goBack Output", () => {
|
||||
it("should emit goBack event when back action is triggered", () => {
|
||||
const goBackSpy = jest.spyOn(component["goBack"], "emit");
|
||||
component["goBack"].emit();
|
||||
expect(goBackSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,18 +103,36 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit
|
||||
protected readonly selectedPlan = signal<PremiumOrgUpgradePlanDetails | null>(null);
|
||||
protected readonly loading = signal(true);
|
||||
protected readonly upgradeToMessage = signal("");
|
||||
protected readonly planMembershipMessage = computed<string>(() => {
|
||||
switch (this.selectedPlanId()) {
|
||||
case "families":
|
||||
return "familiesMembership";
|
||||
case "teams":
|
||||
return "teamsMembership";
|
||||
case "enterprise":
|
||||
return "enterpriseMembership";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
// Use defer to lazily create the observable when subscribed to
|
||||
protected estimatedInvoice$ = defer(() =>
|
||||
this.formGroup.controls.billingAddress.valueChanges.pipe(
|
||||
startWith(this.formGroup.controls.billingAddress.value),
|
||||
combineLatest([
|
||||
this.formGroup.controls.billingAddress.valueChanges,
|
||||
this.formGroup.controls.organizationName.valueChanges,
|
||||
]).pipe(
|
||||
startWith(
|
||||
this.formGroup.controls.billingAddress.value,
|
||||
this.formGroup.controls.organizationName.value,
|
||||
),
|
||||
debounceTime(1000),
|
||||
switchMap(() => this.refreshInvoicePreview$()),
|
||||
),
|
||||
);
|
||||
|
||||
protected readonly estimatedInvoice = toSignal(this.estimatedInvoice$, {
|
||||
initialValue: { tax: this.INITIAL_TAX_VALUE, total: 0, credit: 0 },
|
||||
initialValue: { tax: this.INITIAL_TAX_VALUE, total: 0, credit: 0, proratedAmountOfMonths: 0 },
|
||||
});
|
||||
|
||||
// Cart Summary data
|
||||
@@ -122,7 +140,12 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit
|
||||
if (!this.selectedPlan()) {
|
||||
return {
|
||||
passwordManager: {
|
||||
seats: { translationKey: "", cost: 0, quantity: 0 },
|
||||
seats: {
|
||||
translationKey: this.planMembershipMessage(),
|
||||
cost: 0,
|
||||
quantity: 0,
|
||||
hideBreakdown: true,
|
||||
},
|
||||
},
|
||||
cadence: this.DEFAULT_CADENCE,
|
||||
estimatedTax: this.INITIAL_TAX_VALUE,
|
||||
@@ -132,14 +155,30 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit
|
||||
return {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
translationKey: this.selectedPlan()?.details.name ?? "",
|
||||
translationKey:
|
||||
this.estimatedInvoice()?.proratedAmountOfMonths > 0
|
||||
? "planProratedMembershipInMonths"
|
||||
: this.planMembershipMessage(),
|
||||
translationParams:
|
||||
this.estimatedInvoice()?.proratedAmountOfMonths > 0
|
||||
? [
|
||||
this.selectedPlan()!.details.name,
|
||||
`${this.estimatedInvoice()?.proratedAmountOfMonths} month${this.estimatedInvoice()?.proratedAmountOfMonths > 1 ? "s" : ""}`,
|
||||
]
|
||||
: [],
|
||||
cost: this.selectedPlan()?.cost ?? 0,
|
||||
quantity: this.DEFAULT_SEAT_COUNT,
|
||||
hideBreakdown: true,
|
||||
},
|
||||
},
|
||||
cadence: this.DEFAULT_CADENCE,
|
||||
estimatedTax: this.estimatedInvoice().tax,
|
||||
discount: { type: "amount-off", value: this.estimatedInvoice().credit },
|
||||
discount: {
|
||||
type: "amount-off",
|
||||
value: this.estimatedInvoice().credit,
|
||||
translationKey: "premiumMembershipDiscount",
|
||||
quantity: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -183,7 +222,22 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit
|
||||
cost: this.getPlanPrice(planDetails),
|
||||
});
|
||||
|
||||
this.upgradeToMessage.set(this.i18nService.t("startFreeTrial", planDetails.name));
|
||||
switch (this.selectedPlanId()) {
|
||||
case "families":
|
||||
this.upgradeToMessage.set(this.i18nService.t("upgradeToFamilies", planDetails.name));
|
||||
break;
|
||||
case "teams":
|
||||
this.upgradeToMessage.set(this.i18nService.t("upgradeToTeams", planDetails.name));
|
||||
break;
|
||||
case "enterprise":
|
||||
this.upgradeToMessage.set(
|
||||
this.i18nService.t("upgradeToEnterprise", planDetails.name),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
this.upgradeToMessage.set("");
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
this.complete.emit({
|
||||
status: PremiumOrgUpgradePaymentStatus.Closed,
|
||||
@@ -215,7 +269,7 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit
|
||||
const result = await this.processUpgrade();
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("organizationUpdated", this.selectedPlan()?.details.name),
|
||||
message: this.i18nService.t("plansUpdated", this.selectedPlan()?.details.name),
|
||||
});
|
||||
this.complete.emit(result);
|
||||
} catch (error: unknown) {
|
||||
@@ -306,12 +360,12 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit
|
||||
*/
|
||||
private refreshInvoicePreview$(): Observable<InvoicePreview> {
|
||||
if (this.formGroup.invalid || !this.selectedPlan()) {
|
||||
return of({ tax: this.INITIAL_TAX_VALUE, total: 0, credit: 0 });
|
||||
return of({ tax: this.INITIAL_TAX_VALUE, total: 0, credit: 0, proratedAmountOfMonths: 0 });
|
||||
}
|
||||
|
||||
const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress);
|
||||
if (!billingAddress.country || !billingAddress.postalCode) {
|
||||
return of({ tax: this.INITIAL_TAX_VALUE, total: 0, credit: 0 });
|
||||
return of({ tax: this.INITIAL_TAX_VALUE, total: 0, credit: 0, proratedAmountOfMonths: 0 });
|
||||
}
|
||||
return from(
|
||||
this.premiumOrgUpgradeService.previewProratedInvoice(this.selectedPlan()!, billingAddress),
|
||||
@@ -322,7 +376,7 @@ export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit
|
||||
variant: "error",
|
||||
message: this.i18nService.t("invoicePreviewErrorMessage"),
|
||||
});
|
||||
return of({ tax: this.INITIAL_TAX_VALUE, total: 0, credit: 0 });
|
||||
return of({ tax: this.INITIAL_TAX_VALUE, total: 0, credit: 0, proratedAmountOfMonths: 0 });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,9 @@ describe("PremiumOrgUpgradeService", () => {
|
||||
fullSync: jest.fn().mockResolvedValue(undefined),
|
||||
} as any;
|
||||
keyService = {
|
||||
makeOrgKey: jest.fn().mockResolvedValue(["encrypted-key", "decrypted-key"]),
|
||||
makeOrgKey: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ encryptedString: "encrypted-string" }, "decrypted-key"]),
|
||||
} as any;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
@@ -85,7 +87,7 @@ describe("PremiumOrgUpgradeService", () => {
|
||||
|
||||
expect(accountBillingClient.upgradePremiumToOrganization).toHaveBeenCalledWith(
|
||||
"Test Organization",
|
||||
"encrypted-key",
|
||||
"encrypted-string",
|
||||
2, // ProductTierType.Teams
|
||||
"annually",
|
||||
mockBillingAddress,
|
||||
|
||||
@@ -20,6 +20,7 @@ export type PremiumOrgUpgradePlanDetails = {
|
||||
tier: PersonalSubscriptionPricingTierId | BusinessSubscriptionPricingTierId;
|
||||
details: PersonalSubscriptionPricingTier | BusinessSubscriptionPricingTier;
|
||||
cost: number;
|
||||
proratedAmount?: number;
|
||||
};
|
||||
|
||||
export type PaymentFormValues = {
|
||||
@@ -34,6 +35,7 @@ export interface InvoicePreview {
|
||||
tax: number;
|
||||
total: number;
|
||||
credit: number;
|
||||
proratedAmountOfMonths: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -58,6 +60,7 @@ export class PremiumOrgUpgradeService {
|
||||
tax: invoicePreviewResponse.tax,
|
||||
total: invoicePreviewResponse.total,
|
||||
credit: invoicePreviewResponse.credit,
|
||||
proratedAmountOfMonths: invoicePreviewResponse.proratedAmountOfMonths,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,7 +83,7 @@ export class PremiumOrgUpgradeService {
|
||||
|
||||
await this.accountBillingClient.upgradePremiumToOrganization(
|
||||
organizationName,
|
||||
encryptedKey,
|
||||
encryptedKey.encryptedString,
|
||||
tier,
|
||||
SubscriptionCadenceIds.Annually,
|
||||
billingAddress,
|
||||
|
||||
Reference in New Issue
Block a user