Skip to content
Draft
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
640e3c8
feat(billing): add provided as a required property to premium response
sbrown-livefront Nov 13, 2025
630a3c1
fix(billing): replace hard coded storage variables with retrieved plan
sbrown-livefront Nov 13, 2025
c21f28a
tests(billing): add tests to pricing-summary service
sbrown-livefront Nov 13, 2025
93dc312
feat(billing): add optional property.
sbrown-livefront Nov 14, 2025
05dde04
Merge branch 'main' into billing/pm-27600/replace-hard-coded-pricing-โ€ฆ
sbrown-livefront Nov 14, 2025
0e6b5bf
Merge branch 'main' into billing/pm-27600/replace-hard-coded-pricing-โ€ฆ
sbrown-livefront Nov 14, 2025
1ea8144
fix(billing): update storage logic
sbrown-livefront Nov 14, 2025
1a71cb4
Merge branch 'main' into billing/pm-27600/replace-hard-coded-pricing-โ€ฆ
sbrown-livefront Nov 14, 2025
507525b
fix(billing): remove optional check
sbrown-livefront Nov 14, 2025
ee2b2ef
fix(billing): remove optionality
sbrown-livefront Nov 14, 2025
380d8ef
fix(billing): remove optionality
sbrown-livefront Nov 14, 2025
02dbda2
fix(billing): refactored storage calculation logic
sbrown-livefront Nov 19, 2025
9937eef
feat(billing): add provided amounts to subscription-pricing-service
sbrown-livefront Nov 19, 2025
629d107
fix(billing): update cloud premium component
sbrown-livefront Nov 19, 2025
2afc885
fix(billing): update desktop premium component
sbrown-livefront Nov 19, 2025
f45d803
fix(billing): update org plans component
sbrown-livefront Nov 19, 2025
c72b8e4
Merge branch 'main' into billing/pm-27600/replace-hard-coded-pricing-โ€ฆ
sbrown-livefront Nov 20, 2025
dfcd18c
Merge branch 'main' into billing/pm-27600/replace-hard-coded-pricing-โ€ฆ
sbrown-livefront Nov 20, 2025
125f30d
fix(billing) update stories and tests
sbrown-livefront Nov 20, 2025
2353da4
fix(billing): update messages
sbrown-livefront Nov 20, 2025
26b15ed
fix(billing): replace storage sizes
sbrown-livefront Nov 20, 2025
aae8c9c
Merge branch 'main' into billing/pm-27600/replace-hard-coded-pricing-โ€ฆ
sbrown-livefront Nov 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -620,7 +620,9 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
}

get storageGb() {
return this.sub?.maxStorageGb ? this.sub?.maxStorageGb - 1 : 0;
return this.sub?.maxStorageGb
? this.sub?.maxStorageGb - this.selectedPlan.PasswordManager.baseStorageGb
: 0;
}

passwordManagerSeatTotal(plan: PlanResponse): number {
Expand All @@ -646,9 +648,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {

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)
(this.sub?.maxStorageGb ? this.sub.maxStorageGb - plan.PasswordManager.baseStorageGb : 0)
);
}

Expand Down
232 changes: 232 additions & 0 deletions apps/web/src/app/billing/services/pricing-summary.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export class PricingSummaryService {

const additionalStorageTotal = plan.PasswordManager?.hasAdditionalStorageOption
? plan.PasswordManager.additionalStoragePricePerGb *
(sub?.maxStorageGb ? sub.maxStorageGb - 1 : 0)
(sub?.maxStorageGb ? sub.maxStorageGb - plan.PasswordManager.baseStorageGb : 0)
: 0;

const additionalStoragePriceMonthly = plan.PasswordManager?.additionalStoragePricePerGb || 0;
Expand Down Expand Up @@ -66,7 +66,9 @@ export class PricingSummaryService {
: (sub?.customerDiscount?.percentOff ?? 0);
const discountPercentage = 20;
const acceptingSponsorship = false;
const storageGb = sub?.maxStorageGb ? sub?.maxStorageGb - 1 : 0;
const storageGb = sub?.maxStorageGb
? sub?.maxStorageGb - plan.PasswordManager.baseStorageGb
: 0;

const total = organization?.useSecretsManager
? passwordManagerSubtotal + secretsManagerSubtotal + estimatedTax
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ export class PremiumPlanResponse extends BaseResponse {
seat: {
stripePriceId: string;
price: number;
provided?: number;
};
storage: {
stripePriceId: string;
price: number;
provided: number;
};

constructor(response: any) {
Expand All @@ -30,6 +32,7 @@ export class PremiumPlanResponse extends BaseResponse {
class PurchasableResponse extends BaseResponse {
stripePriceId: string;
price: number;
provided: number;

constructor(response: any) {
super(response);
Expand All @@ -43,5 +46,9 @@ class PurchasableResponse extends BaseResponse {
if (typeof this.price !== "number" || isNaN(this.price)) {
throw new Error("PurchasableResponse: Missing or invalid 'Price' property");
}
this.provided = this.getResponseProperty("Provided");
if (typeof this.provided !== "number" || isNaN(this.provided)) {
throw new Error("PurchasableResponse: Missing or invalid 'Provided' property");
}
}
}
Loading