1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

Billing/pm 24996/implement upgrade from free dialog (#16470)

* feat(billing): add required messages

* feat(billing): Add upgrade from free account dialog

* feat(billing): Add payment dialog for premium upgrade

* feat(billing): Add Upgrade Payment Service

* feat(billing): Add Upgrade flow service

* feat(billing): Add purchase premium subscription method to client

* fix(billing): allow for nullable taxId for families organizations

* fix(billing): Fix Cart Summary Tests

* temp-fix(billing): add currency pipe to pricing card component

* fix(billing): Fix NX error

This should compile just the library files and not its dependency files which was making it error

* fix: Update any type of private function

* update account dialog

* feat(billing): add upgrade error message

* fix(billing): remove upgrade-flow service

* feat(billing): add account billing client

* fix(billing): Remove method from subscriber-billing client

* fix(billing): rename and update upgrade payment component

* fix(billing): Rename and update upgrade payment service

* fix(billing): Rename and upgrade upgrade account component

* fix(billing): Add unified upgrade dialog component

* fix(billing): Update component and service to use new tax service

* fix(billing): Update unified upgrade dialog

* feat(billing): Add feature flag

* feat(billing): Add vault dialog launch logic

* fix(billing): Add stricter validation for payment component

* fix(billing): Update custom dialog close button

* fix(billing): Fix padding in cart summary component

* fix(billing): Update payment method component spacing

* fix(billing): Update Upgrade Payment component spacing

* fix(billing): Update upgrade account component spacing

* fix(billing): Fix accurate typing

* feat(billing): adds unified upgrade prompt service

* fix(billing): Update unified dialog to account for skipped steps

* fix(billing): Use upgradePromptService for vault

* fix(billing): Format

* fix(billing): Fix premium check
This commit is contained in:
Stephon Brown
2025-10-08 10:20:15 -04:00
committed by GitHub
parent 0e56392b34
commit da8a0104ea
24 changed files with 1803 additions and 92 deletions

View File

@@ -0,0 +1,24 @@
import { Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { BillingAddress, TokenizedPaymentMethod } from "../payment/types";
@Injectable()
export class AccountBillingClient {
private endpoint = "/account/billing/vnext";
private apiService: ApiService;
constructor(apiService: ApiService) {
this.apiService = apiService;
}
purchasePremiumSubscription = async (
paymentMethod: TokenizedPaymentMethod,
billingAddress: Pick<BillingAddress, "country" | "postalCode">,
): Promise<void> => {
const path = `${this.endpoint}/subscription`;
const request = { tokenizedPaymentMethod: paymentMethod, billingAddress: billingAddress };
await this.apiService.send("POST", path, request, true, true);
};
}

View File

@@ -1,3 +1,4 @@
export * from "./organization-billing.client";
export * from "./subscriber-billing.client";
export * from "./tax.client";
export * from "./account-billing.client";

View File

@@ -0,0 +1 @@
export * from "./unified-upgrade-prompt.service";

View File

@@ -0,0 +1,172 @@
import { mock, mockReset } from "jest-mock-extended";
import * as rxjs from "rxjs";
import { of } from "rxjs";
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { DialogRef, DialogService } from "@bitwarden/components";
import {
UnifiedUpgradeDialogComponent,
UnifiedUpgradeDialogStatus,
} from "../unified-upgrade-dialog/unified-upgrade-dialog.component";
import { UnifiedUpgradePromptService } from "./unified-upgrade-prompt.service";
describe("UnifiedUpgradePromptService", () => {
let sut: UnifiedUpgradePromptService;
const mockAccountService = mock<AccountService>();
const mockConfigService = mock<ConfigService>();
const mockBillingService = mock<BillingAccountProfileStateService>();
const mockVaultProfileService = mock<VaultProfileService>();
const mockDialogService = mock<DialogService>();
const mockDialogOpen = jest.spyOn(UnifiedUpgradeDialogComponent, "open");
/**
* Creates a mock DialogRef that implements the required properties for testing
* @param result The result that will be emitted by the closed observable
* @returns A mock DialogRef object
*/
function createMockDialogRef<T>(result: T): DialogRef<T> {
// Create a mock that implements the DialogRef interface
return {
// The closed property is readonly in the actual DialogRef
closed: of(result),
} as DialogRef<T>;
}
// Mock the open method of a dialog component to return the provided DialogRefs
// Supports multiple calls by returning different refs in sequence
function mockDialogOpenMethod(...refs: DialogRef<any>[]) {
refs.forEach((ref) => mockDialogOpen.mockReturnValueOnce(ref));
}
function setupTestService() {
sut = new UnifiedUpgradePromptService(
mockAccountService,
mockConfigService,
mockBillingService,
mockVaultProfileService,
mockDialogService,
);
}
const mockAccount: Account = {
id: "test-user-id",
} as Account;
const accountSubject = new rxjs.BehaviorSubject(mockAccount);
describe("initialization", () => {
beforeEach(() => {
setupTestService();
});
it("should be created", () => {
expect(sut).toBeTruthy();
});
it("should subscribe to account and feature flag observables on construction", () => {
expect(mockConfigService.getFeatureFlag$).toHaveBeenCalledWith(
FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog,
);
});
});
describe("displayUpgradePromptConditionally", () => {
beforeEach(async () => {
mockAccountService.activeAccount$ = accountSubject.asObservable();
mockDialogOpen.mockReset();
mockReset(mockConfigService);
mockReset(mockBillingService);
mockReset(mockVaultProfileService);
});
it("should not show dialog when feature flag is disabled", async () => {
// Arrange
mockConfigService.getFeatureFlag$.mockReturnValue(of(false));
setupTestService();
// Act
const result = await sut.displayUpgradePromptConditionally();
// Assert
expect(result).toBeNull();
});
it("should not show dialog when user has premium", async () => {
// Arrange
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(true));
setupTestService();
// Act
const result = await sut.displayUpgradePromptConditionally();
// Assert
expect(result).toBeNull();
});
it("should not show dialog when profile is older than 5 minutes", async () => {
// Arrange
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
const oldDate = new Date();
oldDate.setMinutes(oldDate.getMinutes() - 10); // 10 minutes old
mockVaultProfileService.getProfileCreationDate.mockResolvedValue(oldDate);
setupTestService();
// Act
const result = await sut.displayUpgradePromptConditionally();
// Assert
expect(result).toBeNull();
});
it("should show dialog when all conditions are met", async () => {
//Arrange
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
const recentDate = new Date();
recentDate.setMinutes(recentDate.getMinutes() - 3); // 3 minutes old
mockVaultProfileService.getProfileCreationDate.mockResolvedValue(recentDate);
const expectedResult = { status: UnifiedUpgradeDialogStatus.Closed };
mockDialogOpenMethod(createMockDialogRef(expectedResult));
setupTestService();
// Act
const result = await sut.displayUpgradePromptConditionally();
// Assert
expect(result).toEqual(expectedResult);
expect(mockDialogOpen).toHaveBeenCalled();
});
it("should not show dialog when account is null/undefined", async () => {
// Arrange
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
accountSubject.next(null); // Set account to null
setupTestService();
// Act
const result = await sut.displayUpgradePromptConditionally();
// Assert
expect(result).toBeNull();
});
it("should not show dialog when profile creation date is unavailable", async () => {
// Arrange
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
mockVaultProfileService.getProfileCreationDate.mockResolvedValue(null);
setupTestService();
// Act
const result = await sut.displayUpgradePromptConditionally();
// Assert
expect(result).toBeNull();
});
});
});

View File

@@ -0,0 +1,114 @@
import { Injectable } from "@angular/core";
import { combineLatest, firstValueFrom } from "rxjs";
import { switchMap, take } from "rxjs/operators";
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { DialogRef, DialogService } from "@bitwarden/components";
import {
UnifiedUpgradeDialogComponent,
UnifiedUpgradeDialogResult,
} from "../unified-upgrade-dialog/unified-upgrade-dialog.component";
@Injectable({
providedIn: "root",
})
export class UnifiedUpgradePromptService {
private unifiedUpgradeDialogRef: DialogRef<UnifiedUpgradeDialogResult> | null = null;
constructor(
private accountService: AccountService,
private configService: ConfigService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private vaultProfileService: VaultProfileService,
private dialogService: DialogService,
) {}
private shouldShowPrompt$ = combineLatest([
this.accountService.activeAccount$,
this.configService.getFeatureFlag$(FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog),
]).pipe(
switchMap(async ([account, isFlagEnabled]) => {
if (!account || !account?.id) {
return false;
}
// Early return if feature flag is disabled
if (!isFlagEnabled) {
return false;
}
// Check if user has premium
const hasPremium = await firstValueFrom(
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
);
// Early return if user already has premium
if (hasPremium) {
return false;
}
// Check profile age only if needed
const isProfileLessThanFiveMinutesOld = await this.isProfileLessThanFiveMinutesOld(
account.id,
);
return isFlagEnabled && !hasPremium && isProfileLessThanFiveMinutesOld;
}),
take(1),
);
/**
* Conditionally prompt the user based on predefined criteria.
*
* @returns A promise that resolves to the dialog result if shown, or null if not shown
*/
async displayUpgradePromptConditionally(): Promise<UnifiedUpgradeDialogResult | null> {
const shouldShow = await firstValueFrom(this.shouldShowPrompt$);
if (shouldShow) {
return this.launchUpgradeDialog();
}
return null;
}
/**
* Checks if a user's profile was created less than five minutes ago
* @param userId User ID to check
* @returns Promise that resolves to true if profile was created less than five minutes ago
*/
private async isProfileLessThanFiveMinutesOld(userId: string): Promise<boolean> {
const createdAtDate = await this.vaultProfileService.getProfileCreationDate(userId);
if (!createdAtDate) {
return false;
}
const createdAtInMs = createdAtDate.getTime();
const nowInMs = new Date().getTime();
const differenceInMs = nowInMs - createdAtInMs;
const msInAMinute = 1000 * 60; // Milliseconds in a minute for conversion 1 minute = 60 seconds * 1000 ms
const differenceInMinutes = Math.round(differenceInMs / msInAMinute);
return differenceInMinutes <= 5;
}
private async launchUpgradeDialog(): Promise<UnifiedUpgradeDialogResult | null> {
const account = await firstValueFrom(this.accountService.activeAccount$);
if (!account) {
return null;
}
this.unifiedUpgradeDialogRef = UnifiedUpgradeDialogComponent.open(this.dialogService, {
data: { account },
});
const result = await firstValueFrom(this.unifiedUpgradeDialogRef.closed);
this.unifiedUpgradeDialogRef = null;
// Return the result or null if the dialog was dismissed without a result
return result || null;
}
}

View File

@@ -0,0 +1,10 @@
@if (step() == PlanSelectionStep) {
<app-upgrade-account (planSelected)="onPlanSelected($event)" (closeClicked)="onCloseClicked()" />
} @else if (step() == PaymentStep && selectedPlan() !== null) {
<app-upgrade-payment
[selectedPlanId]="selectedPlan()"
[account]="account()"
(goBack)="previousStep()"
(complete)="onComplete($event)"
/>
}

View File

@@ -0,0 +1,153 @@
import { DIALOG_DATA } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, Inject, OnInit, signal } from "@angular/core";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import {
ButtonModule,
DialogConfig,
DialogModule,
DialogRef,
DialogService,
} from "@bitwarden/components";
import { AccountBillingClient, TaxClient } from "../../../clients";
import { BillingServicesModule } from "../../../services";
import { PersonalSubscriptionPricingTierId } from "../../../types/subscription-pricing-tier";
import { UpgradeAccountComponent } from "../upgrade-account/upgrade-account.component";
import { UpgradePaymentService } from "../upgrade-payment/services/upgrade-payment.service";
import {
UpgradePaymentComponent,
UpgradePaymentResult,
} from "../upgrade-payment/upgrade-payment.component";
export const UnifiedUpgradeDialogStatus = {
Closed: "closed",
UpgradedToPremium: "upgradedToPremium",
UpgradedToFamilies: "upgradedToFamilies",
} as const;
export const UnifiedUpgradeDialogStep = {
PlanSelection: "planSelection",
Payment: "payment",
} as const;
export type UnifiedUpgradeDialogStatus = UnionOfValues<typeof UnifiedUpgradeDialogStatus>;
export type UnifiedUpgradeDialogStep = UnionOfValues<typeof UnifiedUpgradeDialogStep>;
export type UnifiedUpgradeDialogResult = {
status: UnifiedUpgradeDialogStatus;
organizationId?: string | null;
};
/**
* Parameters for the UnifiedUpgradeDialog component.
* In order to open the dialog to a specific step, you must provide the `initialStep` parameter and a `selectedPlan` if the step is `Payment`.
*
* @property {Account} account - The user account information.
* @property {UnifiedUpgradeDialogStep | null} [initialStep] - The initial step to show in the dialog, if any.
* @property {PersonalSubscriptionPricingTierId | null} [selectedPlan] - Pre-selected subscription plan, if any.
*/
export type UnifiedUpgradeDialogParams = {
account: Account;
initialStep?: UnifiedUpgradeDialogStep | null;
selectedPlan?: PersonalSubscriptionPricingTierId | null;
};
@Component({
selector: "app-unified-upgrade-dialog",
imports: [
CommonModule,
DialogModule,
ButtonModule,
UpgradeAccountComponent,
UpgradePaymentComponent,
BillingServicesModule,
],
providers: [UpgradePaymentService, AccountBillingClient, TaxClient],
templateUrl: "./unified-upgrade-dialog.component.html",
})
export class UnifiedUpgradeDialogComponent implements OnInit {
// Use signals for dialog state because inputs depend on parent component
protected step = signal<UnifiedUpgradeDialogStep>(UnifiedUpgradeDialogStep.PlanSelection);
protected selectedPlan = signal<PersonalSubscriptionPricingTierId | null>(null);
protected account = signal<Account | null>(null);
protected readonly PaymentStep = UnifiedUpgradeDialogStep.Payment;
protected readonly PlanSelectionStep = UnifiedUpgradeDialogStep.PlanSelection;
constructor(
private dialogRef: DialogRef<UnifiedUpgradeDialogResult>,
@Inject(DIALOG_DATA) private params: UnifiedUpgradeDialogParams,
) {}
ngOnInit(): void {
this.account.set(this.params.account);
this.step.set(this.params.initialStep ?? UnifiedUpgradeDialogStep.PlanSelection);
this.selectedPlan.set(this.params.selectedPlan ?? null);
}
protected onPlanSelected(planId: PersonalSubscriptionPricingTierId): void {
this.selectedPlan.set(planId);
this.nextStep();
}
protected onCloseClicked(): void {
this.close({ status: UnifiedUpgradeDialogStatus.Closed });
}
private close(result: UnifiedUpgradeDialogResult): void {
this.dialogRef.close(result);
}
protected nextStep() {
if (this.step() === UnifiedUpgradeDialogStep.PlanSelection) {
this.step.set(UnifiedUpgradeDialogStep.Payment);
}
}
protected previousStep(): void {
// If we are on the payment step and there was no initial step, go back to plan selection this is to prevent
// going back to payment step if the dialog was opened directly to payment step
if (this.step() === UnifiedUpgradeDialogStep.Payment && this.params?.initialStep == null) {
this.step.set(UnifiedUpgradeDialogStep.PlanSelection);
this.selectedPlan.set(null);
} else {
this.close({ status: UnifiedUpgradeDialogStatus.Closed });
}
}
protected onComplete(result: UpgradePaymentResult): void {
let status: UnifiedUpgradeDialogStatus;
switch (result.status) {
case "upgradedToPremium":
status = UnifiedUpgradeDialogStatus.UpgradedToPremium;
break;
case "upgradedToFamilies":
status = UnifiedUpgradeDialogStatus.UpgradedToFamilies;
break;
case "closed":
status = UnifiedUpgradeDialogStatus.Closed;
break;
default:
status = UnifiedUpgradeDialogStatus.Closed;
}
this.close({ status, organizationId: result.organizationId });
}
/**
* Opens the unified upgrade dialog.
*
* @param dialogService - The dialog service used to open the component
* @param dialogConfig - The configuration for the dialog including UnifiedUpgradeDialogParams data
* @returns A dialog reference object of type DialogRef<UnifiedUpgradeDialogResult>
*/
static open(
dialogService: DialogService,
dialogConfig: DialogConfig<UnifiedUpgradeDialogParams>,
): DialogRef<UnifiedUpgradeDialogResult> {
return dialogService.open<UnifiedUpgradeDialogResult>(UnifiedUpgradeDialogComponent, {
data: dialogConfig.data,
});
}
}

