1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 13:53:34 +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."
}
}