diff --git a/apps/web/src/app/billing/individual/user-subscription.component.html b/apps/web/src/app/billing/individual/user-subscription.component.html index e801237467a..b7e490cdf2e 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.html +++ b/apps/web/src/app/billing/individual/user-subscription.component.html @@ -37,41 +37,63 @@
{{ sub.expiration | date: "mediumDate" }}
{{ "neverExpires" | i18n }}
-
-
-
-
{{ "status" | i18n }}
-
+
+
+
+
{{ "plan" | i18n }}
+
{{ "premiumMembership" | i18n }}
+
+
+
{{ "status" | i18n }}
+
{{ (subscription && subscriptionStatus) || "-" }} - {{ - "pendingCancellation" | i18n - }} -
-
{{ "nextCharge" | i18n }}
-
- {{ - nextInvoice - ? (sub.subscription.periodEndDate | date: "mediumDate") + - ", " + - (nextInvoice.amount | currency: "$") - : "-" - }} -
-
-
-
- {{ "details" | i18n }} - - - - - {{ i.name }} {{ i.quantity > 1 ? "×" + i.quantity : "" }} @ - {{ i.amount | currency: "$" }} - - {{ i.quantity * i.amount | currency: "$" }} /{{ i.interval | i18n }} - - - + {{ "pendingCancellation" | i18n }} +
+
+
+
{{ "nextChargeHeader" | i18n }}
+
+ + +
+ + {{ + (sub.subscription.periodEndDate | date: "MMM d, y") + + ", " + + (discountedSubscriptionAmount | currency: "$") + }} + + +
+
+ +
+ + {{ + (sub.subscription.periodEndDate | date: "MMM d, y") + + ", " + + (subscriptionAmount | currency: "$") + }} + +
+
+
+ - +
+
@@ -90,8 +112,27 @@ - -
+
+

{{ "storage" | i18n }}

+

+ {{ "subscriptionStorage" | i18n: sub.maxStorageGb || 0 : sub.storageName || "0 MB" }} +

+ + +
+
+ + +
+
+
+

{{ "additionalOptions" | i18n }}

+

{{ "additionalOptionsDesc" | i18n }}

+
-

{{ "storage" | i18n }}

-

- {{ "subscriptionStorage" | i18n: sub.maxStorageGb || 0 : sub.storageName || "0 MB" }} -