View File

@@ -0,0 +1,68 @@
@if (!loading()) {
<section
class="tw-bg-background tw-rounded-xl tw-shadow-lg tw-max-w-5xl tw-min-w-[332px] tw-w-[870px] tw-border-secondary-100 tw-border-solid tw-border"
cdkTrapFocus
cdkTrapFocusAutoCapture
>
<header class="tw-flex tw-items-center tw-justify-end tw-pl-6 tw-pt-3 tw-pr-2">
<button
type="button"
bitIconButton="bwi-close"
buttonType="main"
size="default"
[label]="'close' | i18n"
(click)="closeClicked.emit(closeStatus)"
></button>
</header>
<div class="tw-px-14 tw-pb-8">
<div class="tw-flex tw-text-center tw-flex-col tw-pb-4">
<h1 class="tw-font-semibold tw-text-[32px]">
{{ "individualUpgradeWelcomeMessage" | i18n }}
</h1>
<p bitTypography="body1" class="tw-text-muted">
{{ "individualUpgradeDescriptionMessage" | i18n }}
</p>
</div>
<div class="tw-flex tw-flex-row tw-gap-6 tw-mb-4">
@if (premiumCardDetails) {
<billing-pricing-card
class="tw-flex-1 tw-basis-0 tw-min-w-0"
[tagline]="premiumCardDetails.tagline"
[price]="premiumCardDetails.price"
[button]="premiumCardDetails.button"
[features]="premiumCardDetails.features"
(buttonClick)="planSelected.emit(premiumPlanType)"
>
<h3 slot="title" class="tw-m-0" bitTypography="h3">
{{ premiumCardDetails.title }}
</h3>
</billing-pricing-card>
}
@if (familiesCardDetails) {
<billing-pricing-card
class="tw-flex-1 tw-basis-0 tw-min-w-0"
[tagline]="familiesCardDetails.tagline"
[price]="familiesCardDetails.price"
[button]="familiesCardDetails.button"
[features]="familiesCardDetails.features"
(buttonClick)="planSelected.emit(familiesPlanType)"
>
<h3 slot="title" class="tw-m-0" bitTypography="h3">
{{ familiesCardDetails.title }}
</h3>
</billing-pricing-card>
}
</div>
<div class="tw-text-center tw-w-full">
<p bitTypography="helper" class="tw-text-muted tw-italic">
{{ "individualUpgradeTaxInformationMessage" | i18n }}
</p>
<button bitLink linkType="primary" type="button" (click)="closeClicked.emit(closeStatus)">
{{ "continueWithoutUpgrading" | i18n }}
</button>
</div>
</div>
</section>
}

