From 78e5e029b63808810198708cc418832fa54a24f3 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Thu, 9 Oct 2025 11:40:13 -0500 Subject: [PATCH] [PM-26607] Improve premium purchase with account credit experience (#16805) * Fix account credit submission and show warning when user doesn't have enough credit * Kyle's feedback * Whoops --- .../individual/premium/premium.component.html | 9 ++- .../individual/premium/premium.component.ts | 57 ++++++++++++++++--- .../enter-payment-method.component.ts | 16 ++++-- .../payment/types/tokenized-payment-method.ts | 20 ++----- apps/web/src/locales/en/messages.json | 3 + 5 files changed, 77 insertions(+), 28 deletions(-) diff --git a/apps/web/src/app/billing/individual/premium/premium.component.html b/apps/web/src/app/billing/individual/premium/premium.component.html index bcc87686f0..0a3762a1e4 100644 --- a/apps/web/src/app/billing/individual/premium/premium.component.html +++ b/apps/web/src/app/billing/individual/premium/premium.component.html @@ -105,6 +105,7 @@ [group]="formGroup.controls.paymentMethod" [showBankAccount]="false" [showAccountCredit]="true" + [hasEnoughAccountCredit]="hasEnoughAccountCredit$ | async" > {{ "total" | i18n }}: {{ total | currency: "USD $" }}/{{ "year" | i18n }}

- diff --git a/apps/web/src/app/billing/individual/premium/premium.component.ts b/apps/web/src/app/billing/individual/premium/premium.component.ts index d5062e3488..d541ab95b9 100644 --- a/apps/web/src/app/billing/individual/premium/premium.component.ts +++ b/apps/web/src/app/billing/individual/premium/premium.component.ts @@ -4,34 +4,41 @@ import { Component, ViewChild } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl, FormGroup, Validators } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; -import { combineLatest, concatMap, from, Observable, of, switchMap } from "rxjs"; +import { combineLatest, concatMap, from, map, Observable, of, startWith, switchMap } from "rxjs"; 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 { PaymentMethodType } from "@bitwarden/common/billing/enums"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SyncService } from "@bitwarden/common/platform/sync"; import { ToastService } from "@bitwarden/components"; -import { TaxClient } from "@bitwarden/web-vault/app/billing/clients"; +import { SubscriberBillingClient, TaxClient } from "@bitwarden/web-vault/app/billing/clients"; import { EnterBillingAddressComponent, EnterPaymentMethodComponent, getBillingAddressFromForm, } from "@bitwarden/web-vault/app/billing/payment/components"; -import { tokenizablePaymentMethodToLegacyEnum } from "@bitwarden/web-vault/app/billing/payment/types"; +import { + tokenizablePaymentMethodToLegacyEnum, + NonTokenizablePaymentMethods, +} from "@bitwarden/web-vault/app/billing/payment/types"; +import { mapAccountToSubscriber } from "@bitwarden/web-vault/app/billing/types"; @Component({ templateUrl: "./premium.component.html", standalone: false, - providers: [TaxClient], + providers: [SubscriberBillingClient, TaxClient], }) export class PremiumComponent { @ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent; protected hasPremiumFromAnyOrganization$: Observable; + protected accountCredit$: Observable; + protected hasEnoughAccountCredit$: Observable; protected formGroup = new FormGroup({ additionalStorage: new FormControl(0, [Validators.min(0), Validators.max(99)]), @@ -58,6 +65,7 @@ export class PremiumComponent { private syncService: SyncService, private toastService: ToastService, private accountService: AccountService, + private subscriberBillingClient: SubscriberBillingClient, private taxClient: TaxClient, ) { this.isSelfHost = this.platformUtilsService.isSelfHost(); @@ -68,6 +76,26 @@ export class PremiumComponent { ), ); + // Fetch account credit + this.accountCredit$ = this.accountService.activeAccount$.pipe( + mapAccountToSubscriber, + switchMap((account) => this.subscriberBillingClient.getCredit(account)), + ); + + // Check if user has enough account credit for the purchase + this.hasEnoughAccountCredit$ = combineLatest([ + this.accountCredit$, + this.formGroup.valueChanges.pipe(startWith(this.formGroup.value)), + ]).pipe( + map(([credit, formValue]) => { + const selectedPaymentType = formValue.paymentMethod?.type; + if (selectedPaymentType !== NonTokenizablePaymentMethods.accountCredit) { + return true; // Not using account credit, so this check doesn't apply + } + return credit >= this.total; + }), + ); + combineLatest([ this.accountService.activeAccount$.pipe( switchMap((account) => @@ -120,13 +148,26 @@ export class PremiumComponent { return; } - const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + // Check if account credit is selected + const selectedPaymentType = this.formGroup.value.paymentMethod.type; - const legacyEnum = tokenizablePaymentMethodToLegacyEnum(paymentMethod.type); + let paymentMethodType: number; + let paymentToken: string; + + if (selectedPaymentType === NonTokenizablePaymentMethods.accountCredit) { + // Account credit doesn't need tokenization + paymentMethodType = PaymentMethodType.Credit; + paymentToken = ""; + } else { + // Tokenize for card, bank account, or PayPal + const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + paymentMethodType = tokenizablePaymentMethodToLegacyEnum(paymentMethod.type); + paymentToken = paymentMethod.token; + } const formData = new FormData(); - formData.append("paymentMethodType", legacyEnum.toString()); - formData.append("paymentToken", paymentMethod.token); + formData.append("paymentMethodType", paymentMethodType.toString()); + formData.append("paymentToken", paymentToken); formData.append("additionalStorageGb", this.formGroup.value.additionalStorage.toString()); formData.append("country", this.formGroup.value.billingAddress.country); formData.append("postalCode", this.formGroup.value.billingAddress.postalCode); diff --git a/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts b/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts index 31d5efc472..c0a9027388 100644 --- a/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts +++ b/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts @@ -9,6 +9,7 @@ import { PopoverModule, ToastService } from "@bitwarden/components"; import { SharedModule } from "../../../shared"; import { BillingServicesModule, BraintreeService, StripeService } from "../../services"; import { + AccountCreditPaymentMethod, isTokenizablePaymentMethod, selectableCountries, TokenizablePaymentMethod, @@ -17,7 +18,7 @@ import { import { PaymentLabelComponent } from "./payment-label.component"; -type PaymentMethodOption = TokenizablePaymentMethod | "accountCredit"; +type PaymentMethodOption = TokenizablePaymentMethod | AccountCreditPaymentMethod; type PaymentMethodFormGroup = FormGroup<{ type: FormControl; @@ -183,9 +184,15 @@ type PaymentMethodFormGroup = FormGroup<{ } @case ("accountCredit") { - - {{ "makeSureEnoughCredit" | i18n }} - + @if (hasEnoughAccountCredit) { + + {{ "makeSureEnoughCredit" | i18n }} + + } @else { + + {{ "notEnoughAccountCredit" | i18n }} + + } } } @@ -230,6 +237,7 @@ export class EnterPaymentMethodComponent implements OnInit { @Input() private showBankAccount = true; @Input() showPayPal = true; @Input() showAccountCredit = false; + @Input() hasEnoughAccountCredit = true; @Input() includeBillingAddress = false; protected showBankAccount$!: Observable; diff --git a/apps/web/src/app/billing/payment/types/tokenized-payment-method.ts b/apps/web/src/app/billing/payment/types/tokenized-payment-method.ts index 5d212a9cb6..9b867329e6 100644 --- a/apps/web/src/app/billing/payment/types/tokenized-payment-method.ts +++ b/apps/web/src/app/billing/payment/types/tokenized-payment-method.ts @@ -6,9 +6,14 @@ export const TokenizablePaymentMethods = { payPal: "payPal", } as const; +export const NonTokenizablePaymentMethods = { + accountCredit: "accountCredit", +} as const; + export type BankAccountPaymentMethod = typeof TokenizablePaymentMethods.bankAccount; export type CardPaymentMethod = typeof TokenizablePaymentMethods.card; export type PayPalPaymentMethod = typeof TokenizablePaymentMethods.payPal; +export type AccountCreditPaymentMethod = typeof NonTokenizablePaymentMethods.accountCredit; export type TokenizablePaymentMethod = (typeof TokenizablePaymentMethods)[keyof typeof TokenizablePaymentMethods]; @@ -18,21 +23,6 @@ export const isTokenizablePaymentMethod = (value: string): value is TokenizableP return valid.includes(value); }; -export const tokenizablePaymentMethodFromLegacyEnum = ( - legacyEnum: PaymentMethodType, -): TokenizablePaymentMethod | null => { - switch (legacyEnum) { - case PaymentMethodType.BankAccount: - return "bankAccount"; - case PaymentMethodType.Card: - return "card"; - case PaymentMethodType.PayPal: - return "payPal"; - default: - return null; - } -}; - export const tokenizablePaymentMethodToLegacyEnum = ( paymentMethod: TokenizablePaymentMethod, ): PaymentMethodType => { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 3eab92470a..fd237992d6 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -2951,6 +2951,9 @@ "makeSureEnoughCredit": { "message": "Please make sure that your account has enough credit available for this purchase. If your account does not have enough credit available, your default payment method on file will be used for the difference. You can add credit to your account from the Billing page." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Your account's credit can be used to make purchases. Any available credit will be automatically applied towards invoices generated for this account." },