mirror of
https://github.com/bitwarden/browser
synced 2025-12-14 15:23:33 +00:00
[PM-27600] Replace Hard-Coded Storage amount (#17393)
* feat(billing): add provided as a required property to premium response * fix(billing): replace hard coded storage variables with retrieved plan * tests(billing): add tests to pricing-summary service * feat(billing): add optional property. * fix(billing): update storage logic * fix(billing): remove optional check * fix(billing): remove optionality * fix(billing): remove optionality * fix(billing): refactored storage calculation logic * feat(billing): add provided amounts to subscription-pricing-service * fix(billing): update cloud premium component * fix(billing): update desktop premium component * fix(billing): update org plans component * fix(billing) update stories and tests * fix(billing): update messages * fix(billing): replace storage sizes * fix(billing): update messages * fix(billing): update components * fix(billing): update components for pricing and storage retrieval * fix(billing): revert self-hosted change
This commit is contained in:
@@ -24,7 +24,7 @@
|
||||
<ul class="bwi-ul">
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
||||
{{ "premiumSignUpStorage" | i18n }}
|
||||
{{ "premiumSignUpStorageV2" | i18n: `${(providedStorageGb$ | async)} GB` }}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
|
||||
@@ -82,7 +82,10 @@
|
||||
/>
|
||||
<bit-hint>{{
|
||||
"additionalStorageIntervalDesc"
|
||||
| i18n: "1 GB" : (storagePrice$ | async | currency: "$") : ("year" | i18n)
|
||||
| i18n
|
||||
: `${(providedStorageGb$ | async)} GB`
|
||||
: (storagePrice$ | async | currency: "$")
|
||||
: ("year" | i18n)
|
||||
}}</bit-hint>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
|
||||
@@ -22,8 +22,8 @@ import { debounceTime } from "rxjs/operators";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
|
||||
import { DefaultSubscriptionPricingService } from "@bitwarden/common/billing/services/subscription-pricing.service";
|
||||
import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -75,6 +75,7 @@ export class CloudHostedPremiumComponent {
|
||||
return {
|
||||
seat: premiumPlan.passwordManager.annualPrice,
|
||||
storage: premiumPlan.passwordManager.annualPricePerAdditionalStorageGB,
|
||||
providedStorageGb: premiumPlan.passwordManager.providedStorageGB,
|
||||
};
|
||||
}),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
@@ -84,6 +85,8 @@ export class CloudHostedPremiumComponent {
|
||||
|
||||
storagePrice$ = this.premiumPrices$.pipe(map((prices) => prices.storage));
|
||||
|
||||
providedStorageGb$ = this.premiumPrices$.pipe(map((prices) => prices.providedStorageGb));
|
||||
|
||||
protected isLoadingPrices$ = this.premiumPrices$.pipe(
|
||||
map(() => false),
|
||||
startWith(true),
|
||||
@@ -134,7 +137,7 @@ export class CloudHostedPremiumComponent {
|
||||
private accountService: AccountService,
|
||||
private subscriberBillingClient: SubscriberBillingClient,
|
||||
private taxClient: TaxClient,
|
||||
private subscriptionPricingService: DefaultSubscriptionPricingService,
|
||||
private subscriptionPricingService: SubscriptionPricingServiceAbstraction,
|
||||
) {
|
||||
this.hasPremiumFromAnyOrganization$ = this.accountService.activeAccount$.pipe(
|
||||
switchMap((account) =>
|
||||
|
||||
@@ -620,7 +620,10 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
get storageGb() {
|
||||
return this.sub?.maxStorageGb ? this.sub?.maxStorageGb - 1 : 0;
|
||||
return Math.max(
|
||||
0,
|
||||
(this.sub?.maxStorageGb ?? 0) - this.selectedPlan.PasswordManager.baseStorageGb,
|
||||
);
|
||||
}
|
||||
|
||||
passwordManagerSeatTotal(plan: PlanResponse): number {
|
||||
@@ -644,12 +647,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (
|
||||
plan.PasswordManager.additionalStoragePricePerGb *
|
||||
// TODO: Eslint upgrade. Please resolve this since the null check does nothing
|
||||
// eslint-disable-next-line no-constant-binary-expression
|
||||
Math.abs(this.sub?.maxStorageGb ? this.sub?.maxStorageGb - 1 : 0 || 0)
|
||||
);
|
||||
return plan.PasswordManager.additionalStoragePricePerGb * this.storageGb;
|
||||
}
|
||||
|
||||
additionalStoragePriceMonthly(selectedPlan: PlanResponse) {
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
<li *ngIf="selectableProduct.PasswordManager.baseStorageGb">
|
||||
{{
|
||||
"gbEncryptedFileStorage"
|
||||
| i18n: selectableProduct.PasswordManager.baseStorageGb + "GB"
|
||||
| i18n: selectableProduct.PasswordManager.baseStorageGb + " GB"
|
||||
}}
|
||||
</li>
|
||||
<li *ngIf="selectableProduct.hasGroups">
|
||||
@@ -239,7 +239,7 @@
|
||||
<bit-hint class="tw-text-sm">{{
|
||||
"additionalStorageIntervalDesc"
|
||||
| i18n
|
||||
: "1 GB"
|
||||
: `${selectedPlan.PasswordManager.baseStorageGb} GB`
|
||||
: (additionalStoragePriceMonthly(selectedPlan) | currency: "$")
|
||||
: ("month" | i18n)
|
||||
}}</bit-hint>
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { PlanInterval, ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import {
|
||||
BillingCustomerDiscount,
|
||||
OrganizationSubscriptionResponse,
|
||||
} from "@bitwarden/common/billing/models/response/organization-subscription.response";
|
||||
import {
|
||||
PasswordManagerPlanFeaturesResponse,
|
||||
PlanResponse,
|
||||
SecretsManagerPlanFeaturesResponse,
|
||||
} from "@bitwarden/common/billing/models/response/plan.response";
|
||||
|
||||
import { PricingSummaryData } from "../shared/pricing-summary/pricing-summary.component";
|
||||
|
||||
import { PricingSummaryService } from "./pricing-summary.service";
|
||||
|
||||
describe("PricingSummaryService", () => {
|
||||
let service: PricingSummaryService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new PricingSummaryService();
|
||||
});
|
||||
|
||||
describe("getPricingSummaryData", () => {
|
||||
let mockPlan: PlanResponse;
|
||||
let mockSub: OrganizationSubscriptionResponse;
|
||||
let mockOrganization: Organization;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock plan with password manager features
|
||||
mockPlan = {
|
||||
productTier: ProductTierType.Teams,
|
||||
PasswordManager: {
|
||||
basePrice: 0,
|
||||
seatPrice: 48,
|
||||
baseSeats: 0,
|
||||
hasAdditionalSeatsOption: true,
|
||||
hasPremiumAccessOption: false,
|
||||
premiumAccessOptionPrice: 0,
|
||||
hasAdditionalStorageOption: true,
|
||||
additionalStoragePricePerGb: 6,
|
||||
baseStorageGb: 1,
|
||||
} as PasswordManagerPlanFeaturesResponse,
|
||||
SecretsManager: {
|
||||
basePrice: 0,
|
||||
seatPrice: 72,
|
||||
baseSeats: 3,
|
||||
hasAdditionalSeatsOption: true,
|
||||
hasAdditionalServiceAccountOption: true,
|
||||
additionalPricePerServiceAccount: 6,
|
||||
baseServiceAccount: 50,
|
||||
} as SecretsManagerPlanFeaturesResponse,
|
||||
} as PlanResponse;
|
||||
|
||||
// Create mock subscription
|
||||
mockSub = {
|
||||
seats: 5,
|
||||
smSeats: 5,
|
||||
smServiceAccounts: 5,
|
||||
maxStorageGb: 2,
|
||||
customerDiscount: null,
|
||||
} as OrganizationSubscriptionResponse;
|
||||
|
||||
// Create mock organization
|
||||
mockOrganization = {
|
||||
useSecretsManager: false,
|
||||
} as Organization;
|
||||
});
|
||||
|
||||
it("should calculate pricing data correctly for password manager only", async () => {
|
||||
const result = await service.getPricingSummaryData(
|
||||
mockPlan,
|
||||
mockSub,
|
||||
mockOrganization,
|
||||
PlanInterval.Monthly,
|
||||
false,
|
||||
50, // estimatedTax
|
||||
);
|
||||
|
||||
expect(result).toEqual<PricingSummaryData>({
|
||||
selectedPlanInterval: "month",
|
||||
passwordManagerSeats: 5,
|
||||
passwordManagerSeatTotal: 240, // 48 * 5
|
||||
secretsManagerSeatTotal: 360, // 72 * 5
|
||||
additionalStorageTotal: 6, // 6 * (2 - 1)
|
||||
additionalStoragePriceMonthly: 6,
|
||||
additionalServiceAccountTotal: 0, // No additional service accounts (50 base vs 5 used)
|
||||
totalAppliedDiscount: 0,
|
||||
secretsManagerSubtotal: 360, // 0 + 360 + 0
|
||||
passwordManagerSubtotal: 246, // 0 + 240 + 6
|
||||
total: 296, // 246 + 50 (tax) - organization doesn't use secrets manager
|
||||
organization: mockOrganization,
|
||||
sub: mockSub,
|
||||
selectedPlan: mockPlan,
|
||||
selectedInterval: PlanInterval.Monthly,
|
||||
discountPercentageFromSub: 0,
|
||||
discountPercentage: 20,
|
||||
acceptingSponsorship: false,
|
||||
additionalServiceAccount: 0, // 50 - 5 = 45, which is > 0, so return 0
|
||||
storageGb: 1,
|
||||
isSecretsManagerTrial: false,
|
||||
estimatedTax: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it("should calculate pricing data correctly with secrets manager enabled", async () => {
|
||||
mockOrganization.useSecretsManager = true;
|
||||
|
||||
const result = await service.getPricingSummaryData(
|
||||
mockPlan,
|
||||
mockSub,
|
||||
mockOrganization,
|
||||
PlanInterval.Monthly,
|
||||
false,
|
||||
50,
|
||||
);
|
||||
|
||||
expect(result.total).toBe(656); // passwordManagerSubtotal (246) + secretsManagerSubtotal (360) + tax (50)
|
||||
});
|
||||
|
||||
it("should handle secrets manager trial", async () => {
|
||||
const result = await service.getPricingSummaryData(
|
||||
mockPlan,
|
||||
mockSub,
|
||||
mockOrganization,
|
||||
PlanInterval.Monthly,
|
||||
true, // isSecretsManagerTrial
|
||||
50,
|
||||
);
|
||||
|
||||
expect(result.passwordManagerSeatTotal).toBe(0); // Should be 0 during trial
|
||||
expect(result.discountPercentageFromSub).toBe(0); // Should be 0 during trial
|
||||
});
|
||||
|
||||
it("should handle premium access option", async () => {
|
||||
mockPlan.PasswordManager.hasPremiumAccessOption = true;
|
||||
mockPlan.PasswordManager.premiumAccessOptionPrice = 25;
|
||||
|
||||
const result = await service.getPricingSummaryData(
|
||||
mockPlan,
|
||||
mockSub,
|
||||
mockOrganization,
|
||||
PlanInterval.Monthly,
|
||||
false,
|
||||
50,
|
||||
);
|
||||
|
||||
expect(result.passwordManagerSubtotal).toBe(271); // 0 + 240 + 6 + 25
|
||||
});
|
||||
|
||||
it("should handle customer discount", async () => {
|
||||
mockSub.customerDiscount = {
|
||||
id: "discount1",
|
||||
active: true,
|
||||
percentOff: 10,
|
||||
appliesTo: ["subscription"],
|
||||
} as BillingCustomerDiscount;
|
||||
|
||||
const result = await service.getPricingSummaryData(
|
||||
mockPlan,
|
||||
mockSub,
|
||||
mockOrganization,
|
||||
PlanInterval.Monthly,
|
||||
false,
|
||||
50,
|
||||
);
|
||||
|
||||
expect(result.discountPercentageFromSub).toBe(10);
|
||||
});
|
||||
|
||||
it("should handle zero storage calculation", async () => {
|
||||
mockSub.maxStorageGb = 1; // Same as base storage
|
||||
|
||||
const result = await service.getPricingSummaryData(
|
||||
mockPlan,
|
||||
mockSub,
|
||||
mockOrganization,
|
||||
PlanInterval.Monthly,
|
||||
false,
|
||||
50,
|
||||
);
|
||||
|
||||
expect(result.additionalStorageTotal).toBe(0);
|
||||
expect(result.storageGb).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAdditionalServiceAccount", () => {
|
||||
let mockPlan: PlanResponse;
|
||||
let mockSub: OrganizationSubscriptionResponse;
|
||||
|
||||
beforeEach(() => {
|
||||
mockPlan = {
|
||||
SecretsManager: {
|
||||
baseServiceAccount: 50,
|
||||
} as SecretsManagerPlanFeaturesResponse,
|
||||
} as PlanResponse;
|
||||
|
||||
mockSub = {
|
||||
smServiceAccounts: 55,
|
||||
} as OrganizationSubscriptionResponse;
|
||||
});
|
||||
|
||||
it("should return additional service accounts when used exceeds base", () => {
|
||||
const result = service.getAdditionalServiceAccount(mockPlan, mockSub);
|
||||
expect(result).toBe(5); // Math.abs(50 - 55) = 5
|
||||
});
|
||||
|
||||
it("should return 0 when used is less than or equal to base", () => {
|
||||
mockSub.smServiceAccounts = 40;
|
||||
const result = service.getAdditionalServiceAccount(mockPlan, mockSub);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it("should return 0 when used equals base", () => {
|
||||
mockSub.smServiceAccounts = 50;
|
||||
const result = service.getAdditionalServiceAccount(mockPlan, mockSub);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it("should return 0 when plan is null", () => {
|
||||
const result = service.getAdditionalServiceAccount(null, mockSub);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it("should return 0 when plan has no SecretsManager", () => {
|
||||
mockPlan.SecretsManager = null;
|
||||
const result = service.getAdditionalServiceAccount(mockPlan, mockSub);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -31,9 +31,10 @@ export class PricingSummaryService {
|
||||
|
||||
const additionalServiceAccount = this.getAdditionalServiceAccount(plan, sub);
|
||||
|
||||
const storageGb = Math.max(0, (sub?.maxStorageGb ?? 0) - plan.PasswordManager.baseStorageGb);
|
||||
|
||||
const additionalStorageTotal = plan.PasswordManager?.hasAdditionalStorageOption
|
||||
? plan.PasswordManager.additionalStoragePricePerGb *
|
||||
(sub?.maxStorageGb ? sub.maxStorageGb - 1 : 0)
|
||||
? plan.PasswordManager.additionalStoragePricePerGb * storageGb
|
||||
: 0;
|
||||
|
||||
const additionalStoragePriceMonthly = plan.PasswordManager?.additionalStoragePricePerGb || 0;
|
||||
@@ -66,7 +67,6 @@ export class PricingSummaryService {
|
||||
: (sub?.customerDiscount?.percentOff ?? 0);
|
||||
const discountPercentage = 20;
|
||||
const acceptingSponsorship = false;
|
||||
const storageGb = sub?.maxStorageGb ? sub?.maxStorageGb - 1 : 0;
|
||||
|
||||
const total = organization?.useSecretsManager
|
||||
? passwordManagerSubtotal + secretsManagerSubtotal + estimatedTax
|
||||
|
||||
@@ -3060,7 +3060,16 @@
|
||||
"message": "Upgrade your account to a Premium membership and unlock some great additional features."
|
||||
},
|
||||
"premiumSignUpStorage": {
|
||||
"message": "1 GB encrypted storage for file attachments."
|
||||
"message": "1 GB encrypted storage for file attachments."
|
||||
},
|
||||
"premiumSignUpStorageV2": {
|
||||
"message": "$SIZE$ encrypted storage for file attachments.",
|
||||
"placeholders": {
|
||||
"size": {
|
||||
"content": "$1",
|
||||
"example": "1 GB"
|
||||
}
|
||||
}
|
||||
},
|
||||
"premiumSignUpTwoStepOptions": {
|
||||
"message": "Proprietary two-step login options such as YubiKey and Duo."
|
||||
|
||||
Reference in New Issue
Block a user