View File

@@ -0,0 +1,149 @@
import { CdkTrapFocus } from "@angular/cdk/a11y";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PricingCardComponent } from "@bitwarden/pricing";
import { BillingServicesModule } from "../../../services";
import { SubscriptionPricingService } from "../../../services/subscription-pricing.service";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierIds,
} from "../../../types/subscription-pricing-tier";
import { UpgradeAccountComponent, UpgradeAccountStatus } from "./upgrade-account.component";
describe("UpgradeAccountComponent", () => {
let sut: UpgradeAccountComponent;
let fixture: ComponentFixture<UpgradeAccountComponent>;
const mockI18nService = mock<I18nService>();
const mockSubscriptionPricingService = mock<SubscriptionPricingService>();
// Mock pricing tiers data
const mockPricingTiers: PersonalSubscriptionPricingTier[] = [
{
id: PersonalSubscriptionPricingTierIds.Premium,
name: "premium", // Name changed to match i18n key expectation
description: "Premium plan for individuals",
passwordManager: {
annualPrice: 10,
features: [{ value: "Feature 1" }, { value: "Feature 2" }, { value: "Feature 3" }],
},
} as PersonalSubscriptionPricingTier,
{
id: PersonalSubscriptionPricingTierIds.Families,
name: "planNameFamilies", // Name changed to match i18n key expectation
description: "Family plan for up to 6 users",
passwordManager: {
annualPrice: 40,
features: [{ value: "Feature A" }, { value: "Feature B" }, { value: "Feature C" }],
users: 6,
},
} as PersonalSubscriptionPricingTier,
];
beforeEach(async () => {
jest.resetAllMocks();
mockI18nService.t.mockImplementation((key) => key);
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
of(mockPricingTiers),
);
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, UpgradeAccountComponent, PricingCardComponent, CdkTrapFocus],
providers: [
{ provide: I18nService, useValue: mockI18nService },
{ provide: SubscriptionPricingService, useValue: mockSubscriptionPricingService },
],
})
.overrideComponent(UpgradeAccountComponent, {
// Remove BillingServicesModule to avoid conflicts with mocking SubscriptionPricingService dependencies
remove: { imports: [BillingServicesModule] },
})
.compileComponents();
fixture = TestBed.createComponent(UpgradeAccountComponent);
sut = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(sut).toBeTruthy();
});
it("should set up pricing tier details properly", () => {
expect(sut["premiumCardDetails"]).toBeDefined();
expect(sut["familiesCardDetails"]).toBeDefined();
});
it("should create premium card details correctly", () => {
// Because the i18n service is mocked to return the key itself
expect(sut["premiumCardDetails"].title).toBe("premium");
expect(sut["premiumCardDetails"].tagline).toBe("Premium plan for individuals");
expect(sut["premiumCardDetails"].price.amount).toBe(10 / 12);
expect(sut["premiumCardDetails"].price.cadence).toBe("monthly");
expect(sut["premiumCardDetails"].button.type).toBe("primary");
expect(sut["premiumCardDetails"].button.text).toBe("upgradeToPremium");
expect(sut["premiumCardDetails"].features).toEqual(["Feature 1", "Feature 2", "Feature 3"]);
});
it("should create families card details correctly", () => {
// Because the i18n service is mocked to return the key itself
expect(sut["familiesCardDetails"].title).toBe("planNameFamilies");
expect(sut["familiesCardDetails"].tagline).toBe("Family plan for up to 6 users");
expect(sut["familiesCardDetails"].price.amount).toBe(40 / 12);
expect(sut["familiesCardDetails"].price.cadence).toBe("monthly");
expect(sut["familiesCardDetails"].button.type).toBe("secondary");
expect(sut["familiesCardDetails"].button.text).toBe("upgradeToFamilies");
expect(sut["familiesCardDetails"].features).toEqual(["Feature A", "Feature B", "Feature C"]);
});
it("should emit planSelected with premium pricing tier when premium plan is selected", () => {
// Arrange
const emitSpy = jest.spyOn(sut.planSelected, "emit");
// Act
sut.planSelected.emit(PersonalSubscriptionPricingTierIds.Premium);
// Assert
expect(emitSpy).toHaveBeenCalledWith(PersonalSubscriptionPricingTierIds.Premium);
});
it("should emit planSelected with families pricing tier when families plan is selected", () => {
// Arrange
const emitSpy = jest.spyOn(sut.planSelected, "emit");
// Act
sut.planSelected.emit(PersonalSubscriptionPricingTierIds.Families);
// Assert
expect(emitSpy).toHaveBeenCalledWith(PersonalSubscriptionPricingTierIds.Families);
});
it("should emit closeClicked with closed status when close button is clicked", () => {
// Arrange
const emitSpy = jest.spyOn(sut.closeClicked, "emit");
// Act
sut.closeClicked.emit(UpgradeAccountStatus.Closed);
// Assert
expect(emitSpy).toHaveBeenCalledWith(UpgradeAccountStatus.Closed);
});
describe("isFamiliesPlan", () => {
it("should return true for families plan", () => {
const result = sut["isFamiliesPlan"](PersonalSubscriptionPricingTierIds.Families);
expect(result).toBe(true);
});
it("should return false for premium plan", () => {
const result = sut["isFamiliesPlan"](PersonalSubscriptionPricingTierIds.Premium);
expect(result).toBe(false);
});
});
});

View File

