mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 21:33:27 +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:
24
apps/web/src/app/billing/clients/account-billing.client.ts
Normal file
24
apps/web/src/app/billing/clients/account-billing.client.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./organization-billing.client";
|
||||
export * from "./subscriber-billing.client";
|
||||
export * from "./tax.client";
|
||||
export * from "./account-billing.client";
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./unified-upgrade-prompt.service";
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
/>
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user