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 bcc87686f07..0a3762a1e41 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 d5062e34881..d541ab95b95 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 4af5226e7ee..e40b2ddad69 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 5d212a9cb68..9b867329e66 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 5ed393c0295..f806d5502c1 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -2903,6 +2903,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." },