@@ -0,0 +1,125 @@
import { CdkTrapFocus } from "@angular/cdk/a11y";
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, OnInit, output, signal } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import { ButtonType, DialogModule } from "@bitwarden/components";
import { PricingCardComponent } from "@bitwarden/pricing";
import { SharedModule } from "../../../../shared";
import { BillingServicesModule } from "../../../services";
import { SubscriptionPricingService } from "../../../services/subscription-pricing.service";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierId,
PersonalSubscriptionPricingTierIds,
SubscriptionCadence,
SubscriptionCadenceIds,
} from "../../../types/subscription-pricing-tier";
export const UpgradeAccountStatus = {
Closed: "closed",
ProceededToPayment: "proceeded-to-payment",
} as const;
export type UpgradeAccountStatus = UnionOfValues<typeof UpgradeAccountStatus>;
export type UpgradeAccountResult = {
status: UpgradeAccountStatus;
plan: PersonalSubscriptionPricingTierId | null;
};
type CardDetails = {
title: string;
tagline: string;
price: { amount: number; cadence: SubscriptionCadence };
button: { text: string; type: ButtonType };
features: string[];
};
@Component({
selector: "app-upgrade-account",
imports: [
CommonModule,
DialogModule,
SharedModule,
BillingServicesModule,
PricingCardComponent,
CdkTrapFocus,
],
templateUrl: "./upgrade-account.component.html",
})
export class UpgradeAccountComponent implements OnInit {
planSelected = output<PersonalSubscriptionPricingTierId>();
closeClicked = output<UpgradeAccountStatus>();
protected loading = signal(true);
protected premiumCardDetails!: CardDetails;
protected familiesCardDetails!: CardDetails;
protected familiesPlanType = PersonalSubscriptionPricingTierIds.Families;
protected premiumPlanType = PersonalSubscriptionPricingTierIds.Premium;
protected closeStatus = UpgradeAccountStatus.Closed;
constructor(
private i18nService: I18nService,
private subscriptionPricingService: SubscriptionPricingService,
private destroyRef: DestroyRef,
) {}
ngOnInit(): void {
this.subscriptionPricingService
.getPersonalSubscriptionPricingTiers$()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((plans) => {
this.setupCardDetails(plans);
this.loading.set(false);
});
}
/** Setup card details for the pricing tiers.
* This can be extended in the future for business plans, etc.
*/
private setupCardDetails(plans: PersonalSubscriptionPricingTier[]): void {
const premiumTier = plans.find(
(tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium,
);
const familiesTier = plans.find(
(tier) => tier.id === PersonalSubscriptionPricingTierIds.Families,
);
if (premiumTier) {
this.premiumCardDetails = this.createCardDetails(premiumTier, "primary");
}
if (familiesTier) {
this.familiesCardDetails = this.createCardDetails(familiesTier, "secondary");
}
}
private createCardDetails(
tier: PersonalSubscriptionPricingTier,
buttonType: ButtonType,
): CardDetails {
return {
title: tier.name,
tagline: tier.description,
price: {
amount: tier.passwordManager.annualPrice / 12,
cadence: SubscriptionCadenceIds.Monthly,
},
button: {
text: this.i18nService.t(
this.isFamiliesPlan(tier.id) ? "upgradeToFamilies" : "upgradeToPremium",
),
type: buttonType,
},
features: tier.passwordManager.features.map((f: { key: string; value: string }) => f.value),
};
}
private isFamiliesPlan(plan: PersonalSubscriptionPricingTierId): boolean {
return plan === PersonalSubscriptionPricingTierIds.Families;
}
}

View File

@@ -0,0 +1,313 @@
import { TestBed } from "@angular/core/testing";
import { mock, mockReset } from "jest-mock-extended";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response";
import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid";
import { LogService } from "@bitwarden/logging";
import { AccountBillingClient, TaxAmounts, TaxClient } from "../../../../clients";
import { BillingAddress, TokenizedPaymentMethod } from "../../../../payment/types";
import { PersonalSubscriptionPricingTierIds } from "../../../../types/subscription-pricing-tier";
import { UpgradePaymentService, PlanDetails } from "./upgrade-payment.service";
describe("UpgradePaymentService", () => {
const mockOrganizationBillingService = mock<OrganizationBillingServiceAbstraction>();
const mockAccountBillingClient = mock<AccountBillingClient>();
const mockTaxClient = mock<TaxClient>();
const mockLogService = mock<LogService>();
const mockApiService = mock<ApiService>();
const mockSyncService = mock<SyncService>();
mockApiService.refreshIdentityToken.mockResolvedValue({});
mockSyncService.fullSync.mockResolvedValue(true);
let sut: UpgradePaymentService;
const mockAccount = {
id: "user-id" as UserId,
email: "test@example.com",
emailVerified: true,
name: "Test User",
};
const mockTokenizedPaymentMethod: TokenizedPaymentMethod = {
token: "test-token",
type: "card",
};
const mockBillingAddress: BillingAddress = {
line1: "123 Test St",
line2: null,
city: "Test City",
state: "TS",
country: "US",
postalCode: "12345",
taxId: null,
};
const mockPremiumPlanDetails: PlanDetails = {
tier: PersonalSubscriptionPricingTierIds.Premium,
details: {
id: PersonalSubscriptionPricingTierIds.Premium,
name: "Premium",
description: "Premium plan",
availableCadences: ["annually"],
passwordManager: {
type: "standalone",
annualPrice: 10,
annualPricePerAdditionalStorageGB: 4,
features: [
{ key: "feature1", value: "Feature 1" },
{ key: "feature2", value: "Feature 2" },
],
},
},
};
const mockFamiliesPlanDetails: PlanDetails = {
tier: PersonalSubscriptionPricingTierIds.Families,
details: {
id: PersonalSubscriptionPricingTierIds.Families,
name: "Families",
description: "Families plan",
availableCadences: ["annually"],
passwordManager: {
type: "packaged",
annualPrice: 40,
annualPricePerAdditionalStorageGB: 4,
features: [
{ key: "feature1", value: "Feature 1" },
{ key: "feature2", value: "Feature 2" },
],
users: 6,
},
},
};
beforeEach(() => {
mockReset(mockOrganizationBillingService);
mockReset(mockAccountBillingClient);
mockReset(mockTaxClient);
mockReset(mockLogService);
TestBed.configureTestingModule({
providers: [
UpgradePaymentService,
{
provide: OrganizationBillingServiceAbstraction,
useValue: mockOrganizationBillingService,
},
{ provide: AccountBillingClient, useValue: mockAccountBillingClient },
{ provide: TaxClient, useValue: mockTaxClient },
{ provide: LogService, useValue: mockLogService },
{ provide: ApiService, useValue: mockApiService },
{ provide: SyncService, useValue: mockSyncService },
],
});
sut = TestBed.inject(UpgradePaymentService);
});
describe("calculateEstimatedTax", () => {
it("should calculate tax for premium plan", async () => {
// Arrange
const mockResponse = mock<TaxAmounts>();
mockResponse.tax = 2.5;
mockTaxClient.previewTaxForPremiumSubscriptionPurchase.mockResolvedValue(mockResponse);
// Act
const result = await sut.calculateEstimatedTax(mockPremiumPlanDetails, mockBillingAddress);
// Assert
expect(result).toEqual(2.5);
expect(mockTaxClient.previewTaxForPremiumSubscriptionPurchase).toHaveBeenCalledWith(
0,
mockBillingAddress,
);
});
it("should calculate tax for families plan", async () => {
// Arrange
const mockResponse = mock<TaxAmounts>();
mockResponse.tax = 5.0;
mockTaxClient.previewTaxForOrganizationSubscriptionPurchase.mockResolvedValue(mockResponse);
// Act
const result = await sut.calculateEstimatedTax(mockFamiliesPlanDetails, mockBillingAddress);
// Assert
expect(result).toEqual(5.0);
expect(mockTaxClient.previewTaxForOrganizationSubscriptionPurchase).toHaveBeenCalledWith(
{
cadence: "annually",
tier: "families",
passwordManager: {
additionalStorage: 0,
seats: 6,
sponsored: false,
},
},
mockBillingAddress,
);
});
it("should throw and log error if personal tax calculation fails", async () => {
// Arrange
const error = new Error("Tax service error");
mockTaxClient.previewTaxForPremiumSubscriptionPurchase.mockRejectedValue(error);
// Act & Assert
await expect(
sut.calculateEstimatedTax(mockPremiumPlanDetails, mockBillingAddress),
).rejects.toThrow();
expect(mockLogService.error).toHaveBeenCalledWith("Tax calculation failed:", error);
});
it("should throw and log error if organization tax calculation fails", async () => {
// Arrange
const error = new Error("Tax service error");
mockTaxClient.previewTaxForOrganizationSubscriptionPurchase.mockRejectedValue(error);
// Act & Assert
await expect(
sut.calculateEstimatedTax(mockFamiliesPlanDetails, mockBillingAddress),
).rejects.toThrow();
expect(mockLogService.error).toHaveBeenCalledWith("Tax calculation failed:", error);
});
});
describe("upgradeToPremium", () => {
it("should call accountBillingClient to purchase premium subscription and refresh data", async () => {
// Arrange
mockAccountBillingClient.purchasePremiumSubscription.mockResolvedValue();
// Act
await sut.upgradeToPremium(mockTokenizedPaymentMethod, mockBillingAddress);
// Assert
expect(mockAccountBillingClient.purchasePremiumSubscription).toHaveBeenCalledWith(
mockTokenizedPaymentMethod,
mockBillingAddress,
);
expect(mockApiService.refreshIdentityToken).toHaveBeenCalled();
expect(mockSyncService.fullSync).toHaveBeenCalledWith(true);
});
it("should throw error if payment method is incomplete", async () => {
// Arrange
const incompletePaymentMethod = { type: "card" } as TokenizedPaymentMethod;
// Act & Assert
await expect(
sut.upgradeToPremium(incompletePaymentMethod, mockBillingAddress),
).rejects.toThrow("Payment method type or token is missing");
});
it("should throw error if billing address is incomplete", async () => {
// Arrange
const incompleteBillingAddress = { country: "US", postalCode: null } as any;
// Act & Assert
await expect(
sut.upgradeToPremium(mockTokenizedPaymentMethod, incompleteBillingAddress),
).rejects.toThrow("Billing address information is incomplete");
});
});
describe("upgradeToFamilies", () => {
it("should call organizationBillingService to purchase subscription and refresh data", async () => {
// Arrange
mockOrganizationBillingService.purchaseSubscription.mockResolvedValue({
id: "org-id",
name: "Test Organization",
billingEmail: "test@example.com",
} as OrganizationResponse);
// Act
await sut.upgradeToFamilies(
mockAccount,
mockFamiliesPlanDetails,
mockTokenizedPaymentMethod,
{
organizationName: "Test Organization",
billingAddress: mockBillingAddress,
},
);
// Assert
expect(mockOrganizationBillingService.purchaseSubscription).toHaveBeenCalledWith(
expect.objectContaining({
organization: {
name: "Test Organization",
billingEmail: "test@example.com",
},
plan: {
type: PlanType.FamiliesAnnually,
passwordManagerSeats: 6,
},
payment: {
paymentMethod: ["test-token", PaymentMethodType.Card],
billing: {
country: "US",
postalCode: "12345",
},
},
}),
"user-id",
);
expect(mockApiService.refreshIdentityToken).toHaveBeenCalled();
expect(mockSyncService.fullSync).toHaveBeenCalledWith(true);
});
it("should throw error if password manager seats are 0", async () => {
// Arrange
const invalidPlanDetails: PlanDetails = {
tier: PersonalSubscriptionPricingTierIds.Families,
details: {
passwordManager: {
type: "packaged",
users: 0,
annualPrice: 0,
features: [],
annualPricePerAdditionalStorageGB: 0,
},
id: "families",
name: "",
description: "",
availableCadences: ["annually"],
},
};
mockOrganizationBillingService.purchaseSubscription.mockRejectedValue(
new Error("Seats must be greater than 0 for families plan"),
);
// Act & Assert
await expect(
sut.upgradeToFamilies(mockAccount, invalidPlanDetails, mockTokenizedPaymentMethod, {
organizationName: "Test Organization",
billingAddress: mockBillingAddress,
}),
).rejects.toThrow("Seats must be greater than 0 for families plan");
expect(mockOrganizationBillingService.purchaseSubscription).toHaveBeenCalledTimes(1);
});
it("should throw error if payment method is incomplete", async () => {
const incompletePaymentMethod = { type: "card" } as TokenizedPaymentMethod;
await expect(
sut.upgradeToFamilies(mockAccount, mockFamiliesPlanDetails, incompletePaymentMethod, {
organizationName: "Test Organization",
billingAddress: mockBillingAddress,
}),
).rejects.toThrow("Payment method type or token is missing");
});
});
});

View File

@@ -0,0 +1,190 @@
import { Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import {
OrganizationBillingServiceAbstraction,
SubscriptionInformation,
} from "@bitwarden/common/billing/abstractions";
import { PlanType } from "@bitwarden/common/billing/enums";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { LogService } from "@bitwarden/logging";
import {
AccountBillingClient,
OrganizationSubscriptionPurchase,
TaxAmounts,
TaxClient,
} from "../../../../clients";
import {
BillingAddress,
tokenizablePaymentMethodToLegacyEnum,
TokenizedPaymentMethod,
} from "../../../../payment/types";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierId,
PersonalSubscriptionPricingTierIds,
} from "../../../../types/subscription-pricing-tier";
export type PlanDetails = {
tier: PersonalSubscriptionPricingTierId;
details: PersonalSubscriptionPricingTier;
};
export type PaymentFormValues = {
organizationName?: string | null;
billingAddress: {
country: string;
postalCode: string;
};
};
/**
* Service for handling payment submission and sales tax calculation for upgrade payment component
*/
@Injectable()
export class UpgradePaymentService {
constructor(
private organizationBillingService: OrganizationBillingServiceAbstraction,
private accountBillingClient: AccountBillingClient,
private taxClient: TaxClient,
private logService: LogService,
private apiService: ApiService,
private syncService: SyncService,
) {}
/**
* Calculate estimated tax for the selected plan
*/
async calculateEstimatedTax(
planDetails: PlanDetails,
billingAddress: BillingAddress,
): Promise<number> {
try {
const isOrganizationPlan = planDetails.tier === PersonalSubscriptionPricingTierIds.Families;
const isPremiumPlan = planDetails.tier === PersonalSubscriptionPricingTierIds.Premium;
let taxClientCall: Promise<TaxAmounts> | null = null;
if (isOrganizationPlan) {
const seats = this.getPasswordManagerSeats(planDetails);
if (seats === 0) {
throw new Error("Seats must be greater than 0 for organization plan");
}
// Currently, only Families plan is supported for organization plans
const request: OrganizationSubscriptionPurchase = {
tier: "families",
cadence: "annually",
passwordManager: { seats, additionalStorage: 0, sponsored: false },
};
taxClientCall = this.taxClient.previewTaxForOrganizationSubscriptionPurchase(
request,
billingAddress,
);
}
if (isPremiumPlan) {
taxClientCall = this.taxClient.previewTaxForPremiumSubscriptionPurchase(0, billingAddress);
}
if (taxClientCall === null) {
throw new Error("Tax client call is not defined");
}
const preview = await taxClientCall;
return preview.tax;
} catch (error: unknown) {
this.logService.error("Tax calculation failed:", error);
throw error;
}
}
/**
* Process premium upgrade
*/
async upgradeToPremium(
paymentMethod: TokenizedPaymentMethod,
billingAddress: Pick<BillingAddress, "country" | "postalCode">,
): Promise<void> {
this.validatePaymentAndBillingInfo(paymentMethod, billingAddress);
await this.accountBillingClient.purchasePremiumSubscription(paymentMethod, billingAddress);
await this.refreshAndSync();
}
/**
* Process families upgrade
*/
async upgradeToFamilies(
account: Account,
planDetails: PlanDetails,
paymentMethod: TokenizedPaymentMethod,
formValues: PaymentFormValues,
): Promise<OrganizationResponse> {
const billingAddress = formValues.billingAddress;
if (!formValues.organizationName) {
throw new Error("Organization name is required for families upgrade");
}
this.validatePaymentAndBillingInfo(paymentMethod, billingAddress);
const passwordManagerSeats = this.getPasswordManagerSeats(planDetails);
const subscriptionInformation: SubscriptionInformation = {
organization: {
name: formValues.organizationName,
billingEmail: account.email, // Use account email as billing email
},
plan: {
type: PlanType.FamiliesAnnually,
passwordManagerSeats: passwordManagerSeats,
},
payment: {
paymentMethod: [
paymentMethod.token,
tokenizablePaymentMethodToLegacyEnum(paymentMethod.type),
],
billing: {
country: billingAddress.country,
postalCode: billingAddress.postalCode,
},
},
};
const result = await this.organizationBillingService.purchaseSubscription(
subscriptionInformation,
account.id,
);
await this.refreshAndSync();
return result;
}
private getPasswordManagerSeats(planDetails: PlanDetails): number {
return "users" in planDetails.details.passwordManager
? planDetails.details.passwordManager.users
: 0;
}
private validatePaymentAndBillingInfo(
paymentMethod: TokenizedPaymentMethod,
billingAddress: { country: string; postalCode: string },
): void {
if (!paymentMethod?.token || !paymentMethod?.type) {
throw new Error("Payment method type or token is missing");
}
if (!billingAddress?.country || !billingAddress?.postalCode) {
throw new Error("Billing address information is incomplete");
}
}
private async refreshAndSync(): Promise<void> {
await this.apiService.refreshIdentityToken();
await this.syncService.fullSync(true);
}
}

View File

@@ -0,0 +1,62 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="large" [loading]="loading()">
<span bitDialogTitle class="tw-font-semibold">{{ upgradeToMessage }}</span>
<ng-container bitDialogContent>
<section>
@if (isFamiliesPlan) {
<div class="tw-pb-4">
<bit-form-field class="!tw-mb-0">
<bit-label>{{ "organizationName" | i18n }}</bit-label>
<input bitInput type="text" formControlName="organizationName" required />
</bit-form-field>
<p bitTypography="helper" class="tw-text-muted tw-pt-1 tw-pl-1">
{{ "organizationNameDescription" | i18n }}
</p>
</div>
}
<div class="tw-pb-8 !tw-mx-0">
<app-enter-payment-method
[group]="formGroup.controls.paymentForm"
[includeBillingAddress]="true"
#paymentComponent
></app-enter-payment-method>
</div>
</section>
<section>
@if (passwordManager) {
<billing-cart-summary
[passwordManager]="passwordManager"
[estimatedTax]="estimatedTax"
></billing-cart-summary>
@if (isFamiliesPlan) {
<p bitTypography="helper" class="tw-italic tw-text-muted !tw-mb-0">
{{ "paymentChargedWithTrial" | i18n }}
</p>
}
}
</section>
</ng-container>
<ng-container bitDialogFooter>
<button
bitButton
bitFormButton
buttonType="primary"
[disabled]="loading() || !isFormValid()"
type="submit"
>
{{ "upgrade" | i18n }}
</button>
<button
bitButton
type="button"
buttonType="secondary"
(click)="goBack.emit()"
[disabled]="loading()"
>
{{ "back" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@@ -0,0 +1,279 @@
import {
AfterViewInit,
Component,
DestroyRef,
input,
OnInit,
output,
signal,
ViewChild,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { debounceTime, Observable } from "rxjs";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import { ButtonModule, DialogModule, ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import { CartSummaryComponent, LineItem } from "@bitwarden/pricing";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { EnterPaymentMethodComponent } from "../../../payment/components";
import { BillingServicesModule } from "../../../services";
import { SubscriptionPricingService } from "../../../services/subscription-pricing.service";
import { BitwardenSubscriber } from "../../../types";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierId,
PersonalSubscriptionPricingTierIds,
} from "../../../types/subscription-pricing-tier";
import { PlanDetails, UpgradePaymentService } from "./services/upgrade-payment.service";
/**
* Status types for upgrade payment dialog
*/
export const UpgradePaymentStatus = {
Back: "back",
Closed: "closed",
UpgradedToPremium: "upgradedToPremium",
UpgradedToFamilies: "upgradedToFamilies",
} as const;
export type UpgradePaymentStatus = UnionOfValues<typeof UpgradePaymentStatus>;
export type UpgradePaymentResult = {
status: UpgradePaymentStatus;
organizationId: string | null;
};
/**
* Parameters for upgrade payment
*/
export type UpgradePaymentParams = {
plan: PersonalSubscriptionPricingTierId | null;
subscriber: BitwardenSubscriber;
};
@Component({
selector: "app-upgrade-payment",
imports: [
DialogModule,
SharedModule,
CartSummaryComponent,
ButtonModule,
EnterPaymentMethodComponent,
BillingServicesModule,
],
providers: [UpgradePaymentService],
templateUrl: "./upgrade-payment.component.html",
})
export class UpgradePaymentComponent implements OnInit, AfterViewInit {
protected selectedPlanId = input.required<PersonalSubscriptionPricingTierId>();
protected account = input.required<Account>();
protected goBack = output<void>();
protected complete = output<UpgradePaymentResult>();
protected selectedPlan: PlanDetails | null = null;
@ViewChild(EnterPaymentMethodComponent) paymentComponent!: EnterPaymentMethodComponent;
@ViewChild(CartSummaryComponent) cartSummaryComponent!: CartSummaryComponent;
protected formGroup = new FormGroup({
organizationName: new FormControl<string>("", [Validators.required]),
paymentForm: EnterPaymentMethodComponent.getFormGroup(),
});
protected loading = signal(true);
private pricingTiers$!: Observable<PersonalSubscriptionPricingTier[]>;
// Cart Summary data
protected passwordManager!: LineItem;
protected estimatedTax = 0;
// Display data
protected upgradeToMessage = "";
constructor(
private i18nService: I18nService,
private subscriptionPricingService: SubscriptionPricingService,
private toastService: ToastService,
private logService: LogService,
private destroyRef: DestroyRef,
private upgradePaymentService: UpgradePaymentService,
) {}
async ngOnInit(): Promise<void> {
if (!this.isFamiliesPlan) {
this.formGroup.controls.organizationName.disable();
}
this.pricingTiers$ = this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$();
this.pricingTiers$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((plans) => {
const planDetails = plans.find((plan) => plan.id === this.selectedPlanId());
if (planDetails) {
this.selectedPlan = {
tier: this.selectedPlanId(),
details: planDetails,
};
}
});
if (!this.selectedPlan) {
this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null });
return;
}
this.passwordManager = {
name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership",
cost: this.selectedPlan.details.passwordManager.annualPrice,
quantity: 1,
cadence: "year",
};
this.upgradeToMessage = this.i18nService.t(
this.isFamiliesPlan ? "upgradeToFamilies" : "upgradeToPremium",
);
this.estimatedTax = 0;
this.formGroup.valueChanges
.pipe(debounceTime(1000), takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.refreshSalesTax());
this.loading.set(false);
}
ngAfterViewInit(): void {
this.cartSummaryComponent.isExpanded.set(false);
}
protected get isPremiumPlan(): boolean {
return this.selectedPlanId() === PersonalSubscriptionPricingTierIds.Premium;
}
protected get isFamiliesPlan(): boolean {
return this.selectedPlanId() === PersonalSubscriptionPricingTierIds.Families;
}
protected submit = async (): Promise<void> => {
if (!this.isFormValid()) {
this.formGroup.markAllAsTouched();
return;
}
if (!this.selectedPlan) {
throw new Error("No plan selected");
}
try {
const result = await this.processUpgrade();
if (result.status === UpgradePaymentStatus.UpgradedToFamilies) {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("familiesUpdated"),
});
} else if (result.status === UpgradePaymentStatus.UpgradedToPremium) {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("premiumUpdated"),
});
}
this.complete.emit(result);
} catch (error: unknown) {
this.logService.error("Upgrade failed:", error);
this.toastService.showToast({
variant: "error",
message: this.i18nService.t("upgradeErrorMessage"),
});
}
};
protected isFormValid(): boolean {
return this.formGroup.valid && this.paymentComponent?.validate();
}
private async processUpgrade(): Promise<UpgradePaymentResult> {
// Get common values
const country = this.formGroup.value?.paymentForm?.billingAddress?.country;
const postalCode = this.formGroup.value?.paymentForm?.billingAddress?.postalCode;
if (!this.selectedPlan) {
throw new Error("No plan selected");
}
if (!country || !postalCode) {
throw new Error("Billing address is incomplete");
}
// Validate organization name for Families plan
const organizationName = this.formGroup.value?.organizationName;
if (this.isFamiliesPlan && !organizationName) {
throw new Error("Organization name is required");
}
// Get payment method
const tokenizedPaymentMethod = await this.paymentComponent?.tokenize();
if (!tokenizedPaymentMethod) {
throw new Error("Payment method is required");
}
// Process the upgrade based on plan type
if (this.isFamiliesPlan) {
const paymentFormValues = {
organizationName,
billingAddress: { country, postalCode },
};
const response = await this.upgradePaymentService.upgradeToFamilies(
this.account(),
this.selectedPlan,
tokenizedPaymentMethod,
paymentFormValues,
);
return { status: UpgradePaymentStatus.UpgradedToFamilies, organizationId: response.id };
} else {
await this.upgradePaymentService.upgradeToPremium(tokenizedPaymentMethod, {
country,
postalCode,
});
return { status: UpgradePaymentStatus.UpgradedToPremium, organizationId: null };
}
}
private async refreshSalesTax(): Promise<void> {
const billingAddress = {
country: this.formGroup.value.paymentForm?.billingAddress?.country,
postalCode: this.formGroup.value.paymentForm?.billingAddress?.postalCode,
};
if (!this.selectedPlan || !billingAddress.country || !billingAddress.postalCode) {
this.estimatedTax = 0;
return;
}
this.upgradePaymentService
.calculateEstimatedTax(this.selectedPlan, {
line1: null,
line2: null,
city: null,
state: null,
country: billingAddress.country,
postalCode: billingAddress.postalCode,
taxId: null,
})
.then((tax) => {
this.estimatedTax = tax;
})
.catch((error: unknown) => {
this.logService.error("Tax calculation failed:", error);
this.toastService.showToast({
variant: "error",
message: this.i18nService.t("taxCalculationError"),
});
this.estimatedTax = 0;
});
}
}

