1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-16 08:13:42 +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:
Stephon Brown
2025-10-08 10:20:15 -04:00
committed by GitHub
parent 0e56392b34
commit da8a0104ea
24 changed files with 1803 additions and 92 deletions

View File

@@ -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 }}

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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>();