mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 05:43:41 +00:00
[PM-26793] Fetch premium plan from pricing service (#16858)
* Fetch premium plan from pricing service * Resolve Claude feedback
This commit is contained in:
@@ -104,7 +104,7 @@ export class AtRiskPasswordsComponent implements OnInit {
|
|||||||
* The UI utilize a bitBadge which does not support async actions (like bitButton does).
|
* The UI utilize a bitBadge which does not support async actions (like bitButton does).
|
||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
protected launchingCipher = signal<CipherView | null>(null);
|
protected readonly launchingCipher = signal<CipherView | null>(null);
|
||||||
|
|
||||||
private activeUserData$ = this.accountService.activeAccount$.pipe(
|
private activeUserData$ = this.accountService.activeAccount$.pipe(
|
||||||
filterOutNullish(),
|
filterOutNullish(),
|
||||||
|
|||||||
@@ -1,132 +1,153 @@
|
|||||||
<bit-container>
|
@if (isLoadingPrices$ | async) {
|
||||||
<bit-section>
|
<ng-container>
|
||||||
<h2 *ngIf="!isSelfHost" bitTypography="h2">{{ "goPremium" | i18n }}</h2>
|
<i
|
||||||
<bit-callout
|
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||||
type="info"
|
title="{{ 'loading' | i18n }}"
|
||||||
*ngIf="hasPremiumFromAnyOrganization$ | async"
|
aria-hidden="true"
|
||||||
title="{{ 'youHavePremiumAccess' | i18n }}"
|
></i>
|
||||||
icon="bwi bwi-star-f"
|
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||||
>
|
</ng-container>
|
||||||
{{ "alreadyPremiumFromOrg" | i18n }}
|
} @else {
|
||||||
</bit-callout>
|
<bit-container>
|
||||||
<bit-callout type="success">
|
<bit-section>
|
||||||
<p>{{ "premiumUpgradeUnlockFeatures" | i18n }}</p>
|
<h2 *ngIf="!isSelfHost" bitTypography="h2">{{ "goPremium" | i18n }}</h2>
|
||||||
<ul class="bwi-ul">
|
<bit-callout
|
||||||
<li>
|
type="info"
|
||||||
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
*ngIf="hasPremiumFromAnyOrganization$ | async"
|
||||||
{{ "premiumSignUpStorage" | i18n }}
|
title="{{ 'youHavePremiumAccess' | i18n }}"
|
||||||
</li>
|
icon="bwi bwi-star-f"
|
||||||
<li>
|
|
||||||
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
|
||||||
{{ "premiumSignUpTwoStepOptions" | i18n }}
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
|
||||||
{{ "premiumSignUpEmergency" | i18n }}
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
|
||||||
{{ "premiumSignUpReports" | i18n }}
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
|
||||||
{{ "premiumSignUpTotp" | i18n }}
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
|
||||||
{{ "premiumSignUpSupport" | i18n }}
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
|
||||||
{{ "premiumSignUpFuture" | i18n }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p bitTypography="body1" [ngClass]="{ 'tw-mb-0': !isSelfHost }">
|
|
||||||
{{
|
|
||||||
"premiumPriceWithFamilyPlan"
|
|
||||||
| i18n: (premiumPrice | currency: "$") : familyPlanMaxUserCount
|
|
||||||
}}
|
|
||||||
<a
|
|
||||||
bitLink
|
|
||||||
linkType="primary"
|
|
||||||
routerLink="/create-organization"
|
|
||||||
[queryParams]="{ plan: 'families' }"
|
|
||||||
>
|
|
||||||
{{ "bitwardenFamiliesPlan" | i18n }}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
bitButton
|
|
||||||
href="{{ premiumURL }}"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
buttonType="secondary"
|
|
||||||
*ngIf="isSelfHost"
|
|
||||||
>
|
>
|
||||||
{{ "purchasePremium" | i18n }}
|
{{ "alreadyPremiumFromOrg" | i18n }}
|
||||||
</a>
|
</bit-callout>
|
||||||
</bit-callout>
|
<bit-callout type="success">
|
||||||
</bit-section>
|
<p>{{ "premiumUpgradeUnlockFeatures" | i18n }}</p>
|
||||||
<bit-section *ngIf="isSelfHost">
|
<ul class="bwi-ul">
|
||||||
<individual-self-hosting-license-uploader
|
<li>
|
||||||
(onLicenseFileUploaded)="onLicenseFileSelectedChanged()"
|
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
||||||
/>
|
{{ "premiumSignUpStorage" | i18n }}
|
||||||
</bit-section>
|
</li>
|
||||||
<form *ngIf="!isSelfHost" [formGroup]="formGroup" [bitSubmit]="submitPayment">
|
<li>
|
||||||
<bit-section>
|
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
||||||
<h2 bitTypography="h2">{{ "addons" | i18n }}</h2>
|
{{ "premiumSignUpTwoStepOptions" | i18n }}
|
||||||
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
|
</li>
|
||||||
<bit-form-field class="tw-col-span-6">
|
<li>
|
||||||
<bit-label>{{ "additionalStorageGb" | i18n }}</bit-label>
|
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
||||||
<input
|
{{ "premiumSignUpEmergency" | i18n }}
|
||||||
bitInput
|
</li>
|
||||||
formControlName="additionalStorage"
|
<li>
|
||||||
type="number"
|
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
||||||
step="1"
|
{{ "premiumSignUpReports" | i18n }}
|
||||||
placeholder="{{ 'additionalStorageGbDesc' | i18n }}"
|
</li>
|
||||||
/>
|
<li>
|
||||||
<bit-hint>{{
|
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
||||||
"additionalStorageIntervalDesc"
|
{{ "premiumSignUpTotp" | i18n }}
|
||||||
| i18n: "1 GB" : (storageGBPrice | currency: "$") : ("year" | i18n)
|
</li>
|
||||||
}}</bit-hint>
|
<li>
|
||||||
</bit-form-field>
|
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
||||||
</div>
|
{{ "premiumSignUpSupport" | i18n }}
|
||||||
</bit-section>
|
</li>
|
||||||
<bit-section>
|
<li>
|
||||||
<h2 bitTypography="h2">{{ "summary" | i18n }}</h2>
|
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
||||||
{{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }} <br />
|
{{ "premiumSignUpFuture" | i18n }}
|
||||||
{{ "additionalStorageGb" | i18n }}: {{ formGroup.value.additionalStorage || 0 }} GB ×
|
</li>
|
||||||
{{ storageGBPrice | currency: "$" }} =
|
</ul>
|
||||||
{{ additionalStorageCost | currency: "$" }}
|
<p bitTypography="body1" [ngClass]="{ 'tw-mb-0': !isSelfHost }">
|
||||||
<hr class="tw-my-3" />
|
{{
|
||||||
</bit-section>
|
"premiumPriceWithFamilyPlan"
|
||||||
<bit-section>
|
| i18n: (premiumPrice$ | async | currency: "$") : familyPlanMaxUserCount
|
||||||
<h3 bitTypography="h2">{{ "paymentInformation" | i18n }}</h3>
|
}}
|
||||||
<div class="tw-mb-4">
|
<a
|
||||||
<app-enter-payment-method
|
bitLink
|
||||||
[group]="formGroup.controls.paymentMethod"
|
linkType="primary"
|
||||||
[showBankAccount]="false"
|
routerLink="/create-organization"
|
||||||
|
[queryParams]="{ plan: 'families' }"
|
||||||
|
>
|
||||||
|
{{ "bitwardenFamiliesPlan" | i18n }}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
bitButton
|
||||||
|
href="{{ premiumURL }}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
buttonType="secondary"
|
||||||
|
*ngIf="isSelfHost"
|
||||||
>
|
>
|
||||||
</app-enter-payment-method>
|
{{ "purchasePremium" | i18n }}
|
||||||
<app-enter-billing-address
|
</a>
|
||||||
[group]="formGroup.controls.billingAddress"
|
</bit-callout>
|
||||||
[scenario]="{ type: 'checkout', supportsTaxId: false }"
|
</bit-section>
|
||||||
>
|
<bit-section *ngIf="isSelfHost">
|
||||||
</app-enter-billing-address>
|
<individual-self-hosting-license-uploader
|
||||||
</div>
|
(onLicenseFileUploaded)="onLicenseFileSelectedChanged()"
|
||||||
<div class="tw-mb-4">
|
/>
|
||||||
<div class="tw-text-muted tw-text-sm tw-flex tw-flex-col">
|
</bit-section>
|
||||||
<span>{{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }}</span>
|
<form *ngIf="!isSelfHost" [formGroup]="formGroup" [bitSubmit]="submitPayment">
|
||||||
<span>{{ "estimatedTax" | i18n }}: {{ estimatedTax | currency: "USD $" }}</span>
|
<bit-section>
|
||||||
|
<h2 bitTypography="h2">{{ "addons" | i18n }}</h2>
|
||||||
|
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
|
||||||
|
<bit-form-field class="tw-col-span-6">
|
||||||
|
<bit-label>{{ "additionalStorageGb" | i18n }}</bit-label>
|
||||||
|
<input
|
||||||
|
bitInput
|
||||||
|
formControlName="additionalStorage"
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
placeholder="{{ 'additionalStorageGbDesc' | i18n }}"
|
||||||
|
/>
|
||||||
|
<bit-hint>{{
|
||||||
|
"additionalStorageIntervalDesc"
|
||||||
|
| i18n: "1 GB" : (storagePrice$ | async | currency: "$") : ("year" | i18n)
|
||||||
|
}}</bit-hint>
|
||||||
|
</bit-form-field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</bit-section>
|
||||||
<hr class="tw-my-1 tw-w-1/4 tw-ml-0" />
|
<bit-section>
|
||||||
<p bitTypography="body1">
|
<h2 bitTypography="h2">{{ "summary" | i18n }}</h2>
|
||||||
<strong>{{ "total" | i18n }}:</strong> {{ total | currency: "USD $" }}/{{ "year" | i18n }}
|
{{ "premiumMembership" | i18n }}: {{ premiumPrice$ | async | currency: "$" }} <br />
|
||||||
</p>
|
{{ "additionalStorageGb" | i18n }}: {{ formGroup.value.additionalStorage || 0 }} GB ×
|
||||||
<button type="submit" buttonType="primary" bitButton bitFormButton>
|
{{ storagePrice$ | async | currency: "$" }} =
|
||||||
{{ "submit" | i18n }}
|
{{ storageCost$ | async | currency: "$" }}
|
||||||
</button>
|
<hr class="tw-my-3" />
|
||||||
</bit-section>
|
</bit-section>
|
||||||
</form>
|
<bit-section>
|
||||||
</bit-container>
|
<h3 bitTypography="h2">{{ "paymentInformation" | i18n }}</h3>
|
||||||
|
<div class="tw-mb-4">
|
||||||
|
<app-enter-payment-method
|
||||||
|
[group]="formGroup.controls.paymentMethod"
|
||||||
|
[showBankAccount]="false"
|
||||||
|
[showAccountCredit]="true"
|
||||||
|
[hasEnoughAccountCredit]="hasEnoughAccountCredit$ | async"
|
||||||
|
>
|
||||||
|
</app-enter-payment-method>
|
||||||
|
<app-enter-billing-address
|
||||||
|
[group]="formGroup.controls.billingAddress"
|
||||||
|
[scenario]="{ type: 'checkout', supportsTaxId: false }"
|
||||||
|
>
|
||||||
|
</app-enter-billing-address>
|
||||||
|
</div>
|
||||||
|
<div class="tw-mb-4">
|
||||||
|
<div class="tw-text-muted tw-text-sm tw-flex tw-flex-col">
|
||||||
|
<span>{{ "planPrice" | i18n }}: {{ subtotal$ | async | currency: "USD $" }}</span>
|
||||||
|
<span>{{ "estimatedTax" | i18n }}: {{ tax$ | async | currency: "USD $" }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr class="tw-my-1 tw-w-1/4 tw-ml-0" />
|
||||||
|
<p bitTypography="body1">
|
||||||
|
<strong>{{ "total" | i18n }}:</strong> {{ total$ | async | currency: "USD $" }}/{{
|
||||||
|
"year" | i18n
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
buttonType="primary"
|
||||||
|
bitButton
|
||||||
|
bitFormButton
|
||||||
|
[disabled]="!(hasEnoughAccountCredit$ | async)"
|
||||||
|
>
|
||||||
|
{{ "submit" | i18n }}
|
||||||
|
</button>
|
||||||
|
</bit-section>
|
||||||
|
</form>
|
||||||
|
</bit-container>
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,19 @@ import { Component, ViewChild } from "@angular/core";
|
|||||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||||
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
||||||
import { ActivatedRoute, Router } from "@angular/router";
|
import { ActivatedRoute, Router } from "@angular/router";
|
||||||
import { combineLatest, concatMap, from, map, Observable, of, startWith, switchMap } from "rxjs";
|
import {
|
||||||
|
combineLatest,
|
||||||
|
concatMap,
|
||||||
|
filter,
|
||||||
|
from,
|
||||||
|
map,
|
||||||
|
Observable,
|
||||||
|
of,
|
||||||
|
startWith,
|
||||||
|
switchMap,
|
||||||
|
catchError,
|
||||||
|
shareReplay,
|
||||||
|
} from "rxjs";
|
||||||
import { debounceTime } from "rxjs/operators";
|
import { debounceTime } from "rxjs/operators";
|
||||||
|
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
@@ -26,7 +38,9 @@ import {
|
|||||||
tokenizablePaymentMethodToLegacyEnum,
|
tokenizablePaymentMethodToLegacyEnum,
|
||||||
NonTokenizablePaymentMethods,
|
NonTokenizablePaymentMethods,
|
||||||
} from "@bitwarden/web-vault/app/billing/payment/types";
|
} from "@bitwarden/web-vault/app/billing/payment/types";
|
||||||
|
import { SubscriptionPricingService } from "@bitwarden/web-vault/app/billing/services/subscription-pricing.service";
|
||||||
import { mapAccountToSubscriber } from "@bitwarden/web-vault/app/billing/types";
|
import { mapAccountToSubscriber } from "@bitwarden/web-vault/app/billing/types";
|
||||||
|
import { PersonalSubscriptionPricingTierIds } from "@bitwarden/web-vault/app/billing/types/subscription-pricing-tier";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
templateUrl: "./premium.component.html",
|
templateUrl: "./premium.component.html",
|
||||||
@@ -37,7 +51,6 @@ export class PremiumComponent {
|
|||||||
@ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent;
|
@ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent;
|
||||||
|
|
||||||
protected hasPremiumFromAnyOrganization$: Observable<boolean>;
|
protected hasPremiumFromAnyOrganization$: Observable<boolean>;
|
||||||
protected accountCredit$: Observable<number>;
|
|
||||||
protected hasEnoughAccountCredit$: Observable<boolean>;
|
protected hasEnoughAccountCredit$: Observable<boolean>;
|
||||||
|
|
||||||
protected formGroup = new FormGroup({
|
protected formGroup = new FormGroup({
|
||||||
@@ -46,13 +59,66 @@ export class PremiumComponent {
|
|||||||
billingAddress: EnterBillingAddressComponent.getFormGroup(),
|
billingAddress: EnterBillingAddressComponent.getFormGroup(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
premiumPrices$ = this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$().pipe(
|
||||||
|
map((tiers) => {
|
||||||
|
const premiumPlan = tiers.find(
|
||||||
|
(tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!premiumPlan) {
|
||||||
|
throw new Error("Could not find Premium plan");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
seat: premiumPlan.passwordManager.annualPrice,
|
||||||
|
storage: premiumPlan.passwordManager.annualPricePerAdditionalStorageGB,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
shareReplay({ bufferSize: 1, refCount: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
premiumPrice$ = this.premiumPrices$.pipe(map((prices) => prices.seat));
|
||||||
|
|
||||||
|
storagePrice$ = this.premiumPrices$.pipe(map((prices) => prices.storage));
|
||||||
|
|
||||||
|
protected isLoadingPrices$ = this.premiumPrices$.pipe(
|
||||||
|
map(() => false),
|
||||||
|
startWith(true),
|
||||||
|
catchError(() => of(false)),
|
||||||
|
);
|
||||||
|
|
||||||
|
storageCost$ = combineLatest([
|
||||||
|
this.storagePrice$,
|
||||||
|
this.formGroup.controls.additionalStorage.valueChanges.pipe(
|
||||||
|
startWith(this.formGroup.value.additionalStorage),
|
||||||
|
),
|
||||||
|
]).pipe(map(([storagePrice, additionalStorage]) => storagePrice * additionalStorage));
|
||||||
|
|
||||||
|
subtotal$ = combineLatest([this.premiumPrice$, this.storageCost$]).pipe(
|
||||||
|
map(([premiumPrice, storageCost]) => premiumPrice + storageCost),
|
||||||
|
);
|
||||||
|
|
||||||
|
tax$ = this.formGroup.valueChanges.pipe(
|
||||||
|
filter(() => this.formGroup.valid),
|
||||||
|
debounceTime(1000),
|
||||||
|
switchMap(async () => {
|
||||||
|
const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress);
|
||||||
|
const taxAmounts = await this.taxClient.previewTaxForPremiumSubscriptionPurchase(
|
||||||
|
this.formGroup.value.additionalStorage,
|
||||||
|
billingAddress,
|
||||||
|
);
|
||||||
|
return taxAmounts.tax;
|
||||||
|
}),
|
||||||
|
startWith(0),
|
||||||
|
);
|
||||||
|
|
||||||
|
total$ = combineLatest([this.subtotal$, this.tax$]).pipe(
|
||||||
|
map(([subtotal, tax]) => subtotal + tax),
|
||||||
|
);
|
||||||
|
|
||||||
protected cloudWebVaultURL: string;
|
protected cloudWebVaultURL: string;
|
||||||
protected isSelfHost = false;
|
protected isSelfHost = false;
|
||||||
|
|
||||||
protected estimatedTax: number = 0;
|
|
||||||
protected readonly familyPlanMaxUserCount = 6;
|
protected readonly familyPlanMaxUserCount = 6;
|
||||||
protected readonly premiumPrice = 10;
|
|
||||||
protected readonly storageGBPrice = 4;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private activatedRoute: ActivatedRoute,
|
private activatedRoute: ActivatedRoute,
|
||||||
@@ -67,6 +133,7 @@ export class PremiumComponent {
|
|||||||
private accountService: AccountService,
|
private accountService: AccountService,
|
||||||
private subscriberBillingClient: SubscriberBillingClient,
|
private subscriberBillingClient: SubscriberBillingClient,
|
||||||
private taxClient: TaxClient,
|
private taxClient: TaxClient,
|
||||||
|
private subscriptionPricingService: SubscriptionPricingService,
|
||||||
) {
|
) {
|
||||||
this.isSelfHost = this.platformUtilsService.isSelfHost();
|
this.isSelfHost = this.platformUtilsService.isSelfHost();
|
||||||
|
|
||||||
@@ -76,23 +143,23 @@ export class PremiumComponent {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch account credit
|
const accountCredit$ = this.accountService.activeAccount$.pipe(
|
||||||
this.accountCredit$ = this.accountService.activeAccount$.pipe(
|
|
||||||
mapAccountToSubscriber,
|
mapAccountToSubscriber,
|
||||||
switchMap((account) => this.subscriberBillingClient.getCredit(account)),
|
switchMap((account) => this.subscriberBillingClient.getCredit(account)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if user has enough account credit for the purchase
|
|
||||||
this.hasEnoughAccountCredit$ = combineLatest([
|
this.hasEnoughAccountCredit$ = combineLatest([
|
||||||
this.accountCredit$,
|
accountCredit$,
|
||||||
this.formGroup.valueChanges.pipe(startWith(this.formGroup.value)),
|
this.total$,
|
||||||
|
this.formGroup.controls.paymentMethod.controls.type.valueChanges.pipe(
|
||||||
|
startWith(this.formGroup.value.paymentMethod.type),
|
||||||
|
),
|
||||||
]).pipe(
|
]).pipe(
|
||||||
map(([credit, formValue]) => {
|
map(([credit, total, paymentMethod]) => {
|
||||||
const selectedPaymentType = formValue.paymentMethod?.type;
|
if (paymentMethod !== NonTokenizablePaymentMethods.accountCredit) {
|
||||||
if (selectedPaymentType !== NonTokenizablePaymentMethods.accountCredit) {
|
return true;
|
||||||
return true; // Not using account credit, so this check doesn't apply
|
|
||||||
}
|
}
|
||||||
return credit >= this.total;
|
return credit >= total;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -116,14 +183,6 @@ export class PremiumComponent {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.subscribe();
|
.subscribe();
|
||||||
|
|
||||||
this.formGroup.valueChanges
|
|
||||||
.pipe(
|
|
||||||
debounceTime(1000),
|
|
||||||
switchMap(async () => await this.refreshSalesTax()),
|
|
||||||
takeUntilDestroyed(),
|
|
||||||
)
|
|
||||||
.subscribe();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
finalizeUpgrade = async () => {
|
finalizeUpgrade = async () => {
|
||||||
@@ -177,38 +236,11 @@ export class PremiumComponent {
|
|||||||
await this.postFinalizeUpgrade();
|
await this.postFinalizeUpgrade();
|
||||||
};
|
};
|
||||||
|
|
||||||
protected get additionalStorageCost(): number {
|
|
||||||
return this.storageGBPrice * this.formGroup.value.additionalStorage;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected get premiumURL(): string {
|
protected get premiumURL(): string {
|
||||||
return `${this.cloudWebVaultURL}/#/settings/subscription/premium`;
|
return `${this.cloudWebVaultURL}/#/settings/subscription/premium`;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected get subtotal(): number {
|
|
||||||
return this.premiumPrice + this.additionalStorageCost;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected get total(): number {
|
|
||||||
return this.subtotal + this.estimatedTax;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async onLicenseFileSelectedChanged(): Promise<void> {
|
protected async onLicenseFileSelectedChanged(): Promise<void> {
|
||||||
await this.postFinalizeUpgrade();
|
await this.postFinalizeUpgrade();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async refreshSalesTax(): Promise<void> {
|
|
||||||
if (this.formGroup.invalid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress);
|
|
||||||
|
|
||||||
const taxAmounts = await this.taxClient.previewTaxForPremiumSubscriptionPurchase(
|
|
||||||
this.formGroup.value.additionalStorage,
|
|
||||||
billingAddress,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.estimatedTax = taxAmounts.tax;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { TestBed } from "@angular/core/testing";
|
import { TestBed } from "@angular/core/testing";
|
||||||
import { mock, MockProxy } from "jest-mock-extended";
|
import { mock, MockProxy } from "jest-mock-extended";
|
||||||
|
import { of } from "rxjs";
|
||||||
|
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||||
import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
|
import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
|
||||||
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
|
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
|
||||||
|
import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { ToastService } from "@bitwarden/components";
|
import { ToastService } from "@bitwarden/components";
|
||||||
import { LogService } from "@bitwarden/logging";
|
import { LogService } from "@bitwarden/logging";
|
||||||
@@ -18,7 +21,8 @@ import { SubscriptionPricingService } from "./subscription-pricing.service";
|
|||||||
|
|
||||||
describe("SubscriptionPricingService", () => {
|
describe("SubscriptionPricingService", () => {
|
||||||
let service: SubscriptionPricingService;
|
let service: SubscriptionPricingService;
|
||||||
let apiService: MockProxy<ApiService>;
|
let billingApiService: MockProxy<BillingApiServiceAbstraction>;
|
||||||
|
let configService: MockProxy<ConfigService>;
|
||||||
let i18nService: MockProxy<I18nService>;
|
let i18nService: MockProxy<I18nService>;
|
||||||
let logService: MockProxy<LogService>;
|
let logService: MockProxy<LogService>;
|
||||||
let toastService: MockProxy<ToastService>;
|
let toastService: MockProxy<ToastService>;
|
||||||
@@ -217,6 +221,15 @@ describe("SubscriptionPricingService", () => {
|
|||||||
continuationToken: null,
|
continuationToken: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockPremiumPlanResponse: PremiumPlanResponse = {
|
||||||
|
seat: {
|
||||||
|
price: 10,
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
price: 4,
|
||||||
|
},
|
||||||
|
} as PremiumPlanResponse;
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
i18nService = mock<I18nService>();
|
i18nService = mock<I18nService>();
|
||||||
logService = mock<LogService>();
|
logService = mock<LogService>();
|
||||||
@@ -320,14 +333,18 @@ describe("SubscriptionPricingService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
apiService = mock<ApiService>();
|
billingApiService = mock<BillingApiServiceAbstraction>();
|
||||||
|
configService = mock<ConfigService>();
|
||||||
|
|
||||||
apiService.getPlans.mockResolvedValue(mockPlansResponse);
|
billingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||||
|
billingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||||
|
configService.getFeatureFlag$.mockReturnValue(of(false)); // Default to false (use hardcoded value)
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
SubscriptionPricingService,
|
SubscriptionPricingService,
|
||||||
{ provide: ApiService, useValue: apiService },
|
{ provide: BillingApiServiceAbstraction, useValue: billingApiService },
|
||||||
|
{ provide: ConfigService, useValue: configService },
|
||||||
{ provide: I18nService, useValue: i18nService },
|
{ provide: I18nService, useValue: i18nService },
|
||||||
{ provide: LogService, useValue: logService },
|
{ provide: LogService, useValue: logService },
|
||||||
{ provide: ToastService, useValue: toastService },
|
{ provide: ToastService, useValue: toastService },
|
||||||
@@ -406,13 +423,16 @@ describe("SubscriptionPricingService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should handle API errors by logging and showing toast", (done) => {
|
it("should handle API errors by logging and showing toast", (done) => {
|
||||||
const errorApiService = mock<ApiService>();
|
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||||
|
const errorConfigService = mock<ConfigService>();
|
||||||
const errorI18nService = mock<I18nService>();
|
const errorI18nService = mock<I18nService>();
|
||||||
const errorLogService = mock<LogService>();
|
const errorLogService = mock<LogService>();
|
||||||
const errorToastService = mock<ToastService>();
|
const errorToastService = mock<ToastService>();
|
||||||
|
|
||||||
const testError = new Error("API error");
|
const testError = new Error("API error");
|
||||||
errorApiService.getPlans.mockRejectedValue(testError);
|
errorBillingApiService.getPlans.mockRejectedValue(testError);
|
||||||
|
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||||
|
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||||
|
|
||||||
errorI18nService.t.mockImplementation((key: string) => {
|
errorI18nService.t.mockImplementation((key: string) => {
|
||||||
if (key === "unexpectedError") {
|
if (key === "unexpectedError") {
|
||||||
@@ -422,7 +442,8 @@ describe("SubscriptionPricingService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const errorService = new SubscriptionPricingService(
|
const errorService = new SubscriptionPricingService(
|
||||||
errorApiService,
|
errorBillingApiService,
|
||||||
|
errorConfigService,
|
||||||
errorI18nService,
|
errorI18nService,
|
||||||
errorLogService,
|
errorLogService,
|
||||||
errorToastService,
|
errorToastService,
|
||||||
@@ -591,13 +612,16 @@ describe("SubscriptionPricingService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should handle API errors by logging and showing toast", (done) => {
|
it("should handle API errors by logging and showing toast", (done) => {
|
||||||
const errorApiService = mock<ApiService>();
|
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||||
|
const errorConfigService = mock<ConfigService>();
|
||||||
const errorI18nService = mock<I18nService>();
|
const errorI18nService = mock<I18nService>();
|
||||||
const errorLogService = mock<LogService>();
|
const errorLogService = mock<LogService>();
|
||||||
const errorToastService = mock<ToastService>();
|
const errorToastService = mock<ToastService>();
|
||||||
|
|
||||||
const testError = new Error("API error");
|
const testError = new Error("API error");
|
||||||
errorApiService.getPlans.mockRejectedValue(testError);
|
errorBillingApiService.getPlans.mockRejectedValue(testError);
|
||||||
|
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||||
|
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||||
|
|
||||||
errorI18nService.t.mockImplementation((key: string) => {
|
errorI18nService.t.mockImplementation((key: string) => {
|
||||||
if (key === "unexpectedError") {
|
if (key === "unexpectedError") {
|
||||||
@@ -607,7 +631,8 @@ describe("SubscriptionPricingService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const errorService = new SubscriptionPricingService(
|
const errorService = new SubscriptionPricingService(
|
||||||
errorApiService,
|
errorBillingApiService,
|
||||||
|
errorConfigService,
|
||||||
errorI18nService,
|
errorI18nService,
|
||||||
errorLogService,
|
errorLogService,
|
||||||
errorToastService,
|
errorToastService,
|
||||||
@@ -831,13 +856,16 @@ describe("SubscriptionPricingService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should handle API errors by logging and showing toast", (done) => {
|
it("should handle API errors by logging and showing toast", (done) => {
|
||||||
const errorApiService = mock<ApiService>();
|
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||||
|
const errorConfigService = mock<ConfigService>();
|
||||||
const errorI18nService = mock<I18nService>();
|
const errorI18nService = mock<I18nService>();
|
||||||
const errorLogService = mock<LogService>();
|
const errorLogService = mock<LogService>();
|
||||||
const errorToastService = mock<ToastService>();
|
const errorToastService = mock<ToastService>();
|
||||||
|
|
||||||
const testError = new Error("API error");
|
const testError = new Error("API error");
|
||||||
errorApiService.getPlans.mockRejectedValue(testError);
|
errorBillingApiService.getPlans.mockRejectedValue(testError);
|
||||||
|
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||||
|
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||||
|
|
||||||
errorI18nService.t.mockImplementation((key: string) => {
|
errorI18nService.t.mockImplementation((key: string) => {
|
||||||
if (key === "unexpectedError") {
|
if (key === "unexpectedError") {
|
||||||
@@ -847,7 +875,8 @@ describe("SubscriptionPricingService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const errorService = new SubscriptionPricingService(
|
const errorService = new SubscriptionPricingService(
|
||||||
errorApiService,
|
errorBillingApiService,
|
||||||
|
errorConfigService,
|
||||||
errorI18nService,
|
errorI18nService,
|
||||||
errorLogService,
|
errorLogService,
|
||||||
errorToastService,
|
errorToastService,
|
||||||
@@ -871,9 +900,137 @@ describe("SubscriptionPricingService", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Edge case handling", () => {
|
||||||
|
it("should handle getPremiumPlan() error when getPlans() succeeds", (done) => {
|
||||||
|
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||||
|
const errorConfigService = mock<ConfigService>();
|
||||||
|
|
||||||
|
const testError = new Error("Premium plan API error");
|
||||||
|
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||||
|
errorBillingApiService.getPremiumPlan.mockRejectedValue(testError);
|
||||||
|
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag to use premium plan API
|
||||||
|
|
||||||
|
const errorService = new SubscriptionPricingService(
|
||||||
|
errorBillingApiService,
|
||||||
|
errorConfigService,
|
||||||
|
i18nService,
|
||||||
|
logService,
|
||||||
|
toastService,
|
||||||
|
);
|
||||||
|
|
||||||
|
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
|
||||||
|
next: (tiers) => {
|
||||||
|
// Should return empty array due to error in premium plan fetch
|
||||||
|
expect(tiers).toEqual([]);
|
||||||
|
expect(logService.error).toHaveBeenCalledWith(
|
||||||
|
"Failed to fetch premium plan from API",
|
||||||
|
testError,
|
||||||
|
);
|
||||||
|
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||||
|
variant: "error",
|
||||||
|
title: "",
|
||||||
|
message: "An unexpected error has occurred.",
|
||||||
|
});
|
||||||
|
done();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
fail("Observable should not error, it should return empty array");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle malformed premium plan API response", (done) => {
|
||||||
|
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||||
|
const errorConfigService = mock<ConfigService>();
|
||||||
|
|
||||||
|
// Malformed response missing the Seat property
|
||||||
|
const malformedResponse = {
|
||||||
|
Storage: {
|
||||||
|
StripePriceId: "price_storage",
|
||||||
|
Price: 4,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||||
|
errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any);
|
||||||
|
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag
|
||||||
|
|
||||||
|
const errorService = new SubscriptionPricingService(
|
||||||
|
errorBillingApiService,
|
||||||
|
errorConfigService,
|
||||||
|
i18nService,
|
||||||
|
logService,
|
||||||
|
toastService,
|
||||||
|
);
|
||||||
|
|
||||||
|
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
|
||||||
|
next: (tiers) => {
|
||||||
|
// Should return empty array due to validation error
|
||||||
|
expect(tiers).toEqual([]);
|
||||||
|
expect(logService.error).toHaveBeenCalled();
|
||||||
|
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||||
|
variant: "error",
|
||||||
|
title: "",
|
||||||
|
message: "An unexpected error has occurred.",
|
||||||
|
});
|
||||||
|
done();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
fail("Observable should not error, it should return empty array");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle malformed premium plan with invalid price types", (done) => {
|
||||||
|
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||||
|
const errorConfigService = mock<ConfigService>();
|
||||||
|
|
||||||
|
// Malformed response with price as string instead of number
|
||||||
|
const malformedResponse = {
|
||||||
|
Seat: {
|
||||||
|
StripePriceId: "price_seat",
|
||||||
|
Price: "10", // Should be a number
|
||||||
|
},
|
||||||
|
Storage: {
|
||||||
|
StripePriceId: "price_storage",
|
||||||
|
Price: 4,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||||
|
errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any);
|
||||||
|
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag
|
||||||
|
|
||||||
|
const errorService = new SubscriptionPricingService(
|
||||||
|
errorBillingApiService,
|
||||||
|
errorConfigService,
|
||||||
|
i18nService,
|
||||||
|
logService,
|
||||||
|
toastService,
|
||||||
|
);
|
||||||
|
|
||||||
|
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
|
||||||
|
next: (tiers) => {
|
||||||
|
// Should return empty array due to validation error
|
||||||
|
expect(tiers).toEqual([]);
|
||||||
|
expect(logService.error).toHaveBeenCalled();
|
||||||
|
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||||
|
variant: "error",
|
||||||
|
title: "",
|
||||||
|
message: "An unexpected error has occurred.",
|
||||||
|
});
|
||||||
|
done();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
fail("Observable should not error, it should return empty array");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Observable behavior and caching", () => {
|
describe("Observable behavior and caching", () => {
|
||||||
it("should share API response between multiple subscriptions", () => {
|
it("should share API response between multiple subscriptions", () => {
|
||||||
const getPlansResponse = jest.spyOn(apiService, "getPlans");
|
const getPlansResponse = jest.spyOn(billingApiService, "getPlans");
|
||||||
|
|
||||||
// Subscribe to multiple observables
|
// Subscribe to multiple observables
|
||||||
service.getPersonalSubscriptionPricingTiers$().subscribe();
|
service.getPersonalSubscriptionPricingTiers$().subscribe();
|
||||||
@@ -883,5 +1040,67 @@ describe("SubscriptionPricingService", () => {
|
|||||||
// API should only be called once due to shareReplay
|
// API should only be called once due to shareReplay
|
||||||
expect(getPlansResponse).toHaveBeenCalledTimes(1);
|
expect(getPlansResponse).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should share premium plan API response between multiple subscriptions when feature flag is enabled", () => {
|
||||||
|
// Create a new mock to avoid conflicts with beforeEach setup
|
||||||
|
const newBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||||
|
const newConfigService = mock<ConfigService>();
|
||||||
|
|
||||||
|
newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||||
|
newBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||||
|
newConfigService.getFeatureFlag$.mockReturnValue(of(true));
|
||||||
|
|
||||||
|
const getPremiumPlanSpy = jest.spyOn(newBillingApiService, "getPremiumPlan");
|
||||||
|
|
||||||
|
// Create a new service instance with the feature flag enabled
|
||||||
|
const newService = new SubscriptionPricingService(
|
||||||
|
newBillingApiService,
|
||||||
|
newConfigService,
|
||||||
|
i18nService,
|
||||||
|
logService,
|
||||||
|
toastService,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Subscribe to the premium pricing tier multiple times
|
||||||
|
newService.getPersonalSubscriptionPricingTiers$().subscribe();
|
||||||
|
newService.getPersonalSubscriptionPricingTiers$().subscribe();
|
||||||
|
|
||||||
|
// API should only be called once due to shareReplay on premiumPlanResponse$
|
||||||
|
expect(getPremiumPlanSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use hardcoded premium price when feature flag is disabled", (done) => {
|
||||||
|
// Create a new mock to test from scratch
|
||||||
|
const newBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||||
|
const newConfigService = mock<ConfigService>();
|
||||||
|
|
||||||
|
newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||||
|
newBillingApiService.getPremiumPlan.mockResolvedValue({
|
||||||
|
seat: { price: 999 }, // Different price to verify hardcoded value is used
|
||||||
|
storage: { price: 999 },
|
||||||
|
} as PremiumPlanResponse);
|
||||||
|
newConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||||
|
|
||||||
|
// Create a new service instance with the feature flag disabled
|
||||||
|
const newService = new SubscriptionPricingService(
|
||||||
|
newBillingApiService,
|
||||||
|
newConfigService,
|
||||||
|
i18nService,
|
||||||
|
logService,
|
||||||
|
toastService,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Subscribe with feature flag disabled
|
||||||
|
newService.getPersonalSubscriptionPricingTiers$().subscribe((tiers) => {
|
||||||
|
const premiumTier = tiers.find(
|
||||||
|
(tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should use hardcoded value of 10, not the API response value of 999
|
||||||
|
expect(premiumTier!.passwordManager.annualPrice).toBe(10);
|
||||||
|
expect(premiumTier!.passwordManager.annualPricePerAdditionalStorageGB).toBe(4);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { Injectable } from "@angular/core";
|
import { Injectable } from "@angular/core";
|
||||||
import { combineLatest, from, map, Observable, of, shareReplay } from "rxjs";
|
import { combineLatest, from, map, Observable, of, shareReplay, switchMap, take } from "rxjs";
|
||||||
import { catchError } from "rxjs/operators";
|
import { catchError } from "rxjs/operators";
|
||||||
|
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||||
import { PlanType } from "@bitwarden/common/billing/enums";
|
import { PlanType } from "@bitwarden/common/billing/enums";
|
||||||
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
|
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
|
||||||
|
import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response";
|
||||||
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { ToastService } from "@bitwarden/components";
|
import { ToastService } from "@bitwarden/components";
|
||||||
import { LogService } from "@bitwarden/logging";
|
import { LogService } from "@bitwarden/logging";
|
||||||
@@ -20,8 +23,18 @@ import {
|
|||||||
|
|
||||||
@Injectable({ providedIn: BillingServicesModule })
|
@Injectable({ providedIn: BillingServicesModule })
|
||||||
export class SubscriptionPricingService {
|
export class SubscriptionPricingService {
|
||||||
|
/**
|
||||||
|
* Fallback premium pricing used when the feature flag is disabled.
|
||||||
|
* These values represent the legacy pricing model and will not reflect
|
||||||
|
* server-side price changes. They are retained for backward compatibility
|
||||||
|
* during the feature flag rollout period.
|
||||||
|
*/
|
||||||
|
private static readonly FALLBACK_PREMIUM_SEAT_PRICE = 10;
|
||||||
|
private static readonly FALLBACK_PREMIUM_STORAGE_PRICE = 4;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private apiService: ApiService,
|
private billingApiService: BillingApiServiceAbstraction,
|
||||||
|
private configService: ConfigService,
|
||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
private logService: LogService,
|
private logService: LogService,
|
||||||
private toastService: ToastService,
|
private toastService: ToastService,
|
||||||
@@ -55,34 +68,56 @@ export class SubscriptionPricingService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
private plansResponse$: Observable<ListResponse<PlanResponse>> = from(
|
private plansResponse$: Observable<ListResponse<PlanResponse>> = from(
|
||||||
this.apiService.getPlans(),
|
this.billingApiService.getPlans(),
|
||||||
).pipe(shareReplay({ bufferSize: 1, refCount: false }));
|
).pipe(shareReplay({ bufferSize: 1, refCount: false }));
|
||||||
|
|
||||||
private premium$: Observable<PersonalSubscriptionPricingTier> = of({
|
private premiumPlanResponse$: Observable<PremiumPlanResponse> = from(
|
||||||
// premium plan is not configured server-side so for now, hardcode it
|
this.billingApiService.getPremiumPlan(),
|
||||||
basePrice: 10,
|
).pipe(
|
||||||
additionalStoragePricePerGb: 4,
|
catchError((error: unknown) => {
|
||||||
}).pipe(
|
this.logService.error("Failed to fetch premium plan from API", error);
|
||||||
map((details) => ({
|
throw error; // Re-throw to propagate to higher-level error handler
|
||||||
id: PersonalSubscriptionPricingTierIds.Premium,
|
}),
|
||||||
name: this.i18nService.t("premium"),
|
shareReplay({ bufferSize: 1, refCount: false }),
|
||||||
description: this.i18nService.t("planDescPremium"),
|
|
||||||
availableCadences: [SubscriptionCadenceIds.Annually],
|
|
||||||
passwordManager: {
|
|
||||||
type: "standalone",
|
|
||||||
annualPrice: details.basePrice,
|
|
||||||
annualPricePerAdditionalStorageGB: details.additionalStoragePricePerGb,
|
|
||||||
features: [
|
|
||||||
this.featureTranslations.builtInAuthenticator(),
|
|
||||||
this.featureTranslations.secureFileStorage(),
|
|
||||||
this.featureTranslations.emergencyAccess(),
|
|
||||||
this.featureTranslations.breachMonitoring(),
|
|
||||||
this.featureTranslations.andMoreFeatures(),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private premium$: Observable<PersonalSubscriptionPricingTier> = this.configService
|
||||||
|
.getFeatureFlag$(FeatureFlag.PM26793_FetchPremiumPriceFromPricingService)
|
||||||
|
.pipe(
|
||||||
|
take(1), // Lock behavior at first subscription to prevent switching data sources mid-stream
|
||||||
|
switchMap((fetchPremiumFromPricingService) =>
|
||||||
|
fetchPremiumFromPricingService
|
||||||
|
? this.premiumPlanResponse$.pipe(
|
||||||
|
map((premiumPlan) => ({
|
||||||
|
seat: premiumPlan.seat.price,
|
||||||
|
storage: premiumPlan.storage.price,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
: of({
|
||||||
|
seat: SubscriptionPricingService.FALLBACK_PREMIUM_SEAT_PRICE,
|
||||||
|
storage: SubscriptionPricingService.FALLBACK_PREMIUM_STORAGE_PRICE,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
map((premiumPrices) => ({
|
||||||
|
id: PersonalSubscriptionPricingTierIds.Premium,
|
||||||
|
name: this.i18nService.t("premium"),
|
||||||
|
description: this.i18nService.t("planDescPremium"),
|
||||||
|
availableCadences: [SubscriptionCadenceIds.Annually],
|
||||||
|
passwordManager: {
|
||||||
|
type: "standalone",
|
||||||
|
annualPrice: premiumPrices.seat,
|
||||||
|
annualPricePerAdditionalStorageGB: premiumPrices.storage,
|
||||||
|
features: [
|
||||||
|
this.featureTranslations.builtInAuthenticator(),
|
||||||
|
this.featureTranslations.secureFileStorage(),
|
||||||
|
this.featureTranslations.emergencyAccess(),
|
||||||
|
this.featureTranslations.breachMonitoring(),
|
||||||
|
this.featureTranslations.andMoreFeatures(),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
private families$: Observable<PersonalSubscriptionPricingTier> = this.plansResponse$.pipe(
|
private families$: Observable<PersonalSubscriptionPricingTier> = this.plansResponse$.pipe(
|
||||||
map((plans) => {
|
map((plans) => {
|
||||||
const familiesPlan = plans.data.find((plan) => plan.type === PlanType.FamiliesAnnually)!;
|
const familiesPlan = plans.data.find((plan) => plan.type === PlanType.FamiliesAnnually)!;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response";
|
||||||
|
|
||||||
import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request";
|
import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request";
|
||||||
import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request";
|
import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request";
|
||||||
import { OrganizationBillingMetadataResponse } from "../../billing/models/response/organization-billing-metadata.response";
|
import { OrganizationBillingMetadataResponse } from "../../billing/models/response/organization-billing-metadata.response";
|
||||||
@@ -25,6 +27,8 @@ export abstract class BillingApiServiceAbstraction {
|
|||||||
|
|
||||||
abstract getPlans(): Promise<ListResponse<PlanResponse>>;
|
abstract getPlans(): Promise<ListResponse<PlanResponse>>;
|
||||||
|
|
||||||
|
abstract getPremiumPlan(): Promise<PremiumPlanResponse>;
|
||||||
|
|
||||||
abstract getProviderClientInvoiceReport(providerId: string, invoiceId: string): Promise<string>;
|
abstract getProviderClientInvoiceReport(providerId: string, invoiceId: string): Promise<string>;
|
||||||
|
|
||||||
abstract getProviderInvoices(providerId: string): Promise<InvoicesResponse>;
|
abstract getProviderInvoices(providerId: string): Promise<InvoicesResponse>;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||||
|
|
||||||
|
export class PremiumPlanResponse extends BaseResponse {
|
||||||
|
seat: {
|
||||||
|
stripePriceId: string;
|
||||||
|
price: number;
|
||||||
|
};
|
||||||
|
storage: {
|
||||||
|
stripePriceId: string;
|
||||||
|
price: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(response: any) {
|
||||||
|
super(response);
|
||||||
|
|
||||||
|
const seat = this.getResponseProperty("Seat");
|
||||||
|
if (!seat || typeof seat !== "object") {
|
||||||
|
throw new Error("PremiumPlanResponse: Missing or invalid 'Seat' property");
|
||||||
|
}
|
||||||
|
this.seat = new PurchasableResponse(seat);
|
||||||
|
|
||||||
|
const storage = this.getResponseProperty("Storage");
|
||||||
|
if (!storage || typeof storage !== "object") {
|
||||||
|
throw new Error("PremiumPlanResponse: Missing or invalid 'Storage' property");
|
||||||
|
}
|
||||||
|
this.storage = new PurchasableResponse(storage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PurchasableResponse extends BaseResponse {
|
||||||
|
stripePriceId: string;
|
||||||
|
price: number;
|
||||||
|
|
||||||
|
constructor(response: any) {
|
||||||
|
super(response);
|
||||||
|
|
||||||
|
this.stripePriceId = this.getResponseProperty("StripePriceId");
|
||||||
|
if (!this.stripePriceId || typeof this.stripePriceId !== "string") {
|
||||||
|
throw new Error("PurchasableResponse: Missing or invalid 'StripePriceId' property");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.price = this.getResponseProperty("Price");
|
||||||
|
if (typeof this.price !== "number" || isNaN(this.price)) {
|
||||||
|
throw new Error("PurchasableResponse: Missing or invalid 'Price' property");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
// FIXME: Update this file to be type safe and remove this and next line
|
// FIXME: Update this file to be type safe and remove this and next line
|
||||||
// @ts-strict-ignore
|
// @ts-strict-ignore
|
||||||
|
|
||||||
|
import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response";
|
||||||
|
|
||||||
import { ApiService } from "../../abstractions/api.service";
|
import { ApiService } from "../../abstractions/api.service";
|
||||||
import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request";
|
import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request";
|
||||||
import { ListResponse } from "../../models/response/list.response";
|
import { ListResponse } from "../../models/response/list.response";
|
||||||
@@ -61,10 +63,15 @@ export class BillingApiService implements BillingApiServiceAbstraction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getPlans(): Promise<ListResponse<PlanResponse>> {
|
async getPlans(): Promise<ListResponse<PlanResponse>> {
|
||||||
const r = await this.apiService.send("GET", "/plans", null, false, true);
|
const r = await this.apiService.send("GET", "/plans", null, true, true);
|
||||||
return new ListResponse(r, PlanResponse);
|
return new ListResponse(r, PlanResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPremiumPlan(): Promise<PremiumPlanResponse> {
|
||||||
|
const response = await this.apiService.send("GET", "/plans/premium", null, true, true);
|
||||||
|
return new PremiumPlanResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
async getProviderClientInvoiceReport(providerId: string, invoiceId: string): Promise<string> {
|
async getProviderClientInvoiceReport(providerId: string, invoiceId: string): Promise<string> {
|
||||||
const response = await this.apiService.send(
|
const response = await this.apiService.send(
|
||||||
"GET",
|
"GET",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export enum FeatureFlag {
|
|||||||
PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure",
|
PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure",
|
||||||
PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog",
|
PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog",
|
||||||
PM24033PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page",
|
PM24033PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page",
|
||||||
|
PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service",
|
||||||
|
|
||||||
/* Key Management */
|
/* Key Management */
|
||||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||||
@@ -115,6 +116,7 @@ export const DefaultFeatureFlagValue = {
|
|||||||
[FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE,
|
[FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE,
|
||||||
[FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE,
|
[FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE,
|
||||||
[FeatureFlag.PM24033PremiumUpgradeNewDesign]: FALSE,
|
[FeatureFlag.PM24033PremiumUpgradeNewDesign]: FALSE,
|
||||||
|
[FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE,
|
||||||
|
|
||||||
/* Key Management */
|
/* Key Management */
|
||||||
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
||||||
|
|||||||
Reference in New Issue
Block a user