View File

@@ -190,7 +190,7 @@ type PaymentMethodFormGroup = FormGroup<{
}
}
@if (showBillingDetails) {
<h5 bitTypography="h5">{{ "billingAddress" | i18n }}</h5>
<h5 bitTypography="h5" class="tw-pt-4">{{ "billingAddress" | i18n }}</h5>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<div class="tw-col-span-6">
<bit-form-field [disableMargin]="true">

View File

@@ -103,6 +103,7 @@ import {
CollectionDialogTabType,
openCollectionDialog,
} from "../../admin-console/organizations/shared/components/collection-dialog";
import { UnifiedUpgradePromptService } from "../../billing/individual/upgrade/services/unified-upgrade-prompt.service";
import { SharedModule } from "../../shared/shared.module";
import { AssignCollectionsWebComponent } from "../components/assign-collections";
import {
@@ -306,6 +307,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
private restrictedItemTypesService: RestrictedItemTypesService,
private cipherArchiveService: CipherArchiveService,
private organizationWarningsService: OrganizationWarningsService,
private unifiedUpgradePromptService: UnifiedUpgradePromptService,
) {}
async ngOnInit() {
@@ -606,6 +608,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
this.changeDetectorRef.markForCheck();
},
);
await this.unifiedUpgradePromptService.displayUpgradePromptConditionally();
}
ngOnDestroy() {

View File

@@ -11750,5 +11750,38 @@
},
"seamlessIntegration": {
"message": "Seamless integration"
},
"families": {
"message": "Families"
},
"upgradeToFamilies": {
"message": "Upgrade to Families"
},
"upgradeToPremium": {
"message": "Upgrade to Premium"
},
"familiesUpdated": {
"message": "You've upgraded to Families!"
},
"taxCalculationError": {
"message": "There was an error calculating tax for your location. Please try again."
},
"individualUpgradeWelcomeMessage": {
"message": "Welcome to Bitwarden"
},
"individualUpgradeDescriptionMessage": {
"message": "Unlock more security features with Premium, or start sharing items with Families"
},
"individualUpgradeTaxInformationMessage": {
"message": "Prices exclude tax and are billed annually."
},
"organizationNameDescription": {
"message": "Your organization name will appear in invitations you send to members."
},
"continueWithoutUpgrading": {
"message": "Continue without upgrading"
},
"upgradeErrorMessage": {
"message": "We encountered an error while processing your upgrade. Please try again."
}
}

