mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
Billing/pm 24996/implement upgrade from free dialog (#16470)
* feat(billing): add required messages * feat(billing): Add upgrade from free account dialog * feat(billing): Add payment dialog for premium upgrade * feat(billing): Add Upgrade Payment Service * feat(billing): Add Upgrade flow service * feat(billing): Add purchase premium subscription method to client * fix(billing): allow for nullable taxId for families organizations * fix(billing): Fix Cart Summary Tests * temp-fix(billing): add currency pipe to pricing card component * fix(billing): Fix NX error This should compile just the library files and not its dependency files which was making it error * fix: Update any type of private function * update account dialog * feat(billing): add upgrade error message * fix(billing): remove upgrade-flow service * feat(billing): add account billing client * fix(billing): Remove method from subscriber-billing client * fix(billing): rename and update upgrade payment component * fix(billing): Rename and update upgrade payment service * fix(billing): Rename and upgrade upgrade account component * fix(billing): Add unified upgrade dialog component * fix(billing): Update component and service to use new tax service * fix(billing): Update unified upgrade dialog * feat(billing): Add feature flag * feat(billing): Add vault dialog launch logic * fix(billing): Add stricter validation for payment component * fix(billing): Update custom dialog close button * fix(billing): Fix padding in cart summary component * fix(billing): Update payment method component spacing * fix(billing): Update Upgrade Payment component spacing * fix(billing): Update upgrade account component spacing * fix(billing): Fix accurate typing * feat(billing): adds unified upgrade prompt service * fix(billing): Update unified dialog to account for skipped steps * fix(billing): Use upgradePromptService for vault * fix(billing): Format * fix(billing): Fix premium check
This commit is contained in:
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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export enum FeatureFlag {
|
||||
PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships",
|
||||
PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover",
|
||||
PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings",
|
||||
PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog",
|
||||
|
||||
/* Key Management */
|
||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||
@@ -101,6 +102,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE,
|
||||
[FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE,
|
||||
[FeatureFlag.PM22415_TaxIDWarnings]: FALSE,
|
||||
[FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE,
|
||||
|
||||
/* Key Management */
|
||||
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
{
|
||||
"name": "pricing",
|
||||
"name": "@bitwarden/pricing",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/pricing/src",
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"tags": ["scope:pricing", "type:lib"],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/js:tsc",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"executor": "nx:run-script",
|
||||
"dependsOn": [],
|
||||
"options": {
|
||||
"outputPath": "dist/libs/pricing",
|
||||
"main": "libs/pricing/src/index.ts",
|
||||
"tsConfig": "libs/pricing/tsconfig.lib.json",
|
||||
"assets": ["libs/pricing/*.md"]
|
||||
"script": "build"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
@let additionalServiceAccounts = this.secretsManager()?.additionalServiceAccounts;
|
||||
|
||||
<div class="tw-size-full">
|
||||
<div class="tw-flex tw-items-center tw-pb-4">
|
||||
<div class="tw-flex tw-items-center tw-pb-2">
|
||||
<div class="tw-flex tw-items-center">
|
||||
<h2
|
||||
bitTypography="h4"
|
||||
@@ -34,13 +34,13 @@
|
||||
@if (isExpanded()) {
|
||||
<div id="purchase-summary-details" class="tw-pb-2">
|
||||
<!-- Password Manager Section -->
|
||||
<div class="tw-border-b tw-border-secondary-100 tw-pb-2">
|
||||
<div id="password-manager" class="tw-border-b tw-border-secondary-100 tw-pb-2">
|
||||
<div class="tw-flex tw-justify-between tw-mb-1">
|
||||
<h3 bitTypography="h5" class="tw-text-muted">{{ "passwordManager" | i18n }}</h3>
|
||||
</div>
|
||||
|
||||
<!-- Password Manager Members -->
|
||||
<div class="tw-flex tw-justify-between">
|
||||
<div id="password-manager-members" class="tw-flex tw-justify-between">
|
||||
<div class="tw-flex-1">
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
{{ passwordManager.quantity }} {{ passwordManager.name | i18n }} x
|
||||
@@ -56,7 +56,7 @@
|
||||
|
||||
<!-- Additional Storage -->
|
||||
@if (additionalStorage) {
|
||||
<div class="tw-flex tw-justify-between">
|
||||
<div id="additional-storage" class="tw-flex tw-justify-between">
|
||||
<div class="tw-flex-1">
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
{{ additionalStorage.quantity }} {{ additionalStorage.name | i18n }} x
|
||||
@@ -73,13 +73,13 @@
|
||||
|
||||
<!-- Secrets Manager Section -->
|
||||
@if (secretsManager) {
|
||||
<div class="tw-border-b tw-border-secondary-100 tw-py-2">
|
||||
<div id="secrets-manager" class="tw-border-b tw-border-secondary-100 tw-py-2">
|
||||
<div class="tw-flex tw-justify-between">
|
||||
<h3 bitTypography="h5" class="tw-text-muted">{{ "secretsManager" | i18n }}</h3>
|
||||
</div>
|
||||
|
||||
<!-- Secrets Manager Members -->
|
||||
<div class="tw-flex tw-justify-between">
|
||||
<div id="secrets-manager-members" class="tw-flex tw-justify-between">
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
{{ secretsManager.seats.quantity }} {{ secretsManager.seats.name | i18n }} x
|
||||
{{ secretsManager.seats.cost | currency: "USD" : "symbol" }}
|
||||
@@ -96,7 +96,7 @@
|
||||
|
||||
<!-- Additional Service Accounts -->
|
||||
@if (additionalServiceAccounts) {
|
||||
<div class="tw-flex tw-justify-between">
|
||||
<div id="additional-service-accounts" class="tw-flex tw-justify-between">
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
{{ additionalServiceAccounts.quantity }}
|
||||
{{ additionalServiceAccounts.name | i18n }} x
|
||||
@@ -117,7 +117,10 @@
|
||||
}
|
||||
|
||||
<!-- Estimated Tax -->
|
||||
<div class="tw-flex tw-justify-between tw-border-b tw-border-secondary-100 tw-pt-2 tw-pb-0.5">
|
||||
<div
|
||||
id="estimated-tax-section"
|
||||
class="tw-flex tw-justify-between tw-border-b tw-border-secondary-100 tw-pt-2 tw-pb-0.5"
|
||||
>
|
||||
<h3 bitTypography="h5" class="tw-text-muted">{{ "estimatedTax" | i18n }}</h3>
|
||||
<div bitTypography="body1" class="tw-text-muted" data-testid="estimated-tax">
|
||||
{{ estimatedTax() | currency: "USD" : "symbol" }}
|
||||
@@ -125,7 +128,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Total -->
|
||||
<div class="tw-flex tw-justify-between tw-items-center tw-pt-2">
|
||||
<div id="total-section" class="tw-flex tw-justify-between tw-items-center tw-pt-2">
|
||||
<h3 bitTypography="h5" class="tw-text-muted">{{ "total" | i18n }}</h3>
|
||||
<div bitTypography="body1" class="tw-text-muted" data-testid="final-total">
|
||||
{{ total() | currency: "USD" : "symbol" }} / {{ passwordManager.cadence | i18n }}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { CartSummaryComponent, LineItem } from "./cart-summary.component";
|
||||
|
||||
describe("CartSummaryComponent", () => {
|
||||
@@ -9,14 +11,14 @@ describe("CartSummaryComponent", () => {
|
||||
|
||||
const mockPasswordManager: LineItem = {
|
||||
quantity: 5,
|
||||
name: "Password Manager",
|
||||
name: "members",
|
||||
cost: 50,
|
||||
cadence: "month",
|
||||
};
|
||||
|
||||
const mockAdditionalStorage: LineItem = {
|
||||
quantity: 2,
|
||||
name: "Additional Storage",
|
||||
name: "additionalStorageGB",
|
||||
cost: 10,
|
||||
cadence: "month",
|
||||
};
|
||||
@@ -24,46 +26,26 @@ describe("CartSummaryComponent", () => {
|
||||
const mockSecretsManager = {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: "Secrets Manager Seats",
|
||||
name: "secretsManagerSeats",
|
||||
cost: 30,
|
||||
cadence: "month" as "month" | "year",
|
||||
cadence: "month",
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 2,
|
||||
name: "Additional Service Accounts",
|
||||
name: "additionalServiceAccountsV2",
|
||||
cost: 6,
|
||||
cadence: "month" as "month" | "year",
|
||||
cadence: "month",
|
||||
},
|
||||
};
|
||||
|
||||
const mockEstimatedTax = 9.6;
|
||||
|
||||
function setupComponent(
|
||||
options: {
|
||||
passwordManager?: LineItem;
|
||||
additionalStorage?: LineItem | null;
|
||||
secretsManager?: { seats: LineItem; additionalServiceAccounts?: LineItem } | null;
|
||||
estimatedTax?: number;
|
||||
} = {},
|
||||
) {
|
||||
const pm = options.passwordManager ?? mockPasswordManager;
|
||||
const storage =
|
||||
options.additionalStorage !== null
|
||||
? (options.additionalStorage ?? mockAdditionalStorage)
|
||||
: undefined;
|
||||
const sm =
|
||||
options.secretsManager !== null ? (options.secretsManager ?? mockSecretsManager) : undefined;
|
||||
const tax = options.estimatedTax ?? mockEstimatedTax;
|
||||
|
||||
function setupComponent() {
|
||||
// Set input values
|
||||
fixture.componentRef.setInput("passwordManager", pm);
|
||||
if (storage !== undefined) {
|
||||
fixture.componentRef.setInput("additionalStorage", storage);
|
||||
}
|
||||
if (sm !== undefined) {
|
||||
fixture.componentRef.setInput("secretsManager", sm);
|
||||
}
|
||||
fixture.componentRef.setInput("estimatedTax", tax);
|
||||
fixture.componentRef.setInput("passwordManager", mockPasswordManager);
|
||||
fixture.componentRef.setInput("additionalStorage", mockAdditionalStorage);
|
||||
fixture.componentRef.setInput("secretsManager", mockSecretsManager);
|
||||
fixture.componentRef.setInput("estimatedTax", mockEstimatedTax);
|
||||
|
||||
fixture.detectChanges();
|
||||
}
|
||||
@@ -71,6 +53,49 @@ describe("CartSummaryComponent", () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CartSummaryComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: {
|
||||
t: (key: string) => {
|
||||
switch (key) {
|
||||
case "month":
|
||||
return "month";
|
||||
case "year":
|
||||
return "year";
|
||||
case "members":
|
||||
return "Members";
|
||||
case "additionalStorageGB":
|
||||
return "Additional storage GB";
|
||||
case "additionalServiceAccountsV2":
|
||||
return "Additional machine accounts";
|
||||
case "secretsManagerSeats":
|
||||
return "Secrets Manager seats";
|
||||
case "passwordManager":
|
||||
return "Password Manager";
|
||||
case "secretsManager":
|
||||
return "Secrets Manager";
|
||||
case "additionalStorage":
|
||||
return "Additional Storage";
|
||||
case "estimatedTax":
|
||||
return "Estimated tax";
|
||||
case "total":
|
||||
return "Total";
|
||||
case "expandPurchaseDetails":
|
||||
return "Expand purchase details";
|
||||
case "collapsePurchaseDetails":
|
||||
return "Collapse purchase details";
|
||||
case "familiesMembership":
|
||||
return "Families membership";
|
||||
case "premiumMembership":
|
||||
return "Premium membership";
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(CartSummaryComponent);
|
||||
@@ -116,7 +141,7 @@ describe("CartSummaryComponent", () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
// Act / Assert
|
||||
const detailsSection = fixture.debugElement.query(By.css(".tw-mb-4.tw-pb-4.tw-text-muted"));
|
||||
const detailsSection = fixture.debugElement.query(By.css('[id="purchase-summary-details"]'));
|
||||
expect(detailsSection).toBeFalsy();
|
||||
});
|
||||
|
||||
@@ -126,7 +151,7 @@ describe("CartSummaryComponent", () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
// Act / Assert
|
||||
const detailsSection = fixture.debugElement.query(By.css(".tw-mb-4.tw-pb-4.tw-text-muted"));
|
||||
const detailsSection = fixture.debugElement.query(By.css('[id="purchase-summary-details"]'));
|
||||
expect(detailsSection).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -134,10 +159,10 @@ describe("CartSummaryComponent", () => {
|
||||
describe("Content Rendering", () => {
|
||||
it("should display correct password manager information", () => {
|
||||
// Arrange
|
||||
const pmSection = fixture.debugElement.query(By.css(".tw-mb-3.tw-border-b"));
|
||||
const pmHeading = pmSection.query(By.css(".tw-font-semibold"));
|
||||
const pmLineItem = pmSection.query(By.css(".tw-flex-1 .tw-text-sm"));
|
||||
const pmTotal = pmSection.query(By.css(".tw-text-sm:not(.tw-flex-1 *)"));
|
||||
const pmSection = fixture.debugElement.query(By.css('[id="password-manager"]'));
|
||||
const pmHeading = pmSection.query(By.css("h3"));
|
||||
const pmLineItem = pmSection.query(By.css(".tw-flex-1 .tw-text-muted"));
|
||||
const pmTotal = pmSection.query(By.css("[data-testid='password-manager-total']"));
|
||||
|
||||
// Act/ Assert
|
||||
expect(pmSection).toBeTruthy();
|
||||
@@ -150,55 +175,49 @@ describe("CartSummaryComponent", () => {
|
||||
|
||||
it("should display correct additional storage information", () => {
|
||||
// Arrange
|
||||
const storageItem = fixture.debugElement.query(
|
||||
By.css(".tw-mb-3.tw-border-b .tw-flex-justify-between:nth-of-type(3)"),
|
||||
);
|
||||
const storageText = fixture.debugElement.query(By.css(".tw-mb-3.tw-border-b")).nativeElement
|
||||
.textContent;
|
||||
const storageItem = fixture.debugElement.query(By.css("[id='additional-storage']"));
|
||||
const storageText = storageItem.nativeElement.textContent;
|
||||
// Act/Assert
|
||||
|
||||
expect(storageItem).toBeTruthy();
|
||||
expect(storageText).toContain("2 Additional GB");
|
||||
expect(storageText).toContain("2 Additional storage GB");
|
||||
expect(storageText).toContain("$10.00");
|
||||
expect(storageText).toContain("$20.00");
|
||||
});
|
||||
|
||||
it("should display correct secrets manager information", () => {
|
||||
// Arrange
|
||||
const smSection = fixture.debugElement.queryAll(By.css(".tw-mb-3.tw-border-b"))[1];
|
||||
const smHeading = smSection.query(By.css(".tw-font-semibold"));
|
||||
const sectionText = smSection.nativeElement.textContent;
|
||||
const smSection = fixture.debugElement.query(By.css('[id="secrets-manager"]'));
|
||||
const smHeading = smSection.query(By.css("h3"));
|
||||
const sectionText = fixture.debugElement.query(By.css('[id="secrets-manager-members"]'))
|
||||
.nativeElement.textContent;
|
||||
const additionalSA = fixture.debugElement.query(By.css('[id="additional-service-accounts"]'))
|
||||
.nativeElement.textContent;
|
||||
|
||||
// Act/ Assert
|
||||
expect(smSection).toBeTruthy();
|
||||
expect(smHeading.nativeElement.textContent.trim()).toBe("Secrets Manager");
|
||||
|
||||
// Check seats line item
|
||||
expect(sectionText).toContain("3 Members");
|
||||
expect(sectionText).toContain("3 Secrets Manager seats");
|
||||
expect(sectionText).toContain("$30.00");
|
||||
expect(sectionText).toContain("$90.00"); // 3 * $30
|
||||
|
||||
// Check additional service accounts
|
||||
expect(sectionText).toContain("2 Additional machine accounts");
|
||||
expect(sectionText).toContain("$6.00");
|
||||
expect(sectionText).toContain("$12.00"); // 2 * $6
|
||||
expect(additionalSA).toContain("2 Additional machine accounts");
|
||||
expect(additionalSA).toContain("$6.00");
|
||||
expect(additionalSA).toContain("$12.00"); // 2 * $6
|
||||
});
|
||||
|
||||
it("should display correct tax and total", () => {
|
||||
// Arrange
|
||||
const taxSection = fixture.debugElement.query(
|
||||
By.css(".tw-flex.tw-justify-between.tw-mb-3.tw-border-b:last-of-type"),
|
||||
);
|
||||
const taxSection = fixture.debugElement.query(By.css('[id="estimated-tax-section"]'));
|
||||
const expectedTotal = "$381.60"; // 250 + 20 + 90 + 12 + 9.6
|
||||
const topTotal = fixture.debugElement.query(By.css("h2"));
|
||||
const bottomTotal = fixture.debugElement.query(
|
||||
By.css(
|
||||
".tw-flex.tw-justify-between.tw-items-center:last-child .tw-font-semibold:last-child",
|
||||
),
|
||||
);
|
||||
const bottomTotal = fixture.debugElement.query(By.css("[data-testid='final-total']"));
|
||||
|
||||
// Act / Assert
|
||||
expect(taxSection.nativeElement.textContent).toContain("Estimated Tax");
|
||||
expect(taxSection.nativeElement.textContent).toContain("Estimated tax");
|
||||
expect(taxSection.nativeElement.textContent).toContain("$9.60");
|
||||
|
||||
expect(topTotal.nativeElement.textContent).toContain(expectedTotal);
|
||||
|
||||
@@ -24,9 +24,9 @@
|
||||
@if (price(); as priceValue) {
|
||||
<div class="tw-mb-6">
|
||||
<div class="tw-flex tw-items-baseline tw-gap-1 tw-flex-wrap">
|
||||
<span class="tw-text-3xl tw-font-bold tw-leading-none tw-m-0"
|
||||
>${{ priceValue.amount }}</span
|
||||
>
|
||||
<span class="tw-text-3xl tw-font-bold tw-leading-none tw-m-0">{{
|
||||
priceValue.amount | currency: "USD" : "symbol"
|
||||
}}</span>
|
||||
<span bitTypography="helper" class="tw-text-muted">
|
||||
/ {{ priceValue.cadence }}
|
||||
@if (priceValue.showPerUser) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CurrencyPipe } from "@angular/common";
|
||||
import { Component, EventEmitter, input, Output } from "@angular/core";
|
||||
|
||||
import {
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
@Component({
|
||||
selector: "billing-pricing-card",
|
||||
templateUrl: "./pricing-card.component.html",
|
||||
imports: [BadgeModule, ButtonModule, IconModule, TypographyModule],
|
||||
imports: [BadgeModule, ButtonModule, IconModule, TypographyModule, CurrencyPipe],
|
||||
})
|
||||
export class PricingCardComponent {
|
||||
tagline = input.required<string>();
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
"extends": "../../tsconfig.base",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user