- - -
-
- - -
-
-
- +
diff --git a/apps/web/src/app/billing/individual/user-subscription.component.ts b/apps/web/src/app/billing/individual/user-subscription.component.ts index 19db9ec8e61..c39b5d153b1 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.ts +++ b/apps/web/src/app/billing/individual/user-subscription.component.ts @@ -7,13 +7,17 @@ import { firstValueFrom, lastValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { BillingCustomerDiscount } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { SubscriptionResponse } from "@bitwarden/common/billing/models/response/subscription.response"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService, ToastService } from "@bitwarden/components"; +import { DiscountInfo } from "@bitwarden/pricing"; import { AdjustStorageDialogComponent, @@ -42,6 +46,10 @@ export class UserSubscriptionComponent implements OnInit { cancelPromise: Promise; reinstatePromise: Promise; + protected enableDiscountDisplay$ = this.configService.getFeatureFlag$( + FeatureFlag.PM23341_Milestone_2, + ); + constructor( private apiService: ApiService, private platformUtilsService: PlatformUtilsService, @@ -54,6 +62,7 @@ export class UserSubscriptionComponent implements OnInit { private billingAccountProfileStateService: BillingAccountProfileStateService, private toastService: ToastService, private accountService: AccountService, + private configService: ConfigService, ) { this.selfHosted = this.platformUtilsService.isSelfHost(); } @@ -187,6 +196,28 @@ export class UserSubscriptionComponent implements OnInit { return this.sub != null ? this.sub.upcomingInvoice : null; } + get subscriptionAmount(): number { + if (!this.subscription?.items || this.subscription.items.length === 0) { + return 0; + } + + return this.subscription.items.reduce( + (sum, item) => sum + (item.amount || 0) * (item.quantity || 0), + 0, + ); + } + + get discountedSubscriptionAmount(): number { + // Use the upcoming invoice amount from the server as it already includes discounts, + // taxes, prorations, and all other adjustments. Fall back to subscription amount + // if upcoming invoice is not available. + if (this.nextInvoice?.amount != null) { + return this.nextInvoice.amount; + } + + return this.subscriptionAmount; + } + get storagePercentage() { return this.sub != null && this.sub.maxStorageGb ? +(100 * (this.sub.storageGb / this.sub.maxStorageGb)).toFixed(2) @@ -217,4 +248,15 @@ export class UserSubscriptionComponent implements OnInit { return this.subscription.status; } } + + getDiscountInfo(discount: BillingCustomerDiscount | null): DiscountInfo | null { + if (!discount) { + return null; + } + return { + active: discount.active, + percentOff: discount.percentOff, + amountOff: discount.amountOff, + }; + } } diff --git a/apps/web/src/app/billing/shared/billing-shared.module.ts b/apps/web/src/app/billing/shared/billing-shared.module.ts index fb593b39328..12792cd781a 100644 --- a/apps/web/src/app/billing/shared/billing-shared.module.ts +++ b/apps/web/src/app/billing/shared/billing-shared.module.ts @@ -1,6 +1,7 @@ import { NgModule } from "@angular/core"; import { BannerModule } from "@bitwarden/components"; +import { DiscountBadgeComponent } from "@bitwarden/pricing"; import { EnterBillingAddressComponent, EnterPaymentMethodComponent, @@ -28,6 +29,7 @@ import { UpdateLicenseComponent } from "./update-license.component"; BannerModule, EnterPaymentMethodComponent, EnterBillingAddressComponent, + DiscountBadgeComponent, ], declarations: [ BillingHistoryComponent, @@ -51,6 +53,7 @@ import { UpdateLicenseComponent } from "./update-license.component"; OffboardingSurveyComponent, IndividualSelfHostingLicenseUploaderComponent, OrganizationSelfHostingLicenseUploaderComponent, + DiscountBadgeComponent, ], }) export class BillingSharedModule {} diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 49e29f00748..27faf6f4063 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -3250,9 +3250,18 @@ "nextCharge": { "message": "Next charge" }, + "nextChargeHeader": { + "message": "Next Charge" + }, + "plan": { + "message": "Plan" + }, "details": { "message": "Details" }, + "discount": { + "message": "discount" + }, "downloadLicense": { "message": "Download license" }, diff --git a/libs/common/src/billing/models/response/organization-subscription.response.ts b/libs/common/src/billing/models/response/organization-subscription.response.ts index 6e56eda68c6..f5fdaaba9b2 100644 --- a/libs/common/src/billing/models/response/organization-subscription.response.ts +++ b/libs/common/src/billing/models/response/organization-subscription.response.ts @@ -40,6 +40,7 @@ export class BillingCustomerDiscount extends BaseResponse { id: string; active: boolean; percentOff?: number; + amountOff?: number; appliesTo: string[]; constructor(response: any) { @@ -47,6 +48,7 @@ export class BillingCustomerDiscount extends BaseResponse { this.id = this.getResponseProperty("Id"); this.active = this.getResponseProperty("Active"); this.percentOff = this.getResponseProperty("PercentOff"); - this.appliesTo = this.getResponseProperty("AppliesTo"); + this.amountOff = this.getResponseProperty("AmountOff"); + this.appliesTo = this.getResponseProperty("AppliesTo") || []; } } diff --git a/libs/common/src/billing/models/response/subscription.response.ts b/libs/common/src/billing/models/response/subscription.response.ts index 3bc7d42651c..01ace1ef10a 100644 --- a/libs/common/src/billing/models/response/subscription.response.ts +++ b/libs/common/src/billing/models/response/subscription.response.ts @@ -2,12 +2,15 @@ // @ts-strict-ignore import { BaseResponse } from "../../../models/response/base.response"; +import { BillingCustomerDiscount } from "./organization-subscription.response"; + export class SubscriptionResponse extends BaseResponse { storageName: string; storageGb: number; maxStorageGb: number; subscription: BillingSubscriptionResponse; upcomingInvoice: BillingSubscriptionUpcomingInvoiceResponse; + customerDiscount: BillingCustomerDiscount; license: any; expiration: string; @@ -20,11 +23,14 @@ export class SubscriptionResponse extends BaseResponse { this.expiration = this.getResponseProperty("Expiration"); const subscription = this.getResponseProperty("Subscription"); const upcomingInvoice = this.getResponseProperty("UpcomingInvoice"); + const customerDiscount = this.getResponseProperty("CustomerDiscount"); this.subscription = subscription == null ? null : new BillingSubscriptionResponse(subscription); this.upcomingInvoice = upcomingInvoice == null ? null : new BillingSubscriptionUpcomingInvoiceResponse(upcomingInvoice); + this.customerDiscount = + customerDiscount == null ? null : new BillingCustomerDiscount(customerDiscount); } } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 2d071259aba..7d2d831bfb3 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -33,6 +33,7 @@ export enum FeatureFlag { PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service", PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog", PM26462_Milestone_3 = "pm-26462-milestone-3", + PM23341_Milestone_2 = "pm-23341-milestone-2", /* Key Management */ PrivateKeyRegeneration = "pm-12241-private-key-regeneration", @@ -129,6 +130,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE, [FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE, [FeatureFlag.PM26462_Milestone_3]: FALSE, + [FeatureFlag.PM23341_Milestone_2]: FALSE, /* Key Management */ [FeatureFlag.PrivateKeyRegeneration]: FALSE, diff --git a/libs/pricing/src/components/discount-badge/discount-badge.component.html b/libs/pricing/src/components/discount-badge/discount-badge.component.html new file mode 100644 index 00000000000..e79fbabf355 --- /dev/null +++ b/libs/pricing/src/components/discount-badge/discount-badge.component.html @@ -0,0 +1,10 @@ + + {{ getDiscountText() }} + diff --git a/libs/pricing/src/components/discount-badge/discount-badge.component.mdx b/libs/pricing/src/components/discount-badge/discount-badge.component.mdx new file mode 100644 index 00000000000..d3df2dcf0f6 --- /dev/null +++ b/libs/pricing/src/components/discount-badge/discount-badge.component.mdx @@ -0,0 +1,67 @@ +import { Meta, Story, Canvas } from "@storybook/addon-docs"; +import * as DiscountBadgeStories from "./discount-badge.component.stories"; + + + +# Discount Badge + +A reusable UI component for displaying discount information (percentage or fixed amount) in a badge +format. + + + +## Usage + +The discount badge component is designed to be used in billing and subscription interfaces to +display discount information. + +```ts +import { DiscountBadgeComponent, DiscountInfo } from "@bitwarden/pricing"; +``` + +```html + +``` + +## API + +### Inputs + +| Input | Type | Description | +| ---------- | ---------------------- | -------------------------------------------------------------------------------- | +| `discount` | `DiscountInfo \| null` | **Optional.** Discount information object. If null or inactive, badge is hidden. | + +### DiscountInfo Interface + +```ts +interface DiscountInfo { + /** Whether the discount is currently active */ + active: boolean; + /** Percentage discount (0-100 or 0-1 scale) */ + percentOff?: number; + /** Fixed amount discount in the base currency */ + amountOff?: number; +} +``` + +## Behavior + +- The badge is only displayed when `discount` is provided, `active` is `true`, and either + `percentOff` or `amountOff` is greater than 0. +- If both `percentOff` and `amountOff` are provided, `percentOff` takes precedence. +- Percentage values can be provided as 0-100 (e.g., `20` for 20%) or 0-1 (e.g., `0.2` for 20%). +- Amount values are formatted as currency (USD) with 2 decimal places. + +## Examples + +### Percentage Discount + + + +### Amount Discount + + + +### Inactive Discount + + diff --git a/libs/pricing/src/components/discount-badge/discount-badge.component.spec.ts b/libs/pricing/src/components/discount-badge/discount-badge.component.spec.ts new file mode 100644 index 00000000000..8ccfc5e5d8b --- /dev/null +++ b/libs/pricing/src/components/discount-badge/discount-badge.component.spec.ts @@ -0,0 +1,108 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { DiscountBadgeComponent } from "./discount-badge.component"; + +describe("DiscountBadgeComponent", () => { + let component: DiscountBadgeComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DiscountBadgeComponent], + providers: [ + { + provide: I18nService, + useValue: { + t: (key: string) => key, + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DiscountBadgeComponent); + component = fixture.componentInstance; + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("hasDiscount", () => { + it("should return false when discount is null", () => { + fixture.componentRef.setInput("discount", null); + fixture.detectChanges(); + expect(component.hasDiscount()).toBe(false); + }); + + it("should return false when discount is inactive", () => { + fixture.componentRef.setInput("discount", { active: false, percentOff: 20 }); + fixture.detectChanges(); + expect(component.hasDiscount()).toBe(false); + }); + + it("should return true when discount is active with percentOff", () => { + fixture.componentRef.setInput("discount", { active: true, percentOff: 20 }); + fixture.detectChanges(); + expect(component.hasDiscount()).toBe(true); + }); + + it("should return true when discount is active with amountOff", () => { + fixture.componentRef.setInput("discount", { active: true, amountOff: 10.99 }); + fixture.detectChanges(); + expect(component.hasDiscount()).toBe(true); + }); + + it("should return false when percentOff is 0", () => { + fixture.componentRef.setInput("discount", { active: true, percentOff: 0 }); + fixture.detectChanges(); + expect(component.hasDiscount()).toBe(false); + }); + + it("should return false when amountOff is 0", () => { + fixture.componentRef.setInput("discount", { active: true, amountOff: 0 }); + fixture.detectChanges(); + expect(component.hasDiscount()).toBe(false); + }); + }); + + describe("getDiscountText", () => { + it("should return null when discount is null", () => { + fixture.componentRef.setInput("discount", null); + fixture.detectChanges(); + expect(component.getDiscountText()).toBeNull(); + }); + + it("should return percentage text when percentOff is provided", () => { + fixture.componentRef.setInput("discount", { active: true, percentOff: 20 }); + fixture.detectChanges(); + const text = component.getDiscountText(); + expect(text).toContain("20%"); + expect(text).toContain("discount"); + }); + + it("should convert decimal percentOff to percentage", () => { + fixture.componentRef.setInput("discount", { active: true, percentOff: 0.15 }); + fixture.detectChanges(); + const text = component.getDiscountText(); + expect(text).toContain("15%"); + }); + + it("should return amount text when amountOff is provided", () => { + fixture.componentRef.setInput("discount", { active: true, amountOff: 10.99 }); + fixture.detectChanges(); + const text = component.getDiscountText(); + expect(text).toContain("$10.99"); + expect(text).toContain("discount"); + }); + + it("should prefer percentOff over amountOff", () => { + fixture.componentRef.setInput("discount", { active: true, percentOff: 25, amountOff: 10.99 }); + fixture.detectChanges(); + const text = component.getDiscountText(); + expect(text).toContain("25%"); + expect(text).not.toContain("$10.99"); + }); + }); +}); diff --git a/libs/pricing/src/components/discount-badge/discount-badge.component.stories.ts b/libs/pricing/src/components/discount-badge/discount-badge.component.stories.ts new file mode 100644 index 00000000000..02631a6b940 --- /dev/null +++ b/libs/pricing/src/components/discount-badge/discount-badge.component.stories.ts @@ -0,0 +1,123 @@ +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { BadgeModule } from "@bitwarden/components"; + +import { DiscountBadgeComponent, DiscountInfo } from "./discount-badge.component"; + +export default { + title: "Billing/Discount Badge", + component: DiscountBadgeComponent, + description: "A badge component that displays discount information (percentage or fixed amount).", + decorators: [ + moduleMetadata({ + imports: [BadgeModule], + providers: [ + { + provide: I18nService, + useValue: { + t: (key: string) => { + switch (key) { + case "discount": + return "discount"; + default: + return key; + } + }, + }, + }, + ], + }), + ], +} as Meta; + +type Story = StoryObj; + +export const PercentDiscount: Story = { + render: (args) => ({ + props: args, + template: ``, + }), + args: { + discount: { + active: true, + percentOff: 20, + } as DiscountInfo, + }, +}; + +export const PercentDiscountDecimal: Story = { + render: (args) => ({ + props: args, + template: ``, + }), + args: { + discount: { + active: true, + percentOff: 0.15, // 15% in decimal format + } as DiscountInfo, + }, +}; + +export const AmountDiscount: Story = { + render: (args) => ({ + props: args, + template: ``, + }), + args: { + discount: { + active: true, + amountOff: 10.99, + } as DiscountInfo, + }, +}; + +export const LargeAmountDiscount: Story = { + render: (args) => ({ + props: args, + template: ``, + }), + args: { + discount: { + active: true, + amountOff: 99.99, + } as DiscountInfo, + }, +}; + +export const InactiveDiscount: Story = { + render: (args) => ({ + props: args, + template: ``, + }), + args: { + discount: { + active: false, + percentOff: 20, + } as DiscountInfo, + }, +}; + +export const NoDiscount: Story = { + render: (args) => ({ + props: args, + template: ``, + }), + args: { + discount: null, + }, +}; + +export const PercentAndAmountPreferPercent: Story = { + render: (args) => ({ + props: args, + template: ``, + }), + args: { + discount: { + active: true, + percentOff: 25, + amountOff: 10.99, + } as DiscountInfo, + }, +}; diff --git a/libs/pricing/src/components/discount-badge/discount-badge.component.ts b/libs/pricing/src/components/discount-badge/discount-badge.component.ts new file mode 100644 index 00000000000..6057a4573e9 --- /dev/null +++ b/libs/pricing/src/components/discount-badge/discount-badge.component.ts @@ -0,0 +1,70 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, inject, input } from "@angular/core"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { BadgeModule } from "@bitwarden/components"; + +/** + * Interface for discount information that can be displayed in the discount badge. + * This is abstracted from the response class to avoid tight coupling. + */ +export interface DiscountInfo { + /** Whether the discount is currently active */ + active: boolean; + /** Percentage discount (0-100 or 0-1 scale) */ + percentOff?: number; + /** Fixed amount discount in the base currency */ + amountOff?: number; +} + +@Component({ + selector: "billing-discount-badge", + templateUrl: "./discount-badge.component.html", + standalone: true, + imports: [CommonModule, BadgeModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DiscountBadgeComponent { + readonly discount = input(null); + + private i18nService = inject(I18nService); + + getDiscountText(): string | null { + const discount = this.discount(); + if (!discount) { + return null; + } + + if (discount.percentOff != null && discount.percentOff > 0) { + const percentValue = + discount.percentOff < 1 ? discount.percentOff * 100 : discount.percentOff; + return `${Math.round(percentValue)}% ${this.i18nService.t("discount")}`; + } + + if (discount.amountOff != null && discount.amountOff > 0) { + const formattedAmount = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(discount.amountOff); + return `${formattedAmount} ${this.i18nService.t("discount")}`; + } + + return null; + } + + hasDiscount(): boolean { + const discount = this.discount(); + if (!discount) { + return false; + } + if (!discount.active) { + return false; + } + return ( + (discount.percentOff != null && discount.percentOff > 0) || + (discount.amountOff != null && discount.amountOff > 0) + ); + } +} diff --git a/libs/pricing/src/index.ts b/libs/pricing/src/index.ts index d7c7772bfcb..3405044529e 100644 --- a/libs/pricing/src/index.ts +++ b/libs/pricing/src/index.ts @@ -1,3 +1,4 @@ // Components export * from "./components/pricing-card/pricing-card.component"; export * from "./components/cart-summary/cart-summary.component"; +export * from "./components/discount-badge/discount-badge.component";