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:
@@ -4,7 +4,7 @@
|
||||
@let additionalServiceAccounts = this.secretsManager()?.additionalServiceAccounts;
|
||||
|
||||
<div class="tw-size-full">
|
||||
<div class="tw-flex tw-items-center tw-pb-4">
|
||||
<div class="tw-flex tw-items-center tw-pb-2">
|
||||
<div class="tw-flex tw-items-center">
|
||||
<h2
|
||||
bitTypography="h4"
|
||||
@@ -34,13 +34,13 @@
|
||||
@if (isExpanded()) {
|
||||
<div id="purchase-summary-details" class="tw-pb-2">
|
||||
<!-- Password Manager Section -->
|
||||
<div class="tw-border-b tw-border-secondary-100 tw-pb-2">
|
||||
<div id="password-manager" class="tw-border-b tw-border-secondary-100 tw-pb-2">
|
||||
<div class="tw-flex tw-justify-between tw-mb-1">
|
||||
<h3 bitTypography="h5" class="tw-text-muted">{{ "passwordManager" | i18n }}</h3>
|
||||
</div>
|
||||
|
||||
<!-- Password Manager Members -->
|
||||
<div class="tw-flex tw-justify-between">
|
||||
<div id="password-manager-members" class="tw-flex tw-justify-between">
|
||||
<div class="tw-flex-1">
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
{{ passwordManager.quantity }} {{ passwordManager.name | i18n }} x
|
||||
@@ -56,7 +56,7 @@
|
||||
|
||||
<!-- Additional Storage -->
|
||||
@if (additionalStorage) {
|
||||
<div class="tw-flex tw-justify-between">
|
||||
<div id="additional-storage" class="tw-flex tw-justify-between">
|
||||
<div class="tw-flex-1">
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
{{ additionalStorage.quantity }} {{ additionalStorage.name | i18n }} x
|
||||
@@ -73,13 +73,13 @@
|
||||
|
||||
<!-- Secrets Manager Section -->
|
||||
@if (secretsManager) {
|
||||
<div class="tw-border-b tw-border-secondary-100 tw-py-2">
|
||||
<div id="secrets-manager" class="tw-border-b tw-border-secondary-100 tw-py-2">
|
||||
<div class="tw-flex tw-justify-between">
|
||||
<h3 bitTypography="h5" class="tw-text-muted">{{ "secretsManager" | i18n }}</h3>
|
||||
</div>
|
||||
|
||||
<!-- Secrets Manager Members -->
|
||||
<div class="tw-flex tw-justify-between">
|
||||
<div id="secrets-manager-members" class="tw-flex tw-justify-between">
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
{{ secretsManager.seats.quantity }} {{ secretsManager.seats.name | i18n }} x
|
||||
{{ secretsManager.seats.cost | currency: "USD" : "symbol" }}
|
||||
@@ -96,7 +96,7 @@
|
||||
|
||||
<!-- Additional Service Accounts -->
|
||||
@if (additionalServiceAccounts) {
|
||||
<div class="tw-flex tw-justify-between">
|
||||
<div id="additional-service-accounts" class="tw-flex tw-justify-between">
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
{{ additionalServiceAccounts.quantity }}
|
||||
{{ additionalServiceAccounts.name | i18n }} x
|
||||
@@ -117,7 +117,10 @@
|
||||
}
|
||||
|
||||
<!-- Estimated Tax -->
|
||||
<div class="tw-flex tw-justify-between tw-border-b tw-border-secondary-100 tw-pt-2 tw-pb-0.5">
|
||||
<div
|
||||
id="estimated-tax-section"
|
||||
class="tw-flex tw-justify-between tw-border-b tw-border-secondary-100 tw-pt-2 tw-pb-0.5"
|
||||
>
|
||||
<h3 bitTypography="h5" class="tw-text-muted">{{ "estimatedTax" | i18n }}</h3>
|
||||
<div bitTypography="body1" class="tw-text-muted" data-testid="estimated-tax">
|
||||
{{ estimatedTax() | currency: "USD" : "symbol" }}
|
||||
@@ -125,7 +128,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Total -->
|
||||
<div class="tw-flex tw-justify-between tw-items-center tw-pt-2">
|
||||
<div id="total-section" class="tw-flex tw-justify-between tw-items-center tw-pt-2">
|
||||
<h3 bitTypography="h5" class="tw-text-muted">{{ "total" | i18n }}</h3>
|
||||
<div bitTypography="body1" class="tw-text-muted" data-testid="final-total">
|
||||
{{ total() | currency: "USD" : "symbol" }} / {{ passwordManager.cadence | i18n }}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { CartSummaryComponent, LineItem } from "./cart-summary.component";
|
||||
|
||||
describe("CartSummaryComponent", () => {
|
||||
@@ -9,14 +11,14 @@ describe("CartSummaryComponent", () => {
|
||||
|
||||
const mockPasswordManager: LineItem = {
|
||||
quantity: 5,
|
||||
name: "Password Manager",
|
||||
name: "members",
|
||||
cost: 50,
|
||||
cadence: "month",
|
||||
};
|
||||
|
||||
const mockAdditionalStorage: LineItem = {
|
||||
quantity: 2,
|
||||
name: "Additional Storage",
|
||||
name: "additionalStorageGB",
|
||||
cost: 10,
|
||||
cadence: "month",
|
||||
};
|
||||
@@ -24,46 +26,26 @@ describe("CartSummaryComponent", () => {
|
||||
const mockSecretsManager = {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: "Secrets Manager Seats",
|
||||
name: "secretsManagerSeats",
|
||||
cost: 30,
|
||||
cadence: "month" as "month" | "year",
|
||||
cadence: "month",
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 2,
|
||||
name: "Additional Service Accounts",
|
||||
name: "additionalServiceAccountsV2",
|
||||
cost: 6,
|
||||
cadence: "month" as "month" | "year",
|
||||
cadence: "month",
|
||||
},
|
||||
};
|
||||
|
||||
const mockEstimatedTax = 9.6;
|
||||
|
||||
function setupComponent(
|
||||
options: {
|
||||
passwordManager?: LineItem;
|
||||
additionalStorage?: LineItem | null;
|
||||
secretsManager?: { seats: LineItem; additionalServiceAccounts?: LineItem } | null;
|
||||
estimatedTax?: number;
|
||||
} = {},
|
||||
) {
|
||||
const pm = options.passwordManager ?? mockPasswordManager;
|
||||
const storage =
|
||||
options.additionalStorage !== null
|
||||
? (options.additionalStorage ?? mockAdditionalStorage)
|
||||
: undefined;
|
||||
const sm =
|
||||
options.secretsManager !== null ? (options.secretsManager ?? mockSecretsManager) : undefined;
|
||||
const tax = options.estimatedTax ?? mockEstimatedTax;
|
||||
|
||||
function setupComponent() {
|
||||
// Set input values
|
||||
fixture.componentRef.setInput("passwordManager", pm);
|
||||
if (storage !== undefined) {
|
||||
fixture.componentRef.setInput("additionalStorage", storage);
|
||||
}
|
||||
if (sm !== undefined) {
|
||||
fixture.componentRef.setInput("secretsManager", sm);
|
||||
}
|
||||
fixture.componentRef.setInput("estimatedTax", tax);
|
||||
fixture.componentRef.setInput("passwordManager", mockPasswordManager);
|
||||
fixture.componentRef.setInput("additionalStorage", mockAdditionalStorage);
|
||||
fixture.componentRef.setInput("secretsManager", mockSecretsManager);
|
||||
fixture.componentRef.setInput("estimatedTax", mockEstimatedTax);
|
||||
|
||||
fixture.detectChanges();
|
||||
}
|
||||
@@ -71,6 +53,49 @@ describe("CartSummaryComponent", () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CartSummaryComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: {
|
||||
t: (key: string) => {
|
||||
switch (key) {
|
||||
case "month":
|
||||
return "month";
|
||||
case "year":
|
||||
return "year";
|
||||
case "members":
|
||||
return "Members";
|
||||
case "additionalStorageGB":
|
||||
return "Additional storage GB";
|
||||
case "additionalServiceAccountsV2":
|
||||
return "Additional machine accounts";
|
||||
case "secretsManagerSeats":
|
||||
return "Secrets Manager seats";
|
||||
case "passwordManager":
|
||||
return "Password Manager";
|
||||
case "secretsManager":
|
||||
return "Secrets Manager";
|
||||
case "additionalStorage":
|
||||
return "Additional Storage";
|
||||
case "estimatedTax":
|
||||
return "Estimated tax";
|
||||
case "total":
|
||||
return "Total";
|
||||
case "expandPurchaseDetails":
|
||||
return "Expand purchase details";
|
||||
case "collapsePurchaseDetails":
|
||||
return "Collapse purchase details";
|
||||
case "familiesMembership":
|
||||
return "Families membership";
|
||||
case "premiumMembership":
|
||||
return "Premium membership";
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(CartSummaryComponent);
|
||||
@@ -116,7 +141,7 @@ describe("CartSummaryComponent", () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
// Act / Assert
|
||||
const detailsSection = fixture.debugElement.query(By.css(".tw-mb-4.tw-pb-4.tw-text-muted"));
|
||||
const detailsSection = fixture.debugElement.query(By.css('[id="purchase-summary-details"]'));
|
||||
expect(detailsSection).toBeFalsy();
|
||||
});
|
||||
|
||||
@@ -126,7 +151,7 @@ describe("CartSummaryComponent", () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
// Act / Assert
|
||||
const detailsSection = fixture.debugElement.query(By.css(".tw-mb-4.tw-pb-4.tw-text-muted"));
|
||||
const detailsSection = fixture.debugElement.query(By.css('[id="purchase-summary-details"]'));
|
||||
expect(detailsSection).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -134,10 +159,10 @@ describe("CartSummaryComponent", () => {
|
||||
describe("Content Rendering", () => {
|
||||
it("should display correct password manager information", () => {
|
||||
// Arrange
|
||||
const pmSection = fixture.debugElement.query(By.css(".tw-mb-3.tw-border-b"));
|
||||
const pmHeading = pmSection.query(By.css(".tw-font-semibold"));
|
||||
const pmLineItem = pmSection.query(By.css(".tw-flex-1 .tw-text-sm"));
|
||||
const pmTotal = pmSection.query(By.css(".tw-text-sm:not(.tw-flex-1 *)"));
|
||||
const pmSection = fixture.debugElement.query(By.css('[id="password-manager"]'));
|
||||
const pmHeading = pmSection.query(By.css("h3"));
|
||||
const pmLineItem = pmSection.query(By.css(".tw-flex-1 .tw-text-muted"));
|
||||
const pmTotal = pmSection.query(By.css("[data-testid='password-manager-total']"));
|
||||
|
||||
// Act/ Assert
|
||||
expect(pmSection).toBeTruthy();
|
||||
@@ -150,55 +175,49 @@ describe("CartSummaryComponent", () => {
|
||||
|
||||
it("should display correct additional storage information", () => {
|
||||
// Arrange
|
||||
const storageItem = fixture.debugElement.query(
|
||||
By.css(".tw-mb-3.tw-border-b .tw-flex-justify-between:nth-of-type(3)"),
|
||||
);
|
||||
const storageText = fixture.debugElement.query(By.css(".tw-mb-3.tw-border-b")).nativeElement
|
||||
.textContent;
|
||||
const storageItem = fixture.debugElement.query(By.css("[id='additional-storage']"));
|
||||
const storageText = storageItem.nativeElement.textContent;
|
||||
// Act/Assert
|
||||
|
||||
expect(storageItem).toBeTruthy();
|
||||
expect(storageText).toContain("2 Additional GB");
|
||||
expect(storageText).toContain("2 Additional storage GB");
|
||||
expect(storageText).toContain("$10.00");
|
||||
expect(storageText).toContain("$20.00");
|
||||
});
|
||||
|
||||
it("should display correct secrets manager information", () => {
|
||||
// Arrange
|
||||
const smSection = fixture.debugElement.queryAll(By.css(".tw-mb-3.tw-border-b"))[1];
|
||||
const smHeading = smSection.query(By.css(".tw-font-semibold"));
|
||||
const sectionText = smSection.nativeElement.textContent;
|
||||
const smSection = fixture.debugElement.query(By.css('[id="secrets-manager"]'));
|
||||
const smHeading = smSection.query(By.css("h3"));
|
||||
const sectionText = fixture.debugElement.query(By.css('[id="secrets-manager-members"]'))
|
||||
.nativeElement.textContent;
|
||||
const additionalSA = fixture.debugElement.query(By.css('[id="additional-service-accounts"]'))
|
||||
.nativeElement.textContent;
|
||||
|
||||
// Act/ Assert
|
||||
expect(smSection).toBeTruthy();
|
||||
expect(smHeading.nativeElement.textContent.trim()).toBe("Secrets Manager");
|
||||
|
||||
// Check seats line item
|
||||
expect(sectionText).toContain("3 Members");
|
||||
expect(sectionText).toContain("3 Secrets Manager seats");
|
||||
expect(sectionText).toContain("$30.00");
|
||||
expect(sectionText).toContain("$90.00"); // 3 * $30
|
||||
|
||||
// Check additional service accounts
|
||||
expect(sectionText).toContain("2 Additional machine accounts");
|
||||
expect(sectionText).toContain("$6.00");
|
||||
expect(sectionText).toContain("$12.00"); // 2 * $6
|
||||
expect(additionalSA).toContain("2 Additional machine accounts");
|
||||
expect(additionalSA).toContain("$6.00");
|
||||
expect(additionalSA).toContain("$12.00"); // 2 * $6
|
||||
});
|
||||
|
||||
it("should display correct tax and total", () => {
|
||||
// Arrange
|
||||
const taxSection = fixture.debugElement.query(
|
||||
By.css(".tw-flex.tw-justify-between.tw-mb-3.tw-border-b:last-of-type"),
|
||||
);
|
||||
const taxSection = fixture.debugElement.query(By.css('[id="estimated-tax-section"]'));
|
||||
const expectedTotal = "$381.60"; // 250 + 20 + 90 + 12 + 9.6
|
||||
const topTotal = fixture.debugElement.query(By.css("h2"));
|
||||
const bottomTotal = fixture.debugElement.query(
|
||||
By.css(
|
||||
".tw-flex.tw-justify-between.tw-items-center:last-child .tw-font-semibold:last-child",
|
||||
),
|
||||
);
|
||||
const bottomTotal = fixture.debugElement.query(By.css("[data-testid='final-total']"));
|
||||
|
||||
// Act / Assert
|
||||
expect(taxSection.nativeElement.textContent).toContain("Estimated Tax");
|
||||
expect(taxSection.nativeElement.textContent).toContain("Estimated tax");
|
||||
expect(taxSection.nativeElement.textContent).toContain("$9.60");
|
||||
|
||||
expect(topTotal.nativeElement.textContent).toContain(expectedTotal);
|
||||
|
||||
@@ -24,9 +24,9 @@
|
||||
@if (price(); as priceValue) {
|
||||
<div class="tw-mb-6">
|
||||
<div class="tw-flex tw-items-baseline tw-gap-1 tw-flex-wrap">
|
||||
<span class="tw-text-3xl tw-font-bold tw-leading-none tw-m-0"
|
||||
>${{ priceValue.amount }}</span
|
||||
>
|
||||
<span class="tw-text-3xl tw-font-bold tw-leading-none tw-m-0">{{
|
||||
priceValue.amount | currency: "USD" : "symbol"
|
||||
}}</span>
|
||||
<span bitTypography="helper" class="tw-text-muted">
|
||||
/ {{ priceValue.cadence }}
|
||||
@if (priceValue.showPerUser) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CurrencyPipe } from "@angular/common";
|
||||
import { Component, EventEmitter, input, Output } from "@angular/core";
|
||||
|
||||
import {
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
@Component({
|
||||
selector: "billing-pricing-card",
|
||||
templateUrl: "./pricing-card.component.html",
|
||||
imports: [BadgeModule, ButtonModule, IconModule, TypographyModule],
|
||||
imports: [BadgeModule, ButtonModule, IconModule, TypographyModule, CurrencyPipe],
|
||||
})
|
||||
export class PricingCardComponent {
|
||||
tagline = input.required<string>();
|
||||
|
||||
Reference in New Issue
Block a user