View File

@@ -25,6 +25,7 @@ export enum FeatureFlag {
PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships",
PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover",
PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings",
PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog",
/* Key Management */
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
@@ -101,6 +102,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE,
[FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE,
[FeatureFlag.PM22415_TaxIDWarnings]: FALSE,
[FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE,
/* Key Management */
[FeatureFlag.PrivateKeyRegeneration]: FALSE,

View File

@@ -1,18 +1,15 @@
{
"name": "pricing",
"name": "@bitwarden/pricing",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/pricing/src",
"projectType": "library",
"tags": [],
"tags": ["scope:pricing", "type:lib"],
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"executor": "nx:run-script",
"dependsOn": [],
"options": {
"outputPath": "dist/libs/pricing",
"main": "libs/pricing/src/index.ts",
"tsConfig": "libs/pricing/tsconfig.lib.json",
"assets": ["libs/pricing/*.md"]
"script": "build"
}
},
"lint": {

View File

@@ -4,7 +4,7 @@
@let additionalServiceAccounts = this.secretsManager()?.additionalServiceAccounts;
<div class="tw-size-full">
<div class="tw-flex tw-items-center tw-pb-4">
<div class="tw-flex tw-items-center tw-pb-2">
<div class="tw-flex tw-items-center">
<h2
bitTypography="h4"
@@ -34,13 +34,13 @@
@if (isExpanded()) {
<div id="purchase-summary-details" class="tw-pb-2">
<!-- Password Manager Section -->
<div class="tw-border-b tw-border-secondary-100 tw-pb-2">
<div id="password-manager" class="tw-border-b tw-border-secondary-100 tw-pb-2">
<div class="tw-flex tw-justify-between tw-mb-1">
<h3 bitTypography="h5" class="tw-text-muted">{{ "passwordManager" | i18n }}</h3>
</div>
<!-- Password Manager Members -->
<div class="tw-flex tw-justify-between">
<div id="password-manager-members" class="tw-flex tw-justify-between">
<div class="tw-flex-1">
<div bitTypography="body1" class="tw-text-muted">
{{ passwordManager.quantity }} {{ passwordManager.name | i18n }} x
@@ -56,7 +56,7 @@
<!-- Additional Storage -->
@if (additionalStorage) {
<div class="tw-flex tw-justify-between">
<div id="additional-storage" class="tw-flex tw-justify-between">
<div class="tw-flex-1">
<div bitTypography="body1" class="tw-text-muted">
{{ additionalStorage.quantity }} {{ additionalStorage.name | i18n }} x
@@ -73,13 +73,13 @@
<!-- Secrets Manager Section -->
@if (secretsManager) {
<div class="tw-border-b tw-border-secondary-100 tw-py-2">
<div id="secrets-manager" class="tw-border-b tw-border-secondary-100 tw-py-2">
<div class="tw-flex tw-justify-between">
<h3 bitTypography="h5" class="tw-text-muted">{{ "secretsManager" | i18n }}</h3>
</div>
<!-- Secrets Manager Members -->
<div class="tw-flex tw-justify-between">
<div id="secrets-manager-members" class="tw-flex tw-justify-between">
<div bitTypography="body1" class="tw-text-muted">
{{ secretsManager.seats.quantity }} {{ secretsManager.seats.name | i18n }} x
{{ secretsManager.seats.cost | currency: "USD" : "symbol" }}
@@ -96,7 +96,7 @@
<!-- Additional Service Accounts -->
@if (additionalServiceAccounts) {
<div class="tw-flex tw-justify-between">
<div id="additional-service-accounts" class="tw-flex tw-justify-between">
<div bitTypography="body1" class="tw-text-muted">
{{ additionalServiceAccounts.quantity }}
{{ additionalServiceAccounts.name | i18n }} x
@@ -117,7 +117,10 @@
}
<!-- Estimated Tax -->
<div class="tw-flex tw-justify-between tw-border-b tw-border-secondary-100 tw-pt-2 tw-pb-0.5">
<div
id="estimated-tax-section"
class="tw-flex tw-justify-between tw-border-b tw-border-secondary-100 tw-pt-2 tw-pb-0.5"
>
<h3 bitTypography="h5" class="tw-text-muted">{{ "estimatedTax" | i18n }}</h3>
<div bitTypography="body1" class="tw-text-muted" data-testid="estimated-tax">
{{ estimatedTax() | currency: "USD" : "symbol" }}
@@ -125,7 +128,7 @@
</div>
<!-- Total -->
<div class="tw-flex tw-justify-between tw-items-center tw-pt-2">
<div id="total-section" class="tw-flex tw-justify-between tw-items-center tw-pt-2">
<h3 bitTypography="h5" class="tw-text-muted">{{ "total" | i18n }}</h3>
<div bitTypography="body1" class="tw-text-muted" data-testid="final-total">
{{ total() | currency: "USD" : "symbol" }} / {{ passwordManager.cadence | i18n }}

View File

@@ -1,6 +1,8 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CartSummaryComponent, LineItem } from "./cart-summary.component";
describe("CartSummaryComponent", () => {
@@ -9,14 +11,14 @@ describe("CartSummaryComponent", () => {
const mockPasswordManager: LineItem = {
quantity: 5,
name: "Password Manager",
name: "members",
cost: 50,
cadence: "month",
};
const mockAdditionalStorage: LineItem = {
quantity: 2,
name: "Additional Storage",
name: "additionalStorageGB",
cost: 10,
cadence: "month",
};
@@ -24,46 +26,26 @@ describe("CartSummaryComponent", () => {
const mockSecretsManager = {
seats: {
quantity: 3,
name: "Secrets Manager Seats",
name: "secretsManagerSeats",
cost: 30,
cadence: "month" as "month" | "year",
cadence: "month",
},
additionalServiceAccounts: {
quantity: 2,
name: "Additional Service Accounts",
name: "additionalServiceAccountsV2",
cost: 6,
cadence: "month" as "month" | "year",
cadence: "month",
},
};
const mockEstimatedTax = 9.6;
function setupComponent(
options: {
passwordManager?: LineItem;
additionalStorage?: LineItem | null;
secretsManager?: { seats: LineItem; additionalServiceAccounts?: LineItem } | null;
estimatedTax?: number;
} = {},
) {
const pm = options.passwordManager ?? mockPasswordManager;
const storage =
options.additionalStorage !== null
? (options.additionalStorage ?? mockAdditionalStorage)
: undefined;
const sm =
options.secretsManager !== null ? (options.secretsManager ?? mockSecretsManager) : undefined;
const tax = options.estimatedTax ?? mockEstimatedTax;
function setupComponent() {
// Set input values
fixture.componentRef.setInput("passwordManager", pm);
if (storage !== undefined) {
fixture.componentRef.setInput("additionalStorage", storage);
}
if (sm !== undefined) {
fixture.componentRef.setInput("secretsManager", sm);
}
fixture.componentRef.setInput("estimatedTax", tax);
fixture.componentRef.setInput("passwordManager", mockPasswordManager);
fixture.componentRef.setInput("additionalStorage", mockAdditionalStorage);
fixture.componentRef.setInput("secretsManager", mockSecretsManager);
fixture.componentRef.setInput("estimatedTax", mockEstimatedTax);
fixture.detectChanges();
}
@@ -71,6 +53,49 @@ describe("CartSummaryComponent", () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CartSummaryComponent],
providers: [
{
provide: I18nService,
useValue: {
t: (key: string) => {
switch (key) {
case "month":
return "month";
case "year":
return "year";
case "members":
return "Members";
case "additionalStorageGB":
return "Additional storage GB";
case "additionalServiceAccountsV2":
return "Additional machine accounts";
case "secretsManagerSeats":
return "Secrets Manager seats";
case "passwordManager":
return "Password Manager";
case "secretsManager":
return "Secrets Manager";
case "additionalStorage":
return "Additional Storage";
case "estimatedTax":
return "Estimated tax";
case "total":
return "Total";
case "expandPurchaseDetails":
return "Expand purchase details";
case "collapsePurchaseDetails":
return "Collapse purchase details";
case "familiesMembership":
return "Families membership";
case "premiumMembership":
return "Premium membership";
default:
return key;
}
},
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(CartSummaryComponent);
@@ -116,7 +141,7 @@ describe("CartSummaryComponent", () => {
fixture.detectChanges();
// Act / Assert
const detailsSection = fixture.debugElement.query(By.css(".tw-mb-4.tw-pb-4.tw-text-muted"));
const detailsSection = fixture.debugElement.query(By.css('[id="purchase-summary-details"]'));
expect(detailsSection).toBeFalsy();
});
@@ -126,7 +151,7 @@ describe("CartSummaryComponent", () => {
fixture.detectChanges();
// Act / Assert
const detailsSection = fixture.debugElement.query(By.css(".tw-mb-4.tw-pb-4.tw-text-muted"));
const detailsSection = fixture.debugElement.query(By.css('[id="purchase-summary-details"]'));
expect(detailsSection).toBeTruthy();
});
});
@@ -134,10 +159,10 @@ describe("CartSummaryComponent", () => {
describe("Content Rendering", () => {
it("should display correct password manager information", () => {
// Arrange
const pmSection = fixture.debugElement.query(By.css(".tw-mb-3.tw-border-b"));
const pmHeading = pmSection.query(By.css(".tw-font-semibold"));
const pmLineItem = pmSection.query(By.css(".tw-flex-1 .tw-text-sm"));
const pmTotal = pmSection.query(By.css(".tw-text-sm:not(.tw-flex-1 *)"));
const pmSection = fixture.debugElement.query(By.css('[id="password-manager"]'));
const pmHeading = pmSection.query(By.css("h3"));
const pmLineItem = pmSection.query(By.css(".tw-flex-1 .tw-text-muted"));
const pmTotal = pmSection.query(By.css("[data-testid='password-manager-total']"));
// Act/ Assert
expect(pmSection).toBeTruthy();
@@ -150,55 +175,49 @@ describe("CartSummaryComponent", () => {
it("should display correct additional storage information", () => {
// Arrange
const storageItem = fixture.debugElement.query(
By.css(".tw-mb-3.tw-border-b .tw-flex-justify-between:nth-of-type(3)"),
);
const storageText = fixture.debugElement.query(By.css(".tw-mb-3.tw-border-b")).nativeElement
.textContent;
const storageItem = fixture.debugElement.query(By.css("[id='additional-storage']"));
const storageText = storageItem.nativeElement.textContent;
// Act/Assert
expect(storageItem).toBeTruthy();
expect(storageText).toContain("2 Additional GB");
expect(storageText).toContain("2 Additional storage GB");
expect(storageText).toContain("$10.00");
expect(storageText).toContain("$20.00");
});
it("should display correct secrets manager information", () => {
// Arrange
const smSection = fixture.debugElement.queryAll(By.css(".tw-mb-3.tw-border-b"))[1];
const smHeading = smSection.query(By.css(".tw-font-semibold"));
const sectionText = smSection.nativeElement.textContent;
const smSection = fixture.debugElement.query(By.css('[id="secrets-manager"]'));
const smHeading = smSection.query(By.css("h3"));
const sectionText = fixture.debugElement.query(By.css('[id="secrets-manager-members"]'))
.nativeElement.textContent;
const additionalSA = fixture.debugElement.query(By.css('[id="additional-service-accounts"]'))
.nativeElement.textContent;
// Act/ Assert
expect(smSection).toBeTruthy();
expect(smHeading.nativeElement.textContent.trim()).toBe("Secrets Manager");
// Check seats line item
expect(sectionText).toContain("3 Members");
expect(sectionText).toContain("3 Secrets Manager seats");
expect(sectionText).toContain("$30.00");
expect(sectionText).toContain("$90.00"); // 3 * $30
// Check additional service accounts
expect(sectionText).toContain("2 Additional machine accounts");
expect(sectionText).toContain("$6.00");
expect(sectionText).toContain("$12.00"); // 2 * $6
expect(additionalSA).toContain("2 Additional machine accounts");
expect(additionalSA).toContain("$6.00");
expect(additionalSA).toContain("$12.00"); // 2 * $6
});
it("should display correct tax and total", () => {
// Arrange
const taxSection = fixture.debugElement.query(
By.css(".tw-flex.tw-justify-between.tw-mb-3.tw-border-b:last-of-type"),
);
const taxSection = fixture.debugElement.query(By.css('[id="estimated-tax-section"]'));
const expectedTotal = "$381.60"; // 250 + 20 + 90 + 12 + 9.6
const topTotal = fixture.debugElement.query(By.css("h2"));
const bottomTotal = fixture.debugElement.query(
By.css(
".tw-flex.tw-justify-between.tw-items-center:last-child .tw-font-semibold:last-child",
),
);
const bottomTotal = fixture.debugElement.query(By.css("[data-testid='final-total']"));
// Act / Assert
expect(taxSection.nativeElement.textContent).toContain("Estimated Tax");
expect(taxSection.nativeElement.textContent).toContain("Estimated tax");
expect(taxSection.nativeElement.textContent).toContain("$9.60");
expect(topTotal.nativeElement.textContent).toContain(expectedTotal);

View File

@@ -24,9 +24,9 @@
@if (price(); as priceValue) {
<div class="tw-mb-6">
<div class="tw-flex tw-items-baseline tw-gap-1 tw-flex-wrap">
<span class="tw-text-3xl tw-font-bold tw-leading-none tw-m-0"
>${{ priceValue.amount }}</span
>
<span class="tw-text-3xl tw-font-bold tw-leading-none tw-m-0">{{
priceValue.amount | currency: "USD" : "symbol"
}}</span>
<span bitTypography="helper" class="tw-text-muted">
/ {{ priceValue.cadence }}
@if (priceValue.showPerUser) {

View File

@@ -1,3 +1,4 @@
import { CurrencyPipe } from "@angular/common";
import { Component, EventEmitter, input, Output } from "@angular/core";
import {
@@ -17,7 +18,7 @@ import {
@Component({
selector: "billing-pricing-card",
templateUrl: "./pricing-card.component.html",
imports: [BadgeModule, ButtonModule, IconModule, TypographyModule],
imports: [BadgeModule, ButtonModule, IconModule, TypographyModule, CurrencyPipe],
})
export class PricingCardComponent {
tagline = input.required<string>();

View File

@@ -1,13 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
"extends": "../../tsconfig.base",
"include": ["src"],
"exclude": ["node_modules", "dist"]
}